hi list,

her now my newst version from patch

first a smal benchmark with local data files (no download)

rendering from a city cell
Fork=2 vs. Cores=4 on a quadcore system
workspace on harddisk

Fork=2
real    14m4.269s
user    17m9.113s
sys     1m28.356s

Cores=4
real    5m12.736s
user    12m14.226s
sys     1m29.026s

i think this say all ;)

ok now all new features:
* threaded optimizePNG
* new threaded Renderer
* new test that kill threads befor he reder to large data files
 a 16mb data.osm file consum ~ 1gb ram per renderer so i lets him not start 4
 renderer on a 2gb ram machine this save the renderer a bit (its not the best 
way)

new config options:

Cores=x
 0 or Fork>0 disable threading

MaxMemory=3000
in k 3000 work on my 4gb system perfectly


to the developer: please test it! ;)

i have it testet in xy mod and in loop mod
i hove i have all timing prolemas, killed resorce problems and the GD lib problems fixed ;)

to the testers! search useTestDirData in lib/Tileset.pm

bugs:
the warning "Scalars leaked: 1"
http://sunsite.ualberta.ca/Documentation/Misc/perl-5.6.1/pod/perldiag.html#item_Scalars_leaked:_%d
i tink he men $self, all childs have a copy and not one can freeing his


René

diff -urbBdpN a/lib/optimizePngTasks.pm b/lib/optimizePngTasks.pm
--- a/lib/optimizePngTasks.pm	1970-01-01 01:00:00.000000000 +0100
+++ b/lib/optimizePngTasks.pm	2008-11-12 19:24:50.000000000 +0100
@@ -0,0 +1,261 @@
+package optimizePngTasks;
+
+
+use warnings;
+use strict;
+use File::Temp qw/ tempfile tempdir /;
+use File::Copy;
+use File::Path;
+use Error qw(:try);
+use TahConf;
+use threads;
+use Thread::Semaphore;
+
+
+sub new
+{
+    my $class = shift;
+    my $Config = TahConf->getConfig();
+
+    my %sharedStack :shared;
+    my @sharedFilelist :shared;
+    my @sharedTranslist :shared;
+    my $self = {
+        Config => $Config,
+        SHARED => \%sharedStack,
+        DESTROYED => 0,
+        children => [],
+        };
+
+    $self->{SHARED}->{DESTROYED} = 0;
+    $self->{SHARED}->{JOBS}  = -1;
+    $self->{SHARED}->{JOBSREADY}  = 0;
+    $self->{SHARED}->{JOBSFILES}  = [EMAIL PROTECTED];
+    $self->{SHARED}->{JOBSTRANSPARENT}  = [EMAIL PROTECTED];
+    $self->{SHARED}->{CHILDCRASH} = 0;    # TODO:
+
+    bless $self, $class;
+    return $self;
+}
+
+
+sub DESTROY
+{
+
+}
+
+########
+# kill all children threads
+########
+sub killAllChilds
+{
+    my $self = shift;
+    my $Config = $self->{Config};
+
+    # set the destroy flag (detached childrens!)
+    $self->{'Semaphore'}->down();
+    $self->{SHARED}->{DESTROYED} = 1;
+    $self->{'Semaphore'}->up();
+
+}
+
+
+##########
+# start and init my children
+##########
+sub startChildren {
+    my $self = shift;
+    my $Config = $self->{Config};
+
+    if ($Config->get("Cores")) {
+        $self->{'maxChildren'} = $Config->get("Cores");
+        $self->{'children'} = [];
+        $self->{'Semaphore'} = Thread::Semaphore->new();
+
+        $::currentSubTask ='optimize';
+        $::progressPercent = 0;
+        ::statusMessage("init ". $self->{'maxChildren'} ." optimizePNG Child Tasks", 0, 6);
+        $self->{SHARED}->{'progress'} = 0;
+        for my $childID (1 .. $self->{'maxChildren'}) {
+            $self->{'children'}->[$childID] = threads->create(    sub  {
+                threads->detach();
+                my $sleeping = 0;
+                while (!$self->{SHARED}->{DESTROYED}) {
+
+
+                    sleep 1;
+
+                    while ($self->{SHARED}->{JOBS} < $#{$self->{SHARED}->{JOBSFILES}}) {
+                        my ($png_file, $transparent) = "";
+
+                        # access: lock()
+                        $self->{'Semaphore'}->down();
+                        $self->{SHARED}->{JOBS}++;
+
+                        $self->{SHARED}->{'progress'}++;
+                        if($#{$self->{SHARED}->{JOBSFILES}} >0) {
+                            $::progressPercent = 100 * $self->{SHARED}->{'progress'} / ($#{$self->{SHARED}->{JOBSFILES}}+1);
+                        }
+
+                        $png_file = $self->{SHARED}->{JOBSFILES}->[ $self->{SHARED}->{JOBS} ];
+                        $transparent = $self->{SHARED}->{JOBSTRANSPARENT}->[ $self->{SHARED}->{JOBS} ];
+
+
+                        # access: unlock()
+                        $self->{'Semaphore'}->up();
+
+                        if( $png_file) {    # no new work? go sleeping
+                            #####
+                            # lets do my work now
+                            #####
+
+                            $self->optimizePngClient($png_file, $transparent);
+
+                            $self->{'Semaphore'}->down();
+                            $self->{SHARED}->{JOBSREADY}++;
+                            $self->{'Semaphore'}->up();
+                        }
+                    }
+
+
+                }
+                    ::statusMessage("optimizePNG child $childID exit" ,1,10);
+
+            }
+            ); # threads->create(sub) end
+        } # for
+
+    }
+
+} # sub startChildren
+
+# add a new job ::addJob->($png_file,$transparent)
+sub addJob {
+    my $self = shift;
+    my $Config = $self->{Config};
+    my $png_file =  shift;
+    my $transparent = shift;
+
+    $self->{'Semaphore'}->down();
+
+    my $pos = $#{$self->{SHARED}->{JOBSFILES}}+1;
+
+    $self->{SHARED}->{JOBSFILES}->[ $pos ]  = $png_file;
+    $self->{SHARED}->{JOBSTRANSPARENT}->[ $pos ]  = $transparent;
+
+    $self->{'Semaphore'}->up();
+}
+
+
+# wait of all my jobs
+sub wait {
+    my $self = shift;
+
+    ::statusMessage("Wait of my PNG optimize Children", 0, 6);
+
+    while ($self->{SHARED}->{JOBSREADY} <= $#{$self->{SHARED}->{JOBSFILES}}) {
+        sleep 1;
+#        print $self->{SHARED}->{JOBSREADY} ." ".$#{$self->{SHARED}->{JOBSFILES}} ."\n";
+    }
+
+}
+
+# reset my lists
+sub dataReset {
+    my $self = shift;
+
+    $self->{'Semaphore'}->down();
+
+    $self->{SHARED}->{JOBS}  = -1;
+    $self->{SHARED}->{JOBSREADY}  = 0;
+    undef @{ $self->{SHARED}->{JOBSFILES} };
+    undef @{ $self->{SHARED}->{JOBSTRANSPARENT} };
+
+    $self->{SHARED}->{'progress'} = 0;
+
+    $self->{'Semaphore'}->up();
+}
+
+
+#-----------------------------------------------------------------------------
+# optimize a PNG file
+#
+# Parameters:
+#   $png_file - file name of PNG file
+#   $transparent - whether or not this is a transparent tile
+#-----------------------------------------------------------------------------
+sub optimizePngClient
+{
+    my $self = shift();
+    my $png_file = shift();
+    my $transparent = shift();
+
+    my $Config = $self->{Config};
+
+    my $optipngOptions = "-l 9";
+
+    my $redirect = ($^O eq "MSWin32") ? "" : ">/dev/null";
+    my $tmp_suffix = '.cut';
+    my $tmp_file = $png_file . $tmp_suffix;
+    my (undef, undef, $png_file_name) = File::Spec->splitpath($png_file);
+
+    my $cmd;
+    if ($transparent) {
+        # Don't quantize if it's transparent
+        rename($png_file, $tmp_file);
+    }
+    elsif (($Config->get("PngQuantizer")||'') eq "pngnq") {
+        $cmd = sprintf("\"%s\" -e .png%s -s1 -n256 %s %s",
+                       $Config->get("pngnq"),
+                       $tmp_suffix,
+                       $png_file,
+                       $redirect);
+
+        ::statusMessage("ColorQuantizing $png_file_name", 0, 6);
+        if(::runCommand($cmd, $::PID)) {
+            # Color quantizing successful
+            unlink($png_file);
+        }
+        else {
+            # Color quantizing failed
+            ::statusMessage("ColorQuantizing $png_file_name with ".$Config->get("PngQuantizer")." failed", 1, 0);
+            rename($png_file, $tmp_file);
+        }
+    }
+    else {
+        ::statusMessage("Not Color Quantizing $png_file_name, pngnq not installed?", 0, 6);
+        rename($png_file, $tmp_file);
+    }
+
+    if ($Config->get("PngOptimizer") eq "pngcrush") {
+        $cmd = sprintf("\"%s\" %s -q %s %s %s",
+                       $Config->get("Pngcrush"),
+                       $optipngOptions,
+                       $tmp_file,
+                       $png_file,
+                       $redirect);
+    }
+    elsif ($Config->get("PngOptimizer") eq "optipng") {
+           $cmd = sprintf("\"%s\" %s -out %s %s", #no quiet, because it even suppresses error output
+                          $Config->get("Optipng"),
+                          $tmp_file,
+                          $png_file,
+                          $redirect);
+    }
+    else {
+        ::statusMessage("PngOptimizer not configured (should not happen, update from svn, and check config file)", 1, 0);
+        ::talkInSleep("Install a PNG optimizer and configure it.", 15);
+    }
+
+    ::statusMessage("Optimizing $png_file_name", 0, 6);
+    if(::runCommand($cmd, $::PID)) {
+        unlink($tmp_file);
+    }
+    else {
+        ::statusMessage("Optimizing $png_file_name with " . $Config->get("PngOptimizer") . " failed", 1, 0);
+        rename($tmp_file, $png_file);
+    }
+}
+
+
+1;
diff -urbBdpN a/lib/Tileset.pm b/lib/Tileset.pm
--- a/lib/Tileset.pm	2008-11-12 18:58:51.573154294 +0100
+++ b/lib/Tileset.pm	2008-11-12 20:33:13.000000000 +0100
@@ -30,6 +30,11 @@ use File::Copy;
 use File::Path;
 use GD 2 qw(:DEFAULT :cmp);
 
+use threads;
+use Thread::Semaphore;
+use optimizePngTasks;
+
+
 #-----------------------------------------------------------------------------
 # creates a new Tileset instance and returns it
 # parameter is a request object with x,y,z, and layer atributes set
@@ -62,24 +67,7 @@ sub new
     # create true color images by default
     GD::Image->trueColor(1);
 
-    # create blank comparison images
-    my $EmptyLandImage = new GD::Image(256,256);
-    my $MapLandBackground = $EmptyLandImage->colorAllocate(248,248,248);
-    $EmptyLandImage->fill(127,127,$MapLandBackground);
-
-    my $EmptySeaImage = new GD::Image(256,256);
-    my $MapSeaBackground = $EmptySeaImage->colorAllocate(181,214,241);
-    $EmptySeaImage->fill(127,127,$MapSeaBackground);
-
-    # Some broken versions of Inkscape occasionally produce totally black
-    # output. We detect this case and throw an error when that happens.
-    my $BlackTileImage = new GD::Image(256,256);
-    my $BlackTileBackground = $BlackTileImage->colorAllocate(0,0,0);
-    $BlackTileImage->fill(127,127,$BlackTileBackground);
 
-    $self->{EmptyLandImage} = $EmptyLandImage;
-    $self->{EmptySeaImage} = $EmptySeaImage;
-    $self->{BlackTileImage} = $BlackTileImage;
 
     # Inkscape auto-backup/reset setup
     # Takes a backup copy of ~/.inkscape/preferences.xml if
@@ -119,12 +107,8 @@ sub new
 #-----------------------------------------------------------------------------
 sub DESTROY
 {
-    my $self = shift;
-    # Don't clean up in child threads
-    return if ($self->{childThread});
+# perl call DESTROY more as on time!
 
-    # only cleanup if we are the parent thread
-    $self->cleanup();
 }
 
 #-----------------------------------------------------------------------------
@@ -143,6 +127,141 @@ sub generate
 
     $self->{bbox}= bbox->new(ProjectXY($req->ZXY));
 
+
+
+    #########
+    # init Children for optimizePngTasks and threadedRenderer
+    #########
+    if ($Config->get("Cores") && !$Config->get("Fork") ) {
+
+        # start optimizePng Childs
+        $self->{optimizePngTasks} = optimizePngTasks->new();
+        $self->{optimizePngTasks}->startChildren();
+
+
+        # add renderer childs
+        $self->{'maxChildren'} = $Config->get("Cores");
+        $self->{'rendererChildren'} = [];
+        $self->{'rendererSemaphore'} = Thread::Semaphore->new();
+
+        my %sharedStack :shared;
+        my @sharedJobs :shared;
+        my @sharedJobsLayer :shared;
+        my @sharedJobsLayerDataFile :shared;
+        my @childStop :shared;
+
+        $self->{SHARED} = \%sharedStack;
+        $self->{SHARED}->{DESTROYED} = 0;
+        $self->{SHARED}->{RENDERERJOBS} = [EMAIL PROTECTED];
+        $self->{SHARED}->{RENDERERJOBLAYER} = [EMAIL PROTECTED];
+        $self->{SHARED}->{RENDERERJOBLAYERDATA} = [EMAIL PROTECTED];
+        $self->{SHARED}->{RENDERERJOBSPOS} = -1;
+        $self->{SHARED}->{RENDERERJOBSREADY} = 0;
+        $self->{SHARED}->{CHILDSTOP} = [EMAIL PROTECTED];    # for stop single clients
+
+        ::statusMessage("init ". $self->{'maxChildren'} ." Renderer Child Tasks", 0, 6);
+
+        for my $childID (1 .. $self->{'maxChildren'}) {
+            $self->{SHARED}->{CHILDSTOP}->[$childID] = 0;
+            $self->{'rendererChildren'}->[$childID] = threads->create(    sub  {
+
+                    threads->detach();
+
+                    my $req =  $self->{req};
+                    my $Config = $self->{Config};
+                    my $pos;
+                    my $layer;
+                    my $layerDataFile;
+                    my $zoom;
+
+                   # create blank comparison images
+                    my $EmptyLandImage = new GD::Image(256,256);
+                    my $MapLandBackground = $EmptyLandImage->colorAllocate(248,248,248);
+                    $EmptyLandImage->fill(127,127,$MapLandBackground);
+
+                    my $EmptySeaImage = new GD::Image(256,256);
+                    my $MapSeaBackground = $EmptySeaImage->colorAllocate(181,214,241);
+                    $EmptySeaImage->fill(127,127,$MapSeaBackground);
+
+                    # Some broken versions of Inkscape occasionally produce totally black
+                    # output. We detect this case and throw an error when that happens.
+                    my $BlackTileImage = new GD::Image(256,256);
+                    my $BlackTileBackground = $BlackTileImage->colorAllocate(0,0,0);
+                    $BlackTileImage->fill(127,127,$BlackTileBackground);
+
+                    $self->{EmptyLandImage} = $EmptyLandImage;
+                    $self->{EmptySeaImage} = $EmptySeaImage;
+                    $self->{BlackTileImage} = $BlackTileImage;
+
+                    # wait of the global destroy flag or of the singel stop flag
+                    while(!$self->{SHARED}->{DESTROYED} && !$self->{SHARED}->{CHILDSTOP}->[$childID] ) {
+
+                        while ($self->{SHARED}->{RENDERERJOBSPOS} < $#{ $self->{SHARED}->{RENDERERJOBS} }) {
+
+                            # access: lock()
+                            $self->{'rendererSemaphore'}->down();
+
+                            $self->{SHARED}->{RENDERERJOBSPOS}++;
+                            $pos = $self->{SHARED}->{RENDERERJOBSPOS};
+                            $zoom = $self->{SHARED}->{RENDERERJOBS}->[$pos];
+                            $layer = $self->{SHARED}->{RENDERERJOBLAYER}->[$pos];
+                            $layerDataFile = $self->{SHARED}->{RENDERERJOBLAYERDATA}->[$pos];
+
+                            # access: unlock()
+                            $self->{'rendererSemaphore'}->up();
+
+                            ::statusMessage("Rendererclient $childID get job $pos zoom $zoom on layer $layer $layerDataFile" ,1,10);
+
+                            ####
+                            # i do my work now
+                            ####
+                            $self->Render($layer, $zoom, $layerDataFile);
+
+                            $self->{'rendererSemaphore'}->down();
+                            $self->{SHARED}->{RENDERERJOBSREADY}++;
+                            $self->{'rendererSemaphore'}->up();
+                        }
+
+                        sleep 1;
+                    }
+                    ::statusMessage("Renderer child $childID exit" ,1,10);
+                } #sub;
+                ); #threads->create
+        }    # for
+
+    }
+    else {
+        $self->{optimizePngTasks} = undef;
+        $self->{'rendererChildren'} = undef;
+    }
+
+
+
+
+    # create blank comparison images
+    my $EmptyLandImage = new GD::Image(256,256);
+    my $MapLandBackground = $EmptyLandImage->colorAllocate(248,248,248);
+    $EmptyLandImage->fill(127,127,$MapLandBackground);
+
+    my $EmptySeaImage = new GD::Image(256,256);
+    my $MapSeaBackground = $EmptySeaImage->colorAllocate(181,214,241);
+    $EmptySeaImage->fill(127,127,$MapSeaBackground);
+
+    # Some broken versions of Inkscape occasionally produce totally black
+    # output. We detect this case and throw an error when that happens.
+    my $BlackTileImage = new GD::Image(256,256);
+    my $BlackTileBackground = $BlackTileImage->colorAllocate(0,0,0);
+    $BlackTileImage->fill(127,127,$BlackTileBackground);
+
+    $self->{EmptyLandImage} = $EmptyLandImage;
+    $self->{EmptySeaImage} = $EmptySeaImage;
+    $self->{BlackTileImage} = $BlackTileImage;
+
+
+
+
+
+
     ::statusMessage(sprintf("Tileset (%d,%d,%d) around %.2f,%.2f", $req->ZXY, $self->{bbox}->center), 1, 0);
 
     if($req->Z >= 12)
@@ -152,9 +271,43 @@ sub generate
         #------------------------------------------------------
 
         my $beforeDownload = time();
-        my $FullDataFile = $self->downloadData($req->layers);
+
+        # TODO: FIXME: remove it on the stable version! only for debuging and tests else { its the original}
+        # add a optional testadata directory (download not the data)
+        # DO NOT UPLOAD THE RESULTS REAL!
+        my $testdatadir = File::Spec->join($Config->get("WorkingDirectory"), 'testdatadir');
+        my $testdatafile = File::Spec->join($testdatadir, 'data.osm');
+        my $FullDataFile = "";
+
+        if($Config->get("useTestDirData") && $Config->get("debug") && $Config->get("UploadToDirectory")
+             && -d $testdatadir && -f $testdatafile)
+        {
+            $FullDataFile = File::Spec->join($self->{JobDir}, 'data.osm');
+            copy($testdatafile,$FullDataFile)
+        }
+        else {
+            $FullDataFile = $self->downloadData($req->layers);
+        }
+
         ::statusMessage("Download in ".(time() - $beforeDownload)." sec",1,10); 
 
+        # manage renderer memory usage
+        # a 16mb osm file consum ca 1gb ram as svg "int(16786037/1024/16)"
+        my @datafileStats = stat( $FullDataFile );
+        my $caMemoryUsage = $datafileStats[7]/1024/16;
+
+        if( ( $caMemoryUsage*$self->{'maxChildren'} ) > $Config->get("MaxMemory")) {
+            # too little memory
+            my $newMaxChildren =  int($Config->get("MaxMemory")/$caMemoryUsage);
+            $newMaxChildren = 1 if $self->{'maxChildren'} eq $newMaxChildren;
+
+            ::statusMessage("too little memory for the render job and ". $self->{'maxChildren'} ." Children, stopp ". ($self->{'maxChildren'}-$newMaxChildren) ." renderer childs from ". $self->{'maxChildren'} ,1,10);
+
+            for(my $stopCount = ($self->{'maxChildren'}-$newMaxChildren); $stopCount>0;$stopCount-- ) {
+                $self->{SHARED}->{CHILDSTOP}->[$stopCount]=1;
+            }
+        }
+
         #------------------------------------------------------
         # Handle all layers, one after the other
         #------------------------------------------------------
@@ -321,7 +474,23 @@ sub generate
     $::currentSubTask = "";
     ::keepLog($$,"GenerateTileset","stop",'x='.$req->X.',y='.$req->Y.',z='.$req->Z." for layers ".$req->layers_str);
 
+
+    # stop my childs
+    if(defined $self->{'rendererChildren'} ) {
+        $self->{'rendererSemaphore'}->down();
+        $self->{SHARED}->{DESTROYED} = 1;
+        $self->{'rendererSemaphore'}->up();
+    }
+    if(defined $self->{optimizePngTasks}) {
+        $self->{optimizePngTasks}->killAllChilds();
+        sleep 2; # wait of the childs
+    }
+
+
     # Cleaning up of tmpdirs etc. are called in the destructor DESTROY
+    # TODO: i move it back! DESTORY is not called only one time!
+    # the GC from perl call DESTROY a 2. time and in thread mode 2*child
+    $self->cleanup();
 }
 
 sub generateNormalLayer
@@ -350,6 +519,10 @@ sub generateNormalLayer
     {   # Forking to render zoom levels in parallel
         $self->forkedRender($layer, $layerDataFile);
     }
+    elsif ($self->{Config}->get("Cores") && defined $self->{'rendererChildren'})
+    {    # use threads for rendering zoom levels parallel
+        $self->threadedRender($layer, $layerDataFile);
+    }
     else
     {   # Non-forking render
         $self->nonForkedRender($layer, $layerDataFile);
@@ -996,6 +1169,65 @@ sub nonForkedRender
         $self->Render($layer, $zoom, $layerDataFile)
     }
 
+    if (defined $self->{optimizePngTasks}) {
+        $self->{optimizePngTasks}->wait();
+        $self->{optimizePngTasks}->dataReset();
+    }
+
+
+    if ($Config->get("CreateTilesetFile") and !$Config->get("LocalSlippymap")) {
+        $self->createTilesetFile($layer);
+    }
+    else {
+        $self->createZipFile($layer);
+    }
+}
+
+#-------------------------------------------------------------------
+# renders the tiles, not using threads
+# paramter: ($layer, $layerDataFile)
+#-------------------------------------------------------------------
+sub threadedRender
+{
+    my $self = shift;
+    my ($layer, $layerDataFile) = @_;
+    my $req = $self->{req};
+    my $Config = $self->{Config};
+    my $minzoom = $req->Z;
+    my $maxzoom = $Config->get($layer."_MaxZoom");
+
+    # access: lock()
+    $self->{'rendererSemaphore'}->down();
+
+    for (my $zoom = $maxzoom ; $zoom >= $req->Z; $zoom--) {
+
+        my $pos = $#{ $self->{SHARED}->{RENDERERJOBS} };
+        $pos++;
+
+        ::statusMessage("add renderjob zoom: $zoom at pos $pos" ,1,10);
+
+        $self->{SHARED}->{RENDERERJOBS}->[ $pos ] = $zoom;
+        $self->{SHARED}->{RENDERERJOBLAYER}->[ $pos ] = $layer;
+        $self->{SHARED}->{RENDERERJOBLAYERDATA}->[ $pos ] = $layerDataFile;
+    }
+
+    # access: unlock()
+    $self->{'rendererSemaphore'}->up();
+
+    #############
+    # at this time is the client on work and the main process wait now
+    #############
+    while($self->{SHARED}->{RENDERERJOBSREADY} <= $#{ $self->{SHARED}->{RENDERERJOBS} } ) {
+        sleep 1;
+    }
+
+    sleep 2;    # dead zone
+
+    if (defined $self->{optimizePngTasks}) {
+        $self->{optimizePngTasks}->wait();
+        $self->{optimizePngTasks}->dataReset();
+    }
+
     if ($Config->get("CreateTilesetFile") and !$Config->get("LocalSlippymap")) {
         $self->createTilesetFile($layer);
     }
@@ -1280,11 +1512,16 @@ sub SplitTiles
                     print $fp $png_data;
                     close $fp;
 
+                    if(defined $self->{optimizePngTasks}) {
+                        $self->{optimizePngTasks}->addJob($tile_file,$Config->get("${layer}_Transparent"));
+                    }
+                    else {
                     $self->optimizePng($tile_file, $Config->get("${layer}_Transparent"));
                 }
             }
         }
     }
+    }
 }
 
 
_______________________________________________
Tilesathome mailing list
[email protected]
http://lists.openstreetmap.org/listinfo/tilesathome

Reply via email to