Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions Build/Build Avalonia.Win32 arm64.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@ New-Item -Path $outputPath -ItemType Directory | Out-Null
# Copy the build output to the final destination
Copy-Item -Path "$tempPath\*" -Destination $outputPath -Recurse -Force

# Remove the PDB file
$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb"
if (Test-Path $pdbPath) {
Remove-Item -Path $pdbPath -Force
}
# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua,
# hrtfs and unused plugins; ~275 MB -> ~23 MB)
& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-$platform"

# Remove debug symbols (native PicView.pdb alone is >150 MB)
Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue

#Remove uninstended space
Rename-Item -path $outputPath -NewName $outputPath.Replace(" ","")
Expand Down
11 changes: 6 additions & 5 deletions Build/Build Avalonia.Win32 x64.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,18 @@ New-Item -Path $outputPath -ItemType Directory | Out-Null
# Copy the build output to the final destination
Copy-Item -Path "$tempPath\*" -Destination $outputPath -Recurse -Force

# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua,
# hrtfs and unused plugins; ~275 MB -> ~23 MB)
& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-x64"

# Remove the license file
$licensePath = Join-Path -Path $outputPath -ChildPath "Licenses\XamlAnimatedGif LICENSE.txt"
if (Test-Path $licensePath) {
Remove-Item -Path $licensePath -Force
}

# Remove the PDB file
$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb"
if (Test-Path $pdbPath) {
Remove-Item -Path $pdbPath -Force
}
# Remove debug symbols (native PicView.pdb alone is >150 MB)
Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue

#Remove uninstended space
Rename-Item -path $outputPath -NewName $outputPath.Replace(" ","")
Expand Down
10 changes: 5 additions & 5 deletions Build/Build Avalonia.Win32.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ $avaloniaProjectPath = Join-Path -Path $PSScriptRoot -ChildPath "..\src\PicView.
# Run dotnet publish for the Avalonia project
dotnet publish $avaloniaProjectPath --runtime "win-$Platform" --self-contained true --configuration Release --output $outputPath /p:PublishReadyToRun=true

# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua,
# hrtfs and unused plugins; ~275 MB -> ~23 MB)
& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-$Platform"

# Remove the PDB file
$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb"
if (Test-Path $pdbPath) {
Remove-Item -Path $pdbPath -Force
}
# Remove debug symbols (native PicView.pdb alone is >150 MB)
Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue


102 changes: 102 additions & 0 deletions Build/Trim-LibVLCPlugins.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
param (
[Parameter(Mandatory = $true)]
[string]$LibVlcRoot,

[Parameter(Mandatory = $true)]
[string]$TargetArch
)

# Trims the VideoLAN.LibVLC.Windows payload down to what motion photo playback needs:
# 1. Sibling architecture folders (libvlc\win-*) other than the publish target arch —
# the NuGet package copies all architectures even for a RID-specific publish.
# 2. Link-time-only import libraries (*.lib) in the arch root.
# 3. lua\ scripts (the lua plugin is not kept) and hrtfs\ data (the headphone
# spatializer audio filter is not kept).
# 4. Plugin DLLs not on the whitelist below.
#
# The whitelist was determined empirically via `libvlc -vv` module logs and verified by
# playing h264/aac mp4 files through the software video callbacks (StreamMediaInput),
# including the 1080p d3d11va/dxva2 hardware decode path:
# imem access, mp4 demux, avcodec decoder (+ hw accel), swscale/yuvp/chain +
# d3d11/d3d9 video converters, vmem video output, wasapi/mmdevice audio chain.
# Missing whitelist entries are fine (e.g. win-arm64 has no d3d9/dxva2 plugins).
$whitelist = @(
"access\libaccess_imem_plugin.dll",
"access\libimem_plugin.dll",
"audio_filter\libscaletempo_plugin.dll",
"audio_filter\libtrivial_channel_mixer_plugin.dll",
"audio_filter\libugly_resampler_plugin.dll",
"audio_mixer\libfloat_mixer_plugin.dll",
"audio_output\libmmdevice_plugin.dll",
"audio_output\libwasapi_plugin.dll",
"codec\libavcodec_plugin.dll",
"codec\libd3d11va_plugin.dll",
"codec\libdxva2_plugin.dll",
"d3d11\libdirect3d11_filters_plugin.dll",
"d3d9\libdirect3d9_filters_plugin.dll",
"demux\libmp4_plugin.dll",
"keystore\libmemory_keystore_plugin.dll",
"logger\libconsole_logger_plugin.dll",
"stream_filter\libprefetch_plugin.dll",
"stream_filter\librecord_plugin.dll",
"video_chroma\libchain_plugin.dll",
"video_chroma\libswscale_plugin.dll",
"video_chroma\libyuvp_plugin.dll",
"video_output\libvmem_plugin.dll"
)

if (-not (Test-Path $LibVlcRoot)) {
Write-Warning "libvlc directory not found: $LibVlcRoot"
return
}

# 1. Remove sibling architecture folders (e.g. win-x86, and win-arm64 in an x64 publish)
Get-ChildItem -Path $LibVlcRoot -Directory -Filter "win-*" | Where-Object {
$_.Name -ne $TargetArch
} | ForEach-Object {
$sizeMB = ((Get-ChildItem $_.FullName -Recurse -File) | Measure-Object Length -Sum).Sum / 1MB
Remove-Item -Path $_.FullName -Recurse -Force
Write-Host ("libvlc trim: removed sibling arch {0} ({1:N1} MB)" -f $_.Name, $sizeMB)
}

$archDir = Join-Path -Path $LibVlcRoot -ChildPath $TargetArch
if (-not (Test-Path $archDir)) {
Write-Warning "libvlc arch directory not found: $archDir"
return
}

# 2. Remove link-time-only import libraries
Get-ChildItem -Path $archDir -Filter *.lib | ForEach-Object {
Remove-Item -Path $_.FullName -Force
}

# 3. Remove lua scripts and HRTF data (their plugins/filters are not kept)
foreach ($subDir in "lua", "hrtfs") {
$path = Join-Path -Path $archDir -ChildPath $subDir
if (Test-Path $path) {
Remove-Item -Path $path -Recurse -Force
}
}

# 4. Remove plugin DLLs not on the whitelist
$pluginsDir = Join-Path -Path $archDir -ChildPath "plugins"
if (-not (Test-Path $pluginsDir)) {
Write-Warning "libvlc plugins directory not found: $pluginsDir"
return
}

$removed = 0
Get-ChildItem -Path $pluginsDir -Recurse -Filter *.dll | ForEach-Object {
$relativePath = $_.FullName.Substring($pluginsDir.Length + 1)
if ($whitelist -notcontains $relativePath) {
Remove-Item -Path $_.FullName -Force
$removed++
}
}

# Drop plugin directories left empty
Get-ChildItem -Path $pluginsDir -Directory | Where-Object {
-not (Get-ChildItem -Path $_.FullName -Recurse -File)
} | Remove-Item -Recurse -Force

Write-Host "libvlc trim ($TargetArch): removed $removed plugin DLLs, kept $($whitelist.Count) (motion photo playback set)"
4 changes: 4 additions & 0 deletions src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,8 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup>
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23.1" />
</ItemGroup>
</Project>
7 changes: 7 additions & 0 deletions src/PicView.Avalonia/CustomControls/ZoomPanControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
2 changes: 2 additions & 0 deletions src/PicView.Avalonia/FileSystem/FileSaverHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ async ValueTask<bool> SaveBitmap()
{
case ImageType.AnimatedGif: // TODO: Add animated GIF support
case ImageType.AnimatedWebp: // TODO: Add animated WebP support
case ImageType.MotionPhoto: // Saves the still cover; the embedded video is only kept via the file-copy path
case ImageType.Bitmap:
{
if (tab.Image.CurrentValue is not Bitmap bitmap)
Expand Down Expand Up @@ -132,6 +133,7 @@ async ValueTask<bool> SaveProcessedMagickImage()
{
case ImageType.AnimatedGif: // TODO: Add animated GIF support
case ImageType.AnimatedWebp: // TODO: Add animated WebP support
case ImageType.MotionPhoto: // Saving a processed image drops the embedded video, keep the still only
case ImageType.Bitmap:
{
if (angle is not 0)
Expand Down
91 changes: 91 additions & 0 deletions src/PicView.Avalonia/ImageHandling/GetImageModel.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text;
using Avalonia.Media.Imaging;
using Avalonia.Svg.Skia;
using ImageMagick;
Expand All @@ -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;
Expand Down Expand Up @@ -36,6 +38,14 @@ public static async ValueTask<ImageModel> 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);

Expand Down Expand Up @@ -139,6 +149,8 @@ public static async ValueTask<ImageModel> GetImageModelAsync(FileInfo fileInfo,
break;
}

TryDetectMotionPhoto(fileInfo, magickImage, imageModel);

return imageModel;
}
catch (Exception e)
Expand Down Expand Up @@ -226,6 +238,52 @@ internal static bool IsAnimatedGif(FileInfo fileInfo)
}
}

/// <summary>
/// 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.
/// </summary>
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)
Expand All @@ -248,6 +306,39 @@ private static async ValueTask ProcessBase64Async(FileInfo fileInfo, MagickForma
var bitmap = await GetImage.GetBase64ImageAsync(fileInfo).ConfigureAwait(false);
SetBitmapProperties(bitmap, imageModel);
}

/// <summary>
/// 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.
/// </summary>
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)
{
Expand Down
17 changes: 17 additions & 0 deletions src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -186,6 +187,22 @@ private static async ValueTask<bool> 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)
{
Expand Down
Loading
Loading