From a2f920a7ddda91bc493f4ee24f544b66b57f27c4 Mon Sep 17 00:00:00 2001 From: Matt LaPaglia Date: Sun, 18 Oct 2026 08:14:33 -0400 Subject: [PATCH 1/9] google motion photos --- .../Standalone/PlaceGoogleMotionVideo.php | 7 +- .../ImageProcessing/ExtractMotionPhotos.php | 191 +++++++++++ .../Handlers/GoogleMotionPictureHandler.php | 11 + app/Metadata/Extractor.php | 11 + app/Metadata/MotionPhoto/FtypBox.php | 102 ++++++ app/Metadata/MotionPhoto/JpegHeader.php | 25 ++ app/Metadata/MotionPhoto/JpegXmpReader.php | 144 ++++++++ .../MotionPhoto/MotionPhotoLocator.php | 82 +++++ app/Metadata/MotionPhoto/MotionPhotoXmp.php | 62 ++++ .../MotionPhoto/MotionPhotoXmpHandler.php | 106 ++++++ docs/specs/1-concepts/photos.md | 5 +- .../open-questions.md | 116 +++++++ .../features/098-google-motion-photos/plan.md | 151 ++++++++ .../features/098-google-motion-photos/spec.md | 191 +++++++++++ .../098-google-motion-photos/tasks.md | 113 ++++++ docs/specs/4-architecture/knowledge-map.md | 4 + .../request-lifecycle-photo-upload.md | 2 + docs/specs/4-architecture/roadmap.md | 1 + ...098-01-in-house-motion-photo-xmp-reader.md | 53 +++ docs/specs/6-decisions/README.md | 1 + lang/ar/gallery.php | 1 + lang/bg/gallery.php | 1 + lang/cz/gallery.php | 1 + lang/de/gallery.php | 1 + lang/el/gallery.php | 1 + lang/en/gallery.php | 1 + lang/es/gallery.php | 1 + lang/fa/gallery.php | 1 + lang/fr/gallery.php | 1 + lang/hu/gallery.php | 1 + lang/it/gallery.php | 1 + lang/ja/gallery.php | 1 + lang/nl/gallery.php | 1 + lang/no/gallery.php | 1 + lang/pl/gallery.php | 1 + lang/pt/gallery.php | 1 + lang/ru/gallery.php | 1 + lang/sk/gallery.php | 1 + lang/sv/gallery.php | 1 + lang/tr/gallery.php | 1 + lang/vi/gallery.php | 1 + lang/zh_CN/gallery.php | 1 + lang/zh_TW/gallery.php | 1 + .../gallery/albumModule/AlbumPanel.vue | 5 +- .../Virtualized/PhotoThumbVirtual.vue | 44 ++- .../gallery/albumModule/thumbs/PhotoThumb.vue | 44 ++- .../gallery/albumModule/thumbs/ThumbBadge.vue | 10 +- .../v8/composables/photo/motionUrlLoader.ts | 20 ++ .../composables/photo/useMotionHover.test.ts | 321 ++++++++++++++++++ .../js/v8/composables/photo/useMotionHover.ts | 107 ++++++ .../js/v8/views/gallery-panels/Search.vue | 5 +- .../js/v8/views/gallery-panels/Timeline.vue | 5 +- tests/Constants/TestConstants.php | 6 + .../Commands/ExtractMotionPhotosTest.php | 197 +++++++++++ .../Image/Handlers/BaseImageHandler.php | 99 ++++++ tests/Samples/build_motion_photo_fixtures.php | 159 +++++++++ tests/Samples/google_motion_photo_pixel.jpg | Bin 0 -> 4853046 bytes .../Samples/google_motion_photo_ultrahdr.jpg | Bin 0 -> 210424 bytes tests/Samples/google_motion_photo_v1.jpg | Bin 0 -> 208438 bytes .../MotionPhoto/JpegXmpReaderTest.php | 151 ++++++++ .../MotionPhoto/MotionPhotoLocatorTest.php | 228 +++++++++++++ .../MotionPhoto/MotionPhotoXmpTest.php | 250 ++++++++++++++ 62 files changed, 3037 insertions(+), 15 deletions(-) create mode 100644 app/Console/Commands/ImageProcessing/ExtractMotionPhotos.php create mode 100644 app/Metadata/MotionPhoto/FtypBox.php create mode 100644 app/Metadata/MotionPhoto/JpegHeader.php create mode 100644 app/Metadata/MotionPhoto/JpegXmpReader.php create mode 100644 app/Metadata/MotionPhoto/MotionPhotoLocator.php create mode 100644 app/Metadata/MotionPhoto/MotionPhotoXmp.php create mode 100644 app/Metadata/MotionPhoto/MotionPhotoXmpHandler.php create mode 100644 docs/specs/4-architecture/features/098-google-motion-photos/open-questions.md create mode 100644 docs/specs/4-architecture/features/098-google-motion-photos/plan.md create mode 100644 docs/specs/4-architecture/features/098-google-motion-photos/spec.md create mode 100644 docs/specs/4-architecture/features/098-google-motion-photos/tasks.md create mode 100644 docs/specs/6-decisions/ADR-098-01-in-house-motion-photo-xmp-reader.md create mode 100644 resources/js/v8/composables/photo/motionUrlLoader.ts create mode 100644 resources/js/v8/composables/photo/useMotionHover.test.ts create mode 100644 resources/js/v8/composables/photo/useMotionHover.ts create mode 100644 tests/ImageProcessing/Commands/ExtractMotionPhotosTest.php create mode 100644 tests/Samples/build_motion_photo_fixtures.php create mode 100644 tests/Samples/google_motion_photo_pixel.jpg create mode 100644 tests/Samples/google_motion_photo_ultrahdr.jpg create mode 100644 tests/Samples/google_motion_photo_v1.jpg create mode 100644 tests/Unit/Metadata/MotionPhoto/JpegXmpReaderTest.php create mode 100644 tests/Unit/Metadata/MotionPhoto/MotionPhotoLocatorTest.php create mode 100644 tests/Unit/Metadata/MotionPhoto/MotionPhotoXmpTest.php diff --git a/app/Actions/Photo/Pipes/Standalone/PlaceGoogleMotionVideo.php b/app/Actions/Photo/Pipes/Standalone/PlaceGoogleMotionVideo.php index efd802cf890..619abe1a25d 100644 --- a/app/Actions/Photo/Pipes/Standalone/PlaceGoogleMotionVideo.php +++ b/app/Actions/Photo/Pipes/Standalone/PlaceGoogleMotionVideo.php @@ -10,6 +10,7 @@ use App\DTO\PhotoCreate\StandaloneDTO; use App\Image\Files\FlysystemFile; +use App\Image\Handlers\GoogleMotionPictureHandler; class PlaceGoogleMotionVideo extends AbstractStandalonePipe { @@ -19,11 +20,7 @@ protected function execute(StandaloneDTO $state, \Closure $next): StandaloneDTO // we must move the preliminary extracted video file next to the // final target file if ($state->tmp_video_file !== null) { - $video_target_path = - pathinfo($state->target_file->getRelativePath(), PATHINFO_DIRNAME) . - '/' . - pathinfo($state->target_file->getRelativePath(), PATHINFO_FILENAME) . - $state->tmp_video_file->getExtension(); + $video_target_path = GoogleMotionPictureHandler::videoPathNextTo($state->target_file->getRelativePath()); $video_target_file = new FlysystemFile($state->target_file->getDisk(), $video_target_path); $video_target_file->write($state->tmp_video_file->read()); $state->photo->live_photo_short_path = $video_target_file->getRelativePath(); diff --git a/app/Console/Commands/ImageProcessing/ExtractMotionPhotos.php b/app/Console/Commands/ImageProcessing/ExtractMotionPhotos.php new file mode 100644 index 00000000000..0ce7eb546e1 --- /dev/null +++ b/app/Console/Commands/ImageProcessing/ExtractMotionPhotos.php @@ -0,0 +1,191 @@ +argument('limit'); + $timeout = (int) $this->argument('tm'); + $after = $this->option('after'); + $album_id = $this->option('album'); + $dry_run = $this->option('dry-run') === true; + + try { + set_time_limit($timeout); + } catch (InfoException) { + // Silently do nothing, if `set_time_limit` is denied. + } + + if (!$dry_run && !$config_manager->hasFFmpeg()) { + $this->error('FFmpeg is required to extract motion photo videos (or use --dry-run).'); + + return 1; + } + if (is_string($album_id) && Album::query()->find($album_id) === null) { + $this->error('Album ' . $album_id . ' not found.'); + + return 1; + } + + $photos = Photo::query() + ->with(['size_variants' => fn ($r) => $r->where('type', '=', SizeVariantType::ORIGINAL)]) + ->where('type', '=', self::MIME_TYPE_JPEG) + ->whereNull('live_photo_short_path') + ->when(is_string($album_id), fn ($q) => $q->whereIn('id', DB::table(PA::PHOTO_ALBUM)->select(PA::PHOTO_ID)->where(PA::ALBUM_ID, '=', $album_id))) + // Cursor, not offset: extracted photos leave this set, the others stay in it. + ->when(is_string($after) && $after !== '', fn ($q) => $q->where('id', '>', $after)) + ->orderBy('id') + ->limit($limit) + ->get(); + + if ($photos->isEmpty()) { + $this->line('No photos require motion photo extraction.'); + + return 0; + } + + $found = []; + $failed = 0; + foreach ($photos as $photo) { + $is_motion_photo = $this->process($photo, $dry_run); + $failed += $is_motion_photo === null ? 1 : 0; + if ($is_motion_photo === true) { + $found[] = $photo->id; + $this->line('Motion photo: ' . $photo->id . ' (' . $photo->title . ')'); + } + } + + PhotoSaved::dispatchIf(!$dry_run && $found !== [], $found); + + $this->line(sprintf('Checked %d photos: %d motion photos %s, %d failed.', $photos->count(), count($found), $dry_run ? 'found (dry run)' : 'extracted', $failed)); + if ($photos->count() === $limit) { + $this->line('More photos may remain: continue with --after=' . $photos->last()->id); + } + + return $failed === 0 ? 0 : 1; + } + + /** + * @return bool|null true for a motion photo (its video extracted unless `$dry_run`), false for a still, null if the photo could not be processed + */ + private function process(Photo $photo, bool $dry_run): ?bool + { + $local_file = null; + try { + $original = $photo->size_variants->getOriginal()->getFile(); + if (!$original->exists()) { + throw new MediaFileOperationException('original file is missing'); + } + + $local_file = $this->toLocalFile($original, $photo); + $video_length = MotionPhotoLocator::locate($local_file); + if ($video_length > 0 && !$dry_run) { + $this->extract($photo, $original, $local_file, $video_length); + } + + return $video_length > 0; + } catch (\Throwable $e) { + Log::warning(__METHOD__ . ':' . __LINE__ . ' Motion photo extraction failed for photo ' . $photo->id . ': ' . $e->getMessage()); + + return null; + } finally { + if ($local_file instanceof TemporaryLocalFile) { + $local_file->delete(); + } + } + } + + /** + * Returns the original itself when it is hosted locally, a downloaded + * temporary copy otherwise (as {@see \App\Image\SizeVariantDefaultFactory}). + */ + private function toLocalFile(FlysystemFile $original, Photo $photo): NativeLocalFile + { + if ($original->isLocalFile()) { + return $original->toLocalFile(); + } + + // @codeCoverageIgnoreStart + $local_file = new TemporaryLocalFile($original->getOriginalExtension(), $photo->title); + $local_file->write($original->read(), false, $photo->type); + + return $local_file; + // @codeCoverageIgnoreEnd + } + + private function extract(Photo $photo, FlysystemFile $original, NativeLocalFile $local_file, int $video_length): void + { + $video_file = new TemporaryLocalFile(GoogleMotionPictureHandler::FINAL_VIDEO_FILE_EXTENSION, $local_file->getBasename()); + try { + $handler = new GoogleMotionPictureHandler(); + $handler->load($local_file, $video_length); + $handler->saveVideoStream($video_file); + + $target_file = new FlysystemFile($original->getDisk(), GoogleMotionPictureHandler::videoPathNextTo($original->getRelativePath())); + $target_file->write($video_file->read()); + $photo->live_photo_short_path = $target_file->getRelativePath(); + $photo->save(); + } finally { + $video_file->close(); + $video_file->delete(); + } + } +} diff --git a/app/Image/Handlers/GoogleMotionPictureHandler.php b/app/Image/Handlers/GoogleMotionPictureHandler.php index 839d3815097..69bd7f529f9 100644 --- a/app/Image/Handlers/GoogleMotionPictureHandler.php +++ b/app/Image/Handlers/GoogleMotionPictureHandler.php @@ -46,6 +46,17 @@ class GoogleMotionPictureHandler extends VideoHandler /** @var TemporaryLocalFile a working copy of the video stream */ protected TemporaryLocalFile $working_copy; + /** + * Path at which the video of a motion photo is stored: next to the + * original, under the same file name, with the final video extension. + * + * @param string $original_path path of the original size variant, relative to its disk + */ + public static function videoPathNextTo(string $original_path): string + { + return pathinfo($original_path, PATHINFO_DIRNAME) . '/' . pathinfo($original_path, PATHINFO_FILENAME) . self::FINAL_VIDEO_FILE_EXTENSION; + } + /** * @throws MediaFileOperationException */ diff --git a/app/Metadata/Extractor.php b/app/Metadata/Extractor.php index 5850ca63011..c4856b525f8 100644 --- a/app/Metadata/Extractor.php +++ b/app/Metadata/Extractor.php @@ -12,6 +12,7 @@ use App\Exceptions\Handler; use App\Exceptions\MediaFileOperationException; use App\Image\Files\NativeLocalFile; +use App\Metadata\MotionPhoto\MotionPhotoLocator; use App\Repositories\ConfigManager; use App\Services\Image\FileExtensionService; use Carbon\Exceptions\InvalidFormatException; @@ -32,6 +33,7 @@ class Extractor public const SUFFIX_SEC_UNIT = ' s'; public const ABSOLUTE_ALTITUDE_BOUNDS = 999999.9999; public const MAX_LOCATION_STRING_LENGTH = 255; + private const MIME_TYPE_JPEG = 'image/jpeg'; public ?string $type = null; public int $width = 0; @@ -60,6 +62,12 @@ class Extractor /** @var string|null TODO: What is the difference to {@link Extractor::$position}? */ public ?string $location = null; public ?string $live_photo_content_id = null; + /** + * Length in bytes, counted from the end of the file, of the video that a + * Google Motion Photo appends to its JPEG; `0` if there is none. + * Read by php-exif from the legacy `MicroVideoOffset` (exiftool only), + * otherwise by {@see MotionPhotoLocator} (Feature 098). + */ public int $micro_video_offset = 0; public bool $is_360 = false; public ?int $pano_full_width = null; @@ -172,6 +180,9 @@ public static function createFromFile(NativeLocalFile $file, int $file_last_modi $metadata->img_direction = ($exif->getImgDirection() !== false) ? $exif->getImgDirection() : null; $metadata->live_photo_content_id = ($exif->getContentIdentifier() !== false) ? $exif->getContentIdentifier() : null; $metadata->micro_video_offset = ($exif->getMicroVideoOffset() !== false) ? (int) $exif->getMicroVideoOffset() : 0; + if ($metadata->micro_video_offset === 0 && $file->getMimeType() === self::MIME_TYPE_JPEG) { + $metadata->micro_video_offset = MotionPhotoLocator::locate($file); + } $panorama = PanoramaDetector::detect($exif, $metadata->width); $metadata->is_360 = $panorama->is_360; diff --git a/app/Metadata/MotionPhoto/FtypBox.php b/app/Metadata/MotionPhoto/FtypBox.php new file mode 100644 index 00000000000..60fb06f5602 --- /dev/null +++ b/app/Metadata/MotionPhoto/FtypBox.php @@ -0,0 +1,102 @@ += self::MIN_BOX_SIZE && + $box_size <= self::MAX_BOX_SIZE; + } +} diff --git a/app/Metadata/MotionPhoto/JpegHeader.php b/app/Metadata/MotionPhoto/JpegHeader.php new file mode 100644 index 00000000000..2bda3b26f0b --- /dev/null +++ b/app/Metadata/MotionPhoto/JpegHeader.php @@ -0,0 +1,25 @@ += 0 && ftell($stream) + $payload_length <= $file_size; + + return $is_valid ? $payload_length : null; + } + + /** + * @return string|null the XMP packet if `$payload` is a standard XMP segment payload + */ + private static function xmpOf(string $payload): ?string + { + return str_starts_with($payload, self::XMP_NAMESPACE) ? substr($payload, strlen(self::XMP_NAMESPACE)) : null; + } + + /** + * @param resource $stream + */ + private static function readByte($stream): ?int + { + $byte = self::readBytes($stream, 1); + + return $byte === '' ? null : ord($byte); + } + + /** + * @param resource $stream + * + * @return string fewer than `$length` bytes at the end of the stream + */ + private static function readBytes($stream, int $length): string + { + return $length === 0 ? '' : fread($stream, $length); + } + + /** + * @param resource $stream + * + * @return string always empty, so callers can treat a skipped payload like a read one + */ + private static function skip($stream, int $length): string + { + fseek($stream, $length, SEEK_CUR); + + return ''; + } +} diff --git a/app/Metadata/MotionPhoto/MotionPhotoLocator.php b/app/Metadata/MotionPhoto/MotionPhotoLocator.php new file mode 100644 index 00000000000..39ab2db61cf --- /dev/null +++ b/app/Metadata/MotionPhoto/MotionPhotoLocator.php @@ -0,0 +1,82 @@ +read(), $file->getFilesize()); + } catch (\Throwable $e) { + Log::warning(__METHOD__ . ':' . __LINE__ . ' Motion photo detection failed for ' . $file->getBasename() . ': ' . $e->getMessage()); + + return 0; + } finally { + $file->close(); + } + } + + /** + * @param resource $stream + */ + private static function locateInStream($stream, int $file_size): int + { + $header = JpegXmpReader::read($stream, $file_size); + if ($header === null) { + return 0; + } + + $xmp = MotionPhotoXmp::fromXml($header->xmp ?? ''); + $declared = self::firstValidLength($stream, $file_size, $header, [$xmp->item_length, $xmp->legacy_offset]); + if ($declared !== 0 || !$xmp->is_motion_photo) { + return $declared; + } + + $video_start = FtypBox::scan($stream, $header->image_data_offset, $file_size); + + return $video_start === null ? 0 : $file_size - $video_start; + } + + /** + * @param resource $stream + * @param int[] $lengths candidate video lengths, `0` meaning "not declared" + */ + private static function firstValidLength($stream, int $file_size, JpegHeader $header, array $lengths): int + { + foreach ($lengths as $length) { + $is_after_header = $length > 0 && $length <= $file_size - $header->image_data_offset; + if ($is_after_header && FtypBox::isAt($stream, $file_size - $length)) { + return $length; + } + } + + return 0; + } +} diff --git a/app/Metadata/MotionPhoto/MotionPhotoXmp.php b/app/Metadata/MotionPhoto/MotionPhotoXmp.php new file mode 100644 index 00000000000..5df487438e5 --- /dev/null +++ b/app/Metadata/MotionPhoto/MotionPhotoXmp.php @@ -0,0 +1,62 @@ +start(...), $handler->end(...)); + xml_set_character_data_handler($parser, $handler->text(...)); + + $is_well_formed = xml_parse($parser, $xml, true) === 1; + + return $is_well_formed ? $handler->result() : self::none(); + } +} diff --git a/app/Metadata/MotionPhoto/MotionPhotoXmpHandler.php b/app/Metadata/MotionPhoto/MotionPhotoXmpHandler.php new file mode 100644 index 00000000000..464637dd85b --- /dev/null +++ b/app/Metadata/MotionPhoto/MotionPhotoXmpHandler.php @@ -0,0 +1,106 @@ + + * 1 + * + * Names arrive as `|`, so prefixes do not matter. + * + * @internal + */ +final class MotionPhotoXmpHandler +{ + public const SEPARATOR = '|'; + + private const MOTION_PHOTO = MotionPhotoXmp::NS_CAMERA . self::SEPARATOR . 'MotionPhoto'; + private const MICRO_VIDEO_OFFSET = MotionPhotoXmp::NS_CAMERA . self::SEPARATOR . 'MicroVideoOffset'; + private const CONTAINER_ITEM = MotionPhotoXmp::NS_CONTAINER . self::SEPARATOR . 'Item'; + private const ITEM_SEMANTIC = MotionPhotoXmp::NS_ITEM . self::SEPARATOR . 'Semantic'; + private const ITEM_LENGTH = MotionPhotoXmp::NS_ITEM . self::SEPARATOR . 'Length'; + private const SEMANTIC_MOTION_PHOTO = 'MotionPhoto'; + + /** @var array raw values by qualified name: the two camera properties */ + private array $camera = []; + + /** @var array|null raw `Item:*` values of the container item being read */ + private ?array $item = null; + + private ?string $video_item_length = null; + + /** qualified name of the element whose text is being collected */ + private ?string $text_of = null; + private string $text = ''; + + /** + * @param array $attributes + */ + public function start(\XMLParser $parser, string $name, array $attributes): void + { + $this->camera += array_intersect_key($attributes, [self::MOTION_PHOTO => true, self::MICRO_VIDEO_OFFSET => true]); + $this->item = $name === self::CONTAINER_ITEM ? array_intersect_key($attributes, [self::ITEM_SEMANTIC => true, self::ITEM_LENGTH => true]) : $this->item; + $this->text_of = $name; + $this->text = ''; + } + + public function text(\XMLParser $parser, string $data): void + { + $this->text .= $data; + } + + public function end(\XMLParser $parser, string $name): void + { + $value = trim($this->text); + $is_text_element = $this->text_of === $name; + $this->text_of = null; + $this->text = ''; + + if ($is_text_element && in_array($name, [self::MOTION_PHOTO, self::MICRO_VIDEO_OFFSET], true)) { + $this->camera[$name] ??= $value; + } + if ($is_text_element && $this->item !== null && in_array($name, [self::ITEM_SEMANTIC, self::ITEM_LENGTH], true)) { + $this->item[$name] ??= $value; + } + if ($name === self::CONTAINER_ITEM) { + $this->closeItem(); + } + } + + public function result(): MotionPhotoXmp + { + return new MotionPhotoXmp( + is_motion_photo: trim($this->camera[self::MOTION_PHOTO] ?? '') === '1', + item_length: self::toLength($this->video_item_length), + legacy_offset: self::toLength($this->camera[self::MICRO_VIDEO_OFFSET] ?? null), + ); + } + + private function closeItem(): void + { + $is_video = ($this->item[self::ITEM_SEMANTIC] ?? null) === self::SEMANTIC_MOTION_PHOTO; + $this->video_item_length ??= $is_video ? ($this->item[self::ITEM_LENGTH] ?? null) : null; + $this->item = null; + } + + /** + * @return int the value if it is a plain non-negative decimal integer, `0` otherwise + */ + private static function toLength(?string $raw): int + { + $value = trim($raw ?? ''); + + return ctype_digit($value) ? (int) $value : 0; + } +} diff --git a/docs/specs/1-concepts/photos.md b/docs/specs/1-concepts/photos.md index 16f634bf39a..42e1beaa754 100644 --- a/docs/specs/1-concepts/photos.md +++ b/docs/specs/1-concepts/photos.md @@ -45,9 +45,10 @@ A **Photo** is the fundamental content unit in Lychee. Each photo represents a s - Supported: All Rights Reserved, CC BY, CC BY-SA, CC BY-ND, CC BY-NC, CC BY-NC-SA, CC BY-NC-ND, CC0, Public Domain **Special Features:** -- **Live Photos**: Support for Apple Live Photos (photo + video pair) +- **Live Photos**: Support for Apple Live Photos (photo + video pair) and Google Motion Photos (video embedded in the JPEG, legacy `MicroVideoOffset` and Motion Photo 1.0 / Ultra HDR, Feature 098) - Stores video component path, checksum, and content ID - - Seamlessly integrates motion with still image + - Seamlessly integrates motion with still image; v8 grids badge live photos and play the motion on hover + - `php artisan lychee:extract_motion_photos` extracts the video of motion photos imported as plain stills - **Color Palette**: Extracted dominant colors for visual search and theming - **Tags**: Many-to-many relationship with tag entities (not comma-separated strings) - **Purchasable**: E-commerce integration for selling prints/downloads (webshop feature) diff --git a/docs/specs/4-architecture/features/098-google-motion-photos/open-questions.md b/docs/specs/4-architecture/features/098-google-motion-photos/open-questions.md new file mode 100644 index 00000000000..d2ee730820e --- /dev/null +++ b/docs/specs/4-architecture/features/098-google-motion-photos/open-questions.md @@ -0,0 +1,116 @@ +# Open Questions – Feature 098 + +Open questions for [Feature 098](spec.md). Log every high- and medium-impact question here (table row + Question Details entry) before asking the user; see [open-questions-format.md](../../spec-guidelines/open-questions-format.md). Once answered, fold the outcome into [spec.md](spec.md) (and an ADR when architecturally significant), then mark the entry resolved. + +## Active Questions + +| Question ID | Feature | Priority | Summary | Status | Opened | Updated | +|-------------|---------|----------|---------|--------|--------|---------| +| ~~Q-098-01~~ | 098 – Google Motion Photos | High | How does the album grid let the viewer play the motion? (A hover-to-play + badge, B badge as play button, C hover only) | Resolved (Option A — hover-to-play on hover-capable pointers + motion badge everywhere; owner, 2026-10-17; spec FR-098-03, NFR-098-01, NFR-098-04, UI-098-01/02) | 2026-10-17 | 2026-10-17 | +| ~~Q-098-02~~ | 098 – Google Motion Photos | High | How is the embedded video located in Motion Photo 1.0 files? (A pure-PHP XMP `Container:Directory` read + `ftyp` scan when the item is absent, B extend php-exif to map the tags, C `ftyp` scan only) | Resolved (Option A — pure-PHP `MotionPhotoLocator` called from `Extractor`; owner, 2026-10-17; spec FR-098-01, NFR-098-02, NFR-098-03; ADR-098-01) | 2026-10-17 | 2026-10-17 | +| ~~Q-098-03~~ | 098 – Google Motion Photos | Medium | Does grid playback apply to Apple Live Photos too? (A all live photos, B Google only) | Resolved (Option A — every live photo; owner, 2026-10-17; spec FR-098-03) | 2026-10-17 | 2026-10-17 | +| ~~Q-098-04~~ | 098 – Google Motion Photos | Medium | How are already-imported new-format motion photos backfilled? (A artisan command, B re-upload only) | Resolved (Option A — `lychee:extract_motion_photos`; owner, 2026-10-17; spec FR-098-04, CLI-098-01) | 2026-10-17 | 2026-10-17 | +| ~~Q-098-05~~ | 098 – Google Motion Photos | Medium | Which frontends get grid playback? (A v8 only, B v7 and v8) | Resolved (Option A — v8 only; owner, 2026-10-17; spec FR-098-03, Non-Goals) | 2026-10-17 | 2026-10-17 | +| ~~Q-098-06~~ | 098 – Google Motion Photos | Medium | Which test fixture covers the new format? (A synthetic fixture + one real Pixel sample from the owner, B synthetic only, C issue #1283 images) | Resolved (Option A — synthetic fixtures + one owner-supplied Pixel `.MP.jpg`; owner, 2026-10-17; spec FX-098-01 … 03) | 2026-10-17 | 2026-10-17 | +| ~~Q-098-07~~ | 098 – Google Motion Photos | Medium | Where does a v3 grid tile get its video URL for hover playback? (A fetch tier-3 details on hover intent, B add `live_photo_urls[]` to the tier-2 resources, C hover playback only where the URL is already loaded) | Resolved (Option A — the owning view provides its details loader, called on `mouseenter`; owner, 2026-10-17; spec FR-098-03, FR-098-05, S-098-19, S-098-20) | 2026-10-17 | 2026-10-17 | + +## Question Details + +### ~~Q-098-01~~ – Grid interaction for motion photos + +**Context:** Today the lightbox already plays live photos through LivePhotosKit (`v8/.../PhotoBox.vue:109-133`), with its own LIVE badge. Grid thumbnails (`PhotoThumb.vue`, `PhotoThumbVirtual.vue`) carry only an unstyled `livephoto` class: no badge, no hover playback. The owner asked for "show the video when hovered over, or a play button". Touch devices have no hover, and a tap on a thumbnail opens the lightbox. + +- **Option A (chosen):** hover-to-play on pointer devices plus an always-visible motion badge. + - Pointer with hover (`matchMedia('(hover: hover)')`): after ~250 ms of hover a muted, looping `