Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/.idea/
/bin/
/obj/
/obj/
**/bin/
**/obj/
9 changes: 9 additions & 0 deletions LottieViewConvert/Lang/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions LottieViewConvert/Lang/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,9 @@
<data name="SaveAsGif" xml:space="preserve">
<value>Save as Gif</value>
</data>
<data name="PlaybackSpeed" xml:space="preserve">
<value>Playback Speed</value>
</data>
<data name="Scale" xml:space="preserve">
<value>Scale</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions LottieViewConvert/Lang/Resources.zh.resx
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,9 @@
<data name="SaveAsGif" xml:space="preserve">
<value>保存为 Gif</value>
</data>
<data name="PlaybackSpeed" xml:space="preserve">
<value>播放速度</value>
</data>
<data name="Scale" xml:space="preserve">
<value>缩放</value>
</data>
Expand Down
110 changes: 105 additions & 5 deletions LottieViewConvert/ViewModels/TgsDownloadViewModel.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -21,8 +24,6 @@
using Material.Icons;
using ReactiveUI;
using SukiUI.Toasts;
using System.Collections.Generic;
using ImageMagick;

namespace LottieViewConvert.ViewModels;

Expand Down Expand Up @@ -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}";
Expand Down Expand Up @@ -427,6 +435,70 @@ private async Task SaveSelectedStickers()
.Queue();
}
}

private async Task<double> GetVideoFrameRateAsync(string videoPath, CommandExecutor executor)
{
try
{
// Use ffprobe to get the frame rate
var args = new List<string>
{
"-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
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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<string>
{
"-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))
Expand All @@ -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;
Expand Down
62 changes: 43 additions & 19 deletions LottieViewConvert/Views/TgsDownloadView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -325,25 +325,49 @@
</Button.Content>
</Button>
</Grid>
<!-- Save as GIF button with embedded progress -->
<Button Command="{Binding SaveAsGifCommand}"
Classes="Accent"
IsVisible="{Binding HasGifEligibleStickers}"
IsEnabled="{Binding CanSaveAsGif}"
HorizontalAlignment="Right"
Margin="0,10,0,0">
<Button.Content>
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<material:MaterialIcon Kind="ContentSave" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.SaveAsGif}"/>
<suki:CircleProgressBar Width="20"
Height="20"
StrokeWidth="2"
Value="{Binding SaveGifProgress}"
IsVisible="{Binding IsSavingGif}"/>
</StackPanel>
</Button.Content>
</Button>
<!-- Playback Speed Control and Save as GIF button -->
<Grid IsVisible="{Binding HasGifEligibleStickers}" Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>

<!-- Playback Speed Slider -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="{x:Static lang:Resources.PlaybackSpeed}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding PlaybackSpeed, StringFormat={}{0:F2}x}"
VerticalAlignment="Center"
FontWeight="SemiBold"
MinWidth="45"/>
<Slider Value="{Binding PlaybackSpeed}"
Minimum="0.25"
Maximum="4.0"
Width="150"
TickFrequency="0.25"
IsSnapToTickEnabled="False"
VerticalAlignment="Center"/>
</StackPanel>

<!-- Save as GIF button with embedded progress -->
<Button Grid.Column="2"
Command="{Binding SaveAsGifCommand}"
Classes="Accent"
IsEnabled="{Binding CanSaveAsGif}"
HorizontalAlignment="Right">
<Button.Content>
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<material:MaterialIcon Kind="ContentSave" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.SaveAsGif}"/>
<suki:CircleProgressBar Width="20"
Height="20"
StrokeWidth="2"
Value="{Binding SaveGifProgress}"
IsVisible="{Binding IsSavingGif}"/>
</StackPanel>
</Button.Content>
</Button>
</Grid>
</StackPanel>
</suki:GlassCard>
</Grid>
Expand Down