From 150fa356031089543d86e63533d91cbfcbb22971 Mon Sep 17 00:00:00 2001 From: refly <3380520452@qq.com> Date: Thu, 27 Aug 2026 13:10:12 +0800 Subject: [PATCH 1/3] Add motion photo (Live Photo) playback support Native playback via a purpose-built, statically-linked FFmpeg (picview-ffmpeg): a single C ABI wrapper over mov/mp4 demuxing and h264/hevc decoding with libswscale, video-only by design (no audio). --- .gitignore | 3 + Build/Build-FFmpegNative.ps1 | 172 ++++++ Build/ffmpeg/build-target.sh | 152 +++++ Build/ffmpeg/machonm.c | 104 ++++ Native/ffmpeg/picview_ffmpeg.c | 547 ++++++++++++++++++ .../PicView.Avalonia.Linux.csproj | 18 +- .../PicView.Avalonia.MacOS.csproj | 18 +- .../PicView.Avalonia.Win32.csproj | 23 + .../CustomControls/ZoomPanControl.cs | 7 + .../FileSystem/FileSaverHelper.cs | 2 + .../ImageHandling/GetImageModel.cs | 90 +++ .../Input/MainKeyboardShortcuts.cs | 17 + .../MotionPhoto/FFmpegService.cs | 144 +++++ .../MotionPhoto/MotionPhotoDecoder.cs | 338 +++++++++++ .../MotionPhoto/MotionPhotoVideoSurface.cs | 129 +++++ .../MotionPhoto/MotionPhotoView.axaml | 38 ++ .../MotionPhoto/MotionPhotoView.axaml.cs | 307 ++++++++++ .../Navigation/UpdateImage.cs | 3 + .../Views/Gallery/GalleryItem.axaml | 28 +- .../Views/Main/MainView.axaml | 1 + .../Views/UC/ImageViewer.axaml | 49 +- .../Views/UC/ImageViewer.axaml.cs | 103 ++++ src/PicView.Core/Config/AppSettings.cs | 6 + src/PicView.Core/Config/Languages/ca.json | 1 + src/PicView.Core/Config/Languages/da.json | 1 + src/PicView.Core/Config/Languages/de.json | 1 + src/PicView.Core/Config/Languages/en.json | 1 + src/PicView.Core/Config/Languages/es.json | 1 + src/PicView.Core/Config/Languages/fr.json | 1 + src/PicView.Core/Config/Languages/he.json | 1 + src/PicView.Core/Config/Languages/hu.json | 1 + src/PicView.Core/Config/Languages/it.json | 1 + src/PicView.Core/Config/Languages/ja.json | 1 + src/PicView.Core/Config/Languages/ko.json | 1 + src/PicView.Core/Config/Languages/nl.json | 1 + src/PicView.Core/Config/Languages/pl.json | 1 + src/PicView.Core/Config/Languages/pt-br.json | 1 + src/PicView.Core/Config/Languages/ro.json | 1 + src/PicView.Core/Config/Languages/ru.json | 1 + src/PicView.Core/Config/Languages/sl.json | 1 + .../Config/Languages/sr-Cyrl.json | 1 + .../Config/Languages/sr-Latn.json | 1 + src/PicView.Core/Config/Languages/sv.json | 1 + src/PicView.Core/Config/Languages/tr.json | 1 + src/PicView.Core/Config/Languages/zh-CN.json | 1 + src/PicView.Core/Config/Languages/zh-TW.json | 1 + .../FileHandling/SupportedFiles.cs | 1 + src/PicView.Core/Gallery/GalleryLoader.cs | 12 +- src/PicView.Core/ImageDecoding/ImageType.cs | 1 + .../Localization/LanguageModel.cs | 1 + src/PicView.Core/Models/ImageModel.cs | 2 + .../MotionPhoto/FileSliceStream.cs | 85 +++ .../MotionPhoto/MotionPhotoDetector.cs | 418 +++++++++++++ .../MotionPhoto/MotionPhotoExtractor.cs | 278 +++++++++ .../MotionPhoto/MotionPhotoInfo.cs | 47 ++ .../Navigation/FileWatcherService.cs | 2 + .../ViewModels/GalleryItemViewModel.cs | 10 +- src/PicView.Core/ViewModels/TabViewModel.cs | 32 +- .../ViewModels/TranslationViewModel.cs | 2 + .../MotionPhoto/FileSliceStreamTests.cs | 97 ++++ .../MotionPhoto/MotionPhotoDecoderTests.cs | 92 +++ .../MotionPhoto/MotionPhotoDetectorTests.cs | 268 +++++++++ .../MotionPhoto/MotionPhotoEndToEndTests.cs | 111 ++++ .../MotionPhoto/MotionPhotoExtractorTests.cs | 267 +++++++++ .../MotionPhoto/MotionPhotoFixtures.cs | 157 +++++ .../MotionPhoto/Samples/sample_h264.mp4 | Bin 0 -> 9434 bytes src/PicView.Tests/PicView.Tests.csproj | 17 +- 67 files changed, 4191 insertions(+), 31 deletions(-) create mode 100644 Build/Build-FFmpegNative.ps1 create mode 100644 Build/ffmpeg/build-target.sh create mode 100644 Build/ffmpeg/machonm.c create mode 100644 Native/ffmpeg/picview_ffmpeg.c create mode 100644 src/PicView.Avalonia/MotionPhoto/FFmpegService.cs create mode 100644 src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs create mode 100644 src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs create mode 100644 src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml create mode 100644 src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs create mode 100644 src/PicView.Core/MotionPhoto/FileSliceStream.cs create mode 100644 src/PicView.Core/MotionPhoto/MotionPhotoDetector.cs create mode 100644 src/PicView.Core/MotionPhoto/MotionPhotoExtractor.cs create mode 100644 src/PicView.Core/MotionPhoto/MotionPhotoInfo.cs create mode 100644 src/PicView.Tests/MotionPhoto/FileSliceStreamTests.cs create mode 100644 src/PicView.Tests/MotionPhoto/MotionPhotoDecoderTests.cs create mode 100644 src/PicView.Tests/MotionPhoto/MotionPhotoDetectorTests.cs create mode 100644 src/PicView.Tests/MotionPhoto/MotionPhotoEndToEndTests.cs create mode 100644 src/PicView.Tests/MotionPhoto/MotionPhotoExtractorTests.cs create mode 100644 src/PicView.Tests/MotionPhoto/MotionPhotoFixtures.cs create mode 100644 src/PicView.Tests/MotionPhoto/Samples/sample_h264.mp4 diff --git a/.gitignore b/.gitignore index 6e61f5c6f..d86dfbb84 100644 --- a/.gitignore +++ b/.gitignore @@ -365,3 +365,6 @@ MigrationBackup/ /src/.7z /src/.zip /src/.zip + +# Built picview-ffmpeg native libraries (produced by Build\Build-FFmpegNative.ps1) +Build/ffmpeg-native/ diff --git a/Build/Build-FFmpegNative.ps1 b/Build/Build-FFmpegNative.ps1 new file mode 100644 index 000000000..0927a0398 --- /dev/null +++ b/Build/Build-FFmpegNative.ps1 @@ -0,0 +1,172 @@ +<# +.SYNOPSIS +Builds picview-ffmpeg, the statically-linked, purpose-built FFmpeg used for motion +photo video playback, for all supported targets: + + win-x64, win-arm64, linux-x64, linux-arm64, osx-x64, osx-arm64 + +Output lands in Build\ffmpeg-native\\ and is picked up by the platform +projects at build/publish time (motion photos degrade to still images when absent). + +.DESCRIPTION +The FFmpeg build is trimmed to exactly what motion photo playback needs: +mov/mp4 demuxer + h264/hevc decoders + libswscale. No audio, no network, no +devices, no filters, no programs. The result is statically linked into a single +native library per target that exports only four functions (pv_open, +pv_decode_next, pv_close, pv_version) - see Native\ffmpeg\picview_ffmpeg.c. + +Prerequisites (one-time): + Windows host (win-x64 builds natively; everything else cross-compiles): + * MSYS2 (https://www.msys2.org), installed to C:\msys64 by default: + pacman -Syu + pacman -S base-devel mingw-w64-x86_64-gcc mingw-w64-x86_64-nasm diffutils tar + * zig on PATH (https://ziglang.org - only needed for cross-targets, i.e. + everything except win-x64): + winget install Zig.Zig (or scoop install zig) + macOS host (builds the osx-* targets natively on the host architecture): + * brew install make (plus nasm for Intel hosts, zig for cross-targets) + * GNU make must be reachable as 'make'; set PV_EXTRA_PATH to the directory + that contains it, e.g. "$((brew --prefix make))/libexec/gnubin" + +.PARAMETER Targets +Comma-separated list of targets to build. Defaults to all six. + +.PARAMETER Msys2Root +MSYS2 installation root. Defaults to C:\msys64. + +.EXAMPLE +.\Build-FFmpegNative.ps1 -Targets win-x64 +#> +param ( + [Parameter()] + [string[]]$Targets = @("win-x64", "win-arm64", "linux-x64", "linux-arm64", "osx-x64", "osx-arm64"), + + [Parameter()] + [string]$Msys2Root = "C:\msys64" +) + +$ErrorActionPreference = "Stop" + +$ffmpegVersion = "9.0.1" +$scriptRoot = $PSScriptRoot +$repoRoot = Join-Path $scriptRoot ".." +$shim = Join-Path $repoRoot "Native\ffmpeg\picview_ffmpeg.c" +$outputDir = Join-Path $scriptRoot "ffmpeg-native" + +function Test-TargetRequiresZig([string]$target) { + # win-x64 builds natively with MSYS2's MINGW64 gcc; the osx-* targets build + # natively on a macOS host of the same architecture. Everything else is + # cross-compiled through zig cc. + switch ($target) { + 'win-x64' { return $false } + 'osx-arm64' { return -not ($IsMacOS -and [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) } + 'osx-x64' { return -not ($IsMacOS -and [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::X64) } + default { return $true } + } +} + +$zigCommand = Get-Command zig -ErrorAction SilentlyContinue +$needsZig = @($Targets | Where-Object { Test-TargetRequiresZig $_ }).Count -gt 0 +if ($needsZig -and -not $zigCommand) { + Write-Error "zig not found on PATH. Install it with: winget install Zig.Zig (or scoop install zig / brew install zig)" +} + +if ($IsWindows) { + $bash = Join-Path $Msys2Root "usr\bin\bash.exe" + if (-not (Test-Path $bash)) { + Write-Error "MSYS2 not found at $Msys2Root. Install MSYS2 and run: pacman -S base-devel mingw-w64-x86_64-gcc mingw-w64-x86_64-nasm diffutils tar" + } +} +else { + $bash = (Get-Command bash -ErrorAction SilentlyContinue).Source + if (-not $bash) { + Write-Error "bash not found on PATH" + } +} + +# Scratch space lives outside the repo; the ffmpeg source is cached there. +$workRoot = Join-Path ([System.IO.Path]::GetTempPath()) "picview-ffmpeg-build" +New-Item -ItemType Directory -Force -Path $workRoot | Out-Null +$sourceDir = Join-Path $workRoot "ffmpeg-$ffmpegVersion" + +function ConvertTo-ShellPath([string]$path) { + # MSYS2 bash wants /c/... paths; native shells want the path as-is. + # GetFullPath (unlike Resolve-Path) also works for paths that do not exist + # yet, e.g. the output directory on a fresh checkout. + $full = [System.IO.Path]::GetFullPath($path) + if (-not $IsWindows) { + return $full + } + + $drive = $full.Substring(0, 1).ToLowerInvariant() + return "/$drive/" + ($full.Substring(3) -replace '\\', '/') +} + +# 1. Fetch the FFmpeg source once +if (-not (Test-Path $sourceDir)) { + $tarball = Join-Path $workRoot "ffmpeg-$ffmpegVersion.tar.xz" + if (-not (Test-Path $tarball)) { + Write-Host "Downloading FFmpeg $ffmpegVersion source..." + Invoke-WebRequest -Uri "https://ffmpeg.org/releases/ffmpeg-$ffmpegVersion.tar.xz" -OutFile $tarball + } + Write-Host "Extracting FFmpeg source..." + if ($IsWindows) { + & $bash -lc "tar -xf `"`$(cygpath -u '$tarball')`" -C `"`$(cygpath -u '$workRoot')`"" + if ($LASTEXITCODE -ne 0) { throw "Failed to extract FFmpeg source" } + } + else { + tar -xf $tarball -C $workRoot + if ($LASTEXITCODE -ne 0) { throw "Failed to extract FFmpeg source" } + } +} + +# 2. Build the small Mach-O nm used by configure to detect the '_' symbol prefix +# when cross-compiling for Apple targets from Windows (GNU nm cannot read +# Mach-O objects). Apple hosts use their native nm instead. +$appleTargets = @($Targets | Where-Object { $_ -like 'osx-*' }) +if ($IsWindows -and $appleTargets.Count -gt 0) { + $machonm = Join-Path $workRoot "machonm.exe" + Write-Host "Building machonm helper..." + zig cc -target x86_64-windows (Join-Path $scriptRoot "ffmpeg\machonm.c") -o $machonm + if ($LASTEXITCODE -ne 0) { throw "Failed to build machonm" } +} +else { + $machonm = "nm" +} + +# 3. Build each target +# build-target.sh prepends PV_EXTRA_PATH to its PATH; make sure zig (when needed) +# and any caller-provided toolchain directories (e.g. GNU make on macOS) are reachable +if ($zigCommand) { + $zigDir = ConvertTo-ShellPath (Split-Path $zigCommand.Source) + $env:PV_EXTRA_PATH = [string]::IsNullOrEmpty($env:PV_EXTRA_PATH) ? $zigDir : "$env:PV_EXTRA_PATH`:$zigDir" +} + +$env:PV_ROOT = ConvertTo-ShellPath $workRoot +$env:PV_SRC = ConvertTo-ShellPath $sourceDir +$env:PV_SHIM = ConvertTo-ShellPath $shim +$env:PV_OUT = ConvertTo-ShellPath $outputDir +$env:MACHONM = $IsWindows ? (ConvertTo-ShellPath $machonm) : "nm" +$buildScript = ConvertTo-ShellPath (Join-Path $scriptRoot "ffmpeg\build-target.sh") + +foreach ($target in $Targets) { + Write-Host "" + Write-Host "########## Building picview-ffmpeg for $target ##########" -ForegroundColor Cyan + + if ($IsWindows) { + # MSYS2's login shell sets up the MINGW64 toolchain PATH + $env:MSYSTEM = "MINGW64" + $env:CHERE_INVOKING = "1" + & $bash -lc "bash '$buildScript' $target" + } + else { + & $bash "$buildScript" $target + } + if ($LASTEXITCODE -ne 0) { throw "Build failed for $target" } +} + +Write-Host "" +Write-Host "picview-ffmpeg build complete:" -ForegroundColor Green +Get-ChildItem $outputDir -Recurse -File | ForEach-Object { + "{0,-45} {1,6:N2} MB" -f $_.FullName.Substring($repoRoot.Length), ($_.Length / 1MB) +} diff --git a/Build/ffmpeg/build-target.sh b/Build/ffmpeg/build-target.sh new file mode 100644 index 000000000..a5b057c8f --- /dev/null +++ b/Build/ffmpeg/build-target.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Builds the statically-linked picview-ffmpeg native library for one target. +# Invoked by Build-FFmpegNative.ps1; can also run standalone in MSYS2: +# PV_ROOT= PV_SRC= PV_SHIM= \ +# PV_OUT= build-target.sh +# +# Targets: win-arm64 | linux-x64 | linux-arm64 | osx-x64 | osx-arm64 +# (win-x64 uses the same flow; it is just built with the native MINGW64 gcc.) +set -e + +: "${PV_ROOT:?PV_ROOT (scratch dir) must be set}" +: "${PV_SRC:?PV_SRC (ffmpeg source dir) must be set}" +: "${PV_SHIM:?PV_SHIM (picview_ffmpeg.c path) must be set}" +: "${PV_OUT:?PV_OUT (ffmpeg-native output dir) must be set}" +MACHONM=${MACHONM:-$PV_ROOT/machonm.exe} + +# Toolchain directories outside the MSYS2/standard PATH (e.g. zig): the hosting +# script sets PV_EXTRA_PATH. Scoop's zig shim and /usr/bin (nasm, make) must be +# reachable from MSYS2. +export PATH=/usr/bin${PV_EXTRA_PATH:+:$PV_EXTRA_PATH}:/c/Users/$USER/scoop/shims:$PATH + +TARGET=$1 +BUILD=$PV_ROOT/build-$TARGET + +case $TARGET in + win-x64) + CONFIGURE_FLAGS=(--target-os=mingw64 --arch=x86_64 --x86asmexe=nasm) + LINK_CMD=(gcc -shared) + LINK_EXTRA=(/mingw64/lib/libwinpthread.a -lbcrypt -lm -static-libgcc) + OUTLIB=picview-ffmpeg.dll + ;; + win-arm64) + CONFIGURE_FLAGS=(--target-os=mingw64 --arch=aarch64 --enable-cross-compile --disable-x86asm "--cc=zig cc" "--ld=zig cc --target=aarch64-windows-gnu" "--extra-cflags=--target=aarch64-windows-gnu") + LINK_CMD=(zig cc --target=aarch64-windows-gnu -shared) + LINK_EXTRA=(-lbcrypt -lm) + OUTLIB=picview-ffmpeg.dll + ;; + linux-x64) + CONFIGURE_FLAGS=(--target-os=linux --arch=x86_64 --enable-cross-compile --enable-pic "--cc=zig cc" "--ld=zig cc --target=x86_64-linux-gnu.2.31" "--extra-cflags=--target=x86_64-linux-gnu.2.31" --x86asmexe=nasm) + LINK_CMD=(zig cc --target=x86_64-linux-gnu.2.31 -shared) + # -Bsymbolic: ffmpeg's x86 asm uses PC32 fixups that would otherwise be + # rejected for preemptible symbols in a shared library. + LINK_EXTRA=(-lm -lpthread -Wl,-Bsymbolic -Wl,-s -Wl,--version-script=exports.map) + OUTLIB=libpicviewffmpeg.so + ;; + linux-arm64) + CONFIGURE_FLAGS=(--target-os=linux --arch=aarch64 --enable-cross-compile --enable-pic --disable-x86asm "--cc=zig cc" "--ld=zig cc --target=aarch64-linux-gnu.2.31" "--extra-cflags=--target=aarch64-linux-gnu.2.31") + LINK_CMD=(zig cc --target=aarch64-linux-gnu.2.31 -shared) + LINK_EXTRA=(-lm -lpthread -Wl,-s -Wl,--version-script=exports.map) + OUTLIB=libpicviewffmpeg.so + ;; + osx-x64) + if [ "$(uname)" = "Darwin" ] && [ "$(uname -m)" = "x86_64" ]; then + # Native build on Intel macOS hosts: no cross toolchain required + CONFIGURE_FLAGS=(--target-os=darwin --arch=x86_64 --enable-pic --x86asmexe=nasm) + else + CONFIGURE_FLAGS=(--target-os=darwin --arch=x86_64 --enable-cross-compile --enable-pic "--ar=zig ar" "--ranlib=zig ranlib" "--nm=$MACHONM" "--cc=zig cc" "--ld=zig cc --target=x86_64-macos.11.0" "--extra-cflags=--target=x86_64-macos.11.0" --x86asmexe=nasm) + fi + if [ "$(uname)" = "Darwin" ]; then + # Apple hosts link natively (zig cc rejects -exported_symbols_list) + LINK_CMD=(cc -arch x86_64 -dynamiclib) + LINK_EXTRA=(-lm -lpthread "-Wl,-install_name,@rpath/libpicviewffmpeg.dylib" -Wl,-exported_symbols_list,exports.lst) + else + LINK_CMD=(zig cc --target=x86_64-macos.11.0 -shared) + LINK_EXTRA=(-lm -lpthread -Wl,-s "-Wl,-install_name,@rpath/libpicviewffmpeg.dylib" -Wl,-exported_symbols_list,exports.lst) + fi + OUTLIB=libpicviewffmpeg.dylib + ;; + osx-arm64) + if [ "$(uname)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then + # Native build on Apple Silicon hosts: no cross toolchain required + CONFIGURE_FLAGS=(--target-os=darwin --arch=aarch64 --enable-pic) + else + CONFIGURE_FLAGS=(--target-os=darwin --arch=aarch64 --enable-cross-compile --enable-pic "--ar=zig ar" "--ranlib=zig ranlib" "--nm=$MACHONM" --disable-x86asm "--cc=zig cc" "--ld=zig cc --target=aarch64-macos.11.0" "--extra-cflags=--target=aarch64-macos.11.0") + fi + if [ "$(uname)" = "Darwin" ]; then + # Apple hosts link natively (zig cc rejects -exported_symbols_list) + LINK_CMD=(cc -arch arm64 -dynamiclib) + LINK_EXTRA=(-lm -lpthread "-Wl,-install_name,@rpath/libpicviewffmpeg.dylib" -Wl,-exported_symbols_list,exports.lst) + else + LINK_CMD=(zig cc --target=aarch64-macos.11.0 -shared) + LINK_EXTRA=(-lm -lpthread -Wl,-s "-Wl,-install_name,@rpath/libpicviewffmpeg.dylib" -Wl,-exported_symbols_list,exports.lst) + fi + OUTLIB=libpicviewffmpeg.dylib + ;; + *) + echo "unknown target $TARGET"; exit 1 + ;; +esac + +# zig's bundled glibc has no sys/sysctl.h; ffmpeg's check_func sysctl only tests +# linking, which would set HAVE_SYSCTL=1 and break the build of libavutil/cpu.c. +# (sed -i without a backup suffix is GNU-only; use the portable form.) +case $TARGET in + linux-*) + sed 's/^check_func sysctl$/:/' "$PV_SRC/configure" > "$PV_SRC/configure.tmp" && mv "$PV_SRC/configure.tmp" "$PV_SRC/configure" + ;; + osx-*) + # zig cc rejects -Wl,-dynamic (a ld64 default); the flag breaks every link test + sed 's/add_ldflags -Wl,-dynamic,-search_paths_first/:/' "$PV_SRC/configure" > "$PV_SRC/configure.tmp" && mv "$PV_SRC/configure.tmp" "$PV_SRC/configure" + ;; +esac + +mkdir -p "$BUILD" +cd "$BUILD" + +# Export only the shim ABI; everything statically linked stays internal. +cat > exports.map <<'EOF' +{ + global: pv_*; + local: *; +}; +EOF +cat > exports.lst <<'EOF' +_pv_open +_pv_decode_next +_pv_close +_pv_version +EOF + +# Invoked through sh explicitly: some extraction paths drop the exec bit +sh "$PV_SRC/configure" \ + --prefix="$PV_ROOT/install-$TARGET" \ + "${CONFIGURE_FLAGS[@]}" \ + --enable-static --disable-shared \ + --disable-programs --disable-doc --disable-debug \ + --disable-everything \ + --enable-demuxer=mov,mp4 \ + --enable-decoder=h264,hevc \ + --enable-parser=h264,hevc \ + --enable-swscale \ + --disable-autodetect --disable-network \ + --disable-avdevice --disable-avfilter --disable-swresample \ + --extra-cflags="-O2" > configure.log 2>&1 || { echo "CONFIGURE FAILED ($TARGET):"; tail -25 configure.log; exit 1; } + +# nproc is GNU-only; macOS reports the CPU count via sysctl +JOBS=$(nproc 2>/dev/null || sysctl -n hw.ncpu) +make -j"$JOBS" > make.log 2>&1 || { echo "MAKE FAILED ($TARGET):"; tail -25 make.log; exit 1; } + +echo "=== link shim ($TARGET) ===" +# Link the objects straight from the build tree instead of the static archives: +# Mach-O archives collide on duplicate basenames (cabac.o vs hevc/cabac.o) and +# archive symbol indexes are unreliable under cross toolchains. ops_asmgen.o is +# a host-side code generator, not target code. +find libavcodec libavformat libswscale libavutil -name '*.o' ! -name 'ops_asmgen.o' > objects.txt +"${LINK_CMD[@]}" -O2 -fvisibility=hidden -o $OUTLIB "$PV_SHIM" -I "$PV_SRC" -I "$BUILD" \ + @objects.txt "${LINK_EXTRA[@]}" 2>&1 | head -30 +ls -la $OUTLIB + +mkdir -p "$PV_OUT/$TARGET" +cp $OUTLIB "$PV_OUT/$TARGET/" +echo "=== done: $TARGET ===" diff --git a/Build/ffmpeg/machonm.c b/Build/ffmpeg/machonm.c new file mode 100644 index 000000000..9856f1e8c --- /dev/null +++ b/Build/ffmpeg/machonm.c @@ -0,0 +1,104 @@ +/* + * machonm.c - minimal nm replacement that understands Mach-O 64-bit objects. + * Prints one line per symbol in the classic nm format: + * + * Used by ffmpeg's configure to detect the external symbol prefix on Apple + * targets when cross-compiling from Windows (GNU nm cannot read Mach-O). + */ +#include +#include +#include +#include + +struct mach_header_64 { uint32_t magic, cputype, cpusubtype, filetype; uint32_t ncmds, sizeofcmds, flags, reserved; }; +struct load_command { uint32_t cmd, cmdsize; }; +struct symtab_command { uint32_t cmd, cmdsize, symoff, nsyms, stroff, strsize; }; +struct nlist_64 { uint32_t n_strx; uint8_t n_type, n_sect; uint16_t n_desc; uint64_t n_value; }; + +#define N_EXT 0x01 +#define N_TYPE_MASK 0x0e +#define N_UNDF 0x00 +#define N_ABS 0x02 +#define N_SECT 0x0e + +int main(int argc, char **argv) +{ + const char *path = NULL; + for (int i = 1; i < argc; i++) + { + if (argv[i][0] != '-') + { + path = argv[i]; + } + } + if (!path) + { + return 1; + } + + FILE *f = fopen(path, "rb"); + if (!f) + { + fprintf(stderr, "machonm: cannot open %s\n", path); + return 1; + } + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + uint8_t *buf = (uint8_t *)malloc(size); + if (fread(buf, 1, size, f) != (size_t)size) + { + fclose(f); + return 1; + } + fclose(f); + + struct mach_header_64 *h = (struct mach_header_64 *)buf; + if (h->magic != 0xfeedfacf) + { + fprintf(stderr, "machonm: %s is not a Mach-O 64 object\n", path); + return 1; + } + + uint8_t *p = buf + sizeof(*h); + for (uint32_t i = 0; i < h->ncmds; i++) + { + struct load_command *lc = (struct load_command *)p; + if (lc->cmd == 2 /* LC_SYMTAB */) + { + struct symtab_command *st = (struct symtab_command *)p; + struct nlist_64 *syms = (struct nlist_64 *)(buf + st->symoff); + const char *strs = (const char *)(buf + st->stroff); + for (uint32_t s = 0; s < st->nsyms; s++) + { + uint8_t t = syms[s].n_type; + char letter; + if ((t & N_TYPE_MASK) == N_UNDF) + { + letter = (t & N_EXT) ? 'U' : 'u'; + } + else if ((t & N_TYPE_MASK) == N_ABS) + { + letter = (t & N_EXT) ? 'A' : 'a'; + } + else if ((t & N_TYPE_MASK) == N_SECT) + { + letter = (t & N_EXT) ? 'T' : 't'; + } + else + { + letter = '?'; + } + + printf("%016llx %c %s\n", (unsigned long long)syms[s].n_value, + letter, strs + syms[s].n_strx); + } + free(buf); + return 0; + } + p += lc->cmdsize; + } + + free(buf); + return 0; +} diff --git a/Native/ffmpeg/picview_ffmpeg.c b/Native/ffmpeg/picview_ffmpeg.c new file mode 100644 index 000000000..ae7a7a296 --- /dev/null +++ b/Native/ffmpeg/picview_ffmpeg.c @@ -0,0 +1,547 @@ +/* + * picview_ffmpeg.c + * + * Minimal statically-linked FFmpeg wrapper for motion photo video playback. + * It exposes a tiny, stable C ABI so the managed side never touches FFmpeg + * structs or version-dependent layouts: + * + * pv_open open a media stream via caller-supplied read/seek callbacks + * pv_decode_next decode the next video frame, scaled to BGRA32 + * pv_close release everything + * + * Video-only by design: no audio decoding/output is built into this library + * (the bundled FFmpeg configuration disables everything except the mov/mp4 + * demuxer, the h264/hevc decoders and libswscale). + * + * Threading: a session is driven by exactly one thread at a time. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef PV_DEBUG +#include +#define PV_DBG(...) fprintf(stderr, "[pv] " __VA_ARGS__) +#else +#define PV_DBG(...) ((void)0) +#endif + +#if defined(_WIN32) +#define PV_API __declspec(dllexport) +#else +#define PV_API __attribute__((visibility("default"))) +#endif + +#define PV_AVIO_BUFFER_SIZE (64 * 1024) + +/* Returns bytes read (>0), 0 on EOF, negative on error. */ +typedef int (*PvReadCb)(void *opaque, uint8_t *buf, int size); + +/* Seeks the stream. whence is SEEK_SET/SEEK_CUR/SEEK_END, or AVSEEK_SIZE + * (0x10000) which must return the total stream length. Negative on error. */ +typedef int64_t (*PvSeekCb)(void *opaque, int64_t offset, int whence); + +typedef struct PvVideoInfo +{ + int width; + int height; + double fps; + double duration_sec; +} PvVideoInfo; + +typedef struct PvSession +{ + AVFormatContext *fmt; + AVCodecContext *dec; + AVIOContext *avio; + AVPacket *pkt; + AVFrame *frame; + struct SwsContext *sws; + void *user_opaque; + PvReadCb read_cb; + PvSeekCb seek_cb; + int video_index; + int width; + int height; + int src_w; + int src_h; + enum AVPixelFormat src_fmt; + int rotation; + uint8_t *scratch; + int scratch_capacity; + double time_base; + int64_t start_ts; + int frame_count; + int eof; + int flushed; +} PvSession; + +static int pv_read_trampoline(void *opaque, uint8_t *buf, int size) +{ + PvSession *s = (PvSession *)opaque; + int r = s->read_cb(s->user_opaque, buf, size); + /* FFmpeg's AVIO contract requires AVERROR_EOF (negative) at end of stream; + * a zero return would be treated as "zero bytes of data" and can spin the + * format probing loop forever. */ + return r == 0 ? AVERROR_EOF : r; +} + +/* FFmpeg may OR internal flags (AVSEEK_FORCE) into whence; normalize before + * handing the request to the caller so it only sees SEEK_SET/CUR/END or + * AVSEEK_SIZE. */ +static int64_t pv_seek_trampoline(void *opaque, int64_t offset, int whence) +{ + PvSession *s = (PvSession *)opaque; + if (whence == AVSEEK_SIZE) + { + return s->seek_cb(s->user_opaque, offset, AVSEEK_SIZE); + } + whence &= ~AVSEEK_FORCE; + return s->seek_cb(s->user_opaque, offset, whence); +} + +static void pv_log_silence(void *avcl, int level, const char *fmt, va_list vl) +{ + (void)avcl; + (void)level; + (void)fmt; + (void)vl; +} + +static int pv_read_display_rotation(const AVStream *stream); +static void pv_rotate_bgra(const uint8_t *src, int w, int h, uint8_t *dst, int rotation); + +PV_API void pv_close(PvSession *s); + +PV_API const char *pv_version(void) +{ + return av_version_info(); +} + +PV_API PvSession *pv_open(void *opaque, PvReadCb read_cb, PvSeekCb seek_cb, PvVideoInfo *out_info) +{ + if (read_cb == NULL || seek_cb == NULL || out_info == NULL) + { + return NULL; + } + + av_log_set_callback(pv_log_silence); + + PvSession *s = (PvSession *)av_mallocz(sizeof(PvSession)); + if (s == NULL) + { + return NULL; + } + s->video_index = -1; + s->start_ts = AV_NOPTS_VALUE; + s->user_opaque = opaque; + s->read_cb = read_cb; + s->seek_cb = seek_cb; + int r; + + uint8_t *avio_buffer = (uint8_t *)av_malloc(PV_AVIO_BUFFER_SIZE); + if (avio_buffer == NULL) + { + goto fail; + } + + /* Ownership of avio_buffer passes to the AVIOContext; ffmpeg may replace + * it and avio_context_free releases whichever buffer is current. */ + s->avio = avio_alloc_context(avio_buffer, PV_AVIO_BUFFER_SIZE, 0, s, + pv_read_trampoline, NULL, pv_seek_trampoline); + if (s->avio == NULL) + { + av_free(avio_buffer); + goto fail; + } + + s->fmt = avformat_alloc_context(); + if (s->fmt == NULL) + { + goto fail; + } + s->fmt->pb = s->avio; + + r = avformat_open_input(&s->fmt, NULL, NULL, NULL); + PV_DBG("avformat_open_input -> %d\n", r); + if (r < 0) + { + PV_DBG("avformat_open_input -> %d (%s)\n", r, av_err2str(r)); + s->fmt = NULL; /* freed by avformat_open_input on failure */ + goto fail; + } + + r = avformat_find_stream_info(s->fmt, NULL); + PV_DBG("avformat_find_stream_info -> %d\n", r); + if (r < 0) + { + PV_DBG("avformat_find_stream_info -> %d (%s)\n", r, av_err2str(r)); + goto fail; + } + + const AVCodec *decoder = NULL; + int stream_index = av_find_best_stream(s->fmt, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0); + if (stream_index < 0 || decoder == NULL) + { + PV_DBG("av_find_best_stream -> %d\n", stream_index); + goto fail; + } + + AVStream *stream = s->fmt->streams[stream_index]; + s->video_index = stream_index; + + s->dec = avcodec_alloc_context3(decoder); + if (s->dec == NULL) + { + goto fail; + } + r = avcodec_parameters_to_context(s->dec, stream->codecpar); + if (r < 0) + { + PV_DBG("avcodec_parameters_to_context -> %d (%s)\n", r, av_err2str(r)); + goto fail; + } + PV_DBG("codec=%s extradata_size=%d w=%d h=%d\n", decoder->name, + s->dec->extradata_size, s->dec->width, s->dec->height); + r = avcodec_open2(s->dec, decoder, NULL); + if (r < 0) + { + PV_DBG("avcodec_open2 -> %d (%s)\n", r, av_err2str(r)); + goto fail; + } + + s->pkt = av_packet_alloc(); + s->frame = av_frame_alloc(); + if (s->pkt == NULL || s->frame == NULL) + { + goto fail; + } + + s->width = s->dec->width; + s->height = s->dec->height; + s->src_fmt = (enum AVPixelFormat)AV_PIX_FMT_NONE; + s->rotation = pv_read_display_rotation(stream); + s->time_base = av_q2d(stream->time_base); + s->start_ts = stream->start_time != AV_NOPTS_VALUE ? stream->start_time : 0; + + /* Report the display dimensions (rotated upright); pv_decode_next emits + * every frame at exactly this size. */ + if (s->rotation == 90 || s->rotation == 270) + { + out_info->width = s->height; + out_info->height = s->width; + } + else + { + out_info->width = s->width; + out_info->height = s->height; + } + + AVRational rate = av_guess_frame_rate(s->fmt, stream, NULL); + out_info->fps = (rate.den > 0 && rate.num > 0) ? (double)rate.num / rate.den : 30.0; + + if (stream->duration > 0 && s->time_base > 0) + { + out_info->duration_sec = stream->duration * s->time_base; + } + else if (s->fmt->duration > 0) + { + out_info->duration_sec = (double)s->fmt->duration / AV_TIME_BASE; + } + else + { + out_info->duration_sec = 0; + } + + return s; + +fail: + pv_close(s); + return NULL; +} + +/* + * Reads the container's display rotation (displaymatrix side data, written by + * phone cameras for portrait recordings). av_display_rotation_get() reports how + * the transformation rotates the frame; to display the frame upright the decoded + * pixels must be rotated by the negated angle. Returns that counterclockwise + * angle normalized to 0/90/180/270. + */ +static int pv_read_display_rotation(const AVStream *stream) +{ + const AVPacketSideData *sd = av_packet_side_data_get( + stream->codecpar->coded_side_data, stream->codecpar->nb_coded_side_data, + AV_PKT_DATA_DISPLAYMATRIX); + if (sd == NULL || sd->data == NULL) + { + return 0; + } + + double theta = av_display_rotation_get((const int32_t *)sd->data); + if (isnan(theta)) + { + return 0; + } + + int rotation = -(int)llround(theta); + rotation %= 360; + if (rotation < 0) + { + rotation += 360; + } + + return rotation; +} + +/* + * Rotates a packed BGRA frame counterclockwise by 90/180/270 degrees into dst + * (which must hold the rotated dimensions). + */ +static void pv_rotate_bgra(const uint8_t *src, int w, int h, uint8_t *dst, int rotation) +{ + const uint32_t *s = (const uint32_t *)src; + uint32_t *d = (uint32_t *)dst; + + switch (rotation) + { + case 90: + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + d[(size_t)(w - 1 - x) * h + y] = s[(size_t)y * w + x]; + } + } + break; + case 180: + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + d[(size_t)(h - 1 - y) * w + (w - 1 - x)] = s[(size_t)y * w + x]; + } + } + break; + case 270: + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + d[(size_t)x * h + (h - 1 - y)] = s[(size_t)y * w + x]; + } + } + break; + default: + break; + } +} + +/* + * Prepares the scaler for the given frame. Every frame is scaled to the codec's + * declared dimensions, so pv_decode_next always emits exactly the size reported + * at open time (display dimensions, rotation already applied). Decoded dimensions + * occasionally disagree with the container metadata (phone videos with + * conformance-window cropping); scaling to a fixed size keeps the caller's + * buffers fixed too. + */ +static int pv_ensure_sws(PvSession *s, const AVFrame *frame, int *out_w, int *out_h) +{ + enum AVPixelFormat fmt = (enum AVPixelFormat)frame->format; + int dst_w = s->width; + int dst_h = s->height; + + *out_w = dst_w; + *out_h = dst_h; + + if (s->sws != NULL && fmt == s->src_fmt && + frame->width == s->src_w && frame->height == s->src_h) + { + return 0; + } + + if (s->sws != NULL) + { + sws_freeContext(s->sws); + s->sws = NULL; + } + + s->sws = sws_getContext(frame->width, frame->height, fmt, + dst_w, dst_h, AV_PIX_FMT_BGRA, + SWS_BILINEAR, NULL, NULL, NULL); + if (s->sws == NULL) + { + return -1; + } + + s->src_fmt = fmt; + s->src_w = frame->width; + s->src_h = frame->height; + return 0; +} + +/* Returns the presentation time of the frame in seconds relative to stream start; + * falls back to a constant-fps estimate when the container carries no timestamps. */ +static double pv_frame_pts(PvSession *s, const AVFrame *frame, double fps) +{ + int64_t ts = frame->best_effort_timestamp != AV_NOPTS_VALUE + ? frame->best_effort_timestamp + : frame->pts; + if (ts != AV_NOPTS_VALUE && s->time_base > 0) + { + double t = (double)(ts - s->start_ts) * s->time_base; + if (t >= 0) + { + return t; + } + } + + return fps > 0 ? (double)s->frame_count / fps : 0; +} + +PV_API int pv_decode_next(PvSession *s, uint8_t *dst, int dst_capacity, double *out_pts) +{ + if (s == NULL || dst == NULL || dst_capacity < s->width * s->height * 4 || + out_pts == NULL) + { + return -1; + } + + for (;;) + { + int r = avcodec_receive_frame(s->dec, s->frame); + if (r < 0 && r != AVERROR(EAGAIN) && r != AVERROR_EOF) + { + PV_DBG("avcodec_receive_frame -> %d (%s)\n", r, av_err2str(r)); + } + if (r == 0) + { + int dst_w, dst_h; + if (pv_ensure_sws(s, s->frame, &dst_w, &dst_h) < 0) + { + av_frame_unref(s->frame); + return -1; + } + + if (s->rotation != 0) + { + /* Rotate after conversion, via the scratch buffer. */ + int needed = dst_w * dst_h * 4; + if (s->scratch_capacity < needed) + { + av_free(s->scratch); + s->scratch = (uint8_t *)av_malloc(needed); + s->scratch_capacity = s->scratch != NULL ? needed : 0; + } + + if (s->scratch == NULL) + { + av_frame_unref(s->frame); + return -1; + } + + uint8_t *scratch_data[1] = {s->scratch}; + int scratch_linesize[1] = {dst_w * 4}; + sws_scale(s->sws, (const uint8_t *const *)s->frame->data, s->frame->linesize, + 0, dst_h, scratch_data, scratch_linesize); + pv_rotate_bgra(s->scratch, dst_w, dst_h, dst, s->rotation); + } + else + { + uint8_t *dst_data[1] = {dst}; + int dst_linesize[1] = {dst_w * 4}; + sws_scale(s->sws, (const uint8_t *const *)s->frame->data, s->frame->linesize, + 0, dst_h, dst_data, dst_linesize); + } + + double fps = s->time_base > 0 ? 0 : 30.0; + *out_pts = pv_frame_pts(s, s->frame, fps); + s->frame_count++; + av_frame_unref(s->frame); + return dst_w * dst_h * 4; + } + + if (r != AVERROR(EAGAIN)) + { + /* AVERROR_EOF: the decoder is fully drained. */ + return 0; + } + + /* The decoder needs more input. */ + r = av_read_frame(s->fmt, s->pkt); + if (r < 0) + { + if (!s->flushed) + { + s->flushed = 1; + s->eof = 1; + avcodec_send_packet(s->dec, NULL); + continue; + } + return 0; + } + + if (s->pkt->stream_index != s->video_index) + { + av_packet_unref(s->pkt); + continue; + } + + r = avcodec_send_packet(s->dec, s->pkt); + if (r < 0) + { + PV_DBG("avcodec_send_packet -> %d (%s) size=%d head=%02x%02x%02x%02x%02x\n", + r, av_err2str(r), s->pkt->size, + s->pkt->data[0], s->pkt->data[1], s->pkt->data[2], + s->pkt->data[3], s->pkt->data[4]); + } + av_packet_unref(s->pkt); + if (r < 0 && r != AVERROR(EAGAIN)) + { + return -1; + } + } +} + +PV_API void pv_close(PvSession *s) +{ + if (s == NULL) + { + return; + } + + if (s->sws != NULL) + { + sws_freeContext(s->sws); + } + av_free(s->scratch); + if (s->frame != NULL) + { + av_frame_free(&s->frame); + } + if (s->pkt != NULL) + { + av_packet_free(&s->pkt); + } + if (s->dec != NULL) + { + avcodec_free_context(&s->dec); + } + if (s->fmt != NULL) + { + /* Does not free the externally supplied AVIOContext. */ + avformat_close_input(&s->fmt); + } + if (s->avio != NULL) + { + avio_context_free(&s->avio); + } + + av_free(s); +} diff --git a/src/PicView.Avalonia.Linux/PicView.Avalonia.Linux.csproj b/src/PicView.Avalonia.Linux/PicView.Avalonia.Linux.csproj index c323f36c5..7de8f81c8 100644 --- a/src/PicView.Avalonia.Linux/PicView.Avalonia.Linux.csproj +++ b/src/PicView.Avalonia.Linux/PicView.Avalonia.Linux.csproj @@ -1,4 +1,4 @@ - + WinExe + + + PreserveNewest + PreserveNewest + ffmpeg\linux-x64\libpicviewffmpeg.so + + + + + PreserveNewest + PreserveNewest + ffmpeg\linux-arm64\libpicviewffmpeg.so + + diff --git a/src/PicView.Avalonia.MacOS/PicView.Avalonia.MacOS.csproj b/src/PicView.Avalonia.MacOS/PicView.Avalonia.MacOS.csproj index a0472ca65..8628b742e 100644 --- a/src/PicView.Avalonia.MacOS/PicView.Avalonia.MacOS.csproj +++ b/src/PicView.Avalonia.MacOS/PicView.Avalonia.MacOS.csproj @@ -1,4 +1,4 @@ - + WinExe net11.0 @@ -115,4 +115,20 @@ + + + + + PreserveNewest + PreserveNewest + ffmpeg\osx-x64\libpicviewffmpeg.dylib + + + + + PreserveNewest + PreserveNewest + ffmpeg\osx-arm64\libpicviewffmpeg.dylib + + diff --git a/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj b/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj index 2aeca500c..53812bce2 100644 --- a/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj +++ b/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj @@ -120,4 +120,27 @@ PreserveNewest + + + + + PreserveNewest + PreserveNewest + ffmpeg\win-x64\picview-ffmpeg.dll + + + + + PreserveNewest + PreserveNewest + ffmpeg\win-arm64\picview-ffmpeg.dll + + 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 dc0f0c389..d70f332ab 100644 --- a/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs +++ b/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs @@ -99,6 +99,7 @@ async ValueTask SaveBitmap() case ImageType.AnimatedGif: // TODO: Add animated GIF support case ImageType.AnimatedWebp: // TODO: Add animated WebP support case ImageType.AnimatedAvif: // TODO: Add animated AVIF 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) @@ -136,6 +137,7 @@ async ValueTask SaveProcessedMagickImage() case ImageType.AnimatedGif: // TODO: Add animated GIF support case ImageType.AnimatedWebp: // TODO: Add animated WebP support case ImageType.AnimatedAvif: // TODO: Add animated AVIF 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 1c875575c..0513eff29 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); @@ -147,6 +157,8 @@ public static async ValueTask GetImageModelAsync(FileInfo fileInfo) break; } + TryDetectMotionPhoto(fileInfo, magickImage, imageModel); + return imageModel; } catch (Exception e) @@ -274,6 +286,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) @@ -290,6 +348,38 @@ private static async Task ProcessSvg(FileInfo fileInfo, ImageModel imageModel, M imageModel.ImageType = ImageType.Svg; imageModel.Image = SvgSource.LoadFromSvg(svgData); } +/// + /// 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/FFmpegService.cs b/src/PicView.Avalonia/MotionPhoto/FFmpegService.cs new file mode 100644 index 000000000..4e9569e25 --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/FFmpegService.cs @@ -0,0 +1,144 @@ +using System.Runtime.InteropServices; +using PicView.Core.DebugTools; + +namespace PicView.Avalonia.MotionPhoto; + +/// +/// Loads the statically-linked picview-ffmpeg native library bundled next to the +/// application and exposes its exports. Initialization is lazy and failure is +/// remembered, so a missing native library simply degrades motion photos to regular +/// still images instead of breaking the viewer. This type never throws. +/// +/// The native library is a purpose-built FFmpeg build (mov/mp4 demuxer + h264/hevc +/// decoders + libswscale, no audio) wrapped behind a tiny C ABI, so no FFmpeg types +/// or struct layouts leak into managed code. +/// +/// +public static class FFmpegService +{ + /// whence value with which the native side queries the stream length. + public const int AvSeekSize = 0x10000; + + /// Reads up to bytes; returns bytes read, 0 on EOF, negative on error. + public delegate int PvReadCallback(IntPtr opaque, IntPtr buffer, int size); + + /// Seeks the stream (whence 0/1/2 = set/cur/end) or returns its length for . + public delegate long PvSeekCallback(IntPtr opaque, long offset, int whence); + + [StructLayout(LayoutKind.Sequential)] + public struct PvVideoInfo + { + public int Width; + public int Height; + public double Fps; + public double DurationSec; + } + + public delegate IntPtr PvOpenCallback(IntPtr opaque, PvReadCallback read, PvSeekCallback seek, out PvVideoInfo info); + public delegate int PvDecodeNextCallback(IntPtr session, IntPtr dst, int dstCapacity, out double pts); + public delegate void PvCloseCallback(IntPtr session); + + private static readonly object InitLock = new(); + private static IntPtr _library; + private static bool _initialized; + private static bool _initFailed; + + internal static PvOpenCallback PvOpen { get; private set; } = null!; + internal static PvDecodeNextCallback PvDecodeNext { get; private set; } = null!; + internal static PvCloseCallback PvClose { get; private set; } = null!; + + /// + /// Video playback is supported on all desktop platforms: the native library is + /// bundled per runtime identifier and frames are decoded in software into BGRA32 + /// buffers rendered by the Avalonia compositor. + /// + public static bool IsPlaybackSupported => + OperatingSystem.IsWindows() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(); + + /// + /// Attempts to load the native library. Returns false when playback is + /// unavailable; callers should fall back to the still image. + /// + public static bool TryInitialize() + { + if (_initialized) + { + return true; + } + + if (!IsPlaybackSupported || _initFailed) + { + return false; + } + + lock (InitLock) + { + if (_initialized) + { + return true; + } + + if (_initFailed) + { + return false; + } + + try + { + var libraryPath = GetNativeLibraryPath(); + if (libraryPath is null || !File.Exists(libraryPath)) + { + _initFailed = true; + return false; + } + + _library = NativeLibrary.Load(libraryPath); + PvOpen = Marshal.GetDelegateForFunctionPointer( + NativeLibrary.GetExport(_library, "pv_open")); + PvDecodeNext = Marshal.GetDelegateForFunctionPointer( + NativeLibrary.GetExport(_library, "pv_decode_next")); + PvClose = Marshal.GetDelegateForFunctionPointer( + NativeLibrary.GetExport(_library, "pv_close")); + _initialized = true; + return true; + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(FFmpegService), nameof(TryInitialize), e); + _initFailed = true; + return false; + } + } + } + + /// + /// The native library is deployed to "ffmpeg/<rid>" next to the application by + /// the platform packaging (one self-contained binary per runtime identifier). + /// + private static string? GetNativeLibraryPath() + { + string rid; + string libraryName; + if (OperatingSystem.IsWindows()) + { + rid = RuntimeInformation.ProcessArchitecture is Architecture.Arm64 ? "win-arm64" : "win-x64"; + libraryName = "picview-ffmpeg.dll"; + } + else if (OperatingSystem.IsLinux()) + { + rid = RuntimeInformation.ProcessArchitecture is Architecture.Arm64 ? "linux-arm64" : "linux-x64"; + libraryName = "libpicviewffmpeg.so"; + } + else if (OperatingSystem.IsMacOS()) + { + rid = RuntimeInformation.ProcessArchitecture is Architecture.Arm64 ? "osx-arm64" : "osx-x64"; + libraryName = "libpicviewffmpeg.dylib"; + } + else + { + return null; + } + + return Path.Combine(AppContext.BaseDirectory, "ffmpeg", rid, libraryName); + } +} diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs new file mode 100644 index 000000000..d7544c239 --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs @@ -0,0 +1,338 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using PicView.Core.DebugTools; + +namespace PicView.Avalonia.MotionPhoto; + +/// +/// Decodes a motion photo video into BGRA32 frames using the bundled picview-ffmpeg +/// native library. A single worker thread demuxes and decodes frames, paces them +/// against a presentation clock and hands buffer pointers to subscribers via +/// . The consumer copies the frame (typically into a +/// WriteableBitmap on the UI thread) and then returns the buffer with +/// . +/// +/// Lifetime rules: fires on the worker thread. Disposal must +/// happen on the thread that consumes the frames, which guarantees no frame copy can +/// race with the buffer teardown. +/// +/// +public sealed class MotionPhotoDecoder : IDisposable +{ + /// Number of display buffers handed to the consumer in rotation. + 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 readonly Stream _stream; + private readonly GCHandle _callbackHandle; + private readonly FFmpegService.PvReadCallback _readCallback; + private readonly FFmpegService.PvSeekCallback _seekCallback; + private readonly ConcurrentQueue _freeBuffers = new(); + private readonly ManualResetEventSlim _resumeEvent = new(true); + private readonly Stopwatch _clock = new(); + + private IntPtr _session; + private IntPtr[] _frameBuffers = []; + private int _frameBufferSize; + private Thread? _worker; + private double _startPts = double.NaN; + private double _pausedAt; + private double _pausedTotal; + private volatile bool _stopRequested; + private volatile bool _isPaused; + private bool _disposed; + + public int Width { get; } + public int Height { get; } + public bool IsPaused => _isPaused; + + /// + /// Raised on the worker thread when a frame is ready for display: + /// (buffer index, pointer to BGRA32 data, byte count). The buffer stays valid + /// until is called. + /// + public event Action? FrameReady; + + /// Raised on the worker thread when the end of the video is reached. + public event EventHandler? Ended; + + /// Raised on the worker thread when decoding fails irrecoverably. + public event EventHandler? Failed; + + /// + /// Opens the video carried by (ownership stays with the + /// caller). Returns null when the stream is not a decodable video. + /// + public static MotionPhotoDecoder? Create(Stream stream) + { + if (!FFmpegService.TryInitialize()) + { + return null; + } + + var decoder = new MotionPhotoDecoder(stream); + if (decoder._session == IntPtr.Zero) + { + decoder.Dispose(); + return null; + } + + return decoder; + } + + private MotionPhotoDecoder(Stream stream) + { + _stream = stream; + _readCallback = OnNativeRead; + _seekCallback = OnNativeSeek; + _callbackHandle = GCHandle.Alloc(this); + var opaque = GCHandle.ToIntPtr(_callbackHandle); + + _session = FFmpegService.PvOpen(opaque, _readCallback, _seekCallback, out var info); + if (_session == IntPtr.Zero) + { + return; + } + + Width = info.Width; + Height = info.Height; + _frameBufferSize = Width * Height * 4; + _frameBuffers = new IntPtr[FrameBufferCount + 1]; + for (var i = 0; i < _frameBuffers.Length; i++) + { + _frameBuffers[i] = Marshal.AllocHGlobal(_frameBufferSize); + if (i < FrameBufferCount) + { + _freeBuffers.Enqueue(i); + } + } + } + + /// Starts the decode worker. The first frame resets the presentation clock. + public void Play() + { + if (_worker is not null || _session == IntPtr.Zero || _disposed) + { + return; + } + + _worker = new Thread(WorkerLoop) { IsBackground = true, Name = nameof(MotionPhotoDecoder) }; + _worker.Start(); + } + + public void Pause() + { + if (_isPaused || _disposed) + { + return; + } + + _pausedAt = _clock.Elapsed.TotalSeconds; + _isPaused = true; + _resumeEvent.Reset(); + } + + public void Resume() + { + if (!_isPaused || _disposed) + { + return; + } + + _pausedTotal += _clock.Elapsed.TotalSeconds - _pausedAt; + _isPaused = false; + _resumeEvent.Set(); + } + + /// Returns a display buffer to the rotation after its frame has been copied. + public void ReleaseBuffer(int index) + { + if (index >= 0 && index < FrameBufferCount) + { + _freeBuffers.Enqueue(index); + } + } + + private void WorkerLoop() + { + try + { + while (!_stopRequested) + { + if (_isPaused) + { + _resumeEvent.Wait(); + continue; + } + + if (!_freeBuffers.TryDequeue(out var index)) + { + // Every display buffer is pending on the consumer: decode into the + // overflow buffer instead and drop the frame. + index = OverflowBufferIndex; + } + + var written = FFmpegService.PvDecodeNext(_session, _frameBuffers[index], _frameBufferSize, out var pts); + if (written <= 0) + { + if (index != OverflowBufferIndex) + { + _freeBuffers.Enqueue(index); + } + + if (written is 0) + { + Ended?.Invoke(this, EventArgs.Empty); + } + else + { + Failed?.Invoke(this, EventArgs.Empty); + } + + break; + } + + WaitForPresentationTime(pts); + if (_stopRequested) + { + break; + } + + if (index == OverflowBufferIndex) + { + continue; + } + + FrameReady?.Invoke(index, _frameBuffers[index], written); + } + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoDecoder), nameof(WorkerLoop), e); + Failed?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Blocks until the frame's presentation time relative to the first frame. + /// Pausing freezes the clock; stopping aborts the wait. + /// + private void WaitForPresentationTime(double pts) + { + if (double.IsNaN(_startPts)) + { + _startPts = pts; + _clock.Restart(); + return; + } + + var deadline = pts - _startPts; + while (!_stopRequested) + { + if (_isPaused) + { + _resumeEvent.Wait(); + continue; + } + + var remaining = deadline - (_clock.Elapsed.TotalSeconds - _pausedTotal); + if (remaining <= 0) + { + return; + } + + if (remaining > 0.005) + { + Thread.Sleep(1); + } + } + } + + private unsafe int OnNativeRead(IntPtr opaque, IntPtr buffer, int size) + { + try + { + if (GCHandle.FromIntPtr(opaque).Target is not MotionPhotoDecoder decoder || size <= 0) + { + return 0; + } + + return decoder._stream.Read(new Span(buffer.ToPointer(), size)); + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoDecoder), nameof(OnNativeRead), e); + return -1; + } + } + + private long OnNativeSeek(IntPtr opaque, long offset, int whence) + { + try + { + if (GCHandle.FromIntPtr(opaque).Target is not MotionPhotoDecoder decoder) + { + return -1; + } + + if (whence == FFmpegService.AvSeekSize) + { + return decoder._stream.Length; + } + + var origin = whence switch + { + 0 => SeekOrigin.Begin, + 1 => SeekOrigin.Current, + 2 => SeekOrigin.End, + _ => (SeekOrigin?)null, + }; + + return origin is null ? -1 : decoder._stream.Seek(offset, origin.Value); + } + catch (Exception e) + { + DebugHelper.LogDebug(nameof(MotionPhotoDecoder), nameof(OnNativeSeek), e); + return -1; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _stopRequested = true; + _resumeEvent.Set(); + _worker?.Join(TimeSpan.FromSeconds(5)); + + if (_session != IntPtr.Zero) + { + FFmpegService.PvClose(_session); + _session = IntPtr.Zero; + } + + foreach (var buffer in _frameBuffers) + { + if (buffer != IntPtr.Zero) + { + Marshal.FreeHGlobal(buffer); + } + } + + _frameBuffers = []; + if (_callbackHandle.IsAllocated) + { + _callbackHandle.Free(); + } + + _resumeEvent.Dispose(); + GC.SuppressFinalize(this); + } +} 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..692d098d8 --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs @@ -0,0 +1,307 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +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 as BGRA32 buffers by +/// (a statically-linked, purpose-built FFmpeg) and rendered by +/// , which works on every display stack including +/// Wayland and lets the video follow the normal Avalonia compositor. Playback is +/// video-only by design; motion photos never produce sound. +/// +/// +public partial class MotionPhotoView : UserControl, IDisposable +{ + private Stream? _videoStream; + private MotionPhotoDecoder? _decoder; + private ImageModel? _model; + private bool _isSessionBusy; + private bool _firstFrameShownInSession; + 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; + + /// + /// Raised on the UI thread when the first video frame is shown for a playback + /// session, so the underlying still image can be hidden while the video covers it. + /// + public event EventHandler? FirstFrameShown; + + /// 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; + } + + /// + /// 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 && + FFmpegService.IsPlaybackSupported && + FFmpegService.TryInitialize()) + { + 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 (_decoder is not null && IsPlaying) + { + if (_decoder.IsPaused) + { + _decoder.Resume(); + } + else + { + _decoder.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, decodes it with the + /// bundled FFmpeg and presents the frames 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 (!FFmpegService.TryInitialize()) + { + 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; + _decoder = MotionPhotoDecoder.Create(stream); + if (_decoder is null) + { + CleanupSession(); + _isSessionBusy = false; + return; + } + + _firstFrameShownInSession = false; + _decoder.FrameReady += OnFrameReady; + _decoder.Ended += OnPlaybackEnded; + _decoder.Failed += OnPlaybackEnded; + _decoder.Play(); + + // The surface stays hidden until the first decoded frame arrives, so the + // still image remains untouched while decoding starts up (no visual pop). + 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 OnPlaybackEnded(object? sender, EventArgs e) => + Dispatcher.UIThread.Post(FreezeBackToCover); + + private void OnFrameReady(int index, IntPtr bgra, int byteCount) => + Dispatcher.UIThread.Post(() => + { + var decoder = _decoder; + if (decoder is null || _isDisposed) + { + decoder?.ReleaseBuffer(index); + return; + } + + try + { + VideoSurface.UpdateFrame(bgra, byteCount, decoder.Width, decoder.Height); + if (!VideoSurface.IsVisible) + { + VideoSurface.IsVisible = true; + } + + if (!_firstFrameShownInSession) + { + _firstFrameShownInSession = true; + FirstFrameShown?.Invoke(this, EventArgs.Empty); + } + } + finally + { + decoder.ReleaseBuffer(index); + } + }); + + private void FreezeBackToCover() + { + if (!IsPlaying) + { + return; + } + + Stop(); + // Keep the badge visible so the clip can be replayed + PlayBadge.IsVisible = true; + } + + private void CleanupSession() + { + var decoder = _decoder; + _decoder = null; + if (decoder is not null) + { + decoder.FrameReady -= OnFrameReady; + decoder.Ended -= OnPlaybackEnded; + decoder.Failed -= OnPlaybackEnded; + decoder.Dispose(); + } + + _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 0791ab842..7ad4b15af 100644 --- a/src/PicView.Avalonia/Navigation/UpdateImage.cs +++ b/src/PicView.Avalonia/Navigation/UpdateImage.cs @@ -112,6 +112,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(); @@ -194,6 +196,7 @@ public static void SetSingleImage(MainWindowViewModel vm, MainWindow mainWindow, imageViewer.ResetZoomSlim(); imageViewer.Rotate(0); + imageViewer.UpdateMotionPhoto(tabViewModel); }); var zoom = tabViewModel.ZoomLevel.CurrentValue; diff --git a/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml b/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml index 5404d7f28..d081d90d6 100644 --- a/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml +++ b/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml @@ -20,9 +20,31 @@ 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,18 @@ 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..f3e33fdb9 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,100 @@ 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; + MotionPhotoView.FirstFrameShown += OnMotionPhotoFirstFrameShown; + SecondaryMotionPhotoView.PlaybackStarted += OnMotionPhotoPlaybackStarted; + SecondaryMotionPhotoView.PlaybackStopped += OnMotionPhotoPlaybackStopped; + SecondaryMotionPhotoView.FirstFrameShown += OnMotionPhotoFirstFrameShown; + } + + 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) + { + if (ReferenceEquals(sender, MotionPhotoView)) + { + MainImage.IsVisible = true; + } + else + { + SecondaryImage.IsVisible = true; + } + + ZoomPanControl.IsEnabled = !MotionPhotoView.IsPlaying && !SecondaryMotionPhotoView.IsPlaying; + } + + private void OnMotionPhotoFirstFrameShown(object? sender, EventArgs e) + { + // Hide the still image while its video covers it, so the letterboxed video + // never leaves strips of the still visible along its sides. + if (ReferenceEquals(sender, MotionPhotoView)) + { + MainImage.IsVisible = false; + } + else + { + SecondaryImage.IsVisible = false; + } + } + + /// + /// 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 +281,14 @@ public void Dispose() RemoveHandler(PointerWheelChangedEvent, PreviewOnPointerWheelChanged); RemoveHandler(PointerTouchPadGestureMagnifyEvent, TouchMagnifyEvent); RemoveHandler(PinchEvent, TouchMagnifyEvent); + MotionPhotoView.PlaybackStarted -= OnMotionPhotoPlaybackStarted; + MotionPhotoView.PlaybackStopped -= OnMotionPhotoPlaybackStopped; + MotionPhotoView.FirstFrameShown -= OnMotionPhotoFirstFrameShown; + MotionPhotoView.Dispose(); + SecondaryMotionPhotoView.PlaybackStarted -= OnMotionPhotoPlaybackStarted; + SecondaryMotionPhotoView.PlaybackStopped -= OnMotionPhotoPlaybackStopped; + SecondaryMotionPhotoView.FirstFrameShown -= OnMotionPhotoFirstFrameShown; + SecondaryMotionPhotoView.Dispose(); _disposables.Dispose(); HoverBar.Dispose(); } diff --git a/src/PicView.Core/Config/AppSettings.cs b/src/PicView.Core/Config/AppSettings.cs index 3ab458d44..0d59ff293 100644 --- a/src/PicView.Core/Config/AppSettings.cs +++ b/src/PicView.Core/Config/AppSettings.cs @@ -215,6 +215,12 @@ 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; } public class Theme diff --git a/src/PicView.Core/Config/Languages/ca.json b/src/PicView.Core/Config/Languages/ca.json index 2b32d92df..08c634080 100644 --- a/src/PicView.Core/Config/Languages/ca.json +++ b/src/PicView.Core/Config/Languages/ca.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Botó de ratolí endavant", "MouseSideButtons": "Botons laterals del ratolí", "MouseWheel": "Roda del ratolí", + "MotionPhoto": "Foto en moviment", "MoveToRecycleBin": "Moure a la paperera", "MoveWindow": "Moure finestra", "Navigate": "Navegar", 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 614b59f9f..5bcf5c642 100644 --- a/src/PicView.Core/Config/Languages/es.json +++ b/src/PicView.Core/Config/Languages/es.json @@ -239,6 +239,7 @@ "MouseKeyForward": "Botón adelante del ratón", "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 a15b328bf..300dfcb35 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 963e16d0f..ad3c8fe98 100644 --- a/src/PicView.Core/ImageDecoding/ImageType.cs +++ b/src/PicView.Core/ImageDecoding/ImageType.cs @@ -8,4 +8,5 @@ public enum ImageType AnimatedAvif, 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 fe45340bf..fa72c8876 100644 --- a/src/PicView.Core/Navigation/FileWatcherService.cs +++ b/src/PicView.Core/Navigation/FileWatcherService.cs @@ -6,6 +6,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; @@ -220,6 +221,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 249bbb718..b89b40449 100644 --- a/src/PicView.Core/ViewModels/TabViewModel.cs +++ b/src/PicView.Core/ViewModels/TabViewModel.cs @@ -150,6 +150,8 @@ public void UpdateTabTitle() else { SetSingleTitle(); + // TabTitle/TabTooltip are not touched by SetSingleTitle + AppendMotionPhotoMarkerIfNeeded(includeTabTitles: false); } return; @@ -178,6 +180,8 @@ public void UpdateTabTitle() TabTitle.Value = Model.FileInfo.Name; TabTooltip.Value = Model.FileInfo.FullName; } + + AppendMotionPhotoMarkerIfNeeded(includeTabTitles: true); return; @@ -223,7 +227,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/MotionPhotoDecoderTests.cs b/src/PicView.Tests/MotionPhoto/MotionPhotoDecoderTests.cs new file mode 100644 index 000000000..8c3a34b03 --- /dev/null +++ b/src/PicView.Tests/MotionPhoto/MotionPhotoDecoderTests.cs @@ -0,0 +1,92 @@ +using PicView.Avalonia.MotionPhoto; + +namespace PicView.Tests.MotionPhoto; + +/// +/// Integration tests for the statically-linked picview-ffmpeg decoder. These load the +/// bundled native library and decode a small committed H.264 sample, so they exercise +/// the real demux/decode/scale pipeline end to end. They are skipped automatically +/// when the native library has not been built (Build\Build-FFmpegNative.ps1). +/// +public class MotionPhotoDecoderTests +{ + private static string NativeLibraryPath => Path.Combine( + AppContext.BaseDirectory, "ffmpeg", "win-x64", "picview-ffmpeg.dll"); + + private static string SampleVideoPath => Path.Combine( + AppContext.BaseDirectory, "MotionPhoto", "Samples", "sample_h264.mp4"); + + private static void SkipUnlessNativeAvailable() + { + if (!File.Exists(NativeLibraryPath)) + { + Assert.Skip("picview-ffmpeg native library not built; run Build\\Build-FFmpegNative.ps1"); + } + } + + [Fact] + public void FFmpegService_TryInitialize_WhenLibraryPresent_ReturnsTrue() + { + SkipUnlessNativeAvailable(); + Assert.True(FFmpegService.TryInitialize()); + } + + [Fact] + public void FFmpegService_IsPlaybackSupported_OnDesktop_IsTrue() + { + Assert.True(FFmpegService.IsPlaybackSupported); + } + + [Fact] + public void Decoder_CreateAndDecodeSampleVideo_ProducesBgraFrames() + { + SkipUnlessNativeAvailable(); + if (!File.Exists(SampleVideoPath)) + { + Assert.Skip("sample video missing"); + } + + using var stream = File.OpenRead(SampleVideoPath); + var decoder = MotionPhotoDecoder.Create(stream); + Assert.NotNull(decoder); + Assert.True(decoder!.Width > 0); + Assert.True(decoder.Height > 0); + + var frames = new List<(IntPtr buffer, int byteCount)>(); + var frameReady = new ManualResetEventSlim(false); + var finished = new ManualResetEventSlim(false); + var failed = false; + + decoder.FrameReady += (index, buffer, byteCount) => + { + frames.Add((buffer, byteCount)); + decoder.ReleaseBuffer(index); + }; + decoder.Ended += (_, _) => finished.Set(); + decoder.Failed += (_, _) => + { + failed = true; + finished.Set(); + }; + + decoder.Play(); + Assert.True(finished.Wait(TimeSpan.FromSeconds(20)), "playback did not finish in time"); + Assert.False(failed, "decoding failed"); + Assert.True(frames.Count > 0, "no frames decoded"); + + var expectedBytes = decoder.Width * decoder.Height * 4; + Assert.All(frames, f => Assert.Equal(expectedBytes, f.byteCount)); + + decoder.Dispose(); + } + + [Fact] + public void Decoder_CreateOnNonVideoStream_ReturnsNull() + { + SkipUnlessNativeAvailable(); + + using var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }); + var decoder = MotionPhotoDecoder.Create(stream); + Assert.Null(decoder); + } +} 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/MotionPhotoEndToEndTests.cs b/src/PicView.Tests/MotionPhoto/MotionPhotoEndToEndTests.cs new file mode 100644 index 000000000..ad0c4746b --- /dev/null +++ b/src/PicView.Tests/MotionPhoto/MotionPhotoEndToEndTests.cs @@ -0,0 +1,111 @@ +using System.Text; +using PicView.Avalonia.MotionPhoto; +using PicView.Core.MotionPhoto; + +namespace PicView.Tests.MotionPhoto; + +/// +/// End-to-end pipeline test: synthesizes a Google/Samsung style motion photo +/// (JPEG + XMP + appended MP4), then runs detection, extraction and decoding. +/// +public class MotionPhotoEndToEndTests +{ + private static string SampleVideoPath => Path.Combine( + AppContext.BaseDirectory, "MotionPhoto", "Samples", "sample_h264.mp4"); + + /// Builds a minimal JPEG with the XMP packet in an APP1 segment. + private static byte[] BuildJpegWithXmp(string xmp) + { + var xmpBytes = Encoding.UTF8.GetBytes(xmp); + var nsHeader = "http://ns.adobe.com/xap/1.0/"u8; + // APP1 payload: namespace header + NUL + packet + var payloadLength = nsHeader.Length + 1 + xmpBytes.Length; + + using var ms = new MemoryStream(); + ms.WriteByte(0xFF); // SOI + ms.WriteByte(0xD8); + ms.WriteByte(0xFF); // APP1 marker + ms.WriteByte(0xE1); + ms.WriteByte((byte)((payloadLength + 2) >> 8)); + ms.WriteByte((byte)(payloadLength + 2)); + ms.Write(nsHeader); + ms.WriteByte(0); + ms.Write(xmpBytes); + ms.WriteByte(0xFF); // EOI + ms.WriteByte(0xD9); + return ms.ToArray(); + } + + [Fact] + public async Task SynthesizedMotionPhoto_Detects_Extracts_AndDecodes() + { + if (!FFmpegService.TryInitialize()) + { + Assert.Skip("picview-ffmpeg native library not built; run Build\\Build-FFmpegNative.ps1"); + } + + if (!File.Exists(SampleVideoPath)) + { + Assert.Skip("sample video missing"); + } + + var videoBytes = await File.ReadAllBytesAsync(SampleVideoPath); + var jpeg = BuildJpegWithXmp(MotionPhotoFixtures.NewStandardXmp(videoBytes.Length)); + + var directory = MotionPhotoFixtures.CreateTempDirectory(); + var filePath = Path.Combine(directory, "synthetic-motion-photo.jpg"); + try + { + await using (var output = File.Create(filePath)) + { + await output.WriteAsync(jpeg); + await output.WriteAsync(videoBytes); + } + + var fileInfo = new FileInfo(filePath); + + // 1. Detection + var info = MotionPhotoDetector.TryDetect(fileInfo, null); + Assert.NotNull(info); + Assert.Equal(MotionPhotoSource.EmbeddedXmp, info!.Source); + Assert.Equal(fileInfo.Length - videoBytes.Length, info.VideoOffset); + + // 2. Extraction + var stream = await MotionPhotoExtractor.ExtractAsync(fileInfo, info); + Assert.NotNull(stream); + await using (stream) + { + Assert.Equal(videoBytes.Length, stream!.Length); + + // 3. Decoding + var decoder = MotionPhotoDecoder.Create(stream); + Assert.NotNull(decoder); + + var frameCount = 0; + var finished = new ManualResetEventSlim(false); + var failed = false; + decoder!.FrameReady += (index, _, _) => + { + Interlocked.Increment(ref frameCount); + decoder.ReleaseBuffer(index); + }; + decoder.Ended += (_, _) => finished.Set(); + decoder.Failed += (_, _) => + { + failed = true; + finished.Set(); + }; + + decoder.Play(); + Assert.True(finished.Wait(TimeSpan.FromSeconds(20)), "playback did not finish in time"); + Assert.False(failed, "decoding failed"); + Assert.True(frameCount > 0, "no frames decoded"); + decoder.Dispose(); + } + } + finally + { + MotionPhotoFixtures.DeleteDirectory(directory); + } + } +} 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); + } +} diff --git a/src/PicView.Tests/MotionPhoto/Samples/sample_h264.mp4 b/src/PicView.Tests/MotionPhoto/Samples/sample_h264.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..6246a8b36a600bd1f268e4a19f30f32b4ccb0908 GIT binary patch literal 9434 zcmZvC2Uru$*6=2@&_hRxgeEF2bVTVOpduC!L?r|WJrF6P@39m8dz5ih+(M$38-41F}G0GL>XT&g(Tf@t`Ni2pAhDYa}PqP zqtP15>S*;nGoC>~1U*&NkdP3SYhG?Re_yPhihqEI>Yf)B&mg=nB;!vA^78i!)I+&q zU9hhDXjA~sU0(y`hI8@tclFU%(?jc_QCL5$Z&)Bs9~}zKL)FyOPlW_shtpSAQ-VCYV*`UQgg_rJ0wnq`16K(ce|Psl zT#&x9Ix5IB0MZE5*F^dH`}<%$p$X<6ni?w5*UJ@(EB!2XE{+M=KUtd}2T0ul_s?#Z|ZVDY#>2n)e^d3Xj9Aj}6B z2C?PK)m+(sDtiOrcA39mS8nH$+%Q zz-0ru{PaLYEztSJbA9f%qWOnG&KZ4?{lDx*HF09=;c*X=?I1gxIvIVR{(2!^QT_>v$i9r~ckOq5UxS!+{OXW*T6Vs8H_G zV7|GL#dV;z0?e{XsRB_1J%q+V80W_@F%`YLVBZ_G9(?vtD#AD<~acfR<03VyRfK=ZgYn{Di?8M~rU2elj{{v@yL(&c9(@2cwW)jbjf z8T5>z)=d3{_=Kbx3fm@&V-{O;)!z~u87J7OKF8l z{?6r=)Y=P_w-EH5X#aBMOfU=o=QiJ-hn+qTmX?ft;|1|H9K9CO<`{;?{iovRxlc@C zox_X-T~bQ!6^h>wwZ9(7HFJY(H)=9RBImN6=w}l4KvuM$3!&+9D=WQR0p~At^I5YE zH8l%b33}jsZD)D>^1%F4VM2GH^*?tL(j3*jH+cDd_Y=k)A+H5sC9W>ON>; zaFiLopPwWO*LVLJoK~!$$aB{x;f~?5$$N6E`Ne+TYY%ysAB`bey+fU>G@F$T8EBpm z#pOEPl%zDox85<$=eh1%#3faL=VG3@7;k1%e6hZ&G#o|Hxw`z!QcfY4ImGpa*jg;^Md;)=G)qy74fH zH`VHR-{kn6BcYSl=Dy4KN)CS?H$PjG&`jD+P~`_vB3M!*Dzm|NZPFK8_q?$G;xVwMYga|Gr<&A zRGbN5Bos=d3&lP_o5dyZxj?@`0)Sv|d7G;;#RflOj~=r)JSD@?4l9kJFpPgfyAA2*!YJM+@uu__%=_mkh0YhB(BE61Lxar9WywSZ?d{P$ub4k zT6)nWb!JUWWRw`?z+rMjivEVJYNDdM;;u!Hj6|lWixfBOjo*jZK;e>ZRE&Dh$^Anw zoWglk!g|2IV=Qb@;YKoo-*jVVx4N%J zXAHr43%svN@8-wYjbKNwl0~rR?vMc3q&Rs?;MLnYBpoBQUye6W1?wH$^TX1XW~QKP z1Q;>PT}&@aHyBtPU9)LJroMznCdr5=xp&HuK%&4^wVOGLtz1kDj#g3j zjH6oKKbc5uOk_x1vlu{$!bL{jQ<760LIEpJ?^HMR0*cvXGI5EEiQ0^PwO{nOF+BAQ z37NW*d=j?v(eW=w0a!EoX=@5Pe{G-SNhJV5f}@oY=PPJe=}bT(H}}%PT(Qmz<|e;i zlG)Wv0K^g58>ak=#iEskK#Sg?XH2*(H_aV0gtQ+Kpa>&LpwnkQ@9P!oZR~t78Fu!V z;GHFyWbbZLegUa0MSQ5t%q(mi@9is5YUW{uROR3}HIn{BU*ZIZj>hA56d z1SIF|JNEMfIG_c_L>ZbKv6^oin=vEA zIT(@k3Oa_>za%IrG3C4zFmYIh^ECT1nM=4S?404!Yf~$pHP-GA{*7VH&Ei+0!eHmn z$Ew|IYZ7B`r|*ARK#WcS=#npwk%)|Dhtov7cwD_~z2a0!*nL5O>q`~db}IIXk(e$3 zg1?2x_u6l~uJ6y{NsE%~|5-}8+5b0}q_2IfeG+
  • it75o( zXZ)g@Ug?sOM%j3c0Y0O23UcQ!_3DXd0EedOY15y{vz^T8aH(@+s@-1!V#k;$b-ye; zjIZQ17%9nYLC|HDIEedknQ5BEHAYh1*CzeJZ&7EKzgVL?06QHkV{3hV{i~-ei-8j; zfNvR4-p@T56W3f{i=LBgZ3XrQUXa|LXdJ{GaI7f9PESc^?fgW}#qt=*?AMo$(tY2i zKWAs*K*kzJ0~s6X4P3$R?S4ld$Oc5B3$$bXixLEOSSn76oMitsJH_a2Ej4CItadre zTNgOl>dYx&z9EDjOaGQgAyP|_0_Sm#P3vq4X7Z>OfC+_03m|6IZyv17f$e+h>5J{n zam$XmD@{=!Dl>wy2nEvUOcJQp7`VHoUow8DTAE>Q{Dw&G@Fsva>t~>;tQ6EYDmD+7=QtCutQck247MyxpO|h%-Zb9PGX9%Oro1DIpVZWplwYI>yd$1=|>f*;x_|8lD-f=Lv zz0asJ#9(C<+_wJWALw|4=DkQeV;Bq9?{`#EOOGfA9G`_-8tBINUud{#3(m)1&|Yr6 ze3AxYpElMEw$*s9eWG`~6zd2brORh9A3(GQcVF&&=6mVn;F4ucAopp0kVpkK6w9JNd^6))`R}%m`w>`$V#?Rv-T|8{`ax?@qd|`bI>vdriZtcv zwyal;qLc><*@vyO6AJx#-MU?S3lZCN9Z~h|vY*H=xo{&^*T}V^dO5SF};4Aq@TWG7cUx`?WCUR|2ZK|kE>v-s{ zbDK<6_AF_ zd?w4lSGtMGC(w*6tGTf2?=X?lU*K6Gt-!}Qi}e0MmEKc#?Q%_zHWQ4I=d=*;rJLlw z(DdHUkzBu!BGm{471=X(_)A&BYKF{~y2tiTs}Q>g*;}Di(P$q8}@Gx=-Vt zxq+_Q7-!WJBQ=V3odH2B{L`r+>z%1ZdC;FA9XBzU%Vilcwk~E5oui9a(?uJy z(`Y|;lI3qw6t0@rxUTXEUby|U_rv(mm(

    +~@Orwb=s-oa{pNRV71F$6-EN5r);< zU|Qov-5+4_`DXb35rlH6r(7g&1I2-pr1$Qt_<1_)%W*eTZ=z1h`rcEeah@#~$kmr) zLk5^l8cJZY(53T%SH6>%s(+ML=9E}I_XU78r1QB-8|nwEIWa+j;-!s=efd3$$Z*oM zl#O6!LbIoK5hQy0R^Z;&1E4X#)@mJI2Y#IRHRSJH5sX>h4tW<*wd{E;tC#gvfVEnjz4f7qqJ>w?%Iia*CBTz3a{P<=g@e*= zjCWDoU8RO#z)9TTz3b01|Ep( z_*q91`|^?Rbq>DQotvn;sX!T$3_0s^mhg6Kwjz^tYXpoVFVz2Jn}8v^aRWsuYkfwZ zqnoVEFA5?A3X?c*gcO`V9DSGb;%LufB$Jh?29xS~^>Qy&Lb7mVG>Y1F?n!1Ja^>A= z++F-G^}R{MRKjVg(CMR6!eGniF=gxiYa&7s2Zqx?IugggE@XZaT-ErY^vY%bQm4rHV-J{v+3Pk5h@BJF3-z+x$?ZppMS;7~ z5+VYl_QU~(4JFB94NoqLM*h;c)Xa?RPnP(K`g&@s`eZogo2C2AS0Joir)Vj}NM8;dk8 z5OuP9LPvL^SRzU|c~G@HFDCE$%`$9|Dglo?oD8VDot;d?KIEl1%YD*0nc^3_YnnJsK_b6aXydp z-Ugs{`NpmO*^|np<)6l+XopKHpCD@=13sQ%)%kB1lb@%<_4R27j!wAx$q!Ln!a@M1 z=?JbDHY0RIq`BeZVrD;x=r6+{V$aTwK@Xvw~fEmEa{=p>Q27`faf**4P| zu&iVG1a>RRzJ+kQjz(|;RQ%KxU57S{aYr~!PE3k0NJpaeRElM1iFY^0Zd7hs?a*R$ z{WRk`4%vXeH^fPbgYK`^2k0Y7?`lo?`FgjO+K!e}s|>UIgd-21iRg%{F%THP$O^t# zx!vI6EAy~m%xId~wJRaxp2buM^{Lb1DtfT9HT0|9pTSNO;^z!#r={RHeEV4`K zz_zhRexamX5M4J){xN4|s&3C|mMIk=LS-Q`{MU0NcQ)r0Hd;BcIim4V)apRdhNt~N zd}nB4n}HKb8E)(ew9emJPDWf007M(^x7R4eAT|$3(W2nBNt(YKQCi#_ppd$Z2VF_9 zXL^-JE~a|IB-b$MVSHL20Sn?6L%s>*jlkXW=pSa?eiA1*hXkpKaXbK@?d@b4F2i51((?Yknvpzcfki83Qp2uxJ*rR7cvmtwbfd=A!|}4`Qb=E& zgoQ}r)QPt*wwJBTNPM%4Oy?z6)L!z>k7?RdnCcg?Sr?_6fH=OuWH56UM6`xQHc4~J z(1dm{ga}3=@sKu7@Ke}^yW75 zaqalcsij(E=`}-F%7@o05uq0G6$w3eLgwCsE1}+!s~R;F)4AtORMbPENFV+n^F{9~ zbL0}qvAEdn>pQQmp@pnjE{+^++ajvQu{+=AG?6akI+d`6{>d^Gd$j#>*$#K8K#Mr0}% z{3Z}^cg^^XSMAl8VVih)$B8qCDQSO&pBTSN*YP$I(XJjk39dB8;k?T`R59bTwct~6 zD%kAKyNU$N$Z!i!=KBZy>q3Q5tQ*EpUvY1g$1=N4uy^IRNP)Gsf^Z6g#mHl`vck`!L-;kHE?jxHQ)SXrSzltixZ_!#e zU;Si6R@9_yl~tyS%`Sg5FFLgOz0*F|Qel=qdd5auSntq<`fjIXXQN#YC-7JOz?COm|!aeguR$&mlRaUg{7J5ep7ctH&hKjD$2B&c1dFb(7FYXGQ7tPle7`YYkmVvMw z_|*TrgL`N*rFCyM=qx{`=D4KR0iE4F!v z1EV#S2&5J)z|BjwSal58*=K)hvG+j2)0S`h*FNgc=0x#kZN`&BF1L?F zTyNaT&aM3wdS&rR)?-7v`9*A*WC+-~0FtrwjnXuB%_i3L5Ix2~i}NcVgPzWpB4k}} zmZMtJ@1oi3>L$S@lPe<+?GgFNDVdu0-mzy3!fd`~_AVQ=lAZ#OF20sM;ck3Rt(0Uo z3TW4Vt(a_N9_^Ss7d~c8HV|r~0a&~BT|!e_O)tkSn6qv?&#PYyBTj6v&1P5x14Bq` zJr@%oM;EX(ysv1JG-hyt6+7yNPifPHKIk#bWMA1OwHIriy4{KjuK67li?>^n zwF_K{&Ut(|B!M^Mdv276a2<` zV|yPP;5N3`LFgPxzhjhh6=q5z64Iy z?8d)dJMXKGQbEAtYZ8n@(?cjO--ExhJLV^|sP2x%C0eFOG;#^x{KEtpe3i;;9H`VQ z<+dLh5i>;?D$+FjofYapYiTX}&zlqGBlRL2(yJE;oBosofcbd33!8?k=0*&Lx$#@U ziKuAd2`BB0wP_GQY8|#si#BbZGeBU^a*+x{Z!#s-UeeuhR>;t)+divnrqLfJGFP>p z&L=E(NG+1o6t9YLvh>VBTV=8Ci`nKFHIlhaxRZUY$*~W5Nu?dN(NxpY-hB(JQwy#z zF=cIImW-}&-n7|#S9*ymMaD50CEVc%R&GhQ2Z9Zv;gqb0M}C&HHs6i&X4*(zT#vo z5ixedLsX&c7Tp(@y0$*c(y8q<_J++W##4te%6sog3eO|!PTMu+u>G$uFQVTbd!|pU zG06;JKQ_u>=lxXrz<27y!)LCxcb1;5`sokEXj_SouWVEK&X_u`N+Eh%_7{;ozn|IQ z@QD~%3Ac@X9NS`fP*cc+{h^k8uhxL>`gM4(&c1Jt#y$qUBYf`GQ|@kc%nUNTS^ia# z$Qt^KInBz{{qyfHk|AlKQUN}nR9C8ZO=52SDET$=;4RgDq?ysQvG^&w#!T=0A5V3v z?NLThW+sg9qU5_A0s-W_;&9ZA}e*Sd4=eok3cd${Nhk?!F)UL6(r6iBU{S>Y<=ZaW{JyOl7DdOQZ zOJgR0y>70~V7zcfDU--^q2*9qfU`iKe9`J{g0#@d{>a(`j9*~W5bf(qYS$bXSJ|RL z5rmx%hp%7tAe|Dj$1OGse(THGiWH4h=i?3Go^ZT>`Q-Z#OLG?69qWgq#b%k9Pm#LG z84uu&J@SJ@0;}BBC-(Ev7Q=xwgdi%_x;cD) zx?WS8_u>=i!b_LU20x#GZVb)%?u=ro`z=W~Gy(js*UYG%XtII!rR@amjJb0U zho?%%Mw}9laep&(5bJtpWxa8PA~2Gl*BfSf?Z>++ccA3_ibaE#_{pV|&6a}&Hi+b(}zj~98XyFn10=@HHB|*4W`{We&ftMWi z#mcTU{goY^k?Ql~4KJ_5e|$HnSR9FuxUvK8;>qxKEusfl(vu(6X1E5-?EjWcahIRu*aB-OKZ)@m(WjMp@9ygfU?IL3yG}A6=4~_}8rijmwuE zurL=Km65G5gdscMT3BKUwTRxQXwx3Ux--pKGHy*D9|hRE9j99!fw4C*ncHpKVm6e0 zqNVCmOhW4T!x7XAc)5MqdV)b;vAKb%NvSzNtJK5tTVthVnTW0eS*;(NyUt`!vvhu9 ziTdqZx((Km<8?n9HI~ld4Q&s98McZvlNM+tWQKcrt%G2quoL!J(&*;&M*rHiXA_Ki z*B%Vz^vcbzn`7j%c2Fl4aVU`s?zQiZH&_;Zg`%)l0d_gacf=Y~f-2VF zQ)!Xor+DsC?=;oaF5KEAuND!=GQ9=i~=W!KB7J%TFyB{ENpfkldPV zH`*K{79Cf_UIkwB*0LLRYW{_1zpebk=F}Vi;O$-g#9hM?>VC{pqS>QR(_T=7g`hXM zr8u?a3;;|vcz^$E008*nuX(yb@IM;U3{)Ea(3|JI@Bg;{X9AJ`Yi{(vlK&S5hi(JH zp}+KfAm-&DpFcjC{zLFz)*!$C-TuqZ|LM0FQVRW}D2Hgyc_yEd0yv zA6u{$yqgym;-K(u|GoBO5LpBUz%icMbzj=R;q96z%s*sc^ z%!I-TQUMVU2!4l?qc1ZXL6reO120oMbOQ{qcy`5gp&)T20UJnw;9dZGT@X5U@%Qe+ z2bzJs4gjKoL4S50LjTBcLK)w~1)&A)Wn{1Yqb~u`T>mEhn+D1M!HpnZ_`i620HSk= zL$;1WJs9d2{{{DGf>0NR)Swq3u)V6?f#e~7uK!fgo=^~1ZFYyY8Tz2n{{vE6f?NOq literal 0 HcmV?d00001 diff --git a/src/PicView.Tests/PicView.Tests.csproj b/src/PicView.Tests/PicView.Tests.csproj index 96c1ea876..53f245a38 100644 --- a/src/PicView.Tests/PicView.Tests.csproj +++ b/src/PicView.Tests/PicView.Tests.csproj @@ -1,4 +1,4 @@ - + net11.0 @@ -32,4 +32,19 @@ + + + + PreserveNewest + ffmpeg\win-x64\picview-ffmpeg.dll + + + + + + PreserveNewest + MotionPhoto\Samples\sample_h264.mp4 + + + From ac696405a51c1e6c0f0f605daaefd3552128aefa Mon Sep 17 00:00:00 2001 From: refly <3380520452@qq.com> Date: Thu, 27 Aug 2026 13:20:05 +0800 Subject: [PATCH 2/3] Build picview-ffmpeg natively in CI Each workflow builds the native library for the runner's own architecture (win-x64, osx-arm64) with an actions/cache entry; architectures without a native library (win-arm64, osx-x64, linux) show motion photos as still images. --- .github/workflows/BuildMacOS.yml | 23 +++++++++++++++++++++++ .github/workflows/BuildWin32.yml | 22 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/BuildMacOS.yml b/.github/workflows/BuildMacOS.yml index 822de2a88..b85eca352 100644 --- a/.github/workflows/BuildMacOS.yml +++ b/.github/workflows/BuildMacOS.yml @@ -28,6 +28,29 @@ jobs: id: get-version run: pwsh -File "${{ github.workspace }}/Build/Get-VersionInfo.ps1" + # Step 3.1: Restore or build picview-ffmpeg (osx-arm64), the statically-linked, + # video-only FFmpeg used for motion photo playback. Cached; a full build takes + # roughly ten minutes, cache hits are instant. The osx-x64 build ships without + # the native library and degrades motion photos to still images. + - name: Cache picview-ffmpeg (osx-arm64) + id: cache-ffmpeg-osx-arm64 + uses: actions/cache@v4 + with: + path: Build/ffmpeg-native/osx-arm64 + key: picview-ffmpeg-osx-arm64-${{ hashFiles('Build/Build-FFmpegNative.ps1', 'Build/ffmpeg/**', 'Native/ffmpeg/**') }} + + - name: Build picview-ffmpeg (osx-arm64) + if: steps.cache-ffmpeg-osx-arm64.outputs.cache-hit != 'true' + shell: pwsh + run: | + brew install make + + # FFmpeg needs GNU make; brew installs it keg-only as gmake, so expose + # the directory containing it as 'make' to the build script + $env:PV_EXTRA_PATH = Join-Path (brew --prefix make) 'libexec/gnubin' + + & "${{ github.workspace }}/Build/Build-FFmpegNative.ps1" -Targets osx-arm64 + # Step 4: Restore dependencies - name: Restore dependencies run: dotnet restore src/PicView.Avalonia.MacOS/PicView.Avalonia.MacOS.csproj diff --git a/.github/workflows/BuildWin32.yml b/.github/workflows/BuildWin32.yml index 9b3eb1ecb..09f230d05 100644 --- a/.github/workflows/BuildWin32.yml +++ b/.github/workflows/BuildWin32.yml @@ -28,7 +28,27 @@ jobs: - name: Get version from Directory.Build.props id: get-version run: pwsh -File "${{ github.workspace }}/Build/Get-VersionInfo.ps1" - + + # Step 3.1: Restore or build picview-ffmpeg (win-x64), the statically-linked, + # video-only FFmpeg used for motion photo playback. Cached; a full build takes + # roughly ten minutes, cache hits are instant. The win-arm64 build ships without + # the native library and degrades motion photos to still images. + - name: Cache picview-ffmpeg (win-x64) + id: cache-ffmpeg-win-x64 + uses: actions/cache@v4 + with: + path: Build/ffmpeg-native/win-x64 + key: picview-ffmpeg-win-x64-${{ hashFiles('Build/Build-FFmpegNative.ps1', 'Build/ffmpeg/**', 'Native/ffmpeg/**') }} + + - name: Build picview-ffmpeg (win-x64) + if: steps.cache-ffmpeg-win-x64.outputs.cache-hit != 'true' + shell: pwsh + run: | + # FFmpeg build toolchain inside the preinstalled MSYS2 + & C:\msys64\usr\bin\pacman.exe -Sy --noconfirm --needed base-devel mingw-w64-x86_64-gcc mingw-w64-x86_64-nasm diffutils tar + + & "${{ github.workspace }}\Build\Build-FFmpegNative.ps1" -Targets win-x64 + # Step 4 (x64): Publish x64 version - name: Publish x64 version run: | From fb767928923c8926f9f7f648a7564b748ecc17c0 Mon Sep 17 00:00:00 2001 From: refly <3380520452@qq.com> Date: Thu, 27 Aug 2026 14:34:17 +0800 Subject: [PATCH 3/3] Restore floating play badge; fix motion photo overlay collapse - Float the play badge above the transformed image container, anchored to the image's on-screen top-right corner; it stays upright under rotation and flipping and is clamped 15px inside the panel edges - Fix blank playback: hide the still image with Opacity instead of IsVisible when the first video frame arrives, so the grid cell that sizes the video overlay no longer collapses - Restore per-frame dimension reporting in the decoder ABI (container metadata can disagree with decoded dimensions on phone videos) - Restore the Live Photo glyph for gallery badges and the zh-CN/zh-TW terminology - Re-enable cross-compiled picview-ffmpeg builds so CI bundles the native library for win-arm64 and osx-x64 again --- .github/workflows/BuildMacOS.yml | 31 +++-- .github/workflows/BuildWin32.yml | 31 +++-- .gitignore | 2 +- Build/Build Avalonia.Win32 arm64.ps1 | 7 +- Build/Build Avalonia.Win32 x64.ps1 | 7 +- Build/Build Avalonia.Win32.ps1 | 8 +- Build/Build-FFmpegNative.ps1 | 38 ++---- Build/ffmpeg/build-target.sh | 14 +-- Native/ffmpeg/picview_ffmpeg.c | 55 +++++---- .../MotionPhoto/FFmpegService.cs | 2 +- .../MotionPhoto/MotionPhotoDecoder.cs | 12 +- .../MotionPhoto/MotionPhotoVideoSurface.cs | 10 +- .../MotionPhoto/MotionPhotoView.axaml | 28 +++-- .../MotionPhoto/MotionPhotoView.axaml.cs | 110 +++++++++++++++++- src/PicView.Avalonia/PicViewTheme/Icons.axaml | 6 + .../Views/Gallery/GalleryItem.axaml | 23 ++-- .../Views/Main/MainView.axaml | 4 +- .../Views/UC/ImageViewer.axaml.cs | 107 ++++++++++++++++- src/PicView.Core/Config/Languages/zh-CN.json | 2 +- src/PicView.Core/Config/Languages/zh-TW.json | 2 +- .../MotionPhoto/MotionPhotoDecoderTests.cs | 7 +- .../MotionPhoto/MotionPhotoEndToEndTests.cs | 3 +- 22 files changed, 370 insertions(+), 139 deletions(-) diff --git a/.github/workflows/BuildMacOS.yml b/.github/workflows/BuildMacOS.yml index b85eca352..051cd4681 100644 --- a/.github/workflows/BuildMacOS.yml +++ b/.github/workflows/BuildMacOS.yml @@ -28,10 +28,9 @@ jobs: id: get-version run: pwsh -File "${{ github.workspace }}/Build/Get-VersionInfo.ps1" - # Step 3.1: Restore or build picview-ffmpeg (osx-arm64), the statically-linked, - # video-only FFmpeg used for motion photo playback. Cached; a full build takes - # roughly ten minutes, cache hits are instant. The osx-x64 build ships without - # the native library and degrades motion photos to still images. + # Step 3.1: Restore or build picview-ffmpeg, the statically-linked FFmpeg used + # for motion photo playback. Outputs are cached; a full build takes roughly ten + # minutes, cache hits are instant. - name: Cache picview-ffmpeg (osx-arm64) id: cache-ffmpeg-osx-arm64 uses: actions/cache@v4 @@ -39,17 +38,33 @@ jobs: path: Build/ffmpeg-native/osx-arm64 key: picview-ffmpeg-osx-arm64-${{ hashFiles('Build/Build-FFmpegNative.ps1', 'Build/ffmpeg/**', 'Native/ffmpeg/**') }} - - name: Build picview-ffmpeg (osx-arm64) - if: steps.cache-ffmpeg-osx-arm64.outputs.cache-hit != 'true' + - name: Cache picview-ffmpeg (osx-x64) + id: cache-ffmpeg-osx-x64 + uses: actions/cache@v4 + with: + path: Build/ffmpeg-native/osx-x64 + key: picview-ffmpeg-osx-x64-${{ hashFiles('Build/Build-FFmpegNative.ps1', 'Build/ffmpeg/**', 'Native/ffmpeg/**') }} + + - name: Setup zig + if: steps.cache-ffmpeg-osx-arm64.outputs.cache-hit != 'true' || steps.cache-ffmpeg-osx-x64.outputs.cache-hit != 'true' + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.15.1 + + - name: Build picview-ffmpeg (macOS) + if: steps.cache-ffmpeg-osx-arm64.outputs.cache-hit != 'true' || steps.cache-ffmpeg-osx-x64.outputs.cache-hit != 'true' shell: pwsh run: | - brew install make + brew install nasm make # FFmpeg needs GNU make; brew installs it keg-only as gmake, so expose # the directory containing it as 'make' to the build script $env:PV_EXTRA_PATH = Join-Path (brew --prefix make) 'libexec/gnubin' - & "${{ github.workspace }}/Build/Build-FFmpegNative.ps1" -Targets osx-arm64 + $targets = @() + if ('${{ steps.cache-ffmpeg-osx-arm64.outputs.cache-hit }}' -ne 'true') { $targets += 'osx-arm64' } + if ('${{ steps.cache-ffmpeg-osx-x64.outputs.cache-hit }}' -ne 'true') { $targets += 'osx-x64' } + & "${{ github.workspace }}/Build/Build-FFmpegNative.ps1" -Targets $targets # Step 4: Restore dependencies - name: Restore dependencies diff --git a/.github/workflows/BuildWin32.yml b/.github/workflows/BuildWin32.yml index 09f230d05..1dd1d4326 100644 --- a/.github/workflows/BuildWin32.yml +++ b/.github/workflows/BuildWin32.yml @@ -29,10 +29,9 @@ jobs: id: get-version run: pwsh -File "${{ github.workspace }}/Build/Get-VersionInfo.ps1" - # Step 3.1: Restore or build picview-ffmpeg (win-x64), the statically-linked, - # video-only FFmpeg used for motion photo playback. Cached; a full build takes - # roughly ten minutes, cache hits are instant. The win-arm64 build ships without - # the native library and degrades motion photos to still images. + # Step 3.1: Restore or build picview-ffmpeg, the statically-linked FFmpeg used + # for motion photo playback. Outputs are cached; a full build takes roughly ten + # minutes, cache hits are instant. - name: Cache picview-ffmpeg (win-x64) id: cache-ffmpeg-win-x64 uses: actions/cache@v4 @@ -40,15 +39,31 @@ jobs: path: Build/ffmpeg-native/win-x64 key: picview-ffmpeg-win-x64-${{ hashFiles('Build/Build-FFmpegNative.ps1', 'Build/ffmpeg/**', 'Native/ffmpeg/**') }} - - name: Build picview-ffmpeg (win-x64) - if: steps.cache-ffmpeg-win-x64.outputs.cache-hit != 'true' + - name: Cache picview-ffmpeg (win-arm64) + id: cache-ffmpeg-win-arm64 + uses: actions/cache@v4 + with: + path: Build/ffmpeg-native/win-arm64 + key: picview-ffmpeg-win-arm64-${{ hashFiles('Build/Build-FFmpegNative.ps1', 'Build/ffmpeg/**', 'Native/ffmpeg/**') }} + + - name: Setup zig + if: steps.cache-ffmpeg-win-x64.outputs.cache-hit != 'true' || steps.cache-ffmpeg-win-arm64.outputs.cache-hit != 'true' + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.15.1 + + - name: Build picview-ffmpeg (Windows) + if: steps.cache-ffmpeg-win-x64.outputs.cache-hit != 'true' || steps.cache-ffmpeg-win-arm64.outputs.cache-hit != 'true' shell: pwsh run: | # FFmpeg build toolchain inside the preinstalled MSYS2 & C:\msys64\usr\bin\pacman.exe -Sy --noconfirm --needed base-devel mingw-w64-x86_64-gcc mingw-w64-x86_64-nasm diffutils tar - & "${{ github.workspace }}\Build\Build-FFmpegNative.ps1" -Targets win-x64 - + $targets = @() + if ('${{ steps.cache-ffmpeg-win-x64.outputs.cache-hit }}' -ne 'true') { $targets += 'win-x64' } + if ('${{ steps.cache-ffmpeg-win-arm64.outputs.cache-hit }}' -ne 'true') { $targets += 'win-arm64' } + & "${{ github.workspace }}\Build\Build-FFmpegNative.ps1" -Targets $targets + # Step 4 (x64): Publish x64 version - name: Publish x64 version run: | diff --git a/.gitignore b/.gitignore index d86dfbb84..7196c865d 100644 --- a/.gitignore +++ b/.gitignore @@ -366,5 +366,5 @@ MigrationBackup/ /src/.zip /src/.zip -# Built picview-ffmpeg native libraries (produced by Build\Build-FFmpegNative.ps1) +# Cross-compiled picview-ffmpeg native libraries (produced by Build\Build-FFmpegNative.ps1) Build/ffmpeg-native/ diff --git a/Build/Build Avalonia.Win32 arm64.ps1 b/Build/Build Avalonia.Win32 arm64.ps1 index 7830af26d..67918c010 100644 --- a/Build/Build Avalonia.Win32 arm64.ps1 +++ b/Build/Build Avalonia.Win32 arm64.ps1 @@ -56,11 +56,8 @@ 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 -} +# 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..07b0b4e3f 100644 --- a/Build/Build Avalonia.Win32 x64.ps1 +++ b/Build/Build Avalonia.Win32 x64.ps1 @@ -34,11 +34,8 @@ 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..c22be4c93 100644 --- a/Build/Build Avalonia.Win32.ps1 +++ b/Build/Build Avalonia.Win32.ps1 @@ -37,11 +37,7 @@ $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 - -# 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/Build-FFmpegNative.ps1 b/Build/Build-FFmpegNative.ps1 index 0927a0398..19ec69ab8 100644 --- a/Build/Build-FFmpegNative.ps1 +++ b/Build/Build-FFmpegNative.ps1 @@ -16,15 +16,14 @@ native library per target that exports only four functions (pv_open, pv_decode_next, pv_close, pv_version) - see Native\ffmpeg\picview_ffmpeg.c. Prerequisites (one-time): - Windows host (win-x64 builds natively; everything else cross-compiles): + Windows host (builds every target by cross-compiling): * MSYS2 (https://www.msys2.org), installed to C:\msys64 by default: pacman -Syu pacman -S base-devel mingw-w64-x86_64-gcc mingw-w64-x86_64-nasm diffutils tar - * zig on PATH (https://ziglang.org - only needed for cross-targets, i.e. - everything except win-x64): + * zig on PATH (https://ziglang.org - used to cross-compile Linux/macOS/ARM): winget install Zig.Zig (or scoop install zig) - macOS host (builds the osx-* targets natively on the host architecture): - * brew install make (plus nasm for Intel hosts, zig for cross-targets) + macOS host (builds the osx-* targets): + * brew install nasm zig make * GNU make must be reachable as 'make'; set PV_EXTRA_PATH to the directory that contains it, e.g. "$((brew --prefix make))/libexec/gnubin" @@ -53,21 +52,7 @@ $repoRoot = Join-Path $scriptRoot ".." $shim = Join-Path $repoRoot "Native\ffmpeg\picview_ffmpeg.c" $outputDir = Join-Path $scriptRoot "ffmpeg-native" -function Test-TargetRequiresZig([string]$target) { - # win-x64 builds natively with MSYS2's MINGW64 gcc; the osx-* targets build - # natively on a macOS host of the same architecture. Everything else is - # cross-compiled through zig cc. - switch ($target) { - 'win-x64' { return $false } - 'osx-arm64' { return -not ($IsMacOS -and [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) } - 'osx-x64' { return -not ($IsMacOS -and [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::X64) } - default { return $true } - } -} - -$zigCommand = Get-Command zig -ErrorAction SilentlyContinue -$needsZig = @($Targets | Where-Object { Test-TargetRequiresZig $_ }).Count -gt 0 -if ($needsZig -and -not $zigCommand) { +if (-not (Get-Command zig -ErrorAction SilentlyContinue)) { Write-Error "zig not found on PATH. Install it with: winget install Zig.Zig (or scoop install zig / brew install zig)" } @@ -123,8 +108,7 @@ if (-not (Test-Path $sourceDir)) { # 2. Build the small Mach-O nm used by configure to detect the '_' symbol prefix # when cross-compiling for Apple targets from Windows (GNU nm cannot read # Mach-O objects). Apple hosts use their native nm instead. -$appleTargets = @($Targets | Where-Object { $_ -like 'osx-*' }) -if ($IsWindows -and $appleTargets.Count -gt 0) { +if ($IsWindows) { $machonm = Join-Path $workRoot "machonm.exe" Write-Host "Building machonm helper..." zig cc -target x86_64-windows (Join-Path $scriptRoot "ffmpeg\machonm.c") -o $machonm @@ -135,12 +119,10 @@ else { } # 3. Build each target -# build-target.sh prepends PV_EXTRA_PATH to its PATH; make sure zig (when needed) -# and any caller-provided toolchain directories (e.g. GNU make on macOS) are reachable -if ($zigCommand) { - $zigDir = ConvertTo-ShellPath (Split-Path $zigCommand.Source) - $env:PV_EXTRA_PATH = [string]::IsNullOrEmpty($env:PV_EXTRA_PATH) ? $zigDir : "$env:PV_EXTRA_PATH`:$zigDir" -} +# build-target.sh prepends PV_EXTRA_PATH to its PATH; make sure zig (and any +# caller-provided toolchain directory, e.g. GNU make on macOS) is reachable +$zigDir = ConvertTo-ShellPath (Split-Path (Get-Command zig).Source) +$env:PV_EXTRA_PATH = [string]::IsNullOrEmpty($env:PV_EXTRA_PATH) ? $zigDir : "$env:PV_EXTRA_PATH`:$zigDir" $env:PV_ROOT = ConvertTo-ShellPath $workRoot $env:PV_SRC = ConvertTo-ShellPath $sourceDir diff --git a/Build/ffmpeg/build-target.sh b/Build/ffmpeg/build-target.sh index a5b057c8f..ab381eba7 100644 --- a/Build/ffmpeg/build-target.sh +++ b/Build/ffmpeg/build-target.sh @@ -50,12 +50,7 @@ case $TARGET in OUTLIB=libpicviewffmpeg.so ;; osx-x64) - if [ "$(uname)" = "Darwin" ] && [ "$(uname -m)" = "x86_64" ]; then - # Native build on Intel macOS hosts: no cross toolchain required - CONFIGURE_FLAGS=(--target-os=darwin --arch=x86_64 --enable-pic --x86asmexe=nasm) - else - CONFIGURE_FLAGS=(--target-os=darwin --arch=x86_64 --enable-cross-compile --enable-pic "--ar=zig ar" "--ranlib=zig ranlib" "--nm=$MACHONM" "--cc=zig cc" "--ld=zig cc --target=x86_64-macos.11.0" "--extra-cflags=--target=x86_64-macos.11.0" --x86asmexe=nasm) - fi + CONFIGURE_FLAGS=(--target-os=darwin --arch=x86_64 --enable-cross-compile --enable-pic "--ar=zig ar" "--ranlib=zig ranlib" "--nm=$MACHONM" "--cc=zig cc" "--ld=zig cc --target=x86_64-macos.11.0" "--extra-cflags=--target=x86_64-macos.11.0" --x86asmexe=nasm) if [ "$(uname)" = "Darwin" ]; then # Apple hosts link natively (zig cc rejects -exported_symbols_list) LINK_CMD=(cc -arch x86_64 -dynamiclib) @@ -67,12 +62,7 @@ case $TARGET in OUTLIB=libpicviewffmpeg.dylib ;; osx-arm64) - if [ "$(uname)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then - # Native build on Apple Silicon hosts: no cross toolchain required - CONFIGURE_FLAGS=(--target-os=darwin --arch=aarch64 --enable-pic) - else - CONFIGURE_FLAGS=(--target-os=darwin --arch=aarch64 --enable-cross-compile --enable-pic "--ar=zig ar" "--ranlib=zig ranlib" "--nm=$MACHONM" --disable-x86asm "--cc=zig cc" "--ld=zig cc --target=aarch64-macos.11.0" "--extra-cflags=--target=aarch64-macos.11.0") - fi + CONFIGURE_FLAGS=(--target-os=darwin --arch=aarch64 --enable-cross-compile --enable-pic "--ar=zig ar" "--ranlib=zig ranlib" "--nm=$MACHONM" --disable-x86asm "--cc=zig cc" "--ld=zig cc --target=aarch64-macos.11.0" "--extra-cflags=--target=aarch64-macos.11.0") if [ "$(uname)" = "Darwin" ]; then # Apple hosts link natively (zig cc rejects -exported_symbols_list) LINK_CMD=(cc -arch arm64 -dynamiclib) diff --git a/Native/ffmpeg/picview_ffmpeg.c b/Native/ffmpeg/picview_ffmpeg.c index ae7a7a296..296fc883a 100644 --- a/Native/ffmpeg/picview_ffmpeg.c +++ b/Native/ffmpeg/picview_ffmpeg.c @@ -231,18 +231,8 @@ PV_API PvSession *pv_open(void *opaque, PvReadCb read_cb, PvSeekCb seek_cb, PvVi s->time_base = av_q2d(stream->time_base); s->start_ts = stream->start_time != AV_NOPTS_VALUE ? stream->start_time : 0; - /* Report the display dimensions (rotated upright); pv_decode_next emits - * every frame at exactly this size. */ - if (s->rotation == 90 || s->rotation == 270) - { - out_info->width = s->height; - out_info->height = s->width; - } - else - { - out_info->width = s->width; - out_info->height = s->height; - } + out_info->width = s->width; + out_info->height = s->height; AVRational rate = av_guess_frame_rate(s->fmt, stream, NULL); out_info->fps = (rate.den > 0 && rate.num > 0) ? (double)rate.num / rate.den : 30.0; @@ -267,6 +257,13 @@ PV_API PvSession *pv_open(void *opaque, PvReadCb read_cb, PvSeekCb seek_cb, PvVi return NULL; } +/* + * Prepares the scaler for the given frame. Output dimensions are the frame's own + * dimensions, except when the frame does not fit the caller's buffer (declared at + * open time), in which case it is scaled to the declared size. Container metadata + * dimensions occasionally disagree with the decoded dimensions (phone videos with + * conformance-window cropping), so every frame reports its actual output size. + */ /* * Reads the container's display rotation (displaymatrix side data, written by * phone cameras for portrait recordings). av_display_rotation_get() reports how @@ -343,19 +340,16 @@ static void pv_rotate_bgra(const uint8_t *src, int w, int h, uint8_t *dst, int r } } -/* - * Prepares the scaler for the given frame. Every frame is scaled to the codec's - * declared dimensions, so pv_decode_next always emits exactly the size reported - * at open time (display dimensions, rotation already applied). Decoded dimensions - * occasionally disagree with the container metadata (phone videos with - * conformance-window cropping); scaling to a fixed size keeps the caller's - * buffers fixed too. - */ static int pv_ensure_sws(PvSession *s, const AVFrame *frame, int *out_w, int *out_h) { enum AVPixelFormat fmt = (enum AVPixelFormat)frame->format; - int dst_w = s->width; - int dst_h = s->height; + int dst_w = frame->width; + int dst_h = frame->height; + if ((int64_t)dst_w * dst_h * 4 > (int64_t)s->width * s->height * 4) + { + dst_w = s->width; + dst_h = s->height; + } *out_w = dst_w; *out_h = dst_h; @@ -405,10 +399,11 @@ static double pv_frame_pts(PvSession *s, const AVFrame *frame, double fps) return fps > 0 ? (double)s->frame_count / fps : 0; } -PV_API int pv_decode_next(PvSession *s, uint8_t *dst, int dst_capacity, double *out_pts) +PV_API int pv_decode_next(PvSession *s, uint8_t *dst, int dst_capacity, double *out_pts, + int *out_width, int *out_height) { if (s == NULL || dst == NULL || dst_capacity < s->width * s->height * 4 || - out_pts == NULL) + out_pts == NULL || out_width == NULL || out_height == NULL) { return -1; } @@ -429,6 +424,14 @@ PV_API int pv_decode_next(PvSession *s, uint8_t *dst, int dst_capacity, double * return -1; } + int out_w = dst_w; + int out_h = dst_h; + if (s->rotation == 90 || s->rotation == 270) + { + out_w = dst_h; + out_h = dst_w; + } + if (s->rotation != 0) { /* Rotate after conversion, via the scratch buffer. */ @@ -462,9 +465,11 @@ PV_API int pv_decode_next(PvSession *s, uint8_t *dst, int dst_capacity, double * double fps = s->time_base > 0 ? 0 : 30.0; *out_pts = pv_frame_pts(s, s->frame, fps); + *out_width = out_w; + *out_height = out_h; s->frame_count++; av_frame_unref(s->frame); - return dst_w * dst_h * 4; + return out_w * out_h * 4; } if (r != AVERROR(EAGAIN)) diff --git a/src/PicView.Avalonia/MotionPhoto/FFmpegService.cs b/src/PicView.Avalonia/MotionPhoto/FFmpegService.cs index 4e9569e25..657cd80be 100644 --- a/src/PicView.Avalonia/MotionPhoto/FFmpegService.cs +++ b/src/PicView.Avalonia/MotionPhoto/FFmpegService.cs @@ -35,7 +35,7 @@ public struct PvVideoInfo } public delegate IntPtr PvOpenCallback(IntPtr opaque, PvReadCallback read, PvSeekCallback seek, out PvVideoInfo info); - public delegate int PvDecodeNextCallback(IntPtr session, IntPtr dst, int dstCapacity, out double pts); + public delegate int PvDecodeNextCallback(IntPtr session, IntPtr dst, int dstCapacity, out double pts, out int width, out int height); public delegate void PvCloseCallback(IntPtr session); private static readonly object InitLock = new(); diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs index d7544c239..fa9d86381 100644 --- a/src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs @@ -51,10 +51,12 @@ public sealed class MotionPhotoDecoder : IDisposable ///

    /// Raised on the worker thread when a frame is ready for display: - /// (buffer index, pointer to BGRA32 data, byte count). The buffer stays valid - /// until is called. + /// (buffer index, pointer to BGRA32 data, byte count, frame width, frame height). + /// The dimensions are the frame's actual dimensions, which may differ from + /// / (container metadata can disagree with + /// the decoded size). The buffer stays valid until is called. /// - public event Action? FrameReady; + public event Action? FrameReady; /// Raised on the worker thread when the end of the video is reached. public event EventHandler? Ended; @@ -175,7 +177,7 @@ private void WorkerLoop() index = OverflowBufferIndex; } - var written = FFmpegService.PvDecodeNext(_session, _frameBuffers[index], _frameBufferSize, out var pts); + var written = FFmpegService.PvDecodeNext(_session, _frameBuffers[index], _frameBufferSize, out var pts, out var frameWidth, out var frameHeight); if (written <= 0) { if (index != OverflowBufferIndex) @@ -206,7 +208,7 @@ private void WorkerLoop() continue; } - FrameReady?.Invoke(index, _frameBuffers[index], written); + FrameReady?.Invoke(index, _frameBuffers[index], written, frameWidth, frameHeight); } } catch (Exception e) diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs index 41056d5fa..019613992 100644 --- a/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs @@ -8,11 +8,11 @@ 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). +/// Renders motion photo video frames decoded by +/// (a statically-linked, purpose-built FFmpeg). Frames arrive as BGRA32 bytes and are +/// drawn letterboxed into the control. This works on every display stack, including +/// Wayland, and lets the video participate in the normal Avalonia compositor (zoom, +/// rotation, overlays). /// public sealed class MotionPhotoVideoSurface : Control { diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml index b1f64ce6d..939a580cf 100644 --- a/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml @@ -8,25 +8,37 @@ IsVisible="False"> +