iterator = $iterator; $this->layouts = $this->availableLayouts(); $this->descending = $iterator->isDescending(); } public function __destruct() { $this->iterator->clean(); } private function availableLayouts() { static $layouts = [ // Single panorama 'panorama' => [Image::TYPE_PANORAMA], // A whopping six landscapes? 'sixLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE], // Big-small juxtapositions 'sidePortrait' => [Image::TYPE_PORTRAIT, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE], 'sideLandscape' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE], // Single row of three 'threeLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE], 'threePortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT], // 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], 'singlePortrait' => [Image::TYPE_PORTRAIT], ]; return $layouts; } private static function daysApart(DateTime $a, DateTime $b) { return $a->diff($b)->days; } 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)) { unset($this->queue[$i]); return $image; } } // Check whatever's next up! while (($asset = $this->iterator->next()) && ($image = $asset->getImage())) { // 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() { $requiredImages = array_map('count', $this->layouts); $currentImages = $this->fetchImages(self::NUM_BATCH_PHOTOS); $selectedLayout = null; 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 $image->getType() & $type_mask; } private static function orderPhotosByPriority(Image $a, Image $b) { // Leave images of different types as-is if ($a->isLandscape() !== $b->isLandscape()) return 0; // Otherwise, show images of highest priority first $priority_diff = $a->getPriority() - $b->getPriority(); 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(); } }