diff --git a/Build/Build Avalonia.Win32 arm64.ps1 b/Build/Build Avalonia.Win32 arm64.ps1 index 7830af26d..7094fb6e8 100644 --- a/Build/Build Avalonia.Win32 arm64.ps1 +++ b/Build/Build Avalonia.Win32 arm64.ps1 @@ -56,11 +56,12 @@ New-Item -Path $outputPath -ItemType Directory | Out-Null # Copy the build output to the final destination Copy-Item -Path "$tempPath\*" -Destination $outputPath -Recurse -Force -# Remove the PDB file -$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb" -if (Test-Path $pdbPath) { - Remove-Item -Path $pdbPath -Force -} +# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua, +# hrtfs and unused plugins; ~275 MB -> ~23 MB) +& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-$platform" + +# Remove debug symbols (native PicView.pdb alone is >150 MB) +Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue #Remove uninstended space Rename-Item -path $outputPath -NewName $outputPath.Replace(" ","") diff --git a/Build/Build Avalonia.Win32 x64.ps1 b/Build/Build Avalonia.Win32 x64.ps1 index 4db7de467..d42f73f2a 100644 --- a/Build/Build Avalonia.Win32 x64.ps1 +++ b/Build/Build Avalonia.Win32 x64.ps1 @@ -28,17 +28,18 @@ New-Item -Path $outputPath -ItemType Directory | Out-Null # Copy the build output to the final destination Copy-Item -Path "$tempPath\*" -Destination $outputPath -Recurse -Force +# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua, +# hrtfs and unused plugins; ~275 MB -> ~23 MB) +& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-x64" + # Remove the license file $licensePath = Join-Path -Path $outputPath -ChildPath "Licenses\XamlAnimatedGif LICENSE.txt" if (Test-Path $licensePath) { Remove-Item -Path $licensePath -Force } -# Remove the PDB file -$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb" -if (Test-Path $pdbPath) { - Remove-Item -Path $pdbPath -Force -} +# Remove debug symbols (native PicView.pdb alone is >150 MB) +Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue #Remove uninstended space Rename-Item -path $outputPath -NewName $outputPath.Replace(" ","") diff --git a/Build/Build Avalonia.Win32.ps1 b/Build/Build Avalonia.Win32.ps1 index 4deddd873..1fc284ab6 100644 --- a/Build/Build Avalonia.Win32.ps1 +++ b/Build/Build Avalonia.Win32.ps1 @@ -37,11 +37,11 @@ $avaloniaProjectPath = Join-Path -Path $PSScriptRoot -ChildPath "..\src\PicView. # Run dotnet publish for the Avalonia project dotnet publish $avaloniaProjectPath --runtime "win-$Platform" --self-contained true --configuration Release --output $outputPath /p:PublishReadyToRun=true +# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua, +# hrtfs and unused plugins; ~275 MB -> ~23 MB) +& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-$Platform" -# Remove the PDB file -$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb" -if (Test-Path $pdbPath) { - Remove-Item -Path $pdbPath -Force -} +# Remove debug symbols (native PicView.pdb alone is >150 MB) +Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue diff --git a/Build/Trim-LibVLCPlugins.ps1 b/Build/Trim-LibVLCPlugins.ps1 new file mode 100644 index 000000000..767d8610c --- /dev/null +++ b/Build/Trim-LibVLCPlugins.ps1 @@ -0,0 +1,102 @@ +param ( + [Parameter(Mandatory = $true)] + [string]$LibVlcRoot, + + [Parameter(Mandatory = $true)] + [string]$TargetArch +) + +# Trims the VideoLAN.LibVLC.Windows payload down to what motion photo playback needs: +# 1. Sibling architecture folders (libvlc\win-*) other than the publish target arch — +# the NuGet package copies all architectures even for a RID-specific publish. +# 2. Link-time-only import libraries (*.lib) in the arch root. +# 3. lua\ scripts (the lua plugin is not kept) and hrtfs\ data (the headphone +# spatializer audio filter is not kept). +# 4. Plugin DLLs not on the whitelist below. +# +# The whitelist was determined empirically via `libvlc -vv` module logs and verified by +# playing h264/aac mp4 files through the software video callbacks (StreamMediaInput), +# including the 1080p d3d11va/dxva2 hardware decode path: +# imem access, mp4 demux, avcodec decoder (+ hw accel), swscale/yuvp/chain + +# d3d11/d3d9 video converters, vmem video output, wasapi/mmdevice audio chain. +# Missing whitelist entries are fine (e.g. win-arm64 has no d3d9/dxva2 plugins). +$whitelist = @( + "access\libaccess_imem_plugin.dll", + "access\libimem_plugin.dll", + "audio_filter\libscaletempo_plugin.dll", + "audio_filter\libtrivial_channel_mixer_plugin.dll", + "audio_filter\libugly_resampler_plugin.dll", + "audio_mixer\libfloat_mixer_plugin.dll", + "audio_output\libmmdevice_plugin.dll", + "audio_output\libwasapi_plugin.dll", + "codec\libavcodec_plugin.dll", + "codec\libd3d11va_plugin.dll", + "codec\libdxva2_plugin.dll", + "d3d11\libdirect3d11_filters_plugin.dll", + "d3d9\libdirect3d9_filters_plugin.dll", + "demux\libmp4_plugin.dll", + "keystore\libmemory_keystore_plugin.dll", + "logger\libconsole_logger_plugin.dll", + "stream_filter\libprefetch_plugin.dll", + "stream_filter\librecord_plugin.dll", + "video_chroma\libchain_plugin.dll", + "video_chroma\libswscale_plugin.dll", + "video_chroma\libyuvp_plugin.dll", + "video_output\libvmem_plugin.dll" +) + +if (-not (Test-Path $LibVlcRoot)) { + Write-Warning "libvlc directory not found: $LibVlcRoot" + return +} + +# 1. Remove sibling architecture folders (e.g. win-x86, and win-arm64 in an x64 publish) +Get-ChildItem -Path $LibVlcRoot -Directory -Filter "win-*" | Where-Object { + $_.Name -ne $TargetArch +} | ForEach-Object { + $sizeMB = ((Get-ChildItem $_.FullName -Recurse -File) | Measure-Object Length -Sum).Sum / 1MB + Remove-Item -Path $_.FullName -Recurse -Force + Write-Host ("libvlc trim: removed sibling arch {0} ({1:N1} MB)" -f $_.Name, $sizeMB) +} + +$archDir = Join-Path -Path $LibVlcRoot -ChildPath $TargetArch +if (-not (Test-Path $archDir)) { + Write-Warning "libvlc arch directory not found: $archDir" + return +} + +# 2. Remove link-time-only import libraries +Get-ChildItem -Path $archDir -Filter *.lib | ForEach-Object { + Remove-Item -Path $_.FullName -Force +} + +# 3. Remove lua scripts and HRTF data (their plugins/filters are not kept) +foreach ($subDir in "lua", "hrtfs") { + $path = Join-Path -Path $archDir -ChildPath $subDir + if (Test-Path $path) { + Remove-Item -Path $path -Recurse -Force + } +} + +# 4. Remove plugin DLLs not on the whitelist +$pluginsDir = Join-Path -Path $archDir -ChildPath "plugins" +if (-not (Test-Path $pluginsDir)) { + Write-Warning "libvlc plugins directory not found: $pluginsDir" + return +} + +$removed = 0 +Get-ChildItem -Path $pluginsDir -Recurse -Filter *.dll | ForEach-Object { + $relativePath = $_.FullName.Substring($pluginsDir.Length + 1) + if ($whitelist -notcontains $relativePath) { + Remove-Item -Path $_.FullName -Force + $removed++ + } +} + +# Drop plugin directories left empty +Get-ChildItem -Path $pluginsDir -Directory | Where-Object { + -not (Get-ChildItem -Path $_.FullName -Recurse -File) +} | Remove-Item -Recurse -Force + +Write-Host "libvlc trim ($TargetArch): removed $removed plugin DLLs, kept $($whitelist.Count) (motion photo playback set)" diff --git a/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj b/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj index 2aeca500c..9260d16ed 100644 --- a/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj +++ b/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj @@ -120,4 +120,8 @@ PreserveNewest + + + + diff --git a/src/PicView.Avalonia/CustomControls/ZoomPanControl.cs b/src/PicView.Avalonia/CustomControls/ZoomPanControl.cs index 7c57c70ca..3919130b8 100644 --- a/src/PicView.Avalonia/CustomControls/ZoomPanControl.cs +++ b/src/PicView.Avalonia/CustomControls/ZoomPanControl.cs @@ -239,6 +239,13 @@ private void HandleResetZoomOrStartPanning(object? sender, PointerPressedEventAr return; } + // Interactive children (e.g. the motion photo play badge) handle their own clicks; + // starting a pan here would capture the pointer and swallow their click. + if (e.Source is Button) + { + return; + } + // Panning shouldn't happen when moving the window by holding shift if (e.KeyModifiers == KeyModifiers.Shift) { diff --git a/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs b/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs index b72340245..37c58ce0d 100644 --- a/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs +++ b/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs @@ -96,6 +96,7 @@ async ValueTask SaveBitmap() { case ImageType.AnimatedGif: // TODO: Add animated GIF support case ImageType.AnimatedWebp: // TODO: Add animated WebP support + case ImageType.MotionPhoto: // Saves the still cover; the embedded video is only kept via the file-copy path case ImageType.Bitmap: { if (tab.Image.CurrentValue is not Bitmap bitmap) @@ -132,6 +133,7 @@ async ValueTask SaveProcessedMagickImage() { case ImageType.AnimatedGif: // TODO: Add animated GIF support case ImageType.AnimatedWebp: // TODO: Add animated WebP support + case ImageType.MotionPhoto: // Saving a processed image drops the embedded video, keep the still only case ImageType.Bitmap: { if (angle is not 0) diff --git a/src/PicView.Avalonia/ImageHandling/GetImageModel.cs b/src/PicView.Avalonia/ImageHandling/GetImageModel.cs index 9ff7ccc28..5d9b06eb4 100644 --- a/src/PicView.Avalonia/ImageHandling/GetImageModel.cs +++ b/src/PicView.Avalonia/ImageHandling/GetImageModel.cs @@ -1,3 +1,4 @@ +using System.Text; using Avalonia.Media.Imaging; using Avalonia.Svg.Skia; using ImageMagick; @@ -7,6 +8,7 @@ using PicView.Core.Exif; using PicView.Core.ImageDecoding; using PicView.Core.Models; +using PicView.Core.MotionPhoto; using PicView.Core.Navigation.Tiff; namespace PicView.Avalonia.ImageHandling; @@ -36,6 +38,14 @@ public static async ValueTask GetImageModelAsync(FileInfo fileInfo, try { + // .livp is a zip container that cannot be pinged by Magick, so it must be + // handled before the MagickImage is initialized. + if (fileInfo.Extension.Equals(".livp", StringComparison.InvariantCultureIgnoreCase)) + { + await ProcessLivpAsync(fileInfo, imageModel).ConfigureAwait(false); + return imageModel; + } + // Initialize MagickImage if not provided magickImage ??= GetImage.CreateAndPingMagickImage(fileInfo); @@ -139,6 +149,8 @@ public static async ValueTask GetImageModelAsync(FileInfo fileInfo, break; } + TryDetectMotionPhoto(fileInfo, magickImage, imageModel); + return imageModel; } catch (Exception e) @@ -226,6 +238,52 @@ internal static bool IsAnimatedGif(FileInfo fileInfo) } } + /// + /// Checks whether a successfully decoded bitmap is actually a motion photo (XMP embedded + /// video, Samsung trailer or sidecar file) and upgrades the model accordingly. + /// Only the video location metadata is recorded here; the video bytes are extracted + /// on demand when playback starts. + /// + private static void TryDetectMotionPhoto(FileInfo fileInfo, MagickImage magickImage, ImageModel imageModel) + { + if (imageModel.ImageType is not ImageType.Bitmap) + { + return; + } + + var extension = fileInfo.Extension; + if (!extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".heif", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string? xmpPacket = null; + try + { + var xmpProfile = magickImage.GetXmpProfile(); + if (xmpProfile is not null) + { + xmpPacket = Encoding.UTF8.GetString(xmpProfile.ToByteArray()); + } + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(GetImageModel), nameof(TryDetectMotionPhoto), e); + } + + var info = MotionPhotoDetector.TryDetect(fileInfo, xmpPacket); + if (info is null) + { + return; + } + + imageModel.ImageType = ImageType.MotionPhoto; + imageModel.MotionPhoto = info; + } + #region Image Processing Methods private static async ValueTask ProcessSkBitmapAsync(FileInfo fileInfo, MagickFormat format, ImageModel imageModel) @@ -248,6 +306,39 @@ private static async ValueTask ProcessBase64Async(FileInfo fileInfo, MagickForma var bitmap = await GetImage.GetBase64ImageAsync(fileInfo).ConfigureAwait(false); SetBitmapProperties(bitmap, imageModel); } + + /// + /// Handles Apple .livp containers (a zip holding a still image plus a video). + /// The cover image is extracted to a temporary file and decoded through the regular + /// pipeline, while the model keeps pointing at the original .livp file. + /// + private static async ValueTask ProcessLivpAsync(FileInfo fileInfo, ImageModel imageModel) + { + var tempImagePath = await MotionPhotoExtractor.ExtractLivpCoverToTempFileAsync(fileInfo).ConfigureAwait(false); + if (tempImagePath is null) + { + imageModel.ImageType = ImageType.Invalid; + return; + } + + var tempFileInfo = new FileInfo(tempImagePath); + using var tempMagickImage = GetImage.CreateAndPingMagickImage(tempFileInfo); + if (tempMagickImage.Format is MagickFormat.Jpe or MagickFormat.Jpeg or MagickFormat.Pjpeg) + { + await ProcessSkBitmapAsync(tempFileInfo, tempMagickImage.Format, imageModel).ConfigureAwait(false); + } + else + { + await ProcessNonStandardImageAsync(tempFileInfo, imageModel, tempMagickImage).ConfigureAwait(false); + } + + imageModel.FileInfo = fileInfo; + if (imageModel.ImageType is ImageType.Bitmap) + { + imageModel.ImageType = ImageType.MotionPhoto; + imageModel.MotionPhoto = new MotionPhotoInfo { Source = MotionPhotoSource.LivpContainer }; + } + } private static async ValueTask ProcessRawImageAsync(FileInfo fileInfo, ImageModel imageModel, MagickImage magickImage) { diff --git a/src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs b/src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs index aeecd4c32..6c2afde40 100644 --- a/src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs +++ b/src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs @@ -4,6 +4,7 @@ using Avalonia.Input; using PicView.Avalonia.CustomControls; using PicView.Avalonia.Navigation; +using PicView.Avalonia.Views.UC; using PicView.Core.ViewModels; namespace PicView.Avalonia.Input; @@ -186,6 +187,22 @@ private static async ValueTask HandleSpecialCases(KeyEventArgs e, MainWind return true; } + // Motion photo playback: Space plays/pauses, Escape stops an active playback + if (vm.WindowTabs.ActiveTab.CurrentValue.CurrentView.CurrentValue is ImageViewer imageViewer && + imageViewer.IsMotionPhotoActive) + { + if (e.Key is Key.Space) + { + imageViewer.ToggleMotionPhotoPlayPause(); + return true; + } + + if (e.Key is Key.Escape && imageViewer.StopMotionPhotoIfPlaying()) + { + return true; + } + } + // Handle open dialog if (mainWindow.IsDialogOpen) { diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoService.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoService.cs new file mode 100644 index 000000000..85a0fe438 --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoService.cs @@ -0,0 +1,107 @@ +using System.Runtime.InteropServices; +using LibVLCSharp.Shared; +using PicView.Core.DebugTools; + +namespace PicView.Avalonia.MotionPhoto; + +/// +/// Owns the process-wide instance used for motion photo playback. +/// Initialization is lazy and failure is remembered, so a missing or broken native libvlc +/// simply degrades motion photos to regular still images instead of breaking the viewer. +/// +public static class MotionPhotoService +{ + private static readonly object InitLock = new(); + private static LibVLC? _libVlc; + private static bool _initFailed; + + /// + /// Video playback is supported on all desktop platforms: frames are produced by libvlc + /// software video callbacks and rendered in the Avalonia compositor, so no native window + /// embedding is needed (which also means Wayland sessions are fully supported). + /// Playback still requires libvlc to be installed/available. + /// + public static bool IsPlaybackSupported => + OperatingSystem.IsWindows() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(); + + /// + /// Attempts to get (or lazily create) the shared instance. + /// + /// False when playback is unavailable; callers should fall back to the still image. + public static bool TryGetLibVlc(out LibVLC? libVlc) + { + if (_libVlc is not null) + { + libVlc = _libVlc; + return true; + } + + if (!IsPlaybackSupported || _initFailed) + { + libVlc = null; + return false; + } + + lock (InitLock) + { + if (_libVlc is not null) + { + libVlc = _libVlc; + return true; + } + + if (_initFailed) + { + libVlc = null; + return false; + } + + try + { + InitializeNativeLibrary(); + _libVlc = new LibVLC( + "--no-video-title-show", + "--no-stats", + "--quiet", + // Prefer hardware decoding (dxva2/d3d11va/vaapi/videotoolbox); libvlc + // falls back to software decoding automatically when unavailable. + // Frames are still downloaded to CPU memory for the software video callbacks. + "--avcodec-hw=any"); + libVlc = _libVlc; + return true; + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoService), nameof(TryGetLibVlc), e); + _initFailed = true; + libVlc = null; + return false; + } + } + } + + /// + /// On Windows, points libvlc at the native libraries deployed by the VideoLAN.LibVLC.Windows + /// package (copied to "libvlc\win-x64" or "libvlc\win-arm64" next to the application). + /// On Linux and macOS, libvlc comes from the system installation (VideoLAN does not ship + /// NuGet packages for these platforms) and LibVLCSharp's default search is used. + /// + private static void InitializeNativeLibrary() + { + if (OperatingSystem.IsWindows()) + { + var architectureFolder = RuntimeInformation.ProcessArchitecture is Architecture.Arm64 + ? "win-arm64" + : "win-x64"; + var libVlcDirectory = Path.Combine(AppContext.BaseDirectory, "libvlc", architectureFolder); + if (Directory.Exists(libVlcDirectory)) + { + LibVLCSharp.Shared.Core.Initialize(libVlcDirectory); + return; + } + } + + // Default search: libvlc.so.5 (Linux, system-installed) or the libvlc framework (macOS) + LibVLCSharp.Shared.Core.Initialize(); + } +} diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs new file mode 100644 index 000000000..41056d5fa --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs @@ -0,0 +1,129 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using PicView.Core.DebugTools; + +namespace PicView.Avalonia.MotionPhoto; + +/// +/// Renders motion photo video frames supplied by libvlc software video callbacks +/// (MediaPlayer.SetVideoCallbacks). Frames arrive as BGRA32 ("RV32") bytes and are drawn +/// letterboxed into the control. This works on every display stack, including Wayland +/// (where native child-window embedding is impossible with libvlc 3.x), and lets the +/// video participate in the normal Avalonia compositor (zoom, rotation, overlays). +/// +public sealed class MotionPhotoVideoSurface : Control +{ + private WriteableBitmap? _frameBitmap; + + /// + /// Ensures the frame bitmap matches the given video size. Must be called on the UI thread. + /// + public void EnsureBitmap(int width, int height) + { + if (width <= 0 || height <= 0) + { + return; + } + + if (_frameBitmap is { PixelSize.Width: var w, PixelSize.Height: var h } && w == width && h == height) + { + return; + } + + _frameBitmap?.Dispose(); + _frameBitmap = new WriteableBitmap( + new PixelSize(width, height), + new Vector(96, 96), + PixelFormat.Bgra8888, + AlphaFormat.Unpremul); + } + + /// + /// Copies a BGRA32 frame from an unmanaged buffer into the bitmap and invalidates + /// the visual. UI thread only; the source must stay valid for the duration of the call. + /// + public unsafe void UpdateFrame(IntPtr bgra, int byteCount, int width, int height) + { + try + { + EnsureBitmap(width, height); + var bitmap = _frameBitmap; + if (bitmap is null || bgra == IntPtr.Zero) + { + return; + } + + var srcRowBytes = width * 4; + if (byteCount < srcRowBytes * height) + { + return; + } + + using var framebuffer = bitmap.Lock(); + var dstRowBytes = framebuffer.RowBytes; + var src = (byte*)bgra; + var dst = (byte*)framebuffer.Address; + if (dstRowBytes == srcRowBytes) + { + Buffer.MemoryCopy(src, dst, (long)dstRowBytes * height, byteCount); + } + else + { + // Copy row by row to handle potential framebuffer padding + for (var y = 0; y < height; y++) + { + Buffer.MemoryCopy(src + (long)y * srcRowBytes, dst + (long)y * dstRowBytes, + dstRowBytes, srcRowBytes); + } + } + + InvalidateVisual(); + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoVideoSurface), nameof(UpdateFrame), e); + } + } + + /// + /// Drops the current frame so the surface renders nothing. + /// + public void Clear() + { + _frameBitmap?.Dispose(); + _frameBitmap = null; + InvalidateVisual(); + } + + public sealed override void Render(DrawingContext context) + { + base.Render(context); + + var bitmap = _frameBitmap; + if (bitmap is null) + { + return; + } + + var viewPort = new Rect(Bounds.Size); + var sourceSize = bitmap.Size; + var scale = Stretch.Uniform.CalculateScaling(Bounds.Size, sourceSize); + var scaledSize = sourceSize * scale; + var destRect = viewPort + .CenterRect(new Rect(scaledSize)) + .Intersect(viewPort); + var sourceRect = new Rect(sourceSize) + .CenterRect(new Rect(destRect.Size / scale)); + + context.DrawImage(bitmap, sourceRect, destRect); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + Clear(); + base.OnDetachedFromVisualTree(e); + } +} diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml new file mode 100644 index 000000000..b1f64ce6d --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml @@ -0,0 +1,38 @@ + + + + + + diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs new file mode 100644 index 000000000..81fb7aaf2 --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs @@ -0,0 +1,451 @@ +using System.Runtime.InteropServices; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +using LibVLCSharp.Shared; +using PicView.Core.DebugTools; +using PicView.Core.ImageDecoding; +using PicView.Core.Models; +using PicView.Core.MotionPhoto; +using PicView.Core.ViewModels; + +namespace PicView.Avalonia.MotionPhoto; + +/// +/// Overlay that plays the embedded video of a motion photo on top of the still cover image. +/// Behavior: show the cover with a badge, play the video once when triggered, then freeze +/// back onto the cover (the badge remains so it can be replayed). Any failure degrades to +/// showing only the still image. +/// +/// Video frames are produced by libvlc software video callbacks (BGRA32) and rendered by +/// , which works on every display stack including +/// Wayland (libvlc 3.x has no public wl_surface embedding API) and lets the video follow +/// the normal Avalonia compositor. +/// +/// +public partial class MotionPhotoView : UserControl, IDisposable +{ + private const int BytesPerPixel = 4; + + /// Number of display buffers handed to libvlc in rotation (slack for the UI thread). + private const int FrameBufferCount = 3; + + /// Extra slot decoded into when every display buffer is pending; its frame is dropped. + private const int OverflowBufferIndex = FrameBufferCount; + + private static readonly byte[] Rv32Chroma = [(byte)'R', (byte)'V', (byte)'3', (byte)'2']; + + private readonly MediaPlayer.LibVLCVideoFormatCb _videoFormatCb; + private readonly MediaPlayer.LibVLCVideoCleanupCb _videoCleanupCb; + private readonly MediaPlayer.LibVLCVideoLockCb _videoLockCb; + private readonly MediaPlayer.LibVLCVideoUnlockCb _videoUnlockCb; + private readonly MediaPlayer.LibVLCVideoDisplayCb _videoDisplayCb; + + private readonly Lock _frameLock = new(); + private IntPtr[] _frameBuffers = []; + private int[] _bufferInUse = []; + private int _frameBufferSize; + private int _videoWidth; + private int _videoHeight; + + private Stream? _videoStream; + private StreamMediaInput? _mediaInput; + private Media? _media; + private MediaPlayer? _mediaPlayer; + private ImageModel? _model; + private bool _isSessionBusy; + private bool _isDisposed; + + /// Raised on the UI thread when video playback starts (zoom/pan should be locked). + public event EventHandler? PlaybackStarted; + + /// Raised on the UI thread when video playback stops (zoom/pan can be unlocked). + public event EventHandler? PlaybackStopped; + + /// Whether video is currently playing or paused. + public bool IsPlaying { get; private set; } + + /// Whether the current image is a playable motion photo (badge or video is shown). + public bool IsMotionPhotoActive => IsVisible; + + /// + /// Whether the auto-play setting may trigger playback for this view. Disabled for the + /// secondary (side-by-side) view so two clips never start on their own at once. + /// + public bool AllowAutoPlay { get; set; } = true; + + public MotionPhotoView() + { + InitializeComponent(); + PlayBadge.Click += OnPlayBadgeClicked; + + // Keep explicit references so the delegates are never garbage-collected while + // libvlc may still invoke them (the MediaPlayer also stores them, but be explicit). + _videoFormatCb = VideoFormatCallback; + _videoCleanupCb = VideoCleanupCallback; + _videoLockCb = LockVideoCallback; + _videoUnlockCb = UnlockVideoCallback; + _videoDisplayCb = DisplayVideoCallback; + } + + /// + /// Called whenever a new image is displayed. Stops any running playback and prepares + /// (or hides) the motion photo overlay for the new model. Null hides the overlay. + /// + public void OnImageChanged(ImageModel? model) + { + Stop(); + _model = model; + + if (model?.ImageType is ImageType.MotionPhoto && + model.MotionPhoto is not null && + MotionPhotoService.IsPlaybackSupported && + MotionPhotoService.TryGetLibVlc(out _)) + { + IsVisible = true; + PlayBadge.IsVisible = true; + if (AllowAutoPlay && Settings.UIProperties.AutoPlayMotionPhotos) + { + _ = PlayAsync(); + } + } + else + { + IsVisible = false; + } + } + + /// + /// Toggles between play and pause when playback is running, otherwise starts playback. + /// Used by the Space keyboard shortcut. + /// + public void TogglePlayPause() + { + if (_mediaPlayer is not null && IsPlaying) + { + _mediaPlayer.Pause(); + return; + } + + if (IsVisible && !IsPlaying) + { + _ = PlayAsync(); + } + } + + /// + /// Stops playback and returns to the cover image. Returns true when playback was active. + /// Used by the Escape keyboard shortcut. + /// + public bool StopIfPlaying() + { + if (!IsPlaying) + { + return false; + } + + Stop(); + PlayBadge.IsVisible = true; + return true; + } + + /// + /// Starts motion photo playback: extracts the video on demand, hands it to libvlc + /// as an in-memory stream and plays it once. + /// + public async Task PlayAsync() + { + if (_isSessionBusy || IsPlaying || _isDisposed) + { + return; + } + + var model = _model; + if (model?.ImageType is not ImageType.MotionPhoto || model.MotionPhoto is null || model.FileInfo is null) + { + return; + } + + if (!MotionPhotoService.TryGetLibVlc(out var libVlc)) + { + IsVisible = false; + return; + } + + _isSessionBusy = true; + var cancellationToken = (DataContext as TabViewModel)?.GetTabCancellation().Token ?? default; + Stream? stream = null; + try + { + stream = await MotionPhotoExtractor.ExtractAsync( + model.FileInfo, model.MotionPhoto, cancellationToken).ConfigureAwait(true); + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoView), nameof(PlayAsync), e); + } + + if (stream is null) + { + _isSessionBusy = false; + // Extraction failed: degrade to the still image + IsVisible = false; + return; + } + + try + { + _videoStream = stream; + _mediaInput = new StreamMediaInput(stream); + _media = new Media(libVlc, _mediaInput); + _mediaPlayer = new MediaPlayer(_media) + { + Mute = Settings.UIProperties.MuteMotionPhotos, + }; + _mediaPlayer.SetVideoFormatCallbacks(_videoFormatCb, _videoCleanupCb); + _mediaPlayer.SetVideoCallbacks(_videoLockCb, _videoUnlockCb, _videoDisplayCb); + _mediaPlayer.EndReached += OnEndReached; + _mediaPlayer.EncounteredError += OnEncounteredError; + + // The surface stays hidden until the first decoded frame arrives, so the + // still image remains untouched while libvlc starts up (no visual pop). + if (!_mediaPlayer.Play()) + { + CleanupSession(); + _isSessionBusy = false; + return; + } + + PlayBadge.IsVisible = false; + IsPlaying = true; + PlaybackStarted?.Invoke(this, EventArgs.Empty); + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoView), nameof(PlayAsync), e); + CleanupSession(); + IsVisible = false; + } + finally + { + _isSessionBusy = false; + } + } + + /// + /// Stops playback and releases all playback resources, returning to the cover image. + /// The last decoded frame is cleared so it can never flash up when the surface is + /// shown again (e.g. when replaying or when the next motion photo has a different + /// video resolution). + /// + public void Stop() + { + var wasPlaying = IsPlaying; + CleanupSession(); + VideoSurface.IsVisible = false; + VideoSurface.Clear(); + IsPlaying = false; + if (wasPlaying) + { + PlaybackStopped?.Invoke(this, EventArgs.Empty); + } + } + + private async void OnPlayBadgeClicked(object? sender, RoutedEventArgs e) => await PlayAsync(); + + private void OnEndReached(object? sender, EventArgs e) => + Dispatcher.UIThread.Post(FreezeBackToCover); + + private void OnEncounteredError(object? sender, EventArgs e) => + Dispatcher.UIThread.Post(FreezeBackToCover); + + private void FreezeBackToCover() + { + if (!IsPlaying) + { + return; + } + + Stop(); + // Keep the badge visible so the clip can be replayed + PlayBadge.IsVisible = true; + } + + #region libvlc software video callbacks + + private uint VideoFormatCallback(ref IntPtr opaque, IntPtr chroma, + ref uint width, ref uint height, ref uint pitches, ref uint lines) + { + Marshal.Copy(Rv32Chroma, 0, chroma, Rv32Chroma.Length); + pitches = width * BytesPerPixel; + lines = height; + + lock (_frameLock) + { + FreeFrameBuffersLocked(); + _frameBufferSize = (int)(width * height * BytesPerPixel); + _frameBuffers = new IntPtr[FrameBufferCount + 1]; + _bufferInUse = new int[FrameBufferCount + 1]; + for (var i = 0; i < _frameBuffers.Length; i++) + { + _frameBuffers[i] = Marshal.AllocHGlobal(_frameBufferSize); + } + + _videoWidth = (int)width; + _videoHeight = (int)height; + } + + Dispatcher.UIThread.Post(() => VideoSurface.EnsureBitmap(_videoWidth, _videoHeight)); + return (uint)_frameBuffers.Length; + } + + private void VideoCleanupCallback(ref IntPtr opaque) + { + lock (_frameLock) + { + FreeFrameBuffersLocked(); + } + } + + private IntPtr LockVideoCallback(IntPtr opaque, IntPtr planes) + { + var buffers = _frameBuffers; + var inUse = _bufferInUse; + if (buffers.Length == 0) + { + // Format callback has not run (or cleanup already happened); nothing decodable. + Marshal.WriteIntPtr(planes, IntPtr.Zero); + return (IntPtr)OverflowBufferIndex; + } + + for (var i = 0; i < FrameBufferCount; i++) + { + if (Interlocked.CompareExchange(ref inUse[i], 1, 0) is 0) + { + // The buffer index is returned as the picture cookie, so the display + // callback knows which buffer holds the decoded frame. + Marshal.WriteIntPtr(planes, buffers[i]); + return (IntPtr)i; + } + } + + // Every display buffer is waiting for the UI thread: decode into the overflow + // buffer instead and let the display callback drop the frame. + Marshal.WriteIntPtr(planes, buffers[OverflowBufferIndex]); + return (IntPtr)OverflowBufferIndex; + } + + private void UnlockVideoCallback(IntPtr opaque, IntPtr picture, IntPtr planes) + { + // Nothing to unlock; buffers are released by the UI thread after the frame is copied. + } + + private void DisplayVideoCallback(IntPtr opaque, IntPtr picture) + { + var index = (int)picture; + if (index is OverflowBufferIndex) + { + // Dropped frame; the overflow buffer is never marked in use. + return; + } + + var inUse = _bufferInUse; + if (index < 0 || index >= inUse.Length) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + if (_isDisposed) + { + Interlocked.Exchange(ref inUse[index], 0); + return; + } + + lock (_frameLock) + { + try + { + if (index < _frameBuffers.Length && _frameBuffers[index] != IntPtr.Zero && _frameBufferSize > 0) + { + // Copy straight from the libvlc buffer into the frame bitmap (single copy). + VideoSurface.UpdateFrame(_frameBuffers[index], _frameBufferSize, _videoWidth, _videoHeight); + // Reveal the surface only now that it holds a current frame + if (!VideoSurface.IsVisible) + { + VideoSurface.IsVisible = true; + } + } + } + finally + { + Interlocked.Exchange(ref inUse[index], 0); + } + } + }); + } + + private void FreeFrameBuffersLocked() + { + foreach (var buffer in _frameBuffers) + { + if (buffer != IntPtr.Zero) + { + Marshal.FreeHGlobal(buffer); + } + } + + _frameBuffers = []; + _bufferInUse = []; + _frameBufferSize = 0; + } + + #endregion + + private void CleanupSession() + { + if (_mediaPlayer is not null) + { + _mediaPlayer.EndReached -= OnEndReached; + _mediaPlayer.EncounteredError -= OnEncounteredError; + try + { + _mediaPlayer.Stop(); + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoView), nameof(CleanupSession), e); + } + + _mediaPlayer.Dispose(); + _mediaPlayer = null; + } + + lock (_frameLock) + { + FreeFrameBuffersLocked(); + } + + _media?.Dispose(); + _media = null; + _mediaInput?.Dispose(); + _mediaInput = null; + _videoStream?.Dispose(); + _videoStream = null; + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + PlayBadge.Click -= OnPlayBadgeClicked; + Stop(); + VideoSurface.Clear(); + GC.SuppressFinalize(this); + } +} diff --git a/src/PicView.Avalonia/Navigation/UpdateImage.cs b/src/PicView.Avalonia/Navigation/UpdateImage.cs index e8eee52fd..81508e473 100644 --- a/src/PicView.Avalonia/Navigation/UpdateImage.cs +++ b/src/PicView.Avalonia/Navigation/UpdateImage.cs @@ -111,6 +111,8 @@ public static void ChangeImage(MainWindow mainWindow, TabViewModel tabViewModel, SetWindowAndImageSize(mainWindow, tabViewModel, vm); + imageViewer.UpdateMotionPhoto(tabViewModel); + if (tabViewModel.Gallery.IsDockedGalleryVisible.CurrentValue) { imageViewer.GalleryView.GalleryItemsControl.ScrollToCenterOfCurrentItem(); @@ -193,6 +195,8 @@ public static void SetSingleImage(MainWindowViewModel vm, MainWindow mainWindow, tabViewModel.Model.PixelHeight = height; tabViewModel.SingleImageType = type; + + imageViewer.UpdateMotionPhoto(tabViewModel); tabViewModel.DisposeImageIterator(); } diff --git a/src/PicView.Avalonia/PicView.Avalonia.csproj b/src/PicView.Avalonia/PicView.Avalonia.csproj index 9b5c60b7b..22ec8eb2c 100644 --- a/src/PicView.Avalonia/PicView.Avalonia.csproj +++ b/src/PicView.Avalonia/PicView.Avalonia.csproj @@ -257,6 +257,7 @@ + diff --git a/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml b/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml index 96e5262ec..838ddf378 100644 --- a/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml +++ b/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml @@ -20,10 +20,32 @@ ToolTip.HorizontalOffset="0" ToolTip.Placement="TopEdgeAlignedLeft" ToolTip.VerticalOffset="0"> - + + + + + + + @@ -40,19 +41,24 @@ HorizontalAlignment="Center" VerticalAlignment="Center" LastChildFill="False"> - - + + + + + IsVisible="{CompiledBinding ParentWindowContext.IsSideBySide.Value}"> + + + @@ -81,13 +87,13 @@ x:Name="HoverBar" VerticalAlignment="Bottom" ZIndex="2" /> - + \ No newline at end of file diff --git a/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs b/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs index b0177870c..cef0c300f 100644 --- a/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs +++ b/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs @@ -12,6 +12,7 @@ using PicView.Core.DebugTools; using PicView.Core.Extensions; using PicView.Core.Localization; +using PicView.Core.Models; using PicView.Core.ViewModels; using R3; @@ -37,6 +38,73 @@ private void OnLoaded(object? sender, RoutedEventArgs e) AddHandler(PointerTouchPadGestureMagnifyEvent, TouchMagnifyEvent, RoutingStrategies.Bubble); AddHandler(PinchEvent, TouchMagnifyEvent, RoutingStrategies.Bubble); _disposables.Add(new HoverFadeButtonHandler(GalleryShortcut, GalleryShortcut.InnerButton)); + + // Zoom/pan is locked for the duration of motion photo playback + MotionPhotoView.PlaybackStarted += OnMotionPhotoPlaybackStarted; + MotionPhotoView.PlaybackStopped += OnMotionPhotoPlaybackStopped; + SecondaryMotionPhotoView.PlaybackStarted += OnMotionPhotoPlaybackStarted; + SecondaryMotionPhotoView.PlaybackStopped += OnMotionPhotoPlaybackStopped; + } + + private void OnMotionPhotoPlaybackStarted(object? sender, EventArgs e) + { + ZoomPanControl.IsEnabled = false; + + // Only one clip plays at a time + if (ReferenceEquals(sender, MotionPhotoView)) + { + SecondaryMotionPhotoView.Stop(); + } + else + { + MotionPhotoView.Stop(); + } + } + + private void OnMotionPhotoPlaybackStopped(object? sender, EventArgs e) => + ZoomPanControl.IsEnabled = !MotionPhotoView.IsPlaying && !SecondaryMotionPhotoView.IsPlaying; + + /// + /// Notifies the motion photo overlays that a new image is displayed, + /// stopping any running playback and preparing the badge when applicable. + /// May be called from any thread; UI work is marshalled to the UI thread. + /// + public void UpdateMotionPhoto(TabViewModel tabViewModel) + { + if (Dispatcher.UIThread.CheckAccess()) + { + UpdateMotionPhotoOverlays(tabViewModel); + } + else + { + Dispatcher.UIThread.Post(() => UpdateMotionPhotoOverlays(tabViewModel)); + } + } + + private void UpdateMotionPhotoOverlays(TabViewModel tabViewModel) + { + var isSingleImage = tabViewModel.SingleImageType is not SingleImageType.None; + MotionPhotoView.OnImageChanged(isSingleImage ? null : tabViewModel.Model); + SecondaryMotionPhotoView.OnImageChanged(isSingleImage ? null : tabViewModel.SecondaryModel); + } + + /// Whether the current image is a playable motion photo. + public bool IsMotionPhotoActive => MotionPhotoView.IsMotionPhotoActive || SecondaryMotionPhotoView.IsMotionPhotoActive; + + /// Stops motion photo playback. Returns true when playback was active. + public bool StopMotionPhotoIfPlaying() => MotionPhotoView.StopIfPlaying() | SecondaryMotionPhotoView.StopIfPlaying(); + + /// Starts, pauses or resumes motion photo playback. + public void ToggleMotionPhotoPlayPause() + { + if (MotionPhotoView.IsPlaying || !SecondaryMotionPhotoView.IsMotionPhotoActive) + { + MotionPhotoView.TogglePlayPause(); + } + else + { + SecondaryMotionPhotoView.TogglePlayPause(); + } } public void TriggerScalingModeUpdate(bool invalidate) => @@ -186,6 +254,12 @@ public void Dispose() RemoveHandler(PointerWheelChangedEvent, PreviewOnPointerWheelChanged); RemoveHandler(PointerTouchPadGestureMagnifyEvent, TouchMagnifyEvent); RemoveHandler(PinchEvent, TouchMagnifyEvent); + MotionPhotoView.PlaybackStarted -= OnMotionPhotoPlaybackStarted; + MotionPhotoView.PlaybackStopped -= OnMotionPhotoPlaybackStopped; + MotionPhotoView.Dispose(); + SecondaryMotionPhotoView.PlaybackStarted -= OnMotionPhotoPlaybackStarted; + SecondaryMotionPhotoView.PlaybackStopped -= OnMotionPhotoPlaybackStopped; + SecondaryMotionPhotoView.Dispose(); _disposables.Dispose(); HoverBar.Dispose(); } diff --git a/src/PicView.Core/Config/AppSettings.cs b/src/PicView.Core/Config/AppSettings.cs index 3ab458d44..9eeb0e3e5 100644 --- a/src/PicView.Core/Config/AppSettings.cs +++ b/src/PicView.Core/Config/AppSettings.cs @@ -215,6 +215,17 @@ public class UIProperties public int DoubleClickBehavior { get; set; } = 1; public bool ShowFullPathInTitleBar { get; set; } = false; + + /// + /// Determines whether the embedded video of a motion photo is played automatically + /// when the image is shown. When false, playback starts via the motion photo badge. + /// + public bool AutoPlayMotionPhotos { get; set; } = false; + + /// + /// Determines whether motion photo playback is muted. + /// + public bool MuteMotionPhotos { get; set; } = true; } public class Theme diff --git a/src/PicView.Core/Config/Languages/da.json b/src/PicView.Core/Config/Languages/da.json index aa4fc3112..6d0b46c13 100644 --- a/src/PicView.Core/Config/Languages/da.json +++ b/src/PicView.Core/Config/Languages/da.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Museknap fremad", "MouseSideButtons": "Musens sideknapper", "MouseWheel": "Musehjul", + "MotionPhoto": "Bevægelsesfoto", "MoveToRecycleBin": "Flyt til papirkurv", "MoveWindow": "Flyt vinduet", "Navigate": "Naviger", diff --git a/src/PicView.Core/Config/Languages/de.json b/src/PicView.Core/Config/Languages/de.json index acb5abfb0..77ca42410 100644 --- a/src/PicView.Core/Config/Languages/de.json +++ b/src/PicView.Core/Config/Languages/de.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Maustaste vorwärts", "MouseSideButtons": "Seitentasten der Maus", "MouseWheel": "Mausrad", + "MotionPhoto": "Motion Photo", "MoveToRecycleBin": "In den Papierkorb verschieben", "MoveWindow": "Fenster verschieben", "Navigate": "Navigieren", diff --git a/src/PicView.Core/Config/Languages/en.json b/src/PicView.Core/Config/Languages/en.json index 971f47ef8..6daa48d37 100644 --- a/src/PicView.Core/Config/Languages/en.json +++ b/src/PicView.Core/Config/Languages/en.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Mouse key forward", "MouseSideButtons": "Mouse side buttons", "MouseWheel": "Mouse wheel", + "MotionPhoto": "Motion Photo", "MoveToRecycleBin": "Move to recycle bin", "MoveWindow": "Move window", "Navigate": "Navigate", diff --git a/src/PicView.Core/Config/Languages/es.json b/src/PicView.Core/Config/Languages/es.json index df38bf8c3..4c6fae796 100644 --- a/src/PicView.Core/Config/Languages/es.json +++ b/src/PicView.Core/Config/Languages/es.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Rueda del ratón hacia atrás", "MouseSideButtons": "Botones laterales del ratón", "MouseWheel": "Rueda del ratón", + "MotionPhoto": "Foto en movimiento", "MoveToRecycleBin": "Mover a la papelera de reciclaje", "MoveWindow": "Mover ventana", "Navigate": "Navegar", diff --git a/src/PicView.Core/Config/Languages/fr.json b/src/PicView.Core/Config/Languages/fr.json index cb5f59d75..c88e3f398 100644 --- a/src/PicView.Core/Config/Languages/fr.json +++ b/src/PicView.Core/Config/Languages/fr.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Touche souris en avant", "MouseSideButtons": "Boutons latéraux de la souris", "MouseWheel": "Roulette de la souris", + "MotionPhoto": "Photo animée", "MoveToRecycleBin": "Déplacer vers la corbeille", "MoveWindow": "Déplacer la fenêtre", "Navigate": "Naviguer", diff --git a/src/PicView.Core/Config/Languages/he.json b/src/PicView.Core/Config/Languages/he.json index 06ecb5f03..258a7fe2e 100644 --- a/src/PicView.Core/Config/Languages/he.json +++ b/src/PicView.Core/Config/Languages/he.json @@ -239,6 +239,7 @@ "MouseKeyForward": "לחצן עכבר קדמי", "MouseSideButtons": "כפתורי צד של העכבר", "MouseWheel": "גלגלת עכבר", + "MotionPhoto": "תמונה בתנועה", "MoveToRecycleBin": "העבר לסל המיחזור", "MoveWindow": "הזז חלון", "Navigate": "ניווט", diff --git a/src/PicView.Core/Config/Languages/hu.json b/src/PicView.Core/Config/Languages/hu.json index 73e20acb1..6d05d3b56 100644 --- a/src/PicView.Core/Config/Languages/hu.json +++ b/src/PicView.Core/Config/Languages/hu.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Egérgomb előre", "MouseSideButtons": "Egér oldalsó gombjai", "MouseWheel": "Egérgörgő", + "MotionPhoto": "Mozgófotó", "MoveToRecycleBin": "Áthelyezés a szemetesbe", "MoveWindow": "Ablak áthelyezése", "Navigate": "Navigálás", diff --git a/src/PicView.Core/Config/Languages/it.json b/src/PicView.Core/Config/Languages/it.json index f15cf7ef2..8afd35058 100644 --- a/src/PicView.Core/Config/Languages/it.json +++ b/src/PicView.Core/Config/Languages/it.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Tasto mouse in avanti", "MouseSideButtons": "Pulsanti laterali del mouse", "MouseWheel": "Rotellina del mouse", + "MotionPhoto": "Foto in movimento", "MoveToRecycleBin": "Sposta nel cestino", "MoveWindow": "Sposta la finestra", "Navigate": "Gezin", diff --git a/src/PicView.Core/Config/Languages/ja.json b/src/PicView.Core/Config/Languages/ja.json index fc40cb510..078be2576 100644 --- a/src/PicView.Core/Config/Languages/ja.json +++ b/src/PicView.Core/Config/Languages/ja.json @@ -239,6 +239,7 @@ "MouseKeyForward": "マウスキー進む", "MouseSideButtons": "マウスのサイドボタン", "MouseWheel": "マウスホイール", + "MotionPhoto": "モーションフォト", "MoveToRecycleBin": "ゴミ箱に移動", "MoveWindow": "ウィンドウの移動", "Navigate": "ナビゲート", diff --git a/src/PicView.Core/Config/Languages/ko.json b/src/PicView.Core/Config/Languages/ko.json index 589815460..8027c0816 100644 --- a/src/PicView.Core/Config/Languages/ko.json +++ b/src/PicView.Core/Config/Languages/ko.json @@ -239,6 +239,7 @@ "MouseKeyForward": "마우스 키 앞으로", "MouseSideButtons": "마우스 측면 버튼", "MouseWheel": "마우스 휠", + "MotionPhoto": "모션 포토", "MoveToRecycleBin": "휴지통으로 이동", "MoveWindow": "창 이동", "Navigate": "탐색", diff --git a/src/PicView.Core/Config/Languages/nl.json b/src/PicView.Core/Config/Languages/nl.json index 397604b47..5c3ecc33d 100644 --- a/src/PicView.Core/Config/Languages/nl.json +++ b/src/PicView.Core/Config/Languages/nl.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Muistoets vooruit", "MouseSideButtons": "Zijknoppen van de muis", "MouseWheel": "Muiswiel", + "MotionPhoto": "Bewegingsfoto", "MoveToRecycleBin": "Verplaatsen naar prullenbak", "MoveWindow": "Venster verplaatsen", "Navigate": "Navigeren", diff --git a/src/PicView.Core/Config/Languages/pl.json b/src/PicView.Core/Config/Languages/pl.json index b43bf56a3..2886e70c5 100644 --- a/src/PicView.Core/Config/Languages/pl.json +++ b/src/PicView.Core/Config/Languages/pl.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Przycisk myszy wprzód", "MouseSideButtons": "Boczne przyciski myszy", "MouseWheel": "Kółko myszy", + "MotionPhoto": "Ruchome zdjęcie", "MoveToRecycleBin": "Przenieś do kosza", "MoveWindow": "Przenieś okno", "Navigate": "Nawigacja", diff --git a/src/PicView.Core/Config/Languages/pt-br.json b/src/PicView.Core/Config/Languages/pt-br.json index be63fb740..b210670f0 100644 --- a/src/PicView.Core/Config/Languages/pt-br.json +++ b/src/PicView.Core/Config/Languages/pt-br.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Tecla do mouse para frente", "MouseSideButtons": "Botões laterais do mouse", "MouseWheel": "Roda do mouse", + "MotionPhoto": "Foto em movimento", "MoveToRecycleBin": "Mover para a lixeira", "MoveWindow": "Mover janela", "Navigate": "Navegar", diff --git a/src/PicView.Core/Config/Languages/ro.json b/src/PicView.Core/Config/Languages/ro.json index 7cd5bbf17..24f4f0534 100644 --- a/src/PicView.Core/Config/Languages/ro.json +++ b/src/PicView.Core/Config/Languages/ro.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Tastă maus înainte", "MouseSideButtons": "Butoanele laterale ale mouse-ului", "MouseWheel": "Rotiță maus", + "MotionPhoto": "Fotografie în mișcare", "MoveToRecycleBin": "Mută în coșul de gunoi", "MoveWindow": "Mută fereastra", "Navigate": "Navigare", diff --git a/src/PicView.Core/Config/Languages/ru.json b/src/PicView.Core/Config/Languages/ru.json index 93ff41c05..db73a6169 100644 --- a/src/PicView.Core/Config/Languages/ru.json +++ b/src/PicView.Core/Config/Languages/ru.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Клавиша мыши вперед", "MouseSideButtons": "Боковые кнопки мыши", "MouseWheel": "Колесико мыши", + "MotionPhoto": "Движущееся фото", "MoveToRecycleBin": "Переместить в корзину", "MoveWindow": "Переместить окно", "Navigate": "Навигация", diff --git a/src/PicView.Core/Config/Languages/sl.json b/src/PicView.Core/Config/Languages/sl.json index 8e6ec7e9f..99c3f5cac 100644 --- a/src/PicView.Core/Config/Languages/sl.json +++ b/src/PicView.Core/Config/Languages/sl.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Tipka miške naprej", "MouseSideButtons": "Bokovne tipke miške", "MouseWheel": "Kolešček miške", + "MotionPhoto": "Gibljiva fotografija", "MoveToRecycleBin": "Premakni v koš", "MoveWindow": "Premakni okno", "Navigate": "Navigiraj", diff --git a/src/PicView.Core/Config/Languages/sr-Cyrl.json b/src/PicView.Core/Config/Languages/sr-Cyrl.json index ae238adba..a72c95307 100644 --- a/src/PicView.Core/Config/Languages/sr-Cyrl.json +++ b/src/PicView.Core/Config/Languages/sr-Cyrl.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Тастер миша напред", "MouseSideButtons": "Бочна дугмад миша", "MouseWheel": "Точкић миша", + "MotionPhoto": "Фотографија у покрету", "MoveToRecycleBin": "Премести у канту", "MoveWindow": "Помери прозор", "Navigate": "Навигирај", diff --git a/src/PicView.Core/Config/Languages/sr-Latn.json b/src/PicView.Core/Config/Languages/sr-Latn.json index 12009a3bd..70f0be1dd 100644 --- a/src/PicView.Core/Config/Languages/sr-Latn.json +++ b/src/PicView.Core/Config/Languages/sr-Latn.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Taster miša napred", "MouseSideButtons": "Bočna dugmad miša", "MouseWheel": "Točkić miša", + "MotionPhoto": "Fotografija u pokretu", "MoveToRecycleBin": "Premesti u kantu", "MoveWindow": "Pomeri prozor", "Navigate": "Navigiraj", diff --git a/src/PicView.Core/Config/Languages/sv.json b/src/PicView.Core/Config/Languages/sv.json index 7a2649585..c021ed401 100644 --- a/src/PicView.Core/Config/Languages/sv.json +++ b/src/PicView.Core/Config/Languages/sv.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Musknapp framåt", "MouseSideButtons": "Musens sidoknappar", "MouseWheel": "Mushjul", + "MotionPhoto": "Rörligt foto", "MoveToRecycleBin": "Flytta till papperskorgen", "MoveWindow": "Flytta fönster", "Navigate": "Navigera", diff --git a/src/PicView.Core/Config/Languages/tr.json b/src/PicView.Core/Config/Languages/tr.json index 0f708f314..463b3e766 100644 --- a/src/PicView.Core/Config/Languages/tr.json +++ b/src/PicView.Core/Config/Languages/tr.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Fare tuşu ileri", "MouseSideButtons": "Farenin yan düğmeleri", "MouseWheel": "Fare tekerleği", + "MotionPhoto": "Hareketli fotoğraf", "MoveToRecycleBin": "Geri dönüşüm kutusuna taşı", "MoveWindow": "Pencereyi taşı", "Navigate": "Gezin", diff --git a/src/PicView.Core/Config/Languages/zh-CN.json b/src/PicView.Core/Config/Languages/zh-CN.json index 830c89b50..3efa26c1e 100644 --- a/src/PicView.Core/Config/Languages/zh-CN.json +++ b/src/PicView.Core/Config/Languages/zh-CN.json @@ -239,6 +239,7 @@ "MouseKeyForward": "鼠标拓展键 向前键", "MouseSideButtons": "鼠标侧键", "MouseWheel": "鼠标滚轮", + "MotionPhoto": "动态照片", "MoveToRecycleBin": "移动到回收站", "MoveWindow": "移动窗口", "Navigate": "导航", diff --git a/src/PicView.Core/Config/Languages/zh-TW.json b/src/PicView.Core/Config/Languages/zh-TW.json index 79594d50a..422f74a7b 100644 --- a/src/PicView.Core/Config/Languages/zh-TW.json +++ b/src/PicView.Core/Config/Languages/zh-TW.json @@ -239,6 +239,7 @@ "MouseKeyForward": "滑鼠拓展鍵 向前鍵", "MouseSideButtons": "滑鼠側鍵", "MouseWheel": "滑鼠滾輪", + "MotionPhoto": "動態照片", "MoveToRecycleBin": "移至資源回收筒", "MoveWindow": "移動視窗", "Navigate": "導覽", diff --git a/src/PicView.Core/FileHandling/SupportedFiles.cs b/src/PicView.Core/FileHandling/SupportedFiles.cs index 21ddba0b5..f27b8174f 100644 --- a/src/PicView.Core/FileHandling/SupportedFiles.cs +++ b/src/PicView.Core/FileHandling/SupportedFiles.cs @@ -31,6 +31,7 @@ public static class SupportedFiles ".tga", ".heic", ".heif", + ".livp", ".hdr", ".xcf", ".jxl", diff --git a/src/PicView.Core/Gallery/GalleryLoader.cs b/src/PicView.Core/Gallery/GalleryLoader.cs index ca4083af1..a81f96dc5 100644 --- a/src/PicView.Core/Gallery/GalleryLoader.cs +++ b/src/PicView.Core/Gallery/GalleryLoader.cs @@ -1,4 +1,5 @@ using PicView.Core.DebugTools; +using PicView.Core.MotionPhoto; using PicView.Core.Navigation.Interfaces; using PicView.Core.ViewModels; @@ -138,8 +139,9 @@ async ValueTask CheckAndLoad(GalleryItemViewModel item) thumbnailCache.Add(tab.Id, item.FileInfo.FullName, thumb); } item.Image.Value = thumb; + DetectMotionPhoto(item); } - + async ValueTask LoadItem(GalleryItemViewModel item) { if (item.FileInfo is null) @@ -147,14 +149,20 @@ async ValueTask LoadItem(GalleryItemViewModel item) DebugHelper.LogDebug(nameof(GalleryLoader), nameof(LoadGalleryAsync), "Invalid file"); return; } - + var thumb = await thumbnailLoader.GetThumbnailAsync(item.FileInfo, (uint)maxHeight).ConfigureAwait(false); if (thumb is not null) { thumbnailCache.Add(tab.Id, item.FileInfo.FullName, thumb); } item.Image.Value = thumb; + DetectMotionPhoto(item); } + + // Runs inside the parallel thumbnail loop (thread-pool threads). The detector is + // stateless and thread-safe; cost overlaps with thumbnail I/O. + static void DetectMotionPhoto(GalleryItemViewModel item) => + item.IsMotionPhoto.Value = MotionPhotoDetector.TryDetect(item.FileInfo, null) is not null; } public static async Task ReloadGallery(TabViewModel tab, IReadOnlyList files, IThumbnailLoader thumbnailLoader, IThumbnailCache thumbnailCache, CancellationToken ct) diff --git a/src/PicView.Core/ImageDecoding/ImageType.cs b/src/PicView.Core/ImageDecoding/ImageType.cs index 4ed5d571c..e97dc9c9e 100644 --- a/src/PicView.Core/ImageDecoding/ImageType.cs +++ b/src/PicView.Core/ImageDecoding/ImageType.cs @@ -7,4 +7,5 @@ public enum ImageType AnimatedWebp, Bitmap, Svg, + MotionPhoto, } \ No newline at end of file diff --git a/src/PicView.Core/Localization/LanguageModel.cs b/src/PicView.Core/Localization/LanguageModel.cs index d79deb891..65f6f21dc 100644 --- a/src/PicView.Core/Localization/LanguageModel.cs +++ b/src/PicView.Core/Localization/LanguageModel.cs @@ -254,6 +254,7 @@ public class LanguageModel public string? MouseKeyForward { get; set; } public string? MouseSideButtons { get; set; } public string? MouseWheel { get; set; } + public string? MotionPhoto { get; set; } public string? MoveToRecycleBin { get; set; } public string? MoveWindow { get; set; } public string? Navigate { get; set; } diff --git a/src/PicView.Core/Models/ImageModel.cs b/src/PicView.Core/Models/ImageModel.cs index dc36d04bf..8bf8e1713 100644 --- a/src/PicView.Core/Models/ImageModel.cs +++ b/src/PicView.Core/Models/ImageModel.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using PicView.Core.ImageDecoding; +using PicView.Core.MotionPhoto; using PicView.Core.Navigation.Tiff; namespace PicView.Core.Models; @@ -13,6 +14,7 @@ public class ImageModel : IDisposable public uint PixelHeight { get; set; } public ImageType ImageType { get; set; } public TiffNavigationInfo? TiffNavigation { get; set; } + public MotionPhotoInfo? MotionPhoto { get; set; } public void Dispose() { diff --git a/src/PicView.Core/MotionPhoto/FileSliceStream.cs b/src/PicView.Core/MotionPhoto/FileSliceStream.cs new file mode 100644 index 000000000..304698943 --- /dev/null +++ b/src/PicView.Core/MotionPhoto/FileSliceStream.cs @@ -0,0 +1,85 @@ +namespace PicView.Core.MotionPhoto; + +/// +/// A read-only, seekable window over a region of a file. Used to hand the embedded video +/// portion of a motion photo to consumers (e.g. libvlc) without copying it into memory first. +/// Reads and seeks are clamped to the slice; the underlying file is kept open until disposal. +/// +public sealed class FileSliceStream : Stream +{ + private readonly FileStream _inner; + private readonly long _offset; + private readonly long _length; + + public FileSliceStream(string path, long offset, long length) + { + _inner = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); + _offset = offset; + _length = Math.Min(length, Math.Max(0, _inner.Length - offset)); + _inner.Position = offset; + } + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length => _length; + + public override long Position + { + get => _inner.Position - _offset; + set => Seek(value, SeekOrigin.Begin); + } + + public override long Seek(long offset, SeekOrigin origin) + { + var absolute = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => Position + offset, + SeekOrigin.End => _length + offset, + _ => throw new ArgumentOutOfRangeException(nameof(origin)), + }; + + var clamped = Math.Clamp(absolute, 0, _length); + _inner.Position = _offset + clamped; + return clamped; + } + + public override int Read(byte[] buffer, int offset, int count) => + _inner.Read(buffer, offset, ClampToRemaining(count)); + + public override int Read(Span buffer) => + _inner.Read(buffer.Slice(0, ClampToRemaining(buffer.Length))); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + _inner.ReadAsync(buffer.Slice(0, ClampToRemaining(buffer.Length)), cancellationToken); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + _inner.ReadAsync(buffer.AsMemory(offset, ClampToRemaining(count)), cancellationToken).AsTask(); + + public override void Flush() + { + // Read-only; nothing to flush. + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + private int ClampToRemaining(int count) + { + var remaining = _length - Position; + return (int)Math.Max(0, Math.Min(count, remaining)); + } +} diff --git a/src/PicView.Core/MotionPhoto/MotionPhotoDetector.cs b/src/PicView.Core/MotionPhoto/MotionPhotoDetector.cs new file mode 100644 index 000000000..10e813e3c --- /dev/null +++ b/src/PicView.Core/MotionPhoto/MotionPhotoDetector.cs @@ -0,0 +1,418 @@ +using System.Buffers; +using System.Text; +using PicView.Core.DebugTools; + +namespace PicView.Core.MotionPhoto; + +/// +/// Detects whether an image file is a motion photo (Google/Samsung/DJI/OPPO style embedded +/// video, Apple/vivo style sidecar file, or .livp container). +/// +/// XMP metadata is located with plain string searches instead of XML parsing, because vendor +/// namespaces vary widely (GCamera, OpCamera, dji, Samsung...). This mirrors the approach +/// proven in other viewers. +/// +/// +public static class MotionPhotoDetector +{ + private static readonly byte[] SamsungMarkerBytes = Encoding.ASCII.GetBytes("MotionPhoto_Data"); + private static readonly byte[] JpegXmpHeaderBytes = Encoding.ASCII.GetBytes("http://ns.adobe.com/xap/1.0/"); + private static readonly byte[] XmpEndTagBytes = Encoding.ASCII.GetBytes(""); + + /// Scan up to 32 MB from the file tail when searching for the Samsung trailer marker. + private const int SamsungScanWindowBytes = 32 * 1024 * 1024; + + /// Scan up to 1 MB from the file start when searching for a JPEG XMP packet. + private const int JpegXmpScanWindowBytes = 1024 * 1024; + + /// + /// Attempts to detect motion photo data for the given file. + /// + /// The image file to inspect. + /// + /// Optional XMP packet text (e.g. from Magick.NET). When null and the file is a JPEG, + /// a lightweight APP1 byte scan is used as fallback. + /// + /// A describing the video location, or null if not a motion photo. + public static MotionPhotoInfo? TryDetect(FileInfo fileInfo, string? xmpPacket) + { + try + { + if (!fileInfo.Exists || fileInfo.Length is 0) + { + return null; + } + + var extension = fileInfo.Extension; + if (extension.Equals(".livp", StringComparison.OrdinalIgnoreCase)) + { + return new MotionPhotoInfo { Source = MotionPhotoSource.LivpContainer }; + } + + if (xmpPacket is null && + (extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase))) + { + xmpPacket = ReadJpegXmpPacket(fileInfo); + } + + if (xmpPacket is { Length: > 0 }) + { + var fromXmp = TryDetectFromXmp(fileInfo.Length, xmpPacket); + if (fromXmp is not null) + { + return fromXmp; + } + } + + // The Samsung trailer format only exists in JPEG files; scanning the tail of + // every HEIC image would just waste I/O. + if (extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)) + { + var samsung = TryDetectSamsungTrailer(fileInfo); + if (samsung is not null) + { + return samsung; + } + } + + return TryDetectSidecar(fileInfo); + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoDetector), nameof(TryDetect), e); + return null; + } + } + + /// + /// Searches the XMP packet text for motion photo metadata. + /// Supports the new Container:Directory standard (Item:Semantic=MotionPhoto + Item:Length) + /// and the legacy MicroVideo standard (MicroVideoOffset). + /// + internal static MotionPhotoInfo? TryDetectFromXmp(long fileLength, string xmp) + { + // New standard: Item:Semantic = MotionPhoto, with Item:Length holding the video byte count. + // Handle both element form (MotionPhoto) + // and attribute form (Item:Semantic="MotionPhoto"). + var semanticIndex = xmp.IndexOf(">MotionPhoto<", StringComparison.Ordinal); + if (semanticIndex < 0) + { + semanticIndex = xmp.IndexOf("\"MotionPhoto\"", StringComparison.Ordinal); + } + + if (semanticIndex >= 0) + { + // Only accept an Item:Length that belongs to the same Directory item as the + // MotionPhoto semantic. The still-image item carries its own Item:Length, + // which must never be mistaken for the video length. + var itemEnd = FindItemEnd(xmp, semanticIndex); + var markerIndex = xmp.IndexOf("Item:Length", semanticIndex, itemEnd - semanticIndex, StringComparison.OrdinalIgnoreCase); + if (markerIndex < 0) + { + // Attribute-reordered form: Item:Length before Item:Semantic within the same tag. + var tagStart = xmp.LastIndexOf('<', semanticIndex); + if (tagStart >= 0) + { + markerIndex = xmp.IndexOf("Item:Length", tagStart, semanticIndex - tagStart, StringComparison.OrdinalIgnoreCase); + } + } + + if (markerIndex >= 0) + { + var videoLength = ExtractNumberAfter(xmp, markerIndex + "Item:Length".Length); + if (videoLength is > 0 && videoLength <= fileLength) + { + return new MotionPhotoInfo + { + Source = MotionPhotoSource.EmbeddedXmp, + VideoOffset = fileLength - videoLength.Value, + VideoLength = videoLength.Value, + }; + } + } + } + + // Legacy standard: GCamera:MicroVideoOffset (bytes from end of file) + var microVideoIndex = xmp.IndexOf("MicroVideoOffset", StringComparison.OrdinalIgnoreCase); + if (microVideoIndex >= 0) + { + var offset = ExtractNumberAfter(xmp, microVideoIndex + "MicroVideoOffset".Length); + if (offset is > 0 && offset <= fileLength) + { + return new MotionPhotoInfo + { + Source = MotionPhotoSource.EmbeddedXmp, + VideoOffset = fileLength - offset.Value, + VideoLength = offset.Value, + }; + } + } + + return null; + } + + /// + /// Finds the end of the Directory item containing the given position: the earliest of the + /// self-closing tag end (attribute form), the closing item tag (element form) or the start + /// of the next sibling item. Returns the packet length when no boundary is found. + /// + private static int FindItemEnd(string xmp, int startIndex) + { + var end = xmp.Length; + var selfClose = xmp.IndexOf("/>", startIndex, StringComparison.Ordinal); + if (selfClose >= 0) + { + end = Math.Min(end, selfClose); + } + + var elementClose = xmp.IndexOf("", startIndex, StringComparison.OrdinalIgnoreCase); + if (elementClose >= 0) + { + end = Math.Min(end, elementClose); + } + + var nextItem = xmp.IndexOf("= 0) + { + end = Math.Min(end, nextItem); + } + + return end; + } + + /// + /// Scans the tail of the file for the legacy Samsung "MotionPhoto_Data" trailer marker. + /// The video starts immediately after the marker. + /// + internal static MotionPhotoInfo? TryDetectSamsungTrailer(FileInfo fileInfo) + { + var fileLength = fileInfo.Length; + var minimumSize = SamsungMarkerBytes.Length + 16; + if (fileLength < minimumSize) + { + return null; + } + + var windowLength = (int)Math.Min(fileLength, SamsungScanWindowBytes); + var buffer = ArrayPool.Shared.Rent(windowLength); + try + { + var bytesRead = ReadFileTail(fileInfo, buffer, windowLength); + if (bytesRead < SamsungMarkerBytes.Length) + { + return null; + } + + var markerIndex = buffer.AsSpan(0, bytesRead).LastIndexOf(SamsungMarkerBytes); + if (markerIndex < 0) + { + return null; + } + + var videoStart = fileLength - bytesRead + markerIndex + SamsungMarkerBytes.Length; + if (videoStart >= fileLength) + { + return null; + } + + return new MotionPhotoInfo + { + Source = MotionPhotoSource.SamsungTrailer, + VideoOffset = videoStart, + VideoLength = fileLength - videoStart, + }; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Looks for a same-named sidecar video file (.mov preferred, then .mp4) next to the image. + /// The candidate must start with a valid ISO BMFF "ftyp" box, so unrelated same-named + /// files are not mistaken for a motion photo video. + /// + internal static MotionPhotoInfo? TryDetectSidecar(FileInfo fileInfo) + { + var directory = fileInfo.DirectoryName; + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + var baseName = Path.GetFileNameWithoutExtension(fileInfo.FullName); + foreach (var extension in new[] { ".mov", ".mp4" }) + { + var sidecar = new FileInfo(Path.Combine(directory, baseName + extension)); + if (sidecar.Exists && sidecar.Length > 0 && HasVideoFileHeader(sidecar)) + { + return new MotionPhotoInfo + { + Source = MotionPhotoSource.Sidecar, + SidecarFile = sidecar, + }; + } + } + + return null; + } + + /// + /// Checks whether the file starts with an ISO BMFF box whose type is "ftyp" + /// (4-byte box size followed by the "ftyp" signature). + /// + private static bool HasVideoFileHeader(FileInfo file) + { + Span header = stackalloc byte[8]; + try + { + using var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite, header.Length, FileOptions.SequentialScan); + var totalRead = 0; + while (totalRead < header.Length) + { + var read = stream.Read(header.Slice(totalRead)); + if (read is 0) + { + break; + } + + totalRead += read; + } + + return totalRead == header.Length && + header[4] == (byte)'f' && header[5] == (byte)'t' && + header[6] == (byte)'y' && header[7] == (byte)'p'; + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoDetector), nameof(HasVideoFileHeader), e); + return false; + } + } + + /// + /// Reads the XMP packet from a JPEG file by locating the APP1 segment that starts with the + /// XMP namespace header. Only the head of the file is scanned. + /// + internal static string? ReadJpegXmpPacket(FileInfo fileInfo) + { + var fileLength = fileInfo.Length; + var minimumSize = JpegXmpHeaderBytes.Length + 4; + if (fileLength < minimumSize) + { + return null; + } + + var windowLength = (int)Math.Min(fileLength, JpegXmpScanWindowBytes); + var buffer = ArrayPool.Shared.Rent(windowLength); + try + { + var bytesRead = ReadFileHead(fileInfo, buffer, windowLength); + var span = buffer.AsSpan(0, bytesRead); + var headerIndex = span.IndexOf(JpegXmpHeaderBytes); + if (headerIndex < 0) + { + return null; + } + + var packetStart = span.Slice(headerIndex).IndexOf((byte)'<'); + if (packetStart < 0) + { + return null; + } + + // Stop at the end of the XMP packet instead of converting the rest of the + // scan window (mostly JPEG image data) into a string. + var packetSpan = span.Slice(headerIndex + packetStart); + var packetEnd = packetSpan.IndexOf(XmpEndTagBytes); + if (packetEnd >= 0) + { + packetSpan = packetSpan.Slice(0, packetEnd + XmpEndTagBytes.Length); + } + + return Encoding.UTF8.GetString(packetSpan); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static int ReadFileTail(FileInfo fileInfo, byte[] buffer, int count) + { + using var stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite, 4096, FileOptions.SequentialScan); + stream.Seek(Math.Max(0, stream.Length - count), SeekOrigin.Begin); + var totalRead = 0; + while (totalRead < count) + { + var read = stream.Read(buffer.AsSpan(totalRead, count - totalRead)); + if (read is 0) + { + break; + } + + totalRead += read; + } + + return totalRead; + } + + private static int ReadFileHead(FileInfo fileInfo, byte[] buffer, int count) + { + using var stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite, 4096, FileOptions.SequentialScan); + var totalRead = 0; + while (totalRead < count) + { + var read = stream.Read(buffer.AsSpan(totalRead, count - totalRead)); + if (read is 0) + { + break; + } + + totalRead += read; + } + + return totalRead; + } + + /// + /// Extracts the first run of ASCII digits found at or after , + /// which allows handling both attribute ("Item:Length="123"") and element + /// ("<Item:Length>123</Item:Length>") XMP forms without parsing XML. + /// + private static long? ExtractNumberAfter(string text, int startIndex) + { + var index = startIndex; + while (index < text.Length && !char.IsAsciiDigit(text[index])) + { + index++; + } + + if (index >= text.Length) + { + return null; + } + + long value = 0; + var digitCount = 0; + while (index < text.Length && char.IsAsciiDigit(text[index])) + { + if (value > long.MaxValue / 10) + { + return null; + } + + value = value * 10 + (text[index] - '0'); + digitCount++; + index++; + } + + return digitCount is 0 ? null : value; + } +} diff --git a/src/PicView.Core/MotionPhoto/MotionPhotoExtractor.cs b/src/PicView.Core/MotionPhoto/MotionPhotoExtractor.cs new file mode 100644 index 000000000..d40dfa9fd --- /dev/null +++ b/src/PicView.Core/MotionPhoto/MotionPhotoExtractor.cs @@ -0,0 +1,278 @@ +using System.Buffers.Binary; +using PicView.Core.DebugTools; +using PicView.Core.FileHandling; +using SharpCompress.Archives; +using SharpCompress.Readers; + +namespace PicView.Core.MotionPhoto; + +/// +/// Extracts the video portion of a motion photo as a seekable stream, without writing +/// temporary files. Embedded videos are sliced from the source file (with ftyp box +/// validation to tolerate vendor-specific trailing blocks), sidecars are opened directly +/// and .livp containers are decompressed in memory. +/// +public static class MotionPhotoExtractor +{ + /// Safety cap to avoid allocating unreasonable amounts of memory on corrupt metadata. + private const long MaxVideoBytes = 256 * 1024 * 1024; + + /// + /// The search window (in bytes, both directions) used to correct the expected video start + /// position. Some vendors (e.g. DJI) append trailing blocks after the video, which shifts + /// the naive "last N bytes" slice into the middle of the stream. + /// + private const int FtypSearchWindowBytes = 8 * 1024; + + private const int BoxHeaderSize = 8; + + /// + /// Extracts the motion photo video described by . + /// + /// A seekable stream positioned at zero, or null when extraction fails. + public static async ValueTask ExtractAsync(FileInfo fileInfo, MotionPhotoInfo info, CancellationToken ct = default) + { + try + { + return info.Source switch + { + MotionPhotoSource.EmbeddedXmp or MotionPhotoSource.SamsungTrailer => + await ExtractEmbeddedAsync(fileInfo, info.VideoOffset, ct).ConfigureAwait(false), + MotionPhotoSource.Sidecar => OpenReadOnly(info.SidecarFile), + MotionPhotoSource.LivpContainer => + await ExtractLivpEntryToMemoryAsync(fileInfo, IsVideoFileName, ct).ConfigureAwait(false), + _ => null, + }; + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoExtractor), nameof(ExtractAsync), e); + return null; + } + } + + /// + /// Extracts the cover image of a .livp container to a temporary file so the regular + /// image decoding pipeline can process it. + /// + /// The path of the temporary image file, or null when the container holds no image. + public static async ValueTask ExtractLivpCoverToTempFileAsync(FileInfo fileInfo, CancellationToken ct = default) + { + try + { + var stream = await ExtractLivpEntryToMemoryAsync(fileInfo, IsImageFileName, ct).ConfigureAwait(false); + if (stream is null) + { + return null; + } + + await using (stream.ConfigureAwait(false)) + { + var tempPath = TempFileManager.GetNewTempFilePath("livp-cover.jpg"); + var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, + FileShare.Read, 4096, FileOptions.Asynchronous); + await using (fileStream.ConfigureAwait(false)) + { + await stream.CopyToAsync(fileStream, ct).ConfigureAwait(false); + } + + return tempPath; + } + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoExtractor), nameof(ExtractLivpCoverToTempFileAsync), e); + return null; + } + } + + private static async ValueTask ExtractEmbeddedAsync(FileInfo fileInfo, long expectedStart, CancellationToken ct) + { + var fileLength = fileInfo.Length; + if (expectedStart < 0 || expectedStart >= fileLength) + { + return null; + } + + var stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); + await using (stream.ConfigureAwait(false)) + { + var start = await LocateVideoStartAsync(stream, expectedStart, fileLength, ct).ConfigureAwait(false); + if (start is null) + { + return null; + } + + var length = fileLength - start.Value; + if (length <= 0 || length > MaxVideoBytes) + { + return null; + } + + // Hand out a live window over the source file instead of copying the whole + // video into memory; playback can start immediately and uses no extra RAM. + return new FileSliceStream(fileInfo.FullName, start.Value, length); + } + } + + private static Stream? OpenReadOnly(FileInfo? file) + { + if (file is null || !file.Exists || file.Length is 0) + { + return null; + } + + return new FileStream(file.FullName, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); + } + + private static async ValueTask ExtractLivpEntryToMemoryAsync( + FileInfo fileInfo, Func entryPredicate, CancellationToken ct) + { + if (!fileInfo.Exists || fileInfo.Length is 0 || fileInfo.Length > MaxVideoBytes) + { + return null; + } + + var stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); + await using (stream.ConfigureAwait(false)) + { + using var archive = ArchiveFactory.OpenArchive(stream, new ReaderOptions()); + foreach (var entry in archive.Entries) + { + if (entry.IsDirectory || entry.Key is null || !entryPredicate(entry.Key)) + { + continue; + } + + var size = entry.Size; + if (size <= 0 || size > MaxVideoBytes) + { + continue; + } + + var memory = new MemoryStream((int)size); + var entryStream = entry.OpenEntryStream(); + await using (entryStream.ConfigureAwait(false)) + { + await entryStream.CopyToAsync(memory, ct).ConfigureAwait(false); + } + + memory.Position = 0; + return memory; + } + } + + return null; + } + + /// + /// Verifies that the expected video start position is a valid MP4 "ftyp" box; otherwise + /// searches a ±8 KB window for the closest valid ftyp box. + /// + internal static async ValueTask LocateVideoStartAsync(Stream stream, long expectedStart, long fileLength, CancellationToken ct) + { + if (await IsFtypBoxAtAsync(stream, expectedStart, fileLength, ct).ConfigureAwait(false)) + { + return expectedStart; + } + + var windowStart = Math.Max(0, expectedStart - FtypSearchWindowBytes); + var windowEnd = Math.Min(fileLength, expectedStart + FtypSearchWindowBytes + BoxHeaderSize); + var windowLength = (int)(windowEnd - windowStart); + if (windowLength <= BoxHeaderSize) + { + return null; + } + + var buffer = new byte[windowLength]; + stream.Seek(windowStart, SeekOrigin.Begin); + var totalRead = 0; + while (totalRead < windowLength) + { + var read = await stream.ReadAsync(buffer.AsMemory(totalRead, windowLength - totalRead), ct).ConfigureAwait(false); + if (read is 0) + { + break; + } + + totalRead += read; + } + + return FindFtypStart(buffer.AsSpan(0, totalRead), windowStart, expectedStart, fileLength); + } + + /// + /// Scans a byte window for the valid ftyp box closest to . + /// + internal static long? FindFtypStart(ReadOnlySpan window, long windowStart, long expectedStart, long fileLength) + { + long? best = null; + var bestDistance = long.MaxValue; + for (var offset = 0; offset + BoxHeaderSize <= window.Length; offset++) + { + var absolute = windowStart + offset; + var distance = Math.Abs(absolute - expectedStart); + if (distance >= bestDistance) + { + continue; + } + + if (IsValidFtypBox(window.Slice(offset), absolute, fileLength)) + { + best = absolute; + bestDistance = distance; + } + } + + return best; + } + + private static async ValueTask IsFtypBoxAtAsync(Stream stream, long offset, long fileLength, CancellationToken ct) + { + if (offset < 0 || offset + BoxHeaderSize > fileLength) + { + return false; + } + + var header = new byte[BoxHeaderSize]; + stream.Seek(offset, SeekOrigin.Begin); + var read = await stream.ReadAsync(header.AsMemory(0, BoxHeaderSize), ct).ConfigureAwait(false); + return read == BoxHeaderSize && IsValidFtypBox(header, offset, fileLength); + } + + private static bool IsValidFtypBox(ReadOnlySpan bytes, long absoluteOffset, long fileLength) + { + if (bytes.Length < BoxHeaderSize) + { + return false; + } + + if (bytes[4] != (byte)'f' || bytes[5] != (byte)'t' || bytes[6] != (byte)'y' || bytes[7] != (byte)'p') + { + return false; + } + + var boxSize = BinaryPrimitives.ReadUInt32BigEndian(bytes); + return boxSize >= BoxHeaderSize && absoluteOffset + boxSize <= fileLength; + } + + private static bool IsVideoFileName(string name) + { + var extension = Path.GetExtension(name); + return extension.Equals(".mov", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsImageFileName(string name) + { + var extension = Path.GetExtension(name); + return extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".heic", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".heif", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".png", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/PicView.Core/MotionPhoto/MotionPhotoInfo.cs b/src/PicView.Core/MotionPhoto/MotionPhotoInfo.cs new file mode 100644 index 000000000..1cd6f3c77 --- /dev/null +++ b/src/PicView.Core/MotionPhoto/MotionPhotoInfo.cs @@ -0,0 +1,47 @@ +namespace PicView.Core.MotionPhoto; + +/// +/// Identifies how the motion photo video is stored. +/// +public enum MotionPhotoSource +{ + /// Video is embedded at the end of the image file, located via XMP metadata. + EmbeddedXmp, + + /// Video is embedded after a "MotionPhoto_Data" trailer marker (legacy Samsung format). + SamsungTrailer, + + /// Video is stored in a same-named sidecar file (.mov/.mp4) next to the image. + Sidecar, + + /// Image and video are stored inside a .livp zip container (Apple Live Photo export). + LivpContainer, +} + +/// +/// Describes where the embedded or associated video of a motion photo can be found. +/// The still image itself is decoded through the regular image pipeline; this record +/// only holds the coordinates needed to extract the video on demand. +/// +public sealed record MotionPhotoInfo +{ + public required MotionPhotoSource Source { get; init; } + + /// + /// Byte offset of the video inside the source file. Only meaningful for + /// and . + /// + public long VideoOffset { get; init; } + + /// + /// Length of the video in bytes as reported by the metadata, when known. + /// The extractor reads from to the end of file regardless, + /// as trailing junk is tolerated by the demuxer. + /// + public long VideoLength { get; init; } + + /// + /// The sidecar video file. Only meaningful for . + /// + public FileInfo? SidecarFile { get; init; } +} diff --git a/src/PicView.Core/Navigation/FileWatcherService.cs b/src/PicView.Core/Navigation/FileWatcherService.cs index 9e39e114b..b1ce0445d 100644 --- a/src/PicView.Core/Navigation/FileWatcherService.cs +++ b/src/PicView.Core/Navigation/FileWatcherService.cs @@ -5,6 +5,7 @@ using PicView.Core.FileSorting; using PicView.Core.Gallery; using PicView.Core.Models; +using PicView.Core.MotionPhoto; using PicView.Core.Navigation.Interfaces; using PicView.Core.ViewModels; using R3; @@ -219,6 +220,7 @@ private async ValueTask OnFileCreatedAsync(TabViewModel tab, FileSystemEventArgs thumbnailCache?.Add(tab.Id, newFile.FullName, thumb); } item.Image.Value = thumb; + item.IsMotionPhoto.Value = MotionPhotoDetector.TryDetect(newFile, null) is not null; } } } diff --git a/src/PicView.Core/ViewModels/GalleryItemViewModel.cs b/src/PicView.Core/ViewModels/GalleryItemViewModel.cs index e7458276a..9944f8ca8 100644 --- a/src/PicView.Core/ViewModels/GalleryItemViewModel.cs +++ b/src/PicView.Core/ViewModels/GalleryItemViewModel.cs @@ -11,15 +11,19 @@ public void Dispose() FileName, FileLocation, FileSize, - FileDate); + FileDate, + IsMotionPhoto); } - + // Data Properties public BindableReactiveProperty Image { get; } = new(); public BindableReactiveProperty FileName { get; } = new(); public BindableReactiveProperty FileLocation { get; } = new(); public BindableReactiveProperty FileSize { get; } = new(); public BindableReactiveProperty FileDate { get; } = new(); - + + /// Whether the file carries a motion photo video (drives the gallery badge). + public BindableReactiveProperty IsMotionPhoto { get; } = new(); + public FileInfo? FileInfo { get; set; } } diff --git a/src/PicView.Core/ViewModels/TabViewModel.cs b/src/PicView.Core/ViewModels/TabViewModel.cs index 19855b3aa..00f6c2dcb 100644 --- a/src/PicView.Core/ViewModels/TabViewModel.cs +++ b/src/PicView.Core/ViewModels/TabViewModel.cs @@ -151,6 +151,8 @@ public void UpdateTabTitle() else { SetSingleTitle(); + // TabTitle/TabTooltip are not touched by SetSingleTitle + AppendMotionPhotoMarkerIfNeeded(includeTabTitles: false); } return; @@ -179,6 +181,8 @@ public void UpdateTabTitle() TabTitle.Value = Model.FileInfo.Name; TabTooltip.Value = Model.FileInfo.FullName; } + + AppendMotionPhotoMarkerIfNeeded(includeTabTitles: true); return; @@ -224,7 +228,33 @@ void SetSingleTitle() WindowTitle.Value = singleTitles.TitleWithAppName; TitleTooltip.Value = singleTitles.FilePathTitle; } - + + // Appends a localized " (Motion Photo)" suffix to the computed titles when the + // current image (or the secondary one in side-by-side mode) carries a video. + void AppendMotionPhotoMarkerIfNeeded(bool includeTabTitles) + { + if (Model?.MotionPhoto is null && SecondaryModel?.MotionPhoto is null) + { + return; + } + + var marker = TranslationManager.Translation.MotionPhoto; + if (string.IsNullOrEmpty(marker)) + { + return; + } + + var suffix = $" ({marker})"; + Title.Value += suffix; + WindowTitle.Value += suffix; + TitleTooltip.Value += suffix; + if (includeTabTitles) + { + TabTitle.Value += suffix; + TabTooltip.Value += suffix; + } + } + } public void SetNewTabTitle() diff --git a/src/PicView.Core/ViewModels/TranslationViewModel.cs b/src/PicView.Core/ViewModels/TranslationViewModel.cs index c28bbcacb..1a53bf845 100644 --- a/src/PicView.Core/ViewModels/TranslationViewModel.cs +++ b/src/PicView.Core/ViewModels/TranslationViewModel.cs @@ -188,6 +188,7 @@ public void UpdateLanguage() MouseDrag.Value = t.MouseDrag; MouseSideButtons.Value = t.MouseSideButtons; MouseWheel.Value = t.MouseWheel; + MotionPhoto.Value = t.MotionPhoto; MoveToRecycleBin.Value = t.MoveToRecycleBin; MoveWindow.Value = t.MoveWindow; Navigate.Value = t.Navigate; @@ -575,6 +576,7 @@ public void SubscribeToDynamicTranslationUpdates() public BindableReactiveProperty MouseDrag { get; } = new(); public BindableReactiveProperty MouseSideButtons { get; } = new(); public BindableReactiveProperty MouseWheel { get; } = new(); + public BindableReactiveProperty MotionPhoto { get; } = new(); public BindableReactiveProperty MoveToRecycleBin { get; } = new(); public BindableReactiveProperty MoveWindow { get; } = new(); public BindableReactiveProperty Navigate { get; } = new(); diff --git a/src/PicView.Tests/MotionPhoto/FileSliceStreamTests.cs b/src/PicView.Tests/MotionPhoto/FileSliceStreamTests.cs new file mode 100644 index 000000000..02808ce1d --- /dev/null +++ b/src/PicView.Tests/MotionPhoto/FileSliceStreamTests.cs @@ -0,0 +1,97 @@ +using PicView.Core.MotionPhoto; + +namespace PicView.Tests.MotionPhoto; + +public class FileSliceStreamTests : IDisposable +{ + private readonly string _tempDirectory = MotionPhotoFixtures.CreateTempDirectory(); + + public void Dispose() + { + MotionPhotoFixtures.DeleteDirectory(_tempDirectory); + GC.SuppressFinalize(this); + } + + private string CreateFile(byte[] bytes) + { + var path = Path.Combine(_tempDirectory, Path.GetRandomFileName()); + File.WriteAllBytes(path, bytes); + return path; + } + + [Fact] + public void Read_WithinSlice_ReturnsSlicedBytes() + { + byte[] content = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + var path = CreateFile(content); + + using var stream = new FileSliceStream(path, 4, 3); + + Assert.Equal(3, stream.Length); + var buffer = new byte[3]; + Assert.Equal(3, stream.Read(buffer)); + Assert.Equal([4, 5, 6], buffer); + // Past the end of the slice + Assert.Equal(0, stream.Read(buffer)); + } + + [Fact] + public void Read_RequestedMoreThanRemaining_IsClampedToSlice() + { + byte[] content = [0, 1, 2, 3, 4, 5]; + var path = CreateFile(content); + + using var stream = new FileSliceStream(path, 2, 3); + + var buffer = new byte[16]; + Assert.Equal(3, stream.Read(buffer)); + Assert.Equal([2, 3, 4], buffer.AsSpan(0, 3).ToArray()); + } + + [Fact] + public void Seek_VariousOrigins_StaysWithinSlice() + { + var path = CreateFile(new byte[100]); + + using var stream = new FileSliceStream(path, 10, 20); + + Assert.Equal(20, stream.Length); + Assert.Equal(0, stream.Position); + + Assert.Equal(5, stream.Seek(5, SeekOrigin.Begin)); + Assert.Equal(5, stream.Position); + + Assert.Equal(8, stream.Seek(3, SeekOrigin.Current)); + + Assert.Equal(18, stream.Seek(-2, SeekOrigin.End)); + + // Out-of-range seeks are clamped, reads follow the clamped position + Assert.Equal(20, stream.Seek(999, SeekOrigin.Begin)); + Assert.Equal(0, stream.Read(new byte[1])); + + Assert.Equal(0, stream.Seek(-999, SeekOrigin.Begin)); + } + + [Fact] + public void Length_SliceLongerThanFile_IsClampedToFile() + { + var path = CreateFile(new byte[10]); + + using var stream = new FileSliceStream(path, 6, 1000); + + Assert.Equal(4, stream.Length); + } + + [Fact] + public async Task ReadAsync_WithinSlice_ReturnsSlicedBytes() + { + byte[] content = [10, 11, 12, 13, 14]; + var path = CreateFile(content); + + await using var stream = new FileSliceStream(path, 1, 3); + + var buffer = new byte[3]; + Assert.Equal(3, await stream.ReadAsync(buffer, TestContext.Current.CancellationToken)); + Assert.Equal([11, 12, 13], buffer); + } +} diff --git a/src/PicView.Tests/MotionPhoto/MotionPhotoDetectorTests.cs b/src/PicView.Tests/MotionPhoto/MotionPhotoDetectorTests.cs new file mode 100644 index 000000000..7a0030b82 --- /dev/null +++ b/src/PicView.Tests/MotionPhoto/MotionPhotoDetectorTests.cs @@ -0,0 +1,268 @@ +using PicView.Core.MotionPhoto; + +namespace PicView.Tests.MotionPhoto; + +public class MotionPhotoDetectorTests : IDisposable +{ + private readonly string _tempDirectory = MotionPhotoFixtures.CreateTempDirectory(); + + public void Dispose() + { + MotionPhotoFixtures.DeleteDirectory(_tempDirectory); + GC.SuppressFinalize(this); + } + + [Fact] + public void TryDetectFromXmp_NewStandardElementForm_ReturnsEmbeddedInfo() + { + var xmp = MotionPhotoFixtures.NewStandardXmp(4000); + + var result = MotionPhotoDetector.TryDetectFromXmp(10000, xmp); + + Assert.NotNull(result); + Assert.Equal(MotionPhotoSource.EmbeddedXmp, result.Source); + Assert.Equal(6000, result.VideoOffset); + Assert.Equal(4000, result.VideoLength); + } + + [Fact] + public void TryDetectFromXmp_NewStandardAttributeForm_ReturnsEmbeddedInfo() + { + const string xmp = + """ + + """; + + var result = MotionPhotoDetector.TryDetectFromXmp(8000, xmp); + + Assert.NotNull(result); + Assert.Equal(5500, result.VideoOffset); + Assert.Equal(2500, result.VideoLength); + } + + [Fact] + public void TryDetectFromXmp_LengthBeforeSemantic_FindsLengthViaBackwardSearch() + { + const string xmp = + """ + + """; + + var result = MotionPhotoDetector.TryDetectFromXmp(8000, xmp); + + Assert.NotNull(result); + Assert.Equal(6500, result.VideoOffset); + Assert.Equal(1500, result.VideoLength); + } + + [Fact] + public void TryDetectFromXmp_MicroVideoOffset_ReturnsOffsetFromEnd() + { + var xmp = MotionPhotoFixtures.MicroVideoXmp(3000); + + var result = MotionPhotoDetector.TryDetectFromXmp(10000, xmp); + + Assert.NotNull(result); + Assert.Equal(MotionPhotoSource.EmbeddedXmp, result.Source); + Assert.Equal(7000, result.VideoOffset); + Assert.Equal(3000, result.VideoLength); + } + + [Fact] + public void TryDetectFromXmp_VendorNamespaceVariant_Detected() + { + // Vendor namespaces differ (OpCamera/dji/...); detection must not depend on them. + const string xmp = + """ + + 1200 + + """; + + var result = MotionPhotoDetector.TryDetectFromXmp(5000, xmp); + + Assert.NotNull(result); + Assert.Equal(3800, result.VideoOffset); + } + + [Fact] + public void TryDetectFromXmp_LengthExceedsFileSize_ReturnsNull() + { + var xmp = MotionPhotoFixtures.NewStandardXmp(20000); + + var result = MotionPhotoDetector.TryDetectFromXmp(10000, xmp); + + Assert.Null(result); + } + + [Fact] + public void TryDetectFromXmp_PlainXmp_ReturnsNull() + { + var result = MotionPhotoDetector.TryDetectFromXmp(10000, MotionPhotoFixtures.PlainXmp); + + Assert.Null(result); + } + + [Fact] + public void TryDetect_LivpExtension_ReturnsLivpContainer() + { + var path = Path.Combine(_tempDirectory, "IMG_0001.livp"); + File.WriteAllBytes(path, [1, 2, 3, 4]); + + var result = MotionPhotoDetector.TryDetect(new FileInfo(path), null); + + Assert.NotNull(result); + Assert.Equal(MotionPhotoSource.LivpContainer, result.Source); + } + + [Fact] + public void TryDetect_JpegWithEmbeddedXmp_DetectsEmbeddedVideo() + { + // The XMP packet is only present inside the file bytes (xmpPacket argument is null), + // so this exercises the JPEG APP1 byte-scan fallback. + var video = MotionPhotoFixtures.BuildMp4Head(64); + var xmp = MotionPhotoFixtures.NewStandardXmp(video.Length); + var file = MotionPhotoFixtures.CreateEmbeddedMotionPhoto(_tempDirectory, "pixel.jpg", video, xmp); + + var result = MotionPhotoDetector.TryDetect(file, null); + + Assert.NotNull(result); + Assert.Equal(MotionPhotoSource.EmbeddedXmp, result.Source); + Assert.Equal(file.Length - video.Length, result.VideoOffset); + } + + [Fact] + public void ReadJpegXmpPacket_FileWithXmpSegment_ReturnsPacket() + { + var jpeg = MotionPhotoFixtures.BuildJpegWithXmp(MotionPhotoFixtures.PlainXmp); + var path = Path.Combine(_tempDirectory, "xmp.jpg"); + File.WriteAllBytes(path, jpeg); + + var packet = MotionPhotoDetector.ReadJpegXmpPacket(new FileInfo(path)); + + Assert.NotNull(packet); + Assert.Contains("x:xmpmeta", packet); + } + + [Fact] + public void TryDetect_SamsungTrailer_FindsVideoAfterMarker() + { + var video = MotionPhotoFixtures.BuildMp4Head(48); + var path = Path.Combine(_tempDirectory, "samsung.jpg"); + using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) + { + stream.Write(new byte[1024]); + stream.Write("MotionPhoto_Data"u8); + stream.Write(video); + } + + var result = MotionPhotoDetector.TryDetect(new FileInfo(path), MotionPhotoFixtures.PlainXmp); + + Assert.NotNull(result); + Assert.Equal(MotionPhotoSource.SamsungTrailer, result.Source); + Assert.Equal(1024 + "MotionPhoto_Data".Length, result.VideoOffset); + } + + [Fact] + public void TryDetect_Sidecar_PrefersMovOverMp4() + { + var imagePath = Path.Combine(_tempDirectory, "IMG_100.heic"); + File.WriteAllBytes(imagePath, [1, 2, 3]); + File.WriteAllBytes(Path.Combine(_tempDirectory, "IMG_100.mov"), MotionPhotoFixtures.BuildMp4Head(24)); + File.WriteAllBytes(Path.Combine(_tempDirectory, "IMG_100.mp4"), MotionPhotoFixtures.BuildMp4Head(32)); + + var result = MotionPhotoDetector.TryDetect(new FileInfo(imagePath), MotionPhotoFixtures.PlainXmp); + + Assert.NotNull(result); + Assert.Equal(MotionPhotoSource.Sidecar, result.Source); + Assert.EndsWith(".mov", result.SidecarFile?.Name); + } + + [Fact] + public void TryDetect_Sidecar_FallsBackToMp4() + { + var imagePath = Path.Combine(_tempDirectory, "IMG_200.jpg"); + File.WriteAllBytes(imagePath, [1, 2, 3]); + File.WriteAllBytes(Path.Combine(_tempDirectory, "IMG_200.mp4"), MotionPhotoFixtures.BuildMp4Head(32)); + + var result = MotionPhotoDetector.TryDetect(new FileInfo(imagePath), MotionPhotoFixtures.PlainXmp); + + Assert.NotNull(result); + Assert.Equal(MotionPhotoSource.Sidecar, result.Source); + Assert.EndsWith(".mp4", result.SidecarFile?.Name); + } + + [Fact] + public void TryDetect_SidecarWithoutVideoHeader_ReturnsNull() + { + // A same-named file that is not a video must not be treated as a motion photo sidecar. + var imagePath = Path.Combine(_tempDirectory, "IMG_250.heic"); + File.WriteAllBytes(imagePath, [1, 2, 3]); + File.WriteAllBytes(Path.Combine(_tempDirectory, "IMG_250.mp4"), [5, 6, 7, 8, 9, 10]); + + var result = MotionPhotoDetector.TryDetect(new FileInfo(imagePath), MotionPhotoFixtures.PlainXmp); + + Assert.Null(result); + } + + [Fact] + public void TryDetect_HeicWithSamsungTrailerMarker_ReturnsNull() + { + // The Samsung trailer scan only applies to JPEG files. + var video = MotionPhotoFixtures.BuildMp4Head(48); + var path = Path.Combine(_tempDirectory, "samsung.heic"); + using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) + { + stream.Write(new byte[1024]); + stream.Write("MotionPhoto_Data"u8); + stream.Write(video); + } + + var result = MotionPhotoDetector.TryDetect(new FileInfo(path), MotionPhotoFixtures.PlainXmp); + + Assert.Null(result); + } + + [Fact] + public void TryDetectFromXmp_MotionItemWithoutLength_IgnoresSiblingLength() + { + // The still-image item carries its own Item:Length, which must not be used + // as the video length when the MotionPhoto item lacks one. + const string xmp = + """ + + + + + + + + + + + """; + + var result = MotionPhotoDetector.TryDetectFromXmp(10000, xmp); + + Assert.Null(result); + } + + [Fact] + public void TryDetect_NoMotionPhotoData_ReturnsNull() + { + var imagePath = Path.Combine(_tempDirectory, "plain.jpg"); + File.WriteAllBytes(imagePath, new byte[2048]); + + var result = MotionPhotoDetector.TryDetect(new FileInfo(imagePath), MotionPhotoFixtures.PlainXmp); + + Assert.Null(result); + } + + [Fact] + public void TryDetect_NonexistentFile_ReturnsNull() + { + var result = MotionPhotoDetector.TryDetect(new FileInfo(Path.Combine(_tempDirectory, "missing.jpg")), null); + + Assert.Null(result); + } +} diff --git a/src/PicView.Tests/MotionPhoto/MotionPhotoExtractorTests.cs b/src/PicView.Tests/MotionPhoto/MotionPhotoExtractorTests.cs new file mode 100644 index 000000000..aab502867 --- /dev/null +++ b/src/PicView.Tests/MotionPhoto/MotionPhotoExtractorTests.cs @@ -0,0 +1,267 @@ +using PicView.Core.FileHandling; +using PicView.Core.MotionPhoto; + +namespace PicView.Tests.MotionPhoto; + +public class MotionPhotoExtractorTests : IDisposable +{ + private readonly string _tempDirectory = MotionPhotoFixtures.CreateTempDirectory(); + + public MotionPhotoExtractorTests() + { + TempFileManager.Cleanup(); + } + + public void Dispose() + { + MotionPhotoFixtures.DeleteDirectory(_tempDirectory); + TempFileManager.Cleanup(); + GC.SuppressFinalize(this); + } + + [Fact] + public void FindFtypStart_ExactExpectedPosition_ReturnsExpected() + { + var video = MotionPhotoFixtures.BuildMp4Head(32); + var window = new byte[512]; + video.CopyTo(window.AsSpan(100)); + + var result = MotionPhotoExtractor.FindFtypStart(window, 1000, 1100, 10_000); + + Assert.Equal(1100, result); + } + + [Fact] + public void FindFtypStart_TrailerShiftedStart_FindsClosestValidFtyp() + { + // Simulates a vendor trailer: the real ftyp box is 32 bytes before the + // expected start position (the naive "last N bytes" slice lands mid-video). + var video = MotionPhotoFixtures.BuildMp4Head(32); + var window = new byte[512]; + video.CopyTo(window.AsSpan(100)); + + var result = MotionPhotoExtractor.FindFtypStart(window, 1000, 1132, 10_000); + + Assert.Equal(1100, result); + } + + [Fact] + public void FindFtypStart_InvalidBoxSize_IsRejected() + { + var window = new byte[512]; + // "ftyp" signature but a box size larger than the remaining file -> invalid. + window[100] = 0xFF; + window[101] = 0xFF; + window[102] = 0xFF; + window[103] = 0xFF; + "ftyp"u8.CopyTo(window.AsSpan(104, 4)); + + var result = MotionPhotoExtractor.FindFtypStart(window, 1000, 1100, 10_000); + + Assert.Null(result); + } + + [Fact] + public void FindFtypStart_NoValidBox_ReturnsNull() + { + var window = new byte[512]; + new Random(42).NextBytes(window); + + var result = MotionPhotoExtractor.FindFtypStart(window, 0, 256, 10_000); + + Assert.Null(result); + } + + [Fact] + public async Task ExtractEmbedded_StandardLayout_ReturnsVideoStream() + { + var video = MotionPhotoFixtures.BuildMp4Head(48); + var path = Path.Combine(_tempDirectory, "embedded.jpg"); + await File.WriteAllBytesAsync(path, new byte[1024], TestContext.Current.CancellationToken); + await using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write)) + { + await stream.WriteAsync(video, TestContext.Current.CancellationToken); + } + + var fileInfo = new FileInfo(path); + var info = new MotionPhotoInfo + { + Source = MotionPhotoSource.EmbeddedXmp, + VideoOffset = fileInfo.Length - video.Length, + VideoLength = video.Length, + }; + + var result = await MotionPhotoExtractor.ExtractAsync(fileInfo, info, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + await using (result.ConfigureAwait(false)) + { + Assert.Equal(video.Length, result.Length); + var buffer = new byte[result.Length]; + Assert.Equal(buffer.Length, await result.ReadAsync(buffer, TestContext.Current.CancellationToken)); + Assert.Equal(video, buffer); + } + } + + [Fact] + public async Task ExtractEmbedded_TrailerShiftedOffset_CorrectsStartAndExtracts() + { + // File layout: [jpeg prefix][ftyp video][32 byte vendor trailer]. + // The XMP length only covers the video, so the expected start is shifted + // forward by the trailer size and must be corrected backwards to the ftyp box. + const int trailerSize = 32; + var video = MotionPhotoFixtures.BuildMp4Head(48); + var path = Path.Combine(_tempDirectory, "dji.jpg"); + await File.WriteAllBytesAsync(path, new byte[1024], TestContext.Current.CancellationToken); + await using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write)) + { + await stream.WriteAsync(video, TestContext.Current.CancellationToken); + await stream.WriteAsync(new byte[trailerSize], TestContext.Current.CancellationToken); + } + + var fileInfo = new FileInfo(path); + var ftypPosition = fileInfo.Length - video.Length - trailerSize; + var info = new MotionPhotoInfo + { + Source = MotionPhotoSource.EmbeddedXmp, + VideoOffset = fileInfo.Length - video.Length, + VideoLength = video.Length, + }; + + var result = await MotionPhotoExtractor.ExtractAsync(fileInfo, info, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + await using (result.ConfigureAwait(false)) + { + Assert.Equal(fileInfo.Length - ftypPosition, result.Length); + var buffer = new byte[8]; + Assert.Equal(8, await result.ReadAsync(buffer, TestContext.Current.CancellationToken)); + Assert.Equal("ftyp"u8.ToArray(), buffer.AsSpan(4, 4).ToArray()); + } + } + + [Fact] + public async Task ExtractEmbedded_NoFtypBox_ReturnsNull() + { + var path = Path.Combine(_tempDirectory, "corrupt.jpg"); + var bytes = new byte[4096]; + new Random(42).NextBytes(bytes); + await File.WriteAllBytesAsync(path, bytes, TestContext.Current.CancellationToken); + + var fileInfo = new FileInfo(path); + var info = new MotionPhotoInfo + { + Source = MotionPhotoSource.EmbeddedXmp, + VideoOffset = 2048, + VideoLength = 2048, + }; + + var result = await MotionPhotoExtractor.ExtractAsync(fileInfo, info, TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + [Fact] + public async Task ExtractEmbedded_OffsetOutOfRange_ReturnsNull() + { + var path = Path.Combine(_tempDirectory, "small.jpg"); + await File.WriteAllBytesAsync(path, new byte[128], TestContext.Current.CancellationToken); + + var info = new MotionPhotoInfo + { + Source = MotionPhotoSource.EmbeddedXmp, + VideoOffset = 999_999, + VideoLength = 100, + }; + + var result = await MotionPhotoExtractor.ExtractAsync(new FileInfo(path), info, TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + [Fact] + public async Task ExtractAsync_Sidecar_ReturnsSidecarContent() + { + var videoBytes = MotionPhotoFixtures.BuildMp4Head(40); + var sidecarPath = Path.Combine(_tempDirectory, "IMG_300.mov"); + await File.WriteAllBytesAsync(sidecarPath, videoBytes, TestContext.Current.CancellationToken); + + var info = new MotionPhotoInfo + { + Source = MotionPhotoSource.Sidecar, + SidecarFile = new FileInfo(sidecarPath), + }; + + var result = await MotionPhotoExtractor.ExtractAsync(new FileInfo(Path.Combine(_tempDirectory, "IMG_300.heic")), info, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + await using (result.ConfigureAwait(false)) + { + Assert.Equal(videoBytes.Length, result.Length); + } + } + + [Fact] + public async Task ExtractAsync_MissingSidecar_ReturnsNull() + { + var info = new MotionPhotoInfo + { + Source = MotionPhotoSource.Sidecar, + SidecarFile = new FileInfo(Path.Combine(_tempDirectory, "missing.mov")), + }; + + var result = await MotionPhotoExtractor.ExtractAsync(new FileInfo(Path.Combine(_tempDirectory, "IMG_400.jpg")), info, TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + [Fact] + public async Task ExtractAsync_LivpContainer_ReturnsVideoEntry() + { + var videoBytes = MotionPhotoFixtures.BuildMp4Head(56); + var livp = MotionPhotoFixtures.CreateLivp(_tempDirectory, "IMG_500.livp", [1, 2, 3], videoBytes); + + var info = new MotionPhotoInfo { Source = MotionPhotoSource.LivpContainer }; + + var result = await MotionPhotoExtractor.ExtractAsync(livp, info, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + await using (result.ConfigureAwait(false)) + { + Assert.Equal(videoBytes.Length, result.Length); + var buffer = new byte[result.Length]; + Assert.Equal(buffer.Length, await result.ReadAsync(buffer, TestContext.Current.CancellationToken)); + Assert.Equal(videoBytes, buffer); + } + } + + [Fact] + public async Task ExtractLivpCoverToTempFileAsync_ExtractsImageEntry() + { + byte[] imageBytes = [10, 20, 30, 40]; + var livp = MotionPhotoFixtures.CreateLivp(_tempDirectory, "IMG_600.livp", imageBytes, MotionPhotoFixtures.BuildMp4Head()); + + var tempPath = await MotionPhotoExtractor.ExtractLivpCoverToTempFileAsync(livp, TestContext.Current.CancellationToken); + + Assert.NotNull(tempPath); + Assert.True(File.Exists(tempPath)); + Assert.Equal(imageBytes, await File.ReadAllBytesAsync(tempPath, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ExtractLivpCoverToTempFileAsync_NoImageEntry_ReturnsNull() + { + var path = Path.Combine(_tempDirectory, "video-only.livp"); + await using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) + { + using var zip = new System.IO.Compression.ZipArchive(stream, System.IO.Compression.ZipArchiveMode.Create); + var entry = zip.CreateEntry("video.mov"); + await using var entryStream = entry.Open(); + await entryStream.WriteAsync(MotionPhotoFixtures.BuildMp4Head(), TestContext.Current.CancellationToken); + } + + var result = await MotionPhotoExtractor.ExtractLivpCoverToTempFileAsync(new FileInfo(path), TestContext.Current.CancellationToken); + + Assert.Null(result); + } +} diff --git a/src/PicView.Tests/MotionPhoto/MotionPhotoFixtures.cs b/src/PicView.Tests/MotionPhoto/MotionPhotoFixtures.cs new file mode 100644 index 000000000..0ef728ad1 --- /dev/null +++ b/src/PicView.Tests/MotionPhoto/MotionPhotoFixtures.cs @@ -0,0 +1,157 @@ +using System.IO.Compression; +using System.Text; + +namespace PicView.Tests.MotionPhoto; + +internal static class MotionPhotoFixtures +{ + public static string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "picview-motionphoto-tests", Path.GetRandomFileName()); + Directory.CreateDirectory(path); + return path; + } + + public static void DeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch + { + // Best effort cleanup + } + } + + /// Builds a minimal valid MP4 "ftyp" box followed by filler bytes. + public static byte[] BuildMp4Head(uint boxSize = 32) + { + var box = new byte[boxSize]; + box[0] = (byte)(boxSize >> 24); + box[1] = (byte)(boxSize >> 16); + box[2] = (byte)(boxSize >> 8); + box[3] = (byte)boxSize; + "ftyp"u8.CopyTo(box.AsSpan(4, 4)); + "isom"u8.CopyTo(box.AsSpan(8, 4)); + return box; + } + + public static string NewStandardXmp(long videoLength) => + $""" + + + + + + + + + Primary + image/jpeg + + + + + MotionPhoto + video/mp4 + {videoLength} + + + + + + + + + """; + + public static string MicroVideoXmp(long microVideoOffset) => + $""" + + + + + + + + """; + + public const string PlainXmp = + """ + + + + + Test + + + + + """; + + /// Wraps an XMP packet in a JPEG-like byte structure (SOI + APP1 XMP segment). + public static byte[] BuildJpegWithXmp(string xmpPacket) + { + using var stream = new MemoryStream(); + stream.WriteByte(0xFF); + stream.WriteByte(0xD8); + stream.WriteByte(0xFF); + stream.WriteByte(0xE1); + var header = "http://ns.adobe.com/xap/1.0/\0"u8.ToArray(); + var packet = Encoding.UTF8.GetBytes(xmpPacket); + var segmentLength = 2 + header.Length + packet.Length; + stream.WriteByte((byte)(segmentLength >> 8)); + stream.WriteByte((byte)segmentLength); + stream.Write(header); + stream.Write(packet); + stream.WriteByte(0xFF); + stream.WriteByte(0xD9); + return stream.ToArray(); + } + + /// Creates a synthetic embedded motion photo: jpeg head + XMP + filler + mp4 tail. + public static FileInfo CreateEmbeddedMotionPhoto(string directory, string fileName, byte[] videoBytes, string xmpPacket) + { + var jpeg = BuildJpegWithXmp(xmpPacket); + var path = Path.Combine(directory, fileName); + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write); + stream.Write(jpeg); + stream.Write(videoBytes); + return new FileInfo(path); + } + + /// Creates a .livp (zip) container with the given image and video entries. + public static FileInfo CreateLivp(string directory, string fileName, byte[] imageBytes, byte[] videoBytes) + { + var path = Path.Combine(directory, fileName); + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write); + using var zip = new ZipArchive(stream, ZipArchiveMode.Create); + + var imageEntry = zip.CreateEntry("IMG_0001.jpg"); + using (var imageStream = imageEntry.Open()) + { + imageStream.Write(imageBytes); + } + + var videoEntry = zip.CreateEntry("IMG_0001.MOV"); + using (var videoStream = videoEntry.Open()) + { + videoStream.Write(videoBytes); + } + + return new FileInfo(path); + } +}