Skip to content

PicView Motion Photo (Live Photo) Support - #374

Open
reflyable wants to merge 3 commits into
Ruben2776:devfrom
reflyable:dev
Open

PicView Motion Photo (Live Photo) Support#374
reflyable wants to merge 3 commits into
Ruben2776:devfrom
reflyable:dev

Conversation

@reflyable

@reflyable reflyable commented Aug 14, 2026

Copy link
Copy Markdown

Description

This PR adds motion photo (Live Photo) viewing to PicView. Still images that carry a short video clip are displayed as usual, with a "Motion Photo" badge overlay that plays the clip once and then freezes back onto the cover (no looping, mirroring Google Photos / JarkViewer behavior). Any failure at any stage degrades gracefully to the plain still image.

The specific code is written with AI assistance. I understand the overall idea and general implementation, and I am happy to make any necessary changes or refactoring.

Supported sources:

Source Carrier Detection basis
XMP-embedded (new standard) Google Pixel etc., jpg/heic Item:Length of the Item:Semantic="MotionPhoto" entry in XMP Container:Directory (lookup bounded to the correct Directory item)
XMP-embedded (legacy standard) Samsung/DJI/OPPO/Xiaomi etc., jpg XMP MicroVideoOffset
Legacy Samsung trailer marker jpg only MotionPhoto_Data marker near the file tail
Sidecar file Apple/vivo/Google Photos exports same-named .mov/.mp4, validated with an ftyp header check
.livp container Apple Live Photo export zip package (jpg/heic + mov), decompressed in memory

Implementation in brief:

  • PicView.Core (AOT-safe, zero video dependencies): byte-level MotionPhotoDetector (XMP string search — no XML parsing, vendor-namespace tolerant) and MotionPhotoExtractor (ftyp box correction with a ±8KB window for vendor trailing blocks à la DJI). Embedded videos are served through the new read-only, seekable FileSliceStream window over the source file — no in-memory copy, playback starts immediately; .livp entries are decompressed in memory via the existing SharpCompress dependency. No temp files anywhere on the happy path.
  • PicView.Avalonia: LibVLCSharp plays the extracted stream via software video callbacks, so a single implementation works on Windows, X11, Wayland and macOS (no native window embedding) and the video follows zoom/rotation. A 3+1 unmanaged buffer pool hands frames to the UI thread, which copies them straight into a WriteableBitmap (one memcpy per frame, overflow buffer drops frames instead of tearing). Hardware decoding is preferred via --avcodec-hw=any with automatic software fallback. Windows native libs ship via VideoLAN.LibVLC.Windows; Linux/macOS use the system libvlc and degrade gracefully when it is missing.
  • Integration: new ImageType.MotionPhoto + ImageModel.MotionPhoto (following the TiffNavigation precedent), detection hooked into GetImageModel after the JPEG/HEIC branches, one playback overlay per image (main + side-by-side secondary, each aligned to its own image rect inside the zoom/rotation transform), Space = play/pause, Esc = stop, two new settings (UIProperties.AutoPlayMotionPhotos default off, MuteMotionPhotos default on), FileSaverHelper keeps the video when the file is copied as-is. The badge is localized (new MotionPhoto key in all 22 language files), the window/tab title gets a "(Motion Photo)" marker, and gallery thumbnails show a play-triangle badge.

Bug fixes included (found during development):

  • UI-thread marshalling for UpdateImage.ChangeImage notifications (R3 callbacks are not guaranteed to be on the UI thread; the badge was unclickable).
  • AltButtonsPanel transparent overlay was swallowing all clicks in the top 150px of the image area (pre-existing interaction dead zone; fixed via IsHitTestVisible binding).
  • Zoom flash when starting playback: the video surface used to become visible before the first decoded frame and rendered a stale frame from the previous session; it is now revealed only once the first frame is ready, and Stop() clears the frame bitmap.
  • ZoomPanControl's pan handler no longer swallows badge clicks while zoomed (skips Button event sources).

The full design document (detection/extraction design, state machine, degradation matrix, AOT risk assessment, file inventory) is embedded at the end of this description.

Known limitations / not yet done

  • macOS playback unverified: implemented via the platform-independent software callbacks, but not tested on a real device (the MacOS project has pre-existing build errors on Windows, unrelated to this PR).
  • Gallery badge for HEIC-embedded motion photos: the gallery thumbnail pipeline does not read XMP via Magick, so HEIC-embedded motion photos are not badged in the gallery (sidecar/livp are badged; the main viewer detects everything).
  • Linux deliverable is a JIT self-contained publish (AOT cannot cross-compile from Windows).

Motivation and Context

Phones from Google, Samsung, Xiaomi, OPPO, DJI and Apple all capture short video clips alongside still photos by default (Motion Photo / Live Photo). Today PicView shows these as plain stills and the live moment is invisible — worse, a folder of iPhone exports (IMG_1234.HEIC + IMG_1234.MOV) looks like duplicated content. This PR surfaces the embedded video in the least intrusive way: the still image pipeline is untouched, the video is extracted on demand only, and every failure path falls back to the still image.

#337

How Has This Been Tested?

  • Unit tests: 36 tests in PicView.Tests\MotionPhoto\ (xUnit.v3), all green — detector (new/legacy XMP forms, attribute reordering, sibling-item Item:Length rejection, Samsung marker incl. HEIC skip, sidecar precedence + ftyp validation, .livp), extractor (ftyp window correction, DJI-style trailer shift, corrupt input, livp cover) and FileSliceStream (slice read/seek/length clamping/async read). Fixtures are synthesized in code; no real photos committed.
  • End-to-end smoke: ffmpeg-generated mp4 → synthetic motion photo → extraction → StreamMediaInput playback → EndReached, passing on Windows 11 x64 and Linux (WSL, JIT self-contained linux-x64 build); software video-callback smoke verified per-frame BGRA delivery on both.
  • Native AOT: a PublishAot-published spike console (same LibVLCSharp call pattern: StreamMediaInput + video callbacks + --avcodec-hw=any) plays to EndReached (61 frames); the full Win32 app dotnet publish (Native AOT) succeeds with zero LibVLCSharp trim/AOT warnings, and the artifact launches on a synthetic motion photo without crashing.
  • Plugin trim: playback re-verified against the fully trimmed 22-DLL libvlc (sibling archs/*.lib/lua/hrtfs also removed), via JIT software decode, the 1080p d3d11va/dxva2 hardware-decode path, and the AOT-published spike.
  • Real files: manual verification with genuine DJI and Google/Samsung motion photo files (playback, freeze-back, degradation).
  • Regression: PicView.Core and PicView.Avalonia build clean; the full test suite matches the pre-existing baseline (11 failures in unrelated Navigation/FileWatcher/Tiff/Archive test infrastructure — null ImageIterator ctor argument, xUnit discovery-time version conflict — reproducible without these changes). All 22 language JSON files parse cleanly.
  • Environment: Windows 11 x64, .NET net11.0 (LangVersion preview), LibVLCSharp 3.10.1, VideoLAN.LibVLC.Windows 3.0.23.1. macOS runtime untested (see limitations).

Screenshots (if appropriate):

2026-08-15.00-01-06.mp4

Types of changes

  • Bug fix (non-breaking change which fixes an issue) — incidental pre-existing fixes only (AltButtonsPanel hit-test dead zone); all other fixes are within the new feature
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.

Design document

Click to expand: Motion Photo Support — Design Document

PicView Motion Photo (Live Photo) Support — Design Document

Based on the implementation approach of JarkViewer's MotionPhotoImplementation.md (a reference C++/FFmpeg image viewer),
combined with PicView's existing architecture (verified via DeepWiki and local source), using LibVLCSharp as the video playback engine.

This document is the design proposal + implementation record.


0. Implementation Status (2026-08-13)

Completed (Phase 1 + Phase 3 Windows part):

Item Status
Core detection/extraction (new + legacy XMP standards, Samsung marker, sidecars, livp, ftyp ±8KB correction) ✅ Done, 28 unit tests green
ImageType.MotionPhoto / ImageModel.MotionPhoto / .livp supported extension ✅ Done
GetImageModel detection hook (after the JPEG/HEIC branches + dedicated livp branch, before Magick Ping) ✅ Done
LibVLCSharp playback layer (Service/VideoSurface/View) + UI/keyboard wiring ✅ Done (software frame-callback rendering, unified across all desktop platforms)
LibVLC native library deployment (libvlc\win-x64 + win-arm64, 962 dlls) + Core.Initialize path ✅ Done (Windows); Linux/macOS use the system libvlc
End-to-end smoke: ffmpeg-generated mp4 → extraction → StreamMediaInput playback → EndReached ✅ Passed (verified on Windows and Linux/WSL)
Software video-callback smoke: SetVideoFormatCallbacks/SetVideoCallbacks per-frame callbacks + BGRA frame data ✅ Passed (29 frames each on Windows and Linux/WSL)
Runnable Linux build: JIT self-contained linux-x64 publish + WSL launch verification ✅ Done (artifacts at PicView.Avalonia.Linux\bin\x64\Release\net11.0\linux-x64\publish)
Empirically verified that XMP is readable after Magick.NET Ping (primary detection path) ✅ Passed
AOT publish verification (Phase 2 spike) ⏳ Not done (see risks below)
Linux Wayland sessions ✅ Supported (software frame-callback rendering does not depend on X11/Wayland; no native window embedding)
macOS playback ⚠️ Implemented via the platform-independent software callbacks, not verified on a real device; the MacOS project has pre-existing build errors on Windows

Implementation deviations:

  1. Did not use the LibVLCSharp.Avalonia package. That package is compiled against Avalonia 11 (PicView uses Avalonia 12; the NativeControlHost API survived but is unvalidated by that package). Instead the video surface is implemented in ~100 lines, depending only on the LibVLCSharp core package.
  2. Rendering uses software frame callbacks (Approach B): MediaPlayer.SetVideoFormatCallbacks + SetVideoCallbacks make libvlc hand back BGRA32 frames, which are written into a WriteableBitmap and rendered by MotionPhotoVideoSurface inside the Avalonia compositor. Native window embedding (X11 child windows / NSView / HWND) was dropped: libvlc 3.x has no public wl_surface embedding API, so native embedding is impossible on Wayland; software callbacks natively support Wayland/X11/Windows/macOS, follow zoom/rotation, and have no airspace issues. Audio is still handled by libvlc's own output (governed by MuteMotionPhotos). Cost: one memcpy per frame (~8MB at 1080p — negligible for 3-second clips).
  3. Settings landed in UIProperties.AutoPlayMotionPhotos / MuteMotionPhotos (auto-play off by default, muted by default).
  4. Linux native libraries: VideoLAN ships no NuGet packages for Linux/macOS; playback depends on the system installation (pacman -S vlc / apt install vlc), and LibVLCSharp's default search locates libvlc.so.5; when not installed, the feature degrades gracefully. The Linux deliverable is currently a JIT self-contained publish (AOT cannot cross-compile from Windows).

Integration defects found and fixed (discovered empirically):

  • Thread marshalling: calls to UpdateImage.ChangeImage are not guaranteed to be on the UI thread (R3 EveryValueChanged callbacks fire on the thread that changed the property). Setting Avalonia visual properties (IsVisible) directly in the callback throws InvalidOperationException, which the R3 subscription error handler silently swallows — the symptom being "clicking the badge does nothing". Fix: ImageViewer.UpdateMotionPhoto marshals via Dispatcher.UIThread.CheckAccess()/Post. Any new UI logic hooked into ChangeImage must be marshalled the same way.
  • Transparent overlay hit-test interception: the MainView-level AltButtonsPanel (the hidden-UI hover zone: top 150px, Background=Transparent, Opacity=0, ZIndex=9) — per Avalonia's hit-testing rules (Opacity=0 + transparent background still participate in hit testing, higher ZIndex wins) — swallowed every click in the top part of the image area; the badge was visible but unclickable. Fix: AltButtonsPanel.IsHitTestVisible="{Binding !IsUIShown.Value}" — it no longer intercepts in normal UI mode (which also fixes the pre-existing interaction dead zone in the top 150px of the image), while the hidden-UI hover-to-reveal behavior for the Alt buttons is unchanged. Note: in hidden-UI mode the top region still belongs to the Alt hover zone; use the Space key for playback there.

Remaining risks:

  • Native AOT publish not verified: LibVLCSharp 3.10.1 uses DllImport + event delegate marshalling; smoke tests pass under JIT. Playback/EndReached must be tested empirically under dotnet publish (ILC); if needed, keep the LibVLCSharp assembly via TrimmerRootDescriptor.
  • Plugins not trimmed (full set ~962 dlls); see 6.1 for publish-size optimization.
  • In side-by-side mode the second image's motion photo only shows the cover.

Changelog (2026-08-14): detection/extraction/playback optimizations + playback flash fix

Detector (MotionPhotoDetector.cs)

  • Samsung trailer scan limited to .jpg/.jpeg: HEIC files no longer get their tail (up to 32MB) scanned — previously every HEIC without motion XMP had its entire file tail read, a visible waste during preloading.
  • ReadJpegXmpPacket truncates at </x:xmpmeta>: the remaining ~1MB of JPEG binary data in the scan window is no longer converted into a string, saving one large allocation and speeding up the subsequent string searches.
  • Sidecar candidates are validated against the ftyp header: a candidate .mov/.mp4 must start with a valid 8-byte ftyp box signature, so same-named non-video files (downloads, export leftovers) are no longer misdetected.
  • Item:Length is now bounded to the same Directory item: the new FindItemEnd bounds the search at the earliest of />, </Container:Item> and the next <Container:Item; the backward search is likewise bounded to the same tag. The still-image item's Item:Length can no longer be mistaken for the video length (the unbounded ±1024-char fallback was removed; unconventional XMP naturally falls through to the MicroVideo/Samsung/sidecar branches).

Extractor (MotionPhotoExtractor.cs + new MotionPhoto\FileSliceStream.cs)

  • Embedded videos are no longer copied into memory (previously a MemoryStream up to 256MB): after ftyp location, the extractor returns the new FileSliceStream — a read-only, seekable, bounds-clamped window over the source file. Playback starts immediately with zero extra memory; the Sidecar/Livp paths are unchanged.

Playback (MotionPhotoView.axaml.cs / MotionPhotoVideoSurface.cs / MotionPhotoService.cs)

  • Single-copy frame pipeline: replaced with a 3+1 unmanaged buffer pool — the lock callback hands out buffers in rotation and returns the index as the picture cookie to the display callback; the UI thread copies straight from the unmanaged buffer into the WriteableBitmap via Buffer.MemoryCopy (unsafe; AllowUnsafeBlocks already enabled), eliminating one full-frame Marshal.Copy and the managed intermediate array per frame. When the UI falls more than 3 frames behind, decoding goes into the overflow buffer and the frame is dropped — no tearing, no lock contention (only Interlocked + _frameLock).
  • Fixed the zoom flash on play (zoom-in then restore): root cause — the VideoSurface was made visible before Play(), and Stop() never cleared the bitmap, so the surface first rendered the stale frame from the previous session; when the previous photo's video had a different resolution/aspect ratio this appeared as a "sudden zoom-in then restore". Fix: the surface only becomes visible once the first frame has been written into the bitmap, and Stop() now calls VideoSurface.Clear(). The transition cover → first frame → playback is now seamless.
  • Hardware decoding preferred: added --avcodec-hw=any to the LibVLC init options (auto-selects dxva2/d3d11va/vaapi/videotoolbox per platform, automatic software fallback; frames are still downloaded to CPU memory for the software callbacks — for 3-second clips the gain is mainly lower decode CPU usage).

Tests: 28 → 36, all green. New: sidecar non-video-header rejection, HEIC skips the Samsung scan, sibling-item Item:Length not picked up, FileSliceStreamTests (slice read/seek/length clamping/async read, 5 tests); 2 sidecar tests now use valid MP4 headers. The full suite has 12 additional failures, all pre-existing issues unrelated to this feature (Navigation/FileWatcher/Tiff test infrastructure: null ImageIterator ctor argument, xUnit discovery-time version conflicts, etc.).

File inventory update: Core adds MotionPhoto\FileSliceStream.cs; Tests adds MotionPhoto\FileSliceStreamTests.cs.

Changelog (2026-08-14, batch 2): localization/title/side-by-side/gallery + plugin trim + AOT verification

Localization

  • New MotionPhoto translation key: LanguageModel + all 22 language JSON files + TranslationViewModel; the badge text and tooltip now bind ParentWindowContext.Translation.MotionPhoto.Value instead of a hardcoded English string.

Title marker

  • TabViewModel.UpdateTabTitle() appends a localized " (Motion Photo)" suffix to all title variants when the current image (or either image in side-by-side mode) is a motion photo.

Side-by-side playback

  • MotionPhotoView refactored to be model-driven (OnImageChanged(ImageModel?)); each PicBox is wrapped in a Grid hosting its own overlay (main + secondary with AllowAutoPlay=false). Overlays live inside the zoom/rotation transform: exact per-image alignment, video letterboxes into the image rect. Playback is mutually exclusive; the zoom lock engages while either view plays.
  • ZoomPanControl's tunneled PointerPressed handler skips Button sources so the badge stays clickable while zoomed.

Gallery thumbnail badge

  • GalleryItemViewModel.IsMotionPhoto (reactive); detection runs per item inside GalleryLoader's parallel thumbnail loop and in the FileWatcherService new-file path; thumbnails show a play-triangle badge (hit-test transparent). Known limitation: HEIC-embedded motion photos are not badged in the gallery (no Magick XMP in the thumbnail pipeline).

Neighbor pre-extraction — closed as obsolete: with FileSliceStream, extraction is a bare file open; livp stays on-demand to avoid caching decompressed videos.

libvlc plugin trim

  • Whitelist from -vv module logs: 323 → 23 dlls, 96.3 MB → 23.3 MB; playback-verified (software + 1080p hardware decode paths). New Build\Trim-LibVLCPlugins.ps1 wired into the Win32 build scripts.

Native AOT verification — passed, no TrimmerRootDescriptor

  • PublishAot spike (StreamMediaInput + SetVideoCallbacks + --avcodec-hw=any) plays to EndReached, 61 frames. Full-app AOT publish succeeds with zero LibVLCSharp warnings; artifact launches on a synthetic motion photo without crashing.

Tests: full suite of 198, 11 failures identical to the pre-change baseline (test-infrastructure isolation issues); all 22 language JSON files parse cleanly.

Changelog (2026-08-14, batch 3): deep publish-output trim

Analyzing the x64 AOT publish output (637.8 MB) surfaced more waste, now handled by the rewritten Build\Trim-LibVLCPlugins.ps1 (-LibVlcRoot + -TargetArch) and the Win32 build scripts:

  • Sibling libvlc architectures (win-x86, win-arm64 in an x64 publish): −178.4 MB — the NuGet package copies all three even for RID-specific publishes.
  • Debug symbols (PicView.pdb 156.8 + libSkiaSharp.pdb 84.5 + libHarfBuzzSharp.pdb 22.2 + managed): −264 MB, now deleted right after publish in all three build scripts. This also fixes the CI portable artifacts, which uploaded before the pdb-removal step and shipped the symbols (only the installer was clean).
  • freetype plugin (OSD/subtitle renderer, unused): −3.0 MB, playback-verified removable.
  • *.lib import libs, lua\ scripts, hrtfs\ data: −1.7 MB (link-time only / serving removed plugins and filters).

Net: libvlc 279.3 → 23.2 MB (22 plugins kept); estimated x64 publish output 637.8 → ~117.7 MB. Verified playing to EndReached via JIT software decode, 1080p hardware decode, and the AOT spike on the fully trimmed set.


1. Goals and Scope

Support the following motion photo sources in PicView (covering all three categories JarkViewer supports):

Source Carrier Detection basis
XMP-embedded (new standard) Google Pixel etc., jpg/heic Item:Length of the Item:Semantic="MotionPhoto" entry in XMP Container:Directory
XMP-embedded (legacy standard) Samsung/DJI/OPPO etc., jpg XMP GCamera:MicroVideoOffset
Legacy Samsung trailer marker jpg MotionPhoto_Data marker near the file tail
Sidecar file Apple/vivo/Google Photos exports Same-named .mov / .mp4 file
.livp container Apple Live Photo export zip package (jpg/heic + mov)

Playback behavior aligned with JarkViewer: show the still cover by default → play the embedded video once when triggered → freeze back onto the cover when finished (no looping).
Any failure at any stage degrades gracefully to a plain still image — a broken file must never crash the viewer.


2. PicView Current-State Inventory (verified via DeepWiki + source)

2.1 Loading pipeline

ImageLoader (routes by source)
  → NavigationManager / ImageIterator (directory navigation, index management)
    → PreLoader (cache + forward/background preloading, injected delegate imageModelLoader = GetImageModel.GetImageModelAsync)
      → UpdateImage.ChangeImage (sets TabViewModel.Image / ImageType based on ImageType)
        → PicBox (still: DrawImage in Render; animated: per-frame rendering via the CustomVisualHandler compositor)

Key facts (all file paths verified):

  • The ImageType enum (PicView.Core\ImageDecoding\ImageType.cs) currently has 5 values: Invalid, AnimatedGif, AnimatedWebp, Bitmap, Svg.
  • ImageModel (PicView.Core\Models\ImageModel.cs) carries the bitmap as object? Image; there is an existing precedent for attached data: TiffNavigationInfo? TiffNavigation (multi-page TIFF). Motion photo metadata is attached following this pattern.
  • GetImageModel.GetImageModelAsync (PicView.Avalonia\ImageHandling\GetImageModel.cs) dispatches on magickImage.Format: unrotated JPEG goes through the ProcessSkBitmapAsync fast path; HEIC goes through the default branch ProcessNonStandardImageAsync.
  • ImageAnalyzer (PicView.Core\ImageDecoding\ImageAnalyzer.cs) already has a precedent for "cheap binary detection" (Ping frame count, GIF tail-byte check) — a motion photo detector fits this style.
  • The entire solution has no XMP / video-related code whatsoever (0 matches for keywords like Xmp, Exiv2, GCamera, video, MediaPlayer). All metadata goes through Magick.NET; IXmpProfile (magickImage.GetXmpProfile()) can reuse the existing Magick.NET dependency with zero new additions.
  • SharpCompress is already a PicView.Core dependency.livp (zip) decompression needs zero new dependencies.
  • TempFileManager (PicView.Core\FileHandling\TempFileManager.cs) provides the temp-file convention (URL downloads and base64 drags both use it); it can serve as a fallback path if playback fails.
  • The animated-image playback trio: PicBoxIGifInstance (frame timing/decoding) → CustomVisualHandler (driven by compositor clock CompositionNow, output via DrawBitmap). GIF/WebP always use IterationCount.Infinite.
  • Main image hosting structure (Views\UC\ImageViewer.axaml): ZoomPanControl > AutoScrollViewer > MainBorder > DockPanel > PicBox(MainImage) + PicBox(SecondaryImage); the outer MainPanel stacks overlays (HoverBar, zoom preview, etc.) via ZIndex.
  • UpdateImage.ChangeImage (PicView.Avalonia\Navigation\UpdateImage.cs) only special-cases ImageType.Svg; animation startup is triggered entirely by PicBox itself based on the bound ImageType + CurrentFileInfo.

2.2 Key constraints

  • Both PicView.Core and PicView.Avalonia are configured with PublishAot=true + Trimming=full + IsAotCompatible. LibVLCSharp is not officially AOT-validated (heavy P/Invoke + delegate callbacks) — this is the biggest risk of the plan and requires isolation measures plus fallback plans (see section 8).
  • Platforms are x64;arm64 only, matching the win-x64/win-arm64 runtimes provided by VideoLAN.LibVLC.Windows.
  • Preloading runs in parallel (MaxDegreeOfParallelism = ProcessorCount - 1), so detection logic must be cheap and thread-safe (no shared mutable state).

3. JarkViewer → PicView Concept Mapping

JarkViewer (C++/FFmpeg) PicView (this design)
ImageAsset{format, primaryFrame, frames[]} unified asset ImageModel{Image, ImageType, MotionPhotoInfo}: cover = the Image bitmap; video frames are not pre-decoded, only "slice coordinates" are stored
Exiv2 text-dump string search for XMP Magick.NET GetXmpProfile() string search (no XML parsing)
locateMotionPhotoVideo ftyp-box correction Same ±8KB ftyp search in MotionPhotoExtractor
minizip in-memory IO for .livp SharpCompress in-memory stream decompression
Custom AVIO fully in-memory decoding StreamMediaInput (LibVLCSharp) plays from a seekable stream, zero temp files
Full frame decoding + delayRemain timed playback No frame decoding — handed to libvlc for hardware playback (behavioral difference, see 6.4)
After one loop format=Still freezes on the cover MediaPlayer.EndReached → hide the video layer → freeze on the cover
Decode failure falls back to the still cover Every layer's failure falls back to ImageType.Bitmap

Core architectural difference: JarkViewer decodes the whole video into cv::Mat[] with FFmpeg and plays it with manual timing (Live Photo videos are short, ~3 seconds — feasible but memory-heavy, no audio). With LibVLCSharp, PicView does not decode frames in managed code; the video byte stream is handed directly to libvlc: low memory usage, hardware decoding, native audio support. The byte-handling experience from the detection/extraction layer (XMP search, ftyp correction, fault-tolerant degradation) is fully inherited.


4. Overall Architecture

┌─ PicView.Core (strict AOT constraints, zero video deps, pure byte ops) ┐
│  ImageDecoding\ImageType.cs            + MotionPhoto enum value         │
│  Models\ImageModel.cs                  + MotionPhotoInfo? property      │
│  MotionPhoto\MotionPhotoInfo.cs        [new] video source/offset/length │
│  MotionPhoto\MotionPhotoDetector.cs    [new] XMP/marker/sidecar/livp    │
│  MotionPhoto\MotionPhotoExtractor.cs   [new] slicing/livp/ftyp fix      │
│  MotionPhoto\FileSliceStream.cs        [new] read-only file window      │
└──────────────────────────────────────────────────────────────────────────┘
┌─ PicView.Avalonia (all LibVLCSharp isolated in this layer's folder) ────┐
│  ImageHandling\GetImageModel.cs        detection hook in JPEG/HEIC      │
│  MotionPhoto\MotionPhotoService.cs     [new] LibVLC/MediaPlayer wrapper │
│  MotionPhoto\MotionPhotoView.axaml(.cs)[new] playback view control      │
│  MotionPhoto\MotionPhotoVideoSurface.cs[new] frame surface control      │
│  Navigation\UpdateImage.cs             ChangeImage MotionPhoto hook     │
│  Views\UC\ImageViewer.axaml            MainPanel playback overlay       │
└──────────────────────────────────────────────────────────────────────────┘

Dependency direction is strictly one-way: Core knows nothing about LibVLCSharp; the Avalonia layer hands the Stream extracted by Core to libvlc.


5. Detection and Extraction Design (PicView.Core)

5.1 Data model

// PicView.Core\MotionPhoto\MotionPhotoInfo.cs
public enum MotionPhotoSource { EmbeddedXmp, SamsungTrailer, Sidecar, LivpContainer }

public sealed record MotionPhotoInfo
{
    public required MotionPhotoSource Source { get; init; }
    public long VideoOffset { get; init; }      // Embedded/Samsung: video start within the source file
    public long VideoLength { get; init; }      // byte count (0 for Sidecar/Livp, obtained elsewhere)
    public FileInfo? SidecarFile { get; init; } // Sidecar: same-named mov/mp4
    // Livp: the decompressed video bytes are produced directly by the Extractor, not stored here
}

ImageModel gains public MotionPhotoInfo? MotionPhoto { get; set; } (following the TiffNavigation precedent).

5.2 Detection order (MotionPhotoDetector.TryDetect(FileInfo, string? xmpPacket) → MotionPhotoInfo?)

  1. .livp extensionLivpContainer (record only; bytes are decompressed on demand by the Extractor).
  2. XMP-embedded: perform string search on the XMP packet (inheriting JarkViewer's lesson: no XML parsing, vendor namespaces vary widely):
    • New standard: find the value containing MotionPhoto near Item:Semantic → take the Item:Length number within the same Directory item (bounded lookup, so the still-image item's length is never picked up);
    • Legacy standard: find MicroVideoOffset (or the Offset near the MicroVideo version fields) → take the number;
    • Video start = fileLength - videoLength.
  3. Samsung trailer marker (JPEG only): search backwards for ASCII MotionPhoto_Data in a tail window; if found, the video starts right after the marker.
  4. Sidecar file: try same-directory, same-named .mov.mp4 in order; the candidate must start with a valid ftyp box header.
  5. No hits → null, treat as a regular still image.

XMP packet source: prefer Magick.NET magickImage.GetXmpProfile() (GetImageModelAsync already has a pinged magickImage, zero extra cost; verified that Ping surfaces the XMP profile). HEIC XMP lives in the meta box and is also obtained via Magick; if Magick cannot obtain XMP for some carrier, fall back to a self-implemented JPEG APP1 segment scan (pure byte code, AOT-safe), which truncates the packet at </x:xmpmeta>.

5.3 Extraction (MotionPhotoExtractor)

ExtractAsync(FileInfo, MotionPhotoInfo) → Stream?
├─ EmbeddedXmp / SamsungTrailer:
│    1. Fast path: check whether the expected start is already a valid ftyp box (standard layout, zero overhead)
│    2. Otherwise search within ±8KB of the expected start for an ftyp box with box-size validation,
│       taking the closest one (JarkViewer lesson: DJI etc. append trailing blocks after the video;
│       a misaligned slice destroys the absolute stco offsets)
│    3. None found → return null (upper layer degrades)
│    4. Hand out a FileSliceStream over [start, fileEnd) — no in-memory copy
├─ Sidecar: open the sidecar file stream
└─ LivpContainer: SharpCompress in-memory zip decompression, sort out the video (mov/mp4) by extension → MemoryStream

ftyp box validation: size(4B, big-endian, ≥8 and ≤ remaining length) + "ftyp"(4B ASCII), identical to JarkViewer's box-size validation logic.

5.4 Loading-pipeline hook (GetImageModel.cs)

Hook detection uniformly after the switch in GetImageModelAsync (without intruding into individual branches):

if imageModel.ImageType == Bitmap
   and extension ∈ {.jpg, .jpeg, .heic, .heif, .livp}
   and MotionPhotoDetector.TryDetect(...) hits:
       imageModel.ImageType = ImageType.MotionPhoto
       imageModel.MotionPhoto = info
       // Note: [no video bytes extracted here], only coordinates recorded — keep preloading cheap
  • The cover image keeps the original JPEG/HEIC decoding paths (SkBitmap/NonStandard).
  • Fully transparent to the PreLoader (detection is encapsulated inside the injected imageModelLoader delegate); detection only does Ping-level XMP reads + a tail-window scan on JPEG, sub-millisecond per call, acceptable under parallel preloading.
  • Video bytes are extracted on demand (when the user triggers playback); optional optimization: after the PreLoader hits a motion photo, pre-extract neighboring items' video bytes in the background (typically 2–5MB each, memory impact needs evaluation, Phase 4).

6. Playback Design (PicView.Avalonia + LibVLCSharp)

6.1 New packages

Project Package
PicView.Avalonia LibVLCSharp (the LibVLCSharp.Avalonia VideoView package was evaluated but not adopted, see deviations)
PicView.Avalonia.Win32 VideoLAN.LibVLC.Windows (win-x64/win-arm64 native libs + plugins)
PicView.Avalonia.Linux System libvlc (pacman -S vlc / apt install vlc)
PicView.Avalonia.MacOS System libvlc

Size control: the full libvlc plugin set exceeds 100MB. At publish time, trim libvlc/plugins to keep only: demux/mp4(mov), codec/avcodec (h264/hevc), packetizer/avcodec, swscale/d3d11 video output, the audio output chain. Determine the trim list empirically during the Phase 2 spike (use -vv logs at libvlc startup to observe which modules actually load).

6.2 Rendering approach selection

LibVLCSharp.Avalonia's VideoView is a NativeControlHost (native window handle embedding). This leads to two approaches:

Approach A: VideoView Approach B: software frame callbacks (chosen)
Principle libvlc renders directly into a native child window MediaPlayer.SetVideoCallbacks returns frames → copy into WriteableBitmap → render in the Avalonia compositor
Zoom/rotation Does not follow (native windows ignore RenderTransform) Follows fully (it is just an Avalonia bitmap)
Overlays (airspace) Native window floats above Avalonia visuals; badges/buttons must avoid it No issue
Wayland Impossible with libvlc 3.x (no public wl_surface embedding API) Fully supported
Audio libvlc's built-in output libvlc's built-in output (only the video path is custom)
Performance Hardware decode + GPU presentation One memcpy per frame (~8MB at 1080p), negligible for 3-second clips; hardware decoding still used upstream (--avcodec-hw=any)

Conclusion: Approach B adopted — a single implementation covers Windows/X11/Wayland/macOS, the video follows zoom/rotation, and there are no airspace issues. The frame pipeline uses a 3+1 unmanaged buffer pool: the lock callback hands out buffers and returns the index as the picture cookie; the UI thread copies the addressed buffer straight into the WriteableBitmap (single copy per frame, no managed intermediate array); when the UI lags, decoding goes into an overflow buffer and the frame is dropped instead of tearing.

6.3 Stream playback (inheriting the "zero temp files" principle)

MotionPhotoExtractor produces a seekable Stream (FileSliceStream / FileStream / MemoryStream)
  → new StreamMediaInput(stream)
  → new Media(libVLC, input)
  → mediaPlayer.Play(media)
  • The Stream / Media / StreamMediaInput lifetimes are managed by the playback session; they are Disposed together after Stop / image change / EndReached.
  • libvlc requires custom streams to be seekable; all three stream kinds satisfy this.
  • If some MP4 variant turns out unplayable via a custom stream (rare but possible), fall back: write to disk via TempFileManager.GetNewTempFilePath("xxx.mp4") and play by path (existing project convention).

6.4 Playback state machine

[Cover state Idle]
   │ Trigger: badge click / Space / auto-play setting
   ▼
[Extracting] (async; failure → Idle and hide the badge)
   ▼
[Playing] (video surface visible once the first decoded frame is ready, covering the PicBox area; ZoomPan locked)
   │ Space → pause/resume (MediaPlayer.Pause())
   │ Esc / image change → Stop (surface hidden, frame bitmap cleared)
   ▼
[EndReached] → hide the video layer → back to Idle (frozen cover, badge kept for replay)
  • Loop semantics: opposite of GIF's IterationCount.Infinite — a motion photo plays once and freezes (JarkViewer behavior); replay is explicitly user-triggered.
  • No flash on play: the video surface stays hidden until the first decoded frame has been written into the bitmap, and Stop() clears the bitmap — a stale frame from a previous session can never flash up (previously perceived as a "zoom-in then restore").
  • Audio: Live Photo videos usually carry ambient sound. Controlled by a setting; default muted (sudden audio is a bad experience in an image viewer), via MediaPlayer.Mute.
  • LibVLC singleton: MotionPhotoService lazily loads a single LibVLC instance (--no-video-title-show, --avcodec-hw=any, etc.), released at app exit; each tab owns its own MediaPlayer but shares the LibVLC.

6.5 UI integration

  • Add <mp:MotionPhotoView ZIndex="3" /> inside MainPanel in ImageViewer.axaml (an independent layer above PicBox; while playing it covers the cover image's displayed area).
  • Inside MotionPhotoView: MotionPhotoVideoSurface + corner badge button ("Motion Photo" badge, shown in cover state, click to play, Cursor="Hand").
  • UpdateImage.ChangeImage hook: for ImageType.MotionPhoto set the cover Image as usual and notify the motion photo view (stop any running playback, show the badge, trigger playback directly if auto-play is enabled). On image change the previous playback must be stopped and hidden. The call may come from a background thread, so it must be marshalled to the UI thread.
  • Side-by-side mode: SecondaryImage could be supported the same way (two MediaPlayer instances); not implemented yet.
  • The title bar/EXIF panel may append a "(Motion Photo)" marker after the file name (reusing existing title-building logic); not implemented yet.

6.6 Existing code that must be updated in sync

  • FileSaverHelper: the existing AnimatedGif/AnimatedWebp branches are TODOs; after adding the MotionPhoto value, saving must either go through "still cover" or "copy the file as-is" (the latter implemented — copying the source file preserves the video).
  • Audit every switch (ImageType) in the repo to confirm the new enum value's default behavior is safe (printing, copying, gallery thumbnails, etc. can all treat it as Bitmap).
  • Settings model additions: UIProperties.AutoPlayMotionPhotos (default false), UIProperties.MuteMotionPhotos (default true).

7. Graceful Degradation Matrix (JarkViewer lesson: never let a broken file crash the viewer)

Failure point Behavior
XMP read error / no video marker ImageType.Bitmap, plain still image
ftyp correction failure / slice out of bounds Same as above
livp decompression failure / no video inside Same as above (if Magick cannot read the livp cover, use the existing NonStandard fallback chain)
LibVLC init failure (native libs missing) Catch, permanently disable the playback entry point, badge not shown, still browsing works
Decode error during playback (EncounteredError) Stop → hide video layer → freeze on the cover
Any unexpected exception Log via DebugHelper.LogDebug + degrade to still image, never rethrow

8. Native AOT Risk Assessment and Mitigation

Risk: LibVLCSharp (3.x) does not declare AOT compatibility; it uses P/Invoke, delegate callbacks, delegate* unmanaged, etc. Trimming=full may trim required members; event-callback marshalling under Native AOT needs empirical testing.

Mitigations:

  1. Isolation: LibVLCSharp references only appear in PicView.Avalonia\MotionPhoto\ and the platform startup projects; Core stays clean (Core's AOT compatibility is unaffected).
  2. Early spike (Phase 2 gate): minimal branch validating the AOT publish artifact: after dotnet publish -r win-x64, empirically test the four things: play/pause/EndReached/StreamMediaInput. If any fails, switch to a fallback plan — avoid late rework.
  3. Trim retention: if trimming over-cuts, add a TrimmerRootDescriptor (rd.xml) to keep the whole LibVLCSharp assembly.
  4. Fallback B: libvlc still usable but the managed binding unstable under AOT → switch to a thin direct libvlc C API wrapper (a dozen [LibraryImport] declarations, AOT-friendly), keeping the software video callbacks (libvlc_video_set_callbacks and friends), which need no window embedding at all.
  5. Fallback C: drop libvlc entirely → platform players (Windows Media Foundation / system components), but poor cross-platform consistency — last resort only.

9. Test Plan (PicView.Tests, xUnit.v3)

PicView.Tests\MotionPhoto\ (36 tests):

Test Content
TryDetectFromXmp_NewStandard... Synthetic new-standard XMP (element + attribute forms, reordered attributes)
TryDetectFromXmp_MicroVideoOffset... GCamera:MicroVideoOffset variants (namespace churn: GCamera/OpCamera)
TryDetectFromXmp_MotionItemWithoutLength_IgnoresSiblingLength The still-image item's Item:Length is never picked up
TryDetect_SamsungTrailer... MotionPhoto_Data marker at the file tail (JPEG); skipped for HEIC
TryDetect_Sidecar... Sidecar precedence (.mov over .mp4), ftyp-header validation, non-video rejection
TryDetect_NoMotionPhotoData_ReturnsNull No false positives on ordinary photos
ReadJpegXmpPacket... JPEG APP1 byte-scan fallback
FindFtypStart... / ExtractEmbedded... ftyp window correction incl. DJI-style trailer shift; corrupt input returns null
ExtractAsync_Sidecar/LivpContainer... Sidecar stream + SharpCompress-built zip (jpg+mov) in-memory decompression
ExtractLivpCoverToTempFileAsync... livp cover extraction incl. no-image-entry case
FileSliceStreamTests Slice read/seek/length clamping/async read

Fixtures are synthesized in code (never commit real copyrighted photos). The playback layer (LibVLC) is not unit-tested; it is covered by the E2E smoke run (ffmpeg-generated mp4 → synthetic motion photo → extraction → StreamMediaInput playback → EndReached, verified passing) and manual acceptance.


10. File Inventory

PicView.Core (4 new, 2 modified)

  • MotionPhoto\MotionPhotoInfo.cs (new)
  • MotionPhoto\MotionPhotoDetector.cs (new)
  • MotionPhoto\MotionPhotoExtractor.cs (new)
  • MotionPhoto\FileSliceStream.cs (new)
  • ImageDecoding\ImageType.cs (+MotionPhoto)
  • Models\ImageModel.cs (+MotionPhoto property)

PicView.Avalonia (4 new, several modified)

  • MotionPhoto\MotionPhotoService.cs (new, LibVLC singleton)
  • MotionPhoto\MotionPhotoVideoSurface.cs (new, frame surface control)
  • MotionPhoto\MotionPhotoView.axaml(.cs) (new, video surface + badge + state machine)
  • ImageHandling\GetImageModel.cs (detection hook)
  • Navigation\UpdateImage.cs (MotionPhoto hook)
  • Views\UC\ImageViewer.axaml(.cs) (playback overlay + thread marshalling)
  • Views\Main\MainView.axaml (AltButtonsPanel hit-test fix)
  • Input\MainKeyboardShortcuts.cs (Space/Esc during playback)
  • FileSystem\FileSaverHelper.cs (new enum value handling)
  • PicView.Avalonia.csproj (+LibVLCSharp)

Platform projects: Win32 csproj + VideoLAN.LibVLC.Windows native package; the Build\ installer scripts should include the trimmed libvlc plugins (pending).

Tests: the whole PicView.Tests\MotionPhoto\ suite (36 tests, incl. FileSliceStreamTests.cs).


11. Implementation Phases

Phase Content Acceptance
Phase 1: Core detection/extraction 3 new files + enum/model + unit tests. Pure byte logic, no UI, no LibVLC All tests green; zero regressions on normal images
Phase 2: LibVLC spike (gate) Console shell or temp branch: StreamMediaInput play/stop/EndReached under an AOT publish; determine plugin trim list and package size AOT artifact passes empirically, otherwise fall back
Phase 3: Playback UI integration MotionPhotoService/VideoSurface/View + UpdateImage/ImageViewer wiring + settings Manual acceptance: all sources playable, freeze-back and degradation correct
Phase 4: Polish Neighboring-item video pre-extraction, gallery/title badges, side-by-side, save behavior, Linux/macOS support Full-platform manual regression

12. Key Lessons Inherited from JarkViewer

  1. ✅ XMP via string search, not XML parsing (vendor namespaces vary widely) — with the Item:Length lookup bounded to the correct Directory item.
  2. Do not trust "the video is always at the end of the file" — correct the start with the ftyp box signature (±8KB window + box-size validation).
  3. No temp files: SharpCompress in-memory livp decompression, FileSliceStream file-window playback, zero temp files (TempFileManager only as a fallback and for the livp cover).
  4. Graceful degradation: every layer's failure falls back to the still cover.
  5. ⚠️ JarkViewer's sws lazy-creation / frame-decoding pitfalls do not apply here (no frame decoding; libvlc handles it internally).
  6. ⚠️ JarkViewer's drift-free timed playback does not apply here (libvlc keeps its own clock); but the "play once, freeze on the cover" interaction semantics are fully inherited (EndReached → Still).

- Detector: limit Samsung trailer scan to JPEG, truncate the JPEG XMP
  packet at its end tag, validate sidecar candidates with an ftyp header
  check, and bound the Item:Length lookup to its own Directory item so a
  sibling's length is never mistaken for the video length.
- Extractor: serve embedded videos via the new read-only seekable
  FileSliceStream window instead of copying them into a MemoryStream.
- Playback: single-copy frame pipeline using a 3+1 unmanaged buffer pool
  with the buffer index as picture cookie, copying straight into the
  WriteableBitmap; frames drop via an overflow buffer when the UI lags.
- Fix the zoom flash when starting playback: the video surface is only
  revealed once the first decoded frame is ready, and Stop() now clears
  the frame bitmap so stale frames can never render.
- Prefer hardware decoding via --avcodec-hw=any (automatic software
  fallback).
- Tests: 36 motion photo tests green (new FileSliceStreamTests, sidecar
  ftyp rejection, HEIC Samsung-scan skip, sibling Item:Length cases).
…m libvlc plugins; verify Native AOT.

- Localize the play badge with a new MotionPhoto translation key
  (LanguageModel, TranslationViewModel, all 22 language JSON files).
- Append a localized (Motion Photo) marker to the window/tab titles when
  the current or secondary image carries a video.
- Give the gallery a motion photo badge: IsMotionPhoto on
  GalleryItemViewModel, detection in the parallel thumbnail loop and the
  file-watcher new-item path, play-triangle overlay on thumbnails.
- Support side-by-side playback: MotionPhotoView is now model-driven and
  each PicBox hosts its own overlay inside the zoom/rotation transform,
  aligning exactly with its image rect. Playback is mutually exclusive,
  the zoom lock covers both views, and ZoomPanControl's pan handler skips
  Button sources so the badge stays clickable while zoomed.
- Trim the publish output via Build/Trim-LibVLCPlugins.ps1 (wired into
  the Win32 build scripts): libvlc drops ~279 MB to ~23 MB by removing
  sibling architecture folders the NuGet package always copies, link-time
  *.lib, lua/hrtfs data, and all but the 22 plugin DLLs playback loads
  (whitelist from -vv module logs, verified through software and 1080p
  hardware decode). Publish-root *.pdb (~264 MB, incl. the >150 MB native
  PicView.pdb) is now removed at publish time, which also keeps the CI
  portable artifacts free of debug symbols.
- Verify Native AOT: PublishAot spike plays mp4 via StreamMediaInput +
  software callbacks to EndReached; full-app AOT publish succeeds with no
  LibVLCSharp warnings and launches cleanly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants