diff --git a/models/AssetIterator.php b/models/AssetIterator.php index 3a50cf5..94132aa 100644 --- a/models/AssetIterator.php +++ b/models/AssetIterator.php @@ -8,15 +8,18 @@ class AssetIterator extends Asset { + private Database $db; + private $direction; + private $return_format; private $res_assets; private $res_meta; private $res_thumbs; - private Database $db; - protected function __construct($res_assets, $res_meta, $res_thumbs, $return_format) + protected function __construct($res_assets, $res_meta, $res_thumbs, $return_format, $direction) { $this->db = Registry::get('db'); + $this->direction = $direction; $this->res_assets = $res_assets; $this->res_meta = $res_meta; $this->res_thumbs = $res_thumbs; @@ -174,7 +177,7 @@ class AssetIterator extends Asset '_' => '_', ]); - $iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format); + $iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format, $params['direction']); // Returning total count, too? if ($return_count) @@ -190,4 +193,14 @@ class AssetIterator extends Asset else return $iterator; } + + public function isAscending() + { + return $this->direction === 'asc'; + } + + public function isDescending() + { + return $this->direction === 'desc'; + } } diff --git a/models/Image.php b/models/Image.php index fb4d8c7..0172a9c 100644 --- a/models/Image.php +++ b/models/Image.php @@ -145,6 +145,16 @@ class Image extends Asset return $ratio >= 1 && $ratio <= 2; } + public function getType() + { + if ($this->isPortrait()) + return self::TYPE_PORTRAIT; + elseif ($this->isPanorama()) + return self::TYPE_PANORAMA; + else + return self::TYPE_LANDSCAPE; + } + public function getThumbnails() { return $this->thumbnails; diff --git a/models/PhotoMosaic.php b/models/PhotoMosaic.php index 68089ba..8326e1d 100644 --- a/models/PhotoMosaic.php +++ b/models/PhotoMosaic.php @@ -8,17 +8,21 @@ class PhotoMosaic { + private $descending; private AssetIterator $iterator; private $layouts; private $processedImages = 0; private $queue = []; + const IMAGE_MASK_ALL = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA; const NUM_DAYS_CUTOFF = 7; + const NUM_BATCH_PHOTOS = 6; public function __construct(AssetIterator $iterator) { $this->iterator = $iterator; $this->layouts = $this->availableLayouts(); + $this->descending = $iterator->isDescending(); } public function __destruct() @@ -48,6 +52,7 @@ class PhotoMosaic // Dual layouts 'dualLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE], 'dualPortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT], + 'dualMixed' => [Image::TYPE_LANDSCAPE, Image::TYPE_PORTRAIT], // Fallback layouts 'singleLandscape' => [Image::TYPE_LANDSCAPE], @@ -57,18 +62,24 @@ class PhotoMosaic return $layouts; } - private static function daysApart(Image $a, Image $b) + private static function daysApart(DateTime $a, DateTime $b) { - return $a->getDateCaptured()->diff($b->getDateCaptured())->days; + return $a->diff($b)->days; } - private function fetchImage($desired_type = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA, Image $refDateImage = null) + private function fetchImage($desired_type = self::IMAGE_MASK_ALL, ?DateTime $refDate = null) { // First, check if we have what we're looking for in the queue. foreach ($this->queue as $i => $image) { + // Give up on the queue once the dates are too far apart + if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF) + { + break; + } + // Image has to match the desired type and be taken within a week of the reference image. - if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF)) + if (self::matchTypeMask($image, $desired_type)) { unset($this->queue[$i]); return $image; @@ -78,61 +89,153 @@ class PhotoMosaic // Check whatever's next up! while (($asset = $this->iterator->next()) && ($image = $asset->getImage())) { - // Image has to match the desired type and be taken within a week of the reference image. - if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF)) - return $image; - else + // Give up on the recordset once dates are too far apart + if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF) + { $this->pushToQueue($image); + break; + } + + // Image has to match the desired type and be taken within a week of the reference image. + if (self::matchTypeMask($image, $desired_type)) + { + return $image; + } + else + { + $this->pushToQueue($image); + } } return false; } + public function fetchImages($num, $refDate = null, $spec = self::IMAGE_MASK_ALL) + { + $refDate = null; + $prevImage = true; + $images = []; + + for ($i = 0; $i < $num || !$prevImage; $i++) + { + $image = $this->fetchImage($spec, $refDate); + if ($image !== false) + { + $images[] = $image; + $refDate = $image->getDateCaptured(); + $prevImage = $image; + } + } + + return $images; + } + public function getRow() { - $currentImages = []; + $requiredImages = array_map('count', $this->layouts); + $currentImages = $this->fetchImages(self::NUM_BATCH_PHOTOS); $selectedLayout = null; - foreach ($this->layouts as $layout => $requiredImageTypes) - { - // Fetch images per the layout requirements - // TODO: pass last image back into fetchImage for date cutoff purposes - $currentImages = array_filter(array_map([$this, 'fetchImage'], $requiredImageTypes)); - // Matching requirements? - if (count($currentImages) === count($requiredImageTypes)) - { - $selectedLayout = $layout; - break; - } - - // Push mismatches back into the queue - array_map([$this, 'pushToQueue'], $currentImages); - $currentImages = []; - } - - if ($selectedLayout) - { - // Hurray, we've got something that works - usort($currentImages, [$this, 'orderPhotos']); - $this->processedImages += count($currentImages); - return [$currentImages, $selectedLayout]; - } - else + if (empty($currentImages)) { // Ensure we have no images left in the iterator before giving up assert($this->processedImages === $this->iterator->num()); return false; } + + // Assign fitness score for each layout + $fitnessScores = $this->getScoresByLayout($currentImages); + $scoresByLayout = array_map(fn($el) => $el[0], $fitnessScores); + + // Select the best-fitting layout + $bestLayouts = array_keys($scoresByLayout, max($scoresByLayout)); + $bestLayout = $bestLayouts[0]; + $layoutImages = $fitnessScores[$bestLayout][1]; + + // Push any unused back into the queue + if (count($layoutImages) < count($currentImages)) + { + $diff = array_udiff($currentImages, $layoutImages, function($a, $b) { + return $a->getId() <=> $b->getId(); + }); + array_map([$this, 'pushToQueue'], $diff); + } + + // Finally, allow tweaking image order through display priority + usort($layoutImages, [$this, 'orderPhotosByPriority']); + + // Done! Return the result + $this->processedImages += count($layoutImages); + return [$layoutImages, $bestLayout]; + } + + public function getScoreForRow(array $images, array $specs) + { + assert(count($images) === count($specs)); + + $score = 0; + foreach ($images as $i => $image) + { + if (self::matchTypeMask($image, $specs[$i])) + $score += 1; + else + $score -= 10; + } + + return $score; + } + + public function getScoresByLayout(array $candidateImages) + { + $fitnessScores = []; + foreach ($this->layouts as $layout => $requiredImageTypes) + { + // If we don't have enough candidate images for this layout, skip it + if (count($candidateImages) < count($requiredImageTypes)) + continue; + + $imageSelection = []; + $remainingImages = $candidateImages; + + // Try to satisfy the layout spec using the images available + foreach ($requiredImageTypes as $spec) + { + foreach ($remainingImages as $i => $candidate) + { + // Satisfied spec from selection? + if (self::matchTypeMask($candidate, $spec)) + { + $imageSelection[] = $candidate; + unset($remainingImages[$i]); + continue 2; + } + } + + // Unable to satisfy spec from selection + break; + } + + // Have we satisfied the spec? Great, assign a score + if (count($imageSelection) === count($requiredImageTypes)) + { + $score = $this->getScoreForRow($imageSelection, $requiredImageTypes); + $fitnessScores[$layout] = [$score, $imageSelection]; + + // Perfect score? Bail out early + if ($score === count($requiredImageTypes)) + break; + } + } + + return $fitnessScores; } private static function matchTypeMask(Image $image, $type_mask) { - return ($type_mask & Image::TYPE_PANORAMA) && $image->isPanorama() || - ($type_mask & Image::TYPE_LANDSCAPE) && $image->isLandscape() || - ($type_mask & Image::TYPE_PORTRAIT) && $image->isPortrait(); + return $image->getType() & $type_mask; } - private static function orderPhotos(Image $a, Image $b) + private static function orderPhotosByPriority(Image $a, Image $b) { // Leave images of different types as-is if ($a->isLandscape() !== $b->isLandscape()) @@ -143,8 +246,17 @@ class PhotoMosaic return -$priority_diff; } + private function orderQueueByDate() + { + usort($this->queue, function($a, $b) { + $score = $a->getDateCaptured() <=> $b->getDateCaptured(); + return $score * ($this->descending ? -1 : 1); + }); + } + private function pushToQueue(Image $image) { $this->queue[] = $image; + $this->orderQueueByDate(); } } diff --git a/templates/PhotosIndex.php b/templates/PhotosIndex.php index c9fe715..a1915e0 100644 --- a/templates/PhotosIndex.php +++ b/templates/PhotosIndex.php @@ -270,6 +270,29 @@ class PhotosIndex extends Template '; } + protected function dualMixed(array $photos, $altLayout) + { + $image = array_shift($photos); + + echo ' +
+
'; + + $this->photo($image, 'landscape', static::LANDSCAPE_WIDTH, static::LANDSCAPE_HEIGHT, 'top'); + + echo ' +
+
'; + + $this->photo($image, 'portrait', static::PORTRAIT_WIDTH, static::PORTRAIT_HEIGHT, true); + + echo ' +
+
+ '; + } + protected function dualPortraits(array $photos, $altLayout) { // Recycle the row layout so portraits don't appear too large