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..65e32aa093d --- /dev/null +++ b/app/Console/Commands/ImageProcessing/ExtractMotionPhotos.php @@ -0,0 +1,223 @@ +argument('limit'); + $timeout = (int) $this->argument('tm'); + $after = $this->option('after'); + $album_id = $this->option('album'); + $dry_run = $this->option('dry-run') === true; + $reencode = $this->option('reencode') === 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) + // Re-encoding only concerns Google motion photos: an Apple pair has a content id. + ->when( + $reencode, + fn ($q) => $q->whereNotNull('live_photo_short_path')->whereNull('live_photo_content_id'), + fn ($q) => $q->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); + + $outcome = match (true) { + $dry_run => 'found (dry run)', + $reencode => 're-extracted', + default => 'extracted', + }; + $this->line(sprintf('Checked %d photos: %d motion photos %s, %d failed.', $photos->count(), count($found), $outcome, $failed)); + if ($photos->count() === $limit) { + $this->line('More photos may remain: continue with --after=' . $photos->last()->id); + $this->retryHint($failed, $after); + } + + 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->getRealPath()); + 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()); + $this->warn('Failed: ' . $photo->id . ' (' . $photo->title . '): ' . $e->getMessage()); + + return null; + } finally { + if ($local_file instanceof TemporaryLocalFile) { + $local_file->delete(); + } + } + } + + /** + * Continuing with the printed cursor would skip the photos that failed in + * this batch: tell the operator to run the batch again first. + */ + private function retryHint(int $failed, mixed $after): void + { + if ($failed === 0) { + return; + } + + $same_batch = is_string($after) && $after !== '' ? 'with --after=' . $after : 'without --after'; + $this->line(sprintf('%d photos failed in this batch and would be skipped by that cursor: run it again %s to retry them first.', $failed, $same_batch)); + } + + /** + * 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); + + // A re-encoded clip replaces the stored one; a new clip goes next to the original. + $target_path = $photo->live_photo_short_path ?? GoogleMotionPictureHandler::videoPathNextTo($original->getRelativePath()); + // Staged and moved over the target, so a failed write never truncates a clip in use. + $staged_file = new FlysystemFile($original->getDisk(), $target_path . '.part'); + $staged_file->write($video_file->read()); + $staged_file->move($target_path); + $photo->live_photo_short_path = $target_path; + $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..711539e9141 100644 --- a/app/Image/Handlers/GoogleMotionPictureHandler.php +++ b/app/Image/Handlers/GoogleMotionPictureHandler.php @@ -15,6 +15,7 @@ use App\Exceptions\MediaFileOperationException; use App\Image\Files\NativeLocalFile; use App\Image\Files\TemporaryLocalFile; +use App\ModelFunctions\H264MOVFormat; use App\ModelFunctions\MOVFormat; use App\Repositories\ConfigManager; use FFMpeg\Exception\ExecutableNotFoundException; @@ -42,10 +43,22 @@ class GoogleMotionPictureHandler extends VideoHandler { public const PRELIMINARY_VIDEO_FILE_EXTENSION = '.mp4'; public const FINAL_VIDEO_FILE_EXTENSION = '.mov'; + private const CODEC_H264 = 'h264'; /** @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 */ @@ -106,6 +119,10 @@ public function load(NativeLocalFile $file, int $video_length = 0): void /** * Save the video stream to the provided file. * + * An H.264 stream is copied as is; any other codec (HEVC on current + * Pixel phones) is transcoded to H.264 so that every browser can play it + * (Feature 100, Q-100-09). + * * @param NativeLocalFile $file the file to write into * * @throws MediaFileOperationException @@ -113,9 +130,9 @@ public function load(NativeLocalFile $file, int $video_length = 0): void public function saveVideoStream(NativeLocalFile $file): void { try { - $format = new MOVFormat(); + $format = $this->isH264() ? new MOVFormat() : new H264MOVFormat(); // Add additional parameter to extract the first video stream - $format->setAdditionalParameters(['-map', '0:0']); + $format->setAdditionalParameters([...($format->getAdditionalParameters() ?? []), '-map', '0:0']); $this->video->save($format, $file->getRealPath()); // @codeCoverageIgnoreStart } catch (RuntimeException $e) { @@ -125,4 +142,12 @@ public function saveVideoStream(NativeLocalFile $file): void } // @codeCoverageIgnoreEnd } + + /** + * Whether the first video stream is already H.264. + */ + private function isH264(): bool + { + return $this->video->getStreams()->videos()->first()?->get('codec_name') === self::CODEC_H264; + } } \ No newline at end of file diff --git a/app/ModelFunctions/H264MOVFormat.php b/app/ModelFunctions/H264MOVFormat.php new file mode 100644 index 00000000000..f288694d02a --- /dev/null +++ b/app/ModelFunctions/H264MOVFormat.php @@ -0,0 +1,64 @@ +setVideoCodec(self::FFMPEG_VIDEO_CODEC_ID) + ->setAdditionalParameters(self::ENCODER_PARAMETERS); + // @codeCoverageIgnoreStart + } catch (InvalidArgumentException $e) { + throw LycheeAssertionError::createFromUnexpectedException($e); + } + // @codeCoverageIgnoreEnd + } + + /** + * The parent's `copy` stays available: its constructor sets it first. + * + * @return string[] + */ + public function getAvailableVideoCodecs(): array + { + return [...parent::getAvailableVideoCodecs(), self::FFMPEG_VIDEO_CODEC_ID]; + } +} diff --git a/composer.json b/composer.json index f17133d6f48..2ee680c7909 100644 --- a/composer.json +++ b/composer.json @@ -69,7 +69,7 @@ "league/omnipay": "^3", "lychee-org/lycheeverify": "^2.0.7", "lychee-org/nestedset": "^12.0.0", - "lychee-org/php-exif": "^1.4.0", + "lychee-org/php-exif": "^1.5.0", "maennchen/zipstream-php": "^2.1 || ^3.1", "mavinoo/laravel-batch": "^2.4", "moneyphp/money": "^4.7", diff --git a/composer.lock b/composer.lock index 5caad5a2263..aa46f49e682 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8bdb87358acdb351e20620bd2a80d3b9", + "content-hash": "dfa82bfe9c25d5ec4ee7377518956ca0", "packages": [ { "name": "apimatic/core", @@ -4672,16 +4672,16 @@ }, { "name": "lychee-org/php-exif", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/LycheeOrg/php-exif.git", - "reference": "483a982fbf58551a0e9cd7932d97bb839d0bd905" + "reference": "05f9945e6393a697e4a64b3f4bb87fc86b183785" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/LycheeOrg/php-exif/zipball/483a982fbf58551a0e9cd7932d97bb839d0bd905", - "reference": "483a982fbf58551a0e9cd7932d97bb839d0bd905", + "url": "https://api.github.com/repos/LycheeOrg/php-exif/zipball/05f9945e6393a697e4a64b3f4bb87fc86b183785", + "reference": "05f9945e6393a697e4a64b3f4bb87fc86b183785", "shasum": "" }, "require": { @@ -4759,11 +4759,7 @@ "jpeg", "tiff" ], - "support": { - "source": "https://github.com/LycheeOrg/php-exif/tree/1.4.0", - "issues": "https://github.com/LycheeOrg/php-exif/issues" - }, - "time": "2026-10-03T20:15:01+00:00" + "time": "2026-10-19T07:44:26+00:00" }, { "name": "maennchen/zipstream-php", diff --git a/docs/specs/1-concepts/photos.md b/docs/specs/1-concepts/photos.md index 16f634bf39a..7b2d01925b5 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 100) - 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/100-google-motion-photos/open-questions.md b/docs/specs/4-architecture/features/100-google-motion-photos/open-questions.md new file mode 100644 index 00000000000..4332dc76ec2 --- /dev/null +++ b/docs/specs/4-architecture/features/100-google-motion-photos/open-questions.md @@ -0,0 +1,148 @@ +# Open Questions – Feature 100 + +Open questions for [Feature 100](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-100-01~~ | 100 – 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-100-03, NFR-100-01, NFR-100-04, UI-100-01/02) | 2026-10-17 | 2026-10-17 | +| ~~Q-100-02~~ | 100 – 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-100-01, NFR-100-02, NFR-100-03; reader later moved to php-exif by Q-100-08) | 2026-10-17 | 2026-10-17 | +| ~~Q-100-03~~ | 100 – 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-100-03) | 2026-10-17 | 2026-10-17 | +| ~~Q-100-04~~ | 100 – 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-100-04, CLI-100-01) | 2026-10-17 | 2026-10-17 | +| ~~Q-100-05~~ | 100 – 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-100-03, Non-Goals) | 2026-10-17 | 2026-10-17 | +| ~~Q-100-06~~ | 100 – 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-100-01 … 03) | 2026-10-17 | 2026-10-17 | +| ~~Q-100-07~~ | 100 – 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-100-03, FR-100-05, S-100-19, S-100-20) | 2026-10-17 | 2026-10-17 | +| ~~Q-100-08~~ | 100 – Google Motion Photos | High | Where does motion photo detection live: Lychee or `lychee-org/php-exif`? (A php-exif, B keep it in Lychee) | Resolved (Option A — move it to php-exif, as maintainer ildyria asked on PR #4900; owner, 2026-10-18; ADR-100-01, spec FR-100-01) | 2026-10-18 | 2026-10-18 | +| ~~Q-100-09~~ | 100 – Google Motion Photos | Medium | Transcode HEVC motion clips to H.264 so every browser plays them? (A Google clips at extraction, B every live photo video incl. Apple, C keep HEVC + add an H.264 fallback file, D no transcode) | Resolved (Option A — transcode Google clips at extraction, `--reencode` for stored ones; owner, 2026-10-19; spec FR-100-02, FR-100-04, S-100-21 … 23, Non-Goals) | 2026-10-19 | 2026-10-19 | + +## Question Details + +### ~~Q-100-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 `