diff --git a/.github/workflows/BuildMacOS.yml b/.github/workflows/BuildMacOS.yml index 822de2a88..051cd4681 100644 --- a/.github/workflows/BuildMacOS.yml +++ b/.github/workflows/BuildMacOS.yml @@ -28,6 +28,44 @@ jobs: id: get-version run: pwsh -File "${{ github.workspace }}/Build/Get-VersionInfo.ps1" + # 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 + with: + path: Build/ffmpeg-native/osx-arm64 + key: picview-ffmpeg-osx-arm64-${{ hashFiles('Build/Build-FFmpegNative.ps1', 'Build/ffmpeg/**', 'Native/ffmpeg/**') }} + + - 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 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' + + $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 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..1dd1d4326 100644 --- a/.github/workflows/BuildWin32.yml +++ b/.github/workflows/BuildWin32.yml @@ -28,6 +28,41 @@ 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, 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 + with: + path: Build/ffmpeg-native/win-x64 + key: picview-ffmpeg-win-x64-${{ hashFiles('Build/Build-FFmpegNative.ps1', 'Build/ffmpeg/**', 'Native/ffmpeg/**') }} + + - 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 + + $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 diff --git a/.gitignore b/.gitignore index 6e61f5c6f..7196c865d 100644 --- a/.gitignore +++ b/.gitignore @@ -365,3 +365,6 @@ MigrationBackup/ /src/.7z /src/.zip /src/.zip + +# 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 new file mode 100644 index 000000000..19ec69ab8 --- /dev/null +++ b/Build/Build-FFmpegNative.ps1 @@ -0,0 +1,154 @@ +<# +.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 (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 - used to cross-compile Linux/macOS/ARM): + winget install Zig.Zig (or scoop install zig) + 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" + +.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" + +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)" +} + +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. +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 + 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 (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 +$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..ab381eba7 --- /dev/null +++ b/Build/ffmpeg/build-target.sh @@ -0,0 +1,142 @@ +#!/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) + 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) + 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) + 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) + 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..296fc883a --- /dev/null +++ b/Native/ffmpeg/picview_ffmpeg.c @@ -0,0 +1,552 @@ +/* + * 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; + + 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; +} + +/* + * 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 + * 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; + } +} + +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 = 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; + + 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, + int *out_width, int *out_height) +{ + if (s == NULL || dst == NULL || dst_capacity < s->width * s->height * 4 || + out_pts == NULL || out_width == NULL || out_height == 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; + } + + 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. */ + 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); + *out_width = out_w; + *out_height = out_h; + s->frame_count++; + av_frame_unref(s->frame); + return out_w * out_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..657cd80be --- /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, out int width, out int height); + 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..fa9d86381 --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoDecoder.cs @@ -0,0 +1,340 @@ +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, 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; + + /// 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, out var frameWidth, out var frameHeight); + 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, frameWidth, frameHeight); + } + } + 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..019613992 --- /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 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 +{ + 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..939a580cf --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml @@ -0,0 +1,50 @@ + + + + + + + diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs new file mode 100644 index 000000000..6b4afecdf --- /dev/null +++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs @@ -0,0 +1,413 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using PicView.Core.DebugTools; +using PicView.Core.ImageDecoding; +using PicView.Core.Models; +using PicView.Core.MotionPhoto; +using PicView.Core.Sizing; +using PicView.Core.ViewModels; +using R3; + +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 +{ + /// Inset between the badge and the corner of the image it is anchored to. + private const double BadgeCornerInset = 15; + + /// + /// Vertical offset of the badge below its anchor. When the interface is hidden, + /// the alternative title bar with the window controls overlays the top of the view, + /// so the badge is moved below it; with multiple tabs the tab bar shifts that bar + /// down by the tab height. + /// + private const double BadgeTopInsetBelowAltBar = 45; + + private static readonly double BadgeTopInsetBelowAltBarMultiTab = 45 + SizeDefaults.TabHeight; + + private Stream? _videoStream; + private MotionPhotoDecoder? _decoder; + private ImageModel? _model; + private IDisposable? _uiShownSubscription; + private TranslateTransform? _badgeOffset; + private double _badgeTopInset = BadgeCornerInset; + 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; + } + + /// + /// Re-hosts the play badge into a floating overlay panel outside the transformed + /// image container, so it stays upright when the image is rotated, flipped or + /// zoomed while the hosting view keeps it anchored to the image's on-screen + /// top-right corner. The video surface stays behind to cover the image. + /// Must be called once by the hosting view before the badge can be shown. + /// + public void FloatBadge(Panel overlayHost) + { + if (ReferenceEquals(PlayBadge.Parent, overlayHost)) + { + return; + } + + (PlayBadge.Parent as Panel)?.Children.Remove(PlayBadge); + overlayHost.Children.Add(PlayBadge); + + // The badge is placed top-left in the overlay and translated to its anchor, + // which the hosting view recomputes via UpdateBadgePosition() + _badgeOffset = new TranslateTransform(); + PlayBadge.RenderTransform = _badgeOffset; + } + + /// + /// Anchors the floating badge to the given point, which is the on-screen top-right + /// corner of this view expressed in the overlay host's coordinate space. The badge + /// is clamped inside the host bounds, so it rests against the panel edges when the + /// corner is zoomed or panned out of view. A null corner leaves the previous position. + /// + internal void UpdateBadgePosition(Point? visualTopRightCorner, Size hostSize) + { + if (_badgeOffset is null || visualTopRightCorner is not { } corner) + { + return; + } + + var badgeBounds = PlayBadge.Bounds; + var left = corner.X - badgeBounds.Width - BadgeCornerInset; + var top = corner.Y + _badgeTopInset; + + // Clamped positions keep the same inset from the panel edges as from the + // image corner, so the badge rests 15px inside the panel rather than on it + _badgeOffset.X = Math.Clamp(left, BadgeCornerInset, + Math.Max(BadgeCornerInset, hostSize.Width - badgeBounds.Width - BadgeCornerInset)); + _badgeOffset.Y = Math.Clamp(top, _badgeTopInset, + Math.Max(_badgeTopInset, hostSize.Height - badgeBounds.Height - BadgeCornerInset)); + } + + /// + /// 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; + EnsureUiStateSubscription(); + UpdateBadgeInset(); + + 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 + { + // The badge no longer hides with the view, so it must be hidden explicitly + IsVisible = false; + PlayBadge.IsVisible = false; + } + } + + /// + /// When the interface is hidden (fullscreen), the alternative title bar with the + /// window controls overlays the top of the view and takes pointer input there, so + /// the badge is moved below it. With multiple tabs the tab bar shifts that bar + /// down by the tab height. + /// + private void UpdateBadgeInset() + { + if (DataContext is not TabViewModel tab || tab.ParentWindowContext.IsUIShown.CurrentValue) + { + _badgeTopInset = BadgeCornerInset; + return; + } + + _badgeTopInset = tab.ParentWindowContext.WindowTabs.Tabs.CurrentValue.Count >= 2 + ? BadgeTopInsetBelowAltBarMultiTab + : BadgeTopInsetBelowAltBar; + } + + private void EnsureUiStateSubscription() + { + if (_uiShownSubscription is not null || DataContext is not TabViewModel tab) + { + return; + } + + _uiShownSubscription = tab.ParentWindowContext.IsUIShown.Subscribe(_ => UpdateBadgeInset()); + } + + /// + /// 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; + PlayBadge.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; + PlayBadge.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; + PlayBadge.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, int width, int height) => + Dispatcher.UIThread.Post(() => + { + var decoder = _decoder; + if (decoder is null || _isDisposed) + { + decoder?.ReleaseBuffer(index); + return; + } + + try + { + VideoSurface.UpdateFrame(bgra, byteCount, width, 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; + _uiShownSubscription?.Dispose(); + _uiShownSubscription = null; + 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/PicViewTheme/Icons.axaml b/src/PicView.Avalonia/PicViewTheme/Icons.axaml index eeb0d9e5e..6ffadeac3 100644 --- a/src/PicView.Avalonia/PicViewTheme/Icons.axaml +++ b/src/PicView.Avalonia/PicViewTheme/Icons.axaml @@ -20,6 +20,12 @@ M647-440H160v-80h487L423-744l57-56 320 320-320 320-57-56 224-224Z F1M27.008,0L27.01,31.062 0,15.529z F1M0.002,31.062L0,0 27.01,15.534z + + M18.098,8.426A8.25,8.25 0 0 1 18.098,11.574M16.84,14.613A8.25,8.25 0 0 1 14.613,16.84M11.574,18.098A8.25,8.25 0 0 1 8.426,18.098M5.387,16.84A8.25,8.25 0 0 1 3.16,14.613M1.902,11.574A8.25,8.25 0 0 1 1.902,8.426M3.16,5.387A8.25,8.25 0 0 1 5.387,3.16M8.426,1.902A8.25,8.25 0 0 1 11.574,1.902M14.613,3.16A8.25,8.25 0 0 1 16.84,5.387 + M5,10a5,5 0 1 0 10,0a5,5 0 1 0 -10,0M7.6,10a2.4,2.4 0 1 0 4.8,0a2.4,2.4 0 1 0 -4.8,0 M321.94 98L158.82 237.78a24 24 0 000 36.44L321.94 414c15.57 13.34 39.62 2.28 39.62-18.22v-279.6c0-20.5-24.05-31.56-39.62-18.18z M190.06 414l163.12-139.78a24 24 0 000-36.44L190.06 98c-15.57-13.34-39.62-2.28-39.62 18.22v279.6c0 20.5 24.05 31.56 39.62 18.18z F1 M512,512z M0,0z M98,190.06L237.78,353.18A24,24,0,0,0,274.22,353.18L414,190.06C427.34,174.49,416.28,150.44,395.78,150.44L116.18,150.44C95.6799999999999,150.44,84.6199999999999,174.49,97.9999999999999,190.06z diff --git a/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml b/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml index 5404d7f28..a79eb03fd 100644 --- a/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml +++ b/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml @@ -20,9 +20,40 @@ 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..83174e524 100644 --- a/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs +++ b/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs @@ -3,6 +3,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Media; using Avalonia.Threading; using PicView.Avalonia.CustomControls; using PicView.Avalonia.ImageTransformations; @@ -12,6 +13,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 +39,192 @@ private void OnLoaded(object? sender, RoutedEventArgs e) AddHandler(PointerTouchPadGestureMagnifyEvent, TouchMagnifyEvent, RoutingStrategies.Bubble); AddHandler(PinchEvent, TouchMagnifyEvent, RoutingStrategies.Bubble); _disposables.Add(new HoverFadeButtonHandler(GalleryShortcut, GalleryShortcut.InnerButton)); + + // Float the badges above the transformed image container, so they stay upright + // when the image is rotated, flipped or zoomed, then keep each badge anchored + // to the on-screen top-right corner of its image + MotionPhotoView.FloatBadge(MotionPhotoBadgeHost); + SecondaryMotionPhotoView.FloatBadge(MotionPhotoBadgeHost); + + MainPanel.LayoutUpdated += OnMotionPhotoBadgeAnchorChanged; + MotionPhotoView.LayoutUpdated += OnMotionPhotoBadgeAnchorChanged; + SecondaryMotionPhotoView.LayoutUpdated += OnMotionPhotoBadgeAnchorChanged; + ImageScrollViewer.ScrollChanged += OnMotionPhotoBadgeScrollChanged; + UpdateMotionPhotoBadgePositions(); + + // Zooming and panning move the images through render transforms without + // triggering layout, so observe the transform values while they change. The + // child's render transform is read because animated zooms interpolate it + // while the control's own properties already hold the target values. + // The frame provider ticks with the render loop, so this only runs while + // frames are actually being rendered (i.e. while something visually moves) + var frameProvider = (TopLevel.GetTopLevel(this) as MainWindow)?.FrameProvider; + Observable.EveryValueChanged(ZoomPanControl, zoom => + { + if (zoom.Child?.RenderTransform is TransformGroup group && + group.Children.Count == 2 && + group.Children[0] is ScaleTransform scale && + group.Children[1] is TranslateTransform translate) + { + return (X: scale.ScaleX, Y: translate.X, Z: translate.Y); + } + + return (X: zoom.Scale, Y: zoom.TranslateX, Z: zoom.TranslateY); + }, frameProvider) + .Subscribe(_ => UpdateMotionPhotoBadgePositions(), + DebugHelper.LogError(nameof(ImageViewer), nameof(OnLoaded))) + .AddTo(ref _disposables); + + // The flip animates the RenderTransform of MainTransform without triggering layout + Observable.EveryValueChanged(MainTransform, + transform => (transform.RenderTransform as ScaleTransform)?.ScaleX ?? 1d, + frameProvider) + .Subscribe(_ => UpdateMotionPhotoBadgePositions(), + DebugHelper.LogError(nameof(ImageViewer), nameof(OnLoaded))) + .AddTo(ref _disposables); + + // 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) + { + // Opacity instead of IsVisible: hiding the image would collapse the grid cell + // that sizes the video overlay. + if (ReferenceEquals(sender, MotionPhotoView)) + { + MainImage.Opacity = 1; + } + else + { + SecondaryImage.Opacity = 1; + } + + 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. Opacity is used + // instead of IsVisible so the image keeps sizing the grid cell that hosts + // the video overlay. + if (ReferenceEquals(sender, MotionPhotoView)) + { + MainImage.Opacity = 0; + } + else + { + SecondaryImage.Opacity = 0; + } + } + + /// + /// 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); + UpdateMotionPhotoBadgePositions(); + } + + private void OnMotionPhotoBadgeAnchorChanged(object? sender, EventArgs e) => + UpdateMotionPhotoBadgePositions(); + + private void OnMotionPhotoBadgeScrollChanged(object? sender, ScrollChangedEventArgs e) => + UpdateMotionPhotoBadgePositions(); + + /// + /// Anchors each floating motion photo badge to the on-screen top-right corner + /// of its image. + /// + private void UpdateMotionPhotoBadgePositions() + { + var hostSize = MotionPhotoBadgeHost.Bounds.Size; + MotionPhotoView.UpdateBadgePosition(GetVisualTopRightCorner(MotionPhotoView), hostSize); + SecondaryMotionPhotoView.UpdateBadgePosition(GetVisualTopRightCorner(SecondaryMotionPhotoView), hostSize); + } + + /// + /// Maps the corners of the source into MainPanel coordinates and returns its + /// on-screen top-right corner. Rotations are multiples of 90° and flipping keeps + /// the rectangle axis-aligned, so that is simply (max X, min Y). Returns null + /// when the source cannot currently be mapped. + /// + private Point? GetVisualTopRightCorner(Control source) + { + if (!source.IsVisible || source.Bounds is not { Width: > 0, Height: > 0 }) + { + return null; + } + + var bounds = source.Bounds; + if (source.TranslatePoint(bounds.TopLeft, MainPanel) is not { } topLeft || + source.TranslatePoint(bounds.TopRight, MainPanel) is not { } topRight || + source.TranslatePoint(bounds.BottomLeft, MainPanel) is not { } bottomLeft || + source.TranslatePoint(bounds.BottomRight, MainPanel) is not { } bottomRight) + { + return null; + } + + var x = Math.Max(Math.Max(topLeft.X, topRight.X), Math.Max(bottomLeft.X, bottomRight.X)); + var y = Math.Min(Math.Min(topLeft.Y, topRight.Y), Math.Min(bottomLeft.Y, bottomRight.Y)); + return new Point(x, y); + } + + /// 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 +374,18 @@ public void Dispose() RemoveHandler(PointerWheelChangedEvent, PreviewOnPointerWheelChanged); RemoveHandler(PointerTouchPadGestureMagnifyEvent, TouchMagnifyEvent); RemoveHandler(PinchEvent, TouchMagnifyEvent); + MainPanel.LayoutUpdated -= OnMotionPhotoBadgeAnchorChanged; + MotionPhotoView.LayoutUpdated -= OnMotionPhotoBadgeAnchorChanged; + SecondaryMotionPhotoView.LayoutUpdated -= OnMotionPhotoBadgeAnchorChanged; + ImageScrollViewer.ScrollChanged -= OnMotionPhotoBadgeScrollChanged; + 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..6ebde57cf 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..24617fa4d 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..4d301aa24 --- /dev/null +++ b/src/PicView.Tests/MotionPhoto/MotionPhotoDecoderTests.cs @@ -0,0 +1,91 @@ +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, width, height) => + { + frames.Add((buffer, byteCount)); + Assert.Equal(width * height * 4, 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"); + Assert.All(frames, f => Assert.True(f.byteCount > 0)); + + 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..43db13d68 --- /dev/null +++ b/src/PicView.Tests/MotionPhoto/MotionPhotoEndToEndTests.cs @@ -0,0 +1,112 @@ +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, _, byteCount, width, height) => + { + Assert.Equal(width * height * 4, byteCount); + 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 000000000..6246a8b36 Binary files /dev/null and b/src/PicView.Tests/MotionPhoto/Samples/sample_h264.mp4 differ 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 + + +