diff --git a/.gitignore b/.gitignore index a6c973c..56da5ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /.idea/ /bin/ -/obj/ \ No newline at end of file +/obj/ +**/bin/ +**/obj/ \ No newline at end of file diff --git a/LottieViewConvert/Lang/Resources.Designer.cs b/LottieViewConvert/Lang/Resources.Designer.cs index 7d17426..3c66a5d 100644 --- a/LottieViewConvert/Lang/Resources.Designer.cs +++ b/LottieViewConvert/Lang/Resources.Designer.cs @@ -1788,6 +1788,15 @@ public static string SaveAsGif { } } + /// + /// Looks up a localized string similar to Playback Speed. + /// + public static string PlaybackSpeed { + get { + return ResourceManager.GetString("PlaybackSpeed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Save failed. /// diff --git a/LottieViewConvert/Lang/Resources.resx b/LottieViewConvert/Lang/Resources.resx index 9157d76..b20add0 100644 --- a/LottieViewConvert/Lang/Resources.resx +++ b/LottieViewConvert/Lang/Resources.resx @@ -777,6 +777,9 @@ Save as Gif + + Playback Speed + Scale diff --git a/LottieViewConvert/Lang/Resources.zh.resx b/LottieViewConvert/Lang/Resources.zh.resx index 0c36c1b..1597adf 100644 --- a/LottieViewConvert/Lang/Resources.zh.resx +++ b/LottieViewConvert/Lang/Resources.zh.resx @@ -776,6 +776,9 @@ 保存为 Gif + + 播放速度 + 缩放 diff --git a/LottieViewConvert/ViewModels/TgsDownloadViewModel.cs b/LottieViewConvert/ViewModels/TgsDownloadViewModel.cs index cd9b74a..e36693d 100644 --- a/LottieViewConvert/ViewModels/TgsDownloadViewModel.cs +++ b/LottieViewConvert/ViewModels/TgsDownloadViewModel.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics; using System.IO; using System.Linq; using System.Reactive; @@ -11,6 +13,7 @@ using Avalonia.Controls.Notifications; using Avalonia.Media.Imaging; using Avalonia.Threading; +using ImageMagick; using LottieViewConvert.Helper; using LottieViewConvert.Helper.Convert; using LottieViewConvert.Helper.LogHelper; @@ -21,8 +24,6 @@ using Material.Icons; using ReactiveUI; using SukiUI.Toasts; -using System.Collections.Generic; -using ImageMagick; namespace LottieViewConvert.ViewModels; @@ -192,6 +193,13 @@ public double SaveGifProgress get => _saveGifProgress; set => this.RaiseAndSetIfChanged(ref _saveGifProgress, value); } + + private double _playbackSpeed = 1.0; + public double PlaybackSpeed + { + get => _playbackSpeed; + set => this.RaiseAndSetIfChanged(ref _playbackSpeed, Math.Max(0.25, Math.Min(4.0, value))); + } public string SelectionCountText => $"{Resources.Selected} {StickerItems.Count(x => x.IsSelected)} / {StickerItems.Count}"; public string SelectedCountText => $"{Resources.Selected} {StickerItems.Count(x => x.IsSelected)} {Resources.Sticker}"; @@ -427,6 +435,70 @@ private async Task SaveSelectedStickers() .Queue(); } } + + private async Task GetVideoFrameRateAsync(string videoPath, CommandExecutor executor) + { + try + { + // Use ffprobe to get the frame rate + var args = new List + { + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=r_frame_rate", + "-of", "default=noprint_wrappers=1:nokey=1", + videoPath + }; + + var tempOutput = Path.Combine(Path.GetTempPath(), $"fps_{Guid.NewGuid()}.txt"); + try + { + // Execute ffprobe and capture output + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "ffprobe", + Arguments = string.Join(" ", args.Select(a => a.Contains(" ") ? $"\"{a}\"" : a)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) + { + // Parse frame rate (format is usually "30/1" or "30000/1001") + var parts = output.Trim().Split('/'); + if (parts.Length == 2 && + double.TryParse(parts[0], out var numerator) && + double.TryParse(parts[1], out var denominator) && + denominator > 0) + { + return numerator / denominator; + } + } + } + finally + { + if (File.Exists(tempOutput)) + File.Delete(tempOutput); + } + } + catch (Exception ex) + { + Logger.Error($"Failed to get video frame rate: {ex}"); + } + + // Default to 30 fps if detection fails + return 30.0; + } + private async Task SaveSelectedStickersAsGif() { // initialize UI state for GIF saving @@ -443,6 +515,9 @@ await Dispatcher.UIThread.InvokeAsync(() => try { + // Capture the playback speed value to avoid threading issues + var playbackSpeed = PlaybackSpeed; + await Task.Run(async () => { var totalCount = selectedStickers.Count; @@ -461,6 +536,17 @@ await Task.Run(async () => { using var imgList = new MagickImageCollection(); await imgList.ReadAsync(sticker.FilePath); + // Apply playback speed adjustment for animated images (like WebP) + if (imgList.Count > 1) + { + foreach (var img in imgList) + { + if (img.AnimationDelay > 0) + { + img.AnimationDelay = (uint)Math.Max(1, img.AnimationDelay / playbackSpeed); + } + } + } await imgList.WriteAsync(destPath); } else @@ -469,11 +555,22 @@ await Task.Run(async () => var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); var exec = new CommandExecutor(); + + // Get the frame rate from the WebM file + var fps = await GetVideoFrameRateAsync(sticker.FilePath, exec); + + // Apply speed adjustment using ffmpeg's setpts filter + // For speed adjustment: setpts=PTS/speed (e.g., PTS/2 for 2x speed, PTS*2 for 0.5x speed) + var ptsMultiplier = 1.0 / playbackSpeed; // Inverse for setpts filter + var videoFilter = $"setpts={ptsMultiplier:F4}*PTS,format=yuva420p"; + + // Extract frames with speed adjustment applied var extractArgs = new List { "-hide_banner", "-y", - "-vcodec", "libvpx-vp9", "-i", sticker.FilePath, - "-vf", "format=yuva420p", "-c:v", "png", "-pix_fmt", "rgba", + "-i", sticker.FilePath, + "-vf", videoFilter, + "-c:v", "png", "-pix_fmt", "rgba", Path.Combine(tempDir, "frame_%03d.png") }; if (await exec.ExecuteAsync("ffmpeg", extractArgs, Path.GetDirectoryName(sticker.FilePath) ?? string.Empty)) @@ -486,9 +583,12 @@ await Task.Run(async () => img.Alpha(AlphaOption.Set); imgList.Add(img); } + // Calculate delay based on the actual frame rate + // Since we've already adjusted speed in ffmpeg, use the original fps for delay + var delay = fps > 0 ? (uint)Math.Max(1, Math.Round(100.0 / fps)) : 3; foreach (var img in imgList) { - img.AnimationDelay = 3; + img.AnimationDelay = delay; img.Format = MagickFormat.Gif; img.GifDisposeMethod = GifDisposeMethod.Background; img.BackgroundColor = MagickColors.Transparent; diff --git a/LottieViewConvert/Views/TgsDownloadView.axaml b/LottieViewConvert/Views/TgsDownloadView.axaml index 0aa791c..4557534 100644 --- a/LottieViewConvert/Views/TgsDownloadView.axaml +++ b/LottieViewConvert/Views/TgsDownloadView.axaml @@ -325,25 +325,49 @@ - - + + + + + + + + + + + + + + + + + +