Skip to content

Commit 33c4b05

Browse files
authored
Merge pull request #9 from SwaggyMacro/copilot/add-playback-speed-option
Add playback speed control for WebM to GIF conversion
2 parents fdc3fd9 + 29ef564 commit 33c4b05

6 files changed

Lines changed: 166 additions & 25 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
/.idea/
22
/bin/
3-
/obj/
3+
/obj/
4+
**/bin/
5+
**/obj/

LottieViewConvert/Lang/Resources.Designer.cs

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

LottieViewConvert/Lang/Resources.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,9 @@
777777
<data name="SaveAsGif" xml:space="preserve">
778778
<value>Save as Gif</value>
779779
</data>
780+
<data name="PlaybackSpeed" xml:space="preserve">
781+
<value>Playback Speed</value>
782+
</data>
780783
<data name="Scale" xml:space="preserve">
781784
<value>Scale</value>
782785
</data>

LottieViewConvert/Lang/Resources.zh.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,9 @@
776776
<data name="SaveAsGif" xml:space="preserve">
777777
<value>保存为 Gif</value>
778778
</data>
779+
<data name="PlaybackSpeed" xml:space="preserve">
780+
<value>播放速度</value>
781+
</data>
779782
<data name="Scale" xml:space="preserve">
780783
<value>缩放</value>
781784
</data>

LottieViewConvert/ViewModels/TgsDownloadViewModel.cs

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Collections.ObjectModel;
4+
using System.Diagnostics;
35
using System.IO;
46
using System.Linq;
57
using System.Reactive;
@@ -11,6 +13,7 @@
1113
using Avalonia.Controls.Notifications;
1214
using Avalonia.Media.Imaging;
1315
using Avalonia.Threading;
16+
using ImageMagick;
1417
using LottieViewConvert.Helper;
1518
using LottieViewConvert.Helper.Convert;
1619
using LottieViewConvert.Helper.LogHelper;
@@ -21,8 +24,6 @@
2124
using Material.Icons;
2225
using ReactiveUI;
2326
using SukiUI.Toasts;
24-
using System.Collections.Generic;
25-
using ImageMagick;
2627

2728
namespace LottieViewConvert.ViewModels;
2829

@@ -192,6 +193,13 @@ public double SaveGifProgress
192193
get => _saveGifProgress;
193194
set => this.RaiseAndSetIfChanged(ref _saveGifProgress, value);
194195
}
196+
197+
private double _playbackSpeed = 1.0;
198+
public double PlaybackSpeed
199+
{
200+
get => _playbackSpeed;
201+
set => this.RaiseAndSetIfChanged(ref _playbackSpeed, Math.Max(0.25, Math.Min(4.0, value)));
202+
}
195203

196204
public string SelectionCountText => $"{Resources.Selected} {StickerItems.Count(x => x.IsSelected)} / {StickerItems.Count}";
197205
public string SelectedCountText => $"{Resources.Selected} {StickerItems.Count(x => x.IsSelected)} {Resources.Sticker}";
@@ -427,6 +435,70 @@ private async Task SaveSelectedStickers()
427435
.Queue();
428436
}
429437
}
438+
439+
private async Task<double> GetVideoFrameRateAsync(string videoPath, CommandExecutor executor)
440+
{
441+
try
442+
{
443+
// Use ffprobe to get the frame rate
444+
var args = new List<string>
445+
{
446+
"-v", "error",
447+
"-select_streams", "v:0",
448+
"-show_entries", "stream=r_frame_rate",
449+
"-of", "default=noprint_wrappers=1:nokey=1",
450+
videoPath
451+
};
452+
453+
var tempOutput = Path.Combine(Path.GetTempPath(), $"fps_{Guid.NewGuid()}.txt");
454+
try
455+
{
456+
// Execute ffprobe and capture output
457+
var process = new System.Diagnostics.Process
458+
{
459+
StartInfo = new System.Diagnostics.ProcessStartInfo
460+
{
461+
FileName = "ffprobe",
462+
Arguments = string.Join(" ", args.Select(a => a.Contains(" ") ? $"\"{a}\"" : a)),
463+
RedirectStandardOutput = true,
464+
RedirectStandardError = true,
465+
UseShellExecute = false,
466+
CreateNoWindow = true
467+
}
468+
};
469+
470+
process.Start();
471+
var output = await process.StandardOutput.ReadToEndAsync();
472+
await process.WaitForExitAsync();
473+
474+
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
475+
{
476+
// Parse frame rate (format is usually "30/1" or "30000/1001")
477+
var parts = output.Trim().Split('/');
478+
if (parts.Length == 2 &&
479+
double.TryParse(parts[0], out var numerator) &&
480+
double.TryParse(parts[1], out var denominator) &&
481+
denominator > 0)
482+
{
483+
return numerator / denominator;
484+
}
485+
}
486+
}
487+
finally
488+
{
489+
if (File.Exists(tempOutput))
490+
File.Delete(tempOutput);
491+
}
492+
}
493+
catch (Exception ex)
494+
{
495+
Logger.Error($"Failed to get video frame rate: {ex}");
496+
}
497+
498+
// Default to 30 fps if detection fails
499+
return 30.0;
500+
}
501+
430502
private async Task SaveSelectedStickersAsGif()
431503
{
432504
// initialize UI state for GIF saving
@@ -443,6 +515,9 @@ await Dispatcher.UIThread.InvokeAsync(() =>
443515

444516
try
445517
{
518+
// Capture the playback speed value to avoid threading issues
519+
var playbackSpeed = PlaybackSpeed;
520+
446521
await Task.Run(async () =>
447522
{
448523
var totalCount = selectedStickers.Count;
@@ -461,6 +536,17 @@ await Task.Run(async () =>
461536
{
462537
using var imgList = new MagickImageCollection();
463538
await imgList.ReadAsync(sticker.FilePath);
539+
// Apply playback speed adjustment for animated images (like WebP)
540+
if (imgList.Count > 1)
541+
{
542+
foreach (var img in imgList)
543+
{
544+
if (img.AnimationDelay > 0)
545+
{
546+
img.AnimationDelay = (uint)Math.Max(1, img.AnimationDelay / playbackSpeed);
547+
}
548+
}
549+
}
464550
await imgList.WriteAsync(destPath);
465551
}
466552
else
@@ -469,11 +555,22 @@ await Task.Run(async () =>
469555
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
470556
Directory.CreateDirectory(tempDir);
471557
var exec = new CommandExecutor();
558+
559+
// Get the frame rate from the WebM file
560+
var fps = await GetVideoFrameRateAsync(sticker.FilePath, exec);
561+
562+
// Apply speed adjustment using ffmpeg's setpts filter
563+
// For speed adjustment: setpts=PTS/speed (e.g., PTS/2 for 2x speed, PTS*2 for 0.5x speed)
564+
var ptsMultiplier = 1.0 / playbackSpeed; // Inverse for setpts filter
565+
var videoFilter = $"setpts={ptsMultiplier:F4}*PTS,format=yuva420p";
566+
567+
// Extract frames with speed adjustment applied
472568
var extractArgs = new List<string>
473569
{
474570
"-hide_banner", "-y",
475-
"-vcodec", "libvpx-vp9", "-i", sticker.FilePath,
476-
"-vf", "format=yuva420p", "-c:v", "png", "-pix_fmt", "rgba",
571+
"-i", sticker.FilePath,
572+
"-vf", videoFilter,
573+
"-c:v", "png", "-pix_fmt", "rgba",
477574
Path.Combine(tempDir, "frame_%03d.png")
478575
};
479576
if (await exec.ExecuteAsync("ffmpeg", extractArgs, Path.GetDirectoryName(sticker.FilePath) ?? string.Empty))
@@ -486,9 +583,12 @@ await Task.Run(async () =>
486583
img.Alpha(AlphaOption.Set);
487584
imgList.Add(img);
488585
}
586+
// Calculate delay based on the actual frame rate
587+
// Since we've already adjusted speed in ffmpeg, use the original fps for delay
588+
var delay = fps > 0 ? (uint)Math.Max(1, Math.Round(100.0 / fps)) : 3;
489589
foreach (var img in imgList)
490590
{
491-
img.AnimationDelay = 3;
591+
img.AnimationDelay = delay;
492592
img.Format = MagickFormat.Gif;
493593
img.GifDisposeMethod = GifDisposeMethod.Background;
494594
img.BackgroundColor = MagickColors.Transparent;

LottieViewConvert/Views/TgsDownloadView.axaml

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -325,25 +325,49 @@
325325
</Button.Content>
326326
</Button>
327327
</Grid>
328-
<!-- Save as GIF button with embedded progress -->
329-
<Button Command="{Binding SaveAsGifCommand}"
330-
Classes="Accent"
331-
IsVisible="{Binding HasGifEligibleStickers}"
332-
IsEnabled="{Binding CanSaveAsGif}"
333-
HorizontalAlignment="Right"
334-
Margin="0,10,0,0">
335-
<Button.Content>
336-
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
337-
<material:MaterialIcon Kind="ContentSave" Width="16" Height="16"/>
338-
<TextBlock Text="{x:Static lang:Resources.SaveAsGif}"/>
339-
<suki:CircleProgressBar Width="20"
340-
Height="20"
341-
StrokeWidth="2"
342-
Value="{Binding SaveGifProgress}"
343-
IsVisible="{Binding IsSavingGif}"/>
344-
</StackPanel>
345-
</Button.Content>
346-
</Button>
328+
<!-- Playback Speed Control and Save as GIF button -->
329+
<Grid IsVisible="{Binding HasGifEligibleStickers}" Margin="0,10,0,0">
330+
<Grid.ColumnDefinitions>
331+
<ColumnDefinition Width="Auto"/>
332+
<ColumnDefinition Width="*"/>
333+
<ColumnDefinition Width="Auto"/>
334+
</Grid.ColumnDefinitions>
335+
336+
<!-- Playback Speed Slider -->
337+
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
338+
<TextBlock Text="{x:Static lang:Resources.PlaybackSpeed}" VerticalAlignment="Center"/>
339+
<TextBlock Text="{Binding PlaybackSpeed, StringFormat={}{0:F2}x}"
340+
VerticalAlignment="Center"
341+
FontWeight="SemiBold"
342+
MinWidth="45"/>
343+
<Slider Value="{Binding PlaybackSpeed}"
344+
Minimum="0.25"
345+
Maximum="4.0"
346+
Width="150"
347+
TickFrequency="0.25"
348+
IsSnapToTickEnabled="False"
349+
VerticalAlignment="Center"/>
350+
</StackPanel>
351+
352+
<!-- Save as GIF button with embedded progress -->
353+
<Button Grid.Column="2"
354+
Command="{Binding SaveAsGifCommand}"
355+
Classes="Accent"
356+
IsEnabled="{Binding CanSaveAsGif}"
357+
HorizontalAlignment="Right">
358+
<Button.Content>
359+
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
360+
<material:MaterialIcon Kind="ContentSave" Width="16" Height="16"/>
361+
<TextBlock Text="{x:Static lang:Resources.SaveAsGif}"/>
362+
<suki:CircleProgressBar Width="20"
363+
Height="20"
364+
StrokeWidth="2"
365+
Value="{Binding SaveGifProgress}"
366+
IsVisible="{Binding IsSavingGif}"/>
367+
</StackPanel>
368+
</Button.Content>
369+
</Button>
370+
</Grid>
347371
</StackPanel>
348372
</suki:GlassCard>
349373
</Grid>

0 commit comments

Comments
 (0)