OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 59aeab1

Exported video was silent even with a track loaded
The animation panel loads an audio clip, paints it into the scrubber and can drive channels from it, so a user watches a track drive the animation, exports, and gets a silent file. Nothing warned them. The canonical muxes it (AnimationViewModel.RunFfmpegProcess: a second -i, plus -t to cut audio back to the animation length); this port dropped both. FfmpegFramePipe takes an optional audio path and duration, adding the second input and -t in the canonical's order. Without -t a longer track would extend the video past its last frame, which is the failure the canonical's comment warns about. Wired into BOTH export paths. ExportClip's video branch had the identical bug, so fixing only the new Export > video would have left the older menu item quietly wrong - the same shape of defect, one menu item away. AnimationPanel.AudioPath exposes the loaded clip, which was private state. The export dialog now states the outcome up front - "Audio: track.wav (muxed, cut to the clip length)" or "No audio clip loaded - the video will be silent" - so silence is a stated choice rather than something discovered after a long render. The test is a real encode, because an argument-shape assertion would pass just as happily against a stream ffmpeg rejects: synthesise a 5s tone, pipe 12 frames at 10fps, then require ffprobe to report an audio stream AND a duration under 2s, which is what proves -t actually cut the tone rather than the video running on to 5s. 155 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
59aeab16d0a69359236a929b823d12a297cb4b4d
by plankatron <fleeky@gmail.com> · 2026-08-17T19:23:12.000Z

Changed files (5) +142 -7

AvaloniaShell.Tests/FfmpegPipeTests.cs +88 -0
……@@ -266,6 +266,94 @@ public class FfmpegPipeTests
266266 Assert.DoesNotContain(args, a => a == "-vf");
267267 }
268268
269 /// <summary>
270 /// The animation panel can load an audio clip and drive channels from it, so a user sees the
271 /// track in the scrubber, exports, and gets silence. The canonical muxes it
272 /// (AnimationViewModel.RunFfmpegProcess: a second -i plus -t to cut audio to the animation
273 /// length); this port dropped it with no warning.
274 /// </summary>
275 [Fact]
276 public void AudioIsMuxedAndCutToTheAnimationLength()
277 {
278 if (!Encoding())
279 return;
280
281 string dir = Path.Combine(Path.GetTempPath(), "ifs-audio-" + Guid.NewGuid().ToString("N"));
282 Directory.CreateDirectory(dir);
283 try
284 {
285 // A 5s tone against a 1.2s animation: long on purpose, so -t has something to cut.
286 string wav = Path.Combine(dir, "tone.wav");
287 RunFfmpeg("-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=5", wav);
288 Assert.True(File.Exists(wav), "could not synthesise the test tone");
289
290 string output = Path.Combine(dir, "with-audio.mp4");
291 using var pipe = new FfmpegFramePipe(null, null, output, wav, 1.2);
292 pipe.Start(32, 32, 10);
293 for (int i = 0; i < 12; i++) // 12 frames @ 10fps = 1.2s
294 pipe.Write(Frame(32, 32, (byte)(i * 20)));
295
296 string failure = pipe.Finish();
297 Assert.True(failure is null, "encode with audio failed: " + failure);
298
299 Assert.True(HasAudioStream(output), "the muxed file has no audio stream");
300
301 double seconds = DurationOf(output);
302 Assert.True(seconds < 2.0,
303 $"duration {seconds:0.00}s — the 5s tone was not cut to the animation length");
304 }
305 finally { Directory.Delete(dir, true); }
306 }
307
308 [Fact]
309 public void NoAudioInputWhenNoClipIsLoaded()
310 {
311 List<string> args = FfmpegFramePipe.BuildArgs(64, 64, 30, "-c:v libx264", "o.mp4", null, null);
312 Assert.Single(args, a => a == "-i");
313 Assert.DoesNotContain(args, a => a == "-t");
314 }
315
316 private static void RunFfmpeg(params string[] arguments)
317 {
318 var psi = new ProcessStartInfo(Which("ffmpeg"))
319 {
320 UseShellExecute = false,
321 RedirectStandardOutput = true,
322 RedirectStandardError = true,
323 };
324 foreach (string a in arguments)
325 psi.ArgumentList.Add(a);
326 using Process p = Process.Start(psi);
327 p.StandardError.ReadToEnd();
328 p.WaitForExit();
329 }
330
331 private static string Probe(params string[] arguments)
332 {
333 var psi = new ProcessStartInfo(Which("ffprobe"))
334 {
335 UseShellExecute = false,
336 RedirectStandardOutput = true,
337 RedirectStandardError = true,
338 };
339 foreach (string a in arguments)
340 psi.ArgumentList.Add(a);
341 using Process p = Process.Start(psi);
342 string o = p.StandardOutput.ReadToEnd().Trim();
343 p.WaitForExit();
344 return o;
345 }
346
347 private static bool HasAudioStream(string path) =>
348 Probe("-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type",
349 "-of", "csv=p=0", path).Contains("audio", StringComparison.Ordinal);
350
351 private static double DurationOf(string path) =>
352 double.TryParse(Probe("-v", "error", "-show_entries", "format=duration",
353 "-of", "csv=p=0", path),
354 System.Globalization.NumberStyles.Float,
355 System.Globalization.CultureInfo.InvariantCulture, out double d) ? d : -1;
356
269357 /// <summary>A cancelled export must still leave something playable.</summary>
270358 [Fact]
271359 public void ClosingEarlyLeavesAValidPartialFile()
AvaloniaShell/AnimationPanel.cs +7 -0
……@@ -154,6 +154,13 @@ public sealed class AnimationPanel : Grid
154154 /// <summary>Asks the host to pick an audio file; returns null if cancelled.</summary>
155155 public Func<System.Threading.Tasks.Task<string>> AudioFilePicker { get; set; }
156156
157 /// <summary>
158 /// The loaded audio clip's path, or null. Exposed so a video export can mux it: the clip is
159 /// visible in the scrubber and can drive channels, so exporting without it produced a silent
160 /// file that looked like a working export.
161 /// </summary>
162 public string AudioPath => _audio?.Path;
163
157164 /// <summary>Asks the host for a CSV file to import animation channels from.</summary>
158165 public Func<System.Threading.Tasks.Task<string>> ChannelCsvPicker { get; set; }
159166
AvaloniaShell/Dialogs.cs +9 -1
……@@ -707,7 +707,8 @@ internal static class Dialogs
707707 /// codec and quality are a per-export choice.
708708 /// </remarks>
709709 public static async Task<VideoExportChoice> ShowVideoExport(
710 Window owner, AppSettings settings, string suggestedName, int width, int height, bool exr)
710 Window owner, AppSettings settings, string suggestedName, int width, int height, bool exr,
711 string audioPath = null)
711712 {
712713 var window = Shell("Export video", 560, 430);
713714 VideoExportChoice choice = null;
……@@ -795,6 +796,13 @@ internal static class Dialogs
795796 "Frame format is EXR, which cannot be streamed to an encoder. Switch to PNG in "
796797 + "Settings > Animations to export video.", Accent));
797798 }
799 // Stated up front: the clip is visible in the scrubber, so silence in the exported file
800 // would look like a bug rather than a choice.
801 panel.Children.Add(Label(
802 string.IsNullOrWhiteSpace(audioPath)
803 ? "No audio clip loaded — the video will be silent."
804 : $"Audio: {System.IO.Path.GetFileName(audioPath)} (muxed, cut to the clip length)",
805 Muted));
798806 panel.Children.Add(Row("Output file", "The container is chosen by the codec.", output));
799807 panel.Children.Add(browse);
800808 panel.Children.Add(Row("Preset", "Presets from Library/FfmpegPresets.json.", presets));
AvaloniaShell/FfmpegFramePipe.cs +32 -3
……@@ -40,11 +40,14 @@ internal sealed class FfmpegFramePipe : IDisposable
4040 private Stream _stdin;
4141 private Task _drain;
4242
43 public FfmpegFramePipe(string exePath, string userArgs, string outputPath)
43 public FfmpegFramePipe(string exePath, string userArgs, string outputPath,
44 string audioPath = null, double? durationSeconds = null)
4445 {
4546 ExePath = string.IsNullOrWhiteSpace(exePath) ? "ffmpeg" : exePath;
4647 UserArgs = string.IsNullOrWhiteSpace(userArgs) ? DefaultArgs : userArgs;
4748 OutputPath = outputPath;
49 AudioPath = audioPath;
50 DurationSeconds = durationSeconds;
4851 }
4952
5053 public const string DefaultArgs = "-c:v libx264 -pix_fmt yuv420p -crf 18";
……@@ -53,6 +56,13 @@ internal sealed class FfmpegFramePipe : IDisposable
5356 public string UserArgs { get; }
5457 public string OutputPath { get; }
5558
59 /// <summary>The loaded audio clip, muxed alongside the frames. Null for a silent export.</summary>
60 public string AudioPath { get; }
61
62 /// <summary>Clip length, so a longer audio track is cut to the animation rather than
63 /// extending the video past its last frame.</summary>
64 public double? DurationSeconds { get; }
65
5666 /// <summary>Frames handed to <see cref="Write"/> so far.</summary>
5767 public int FramesWritten { get; private set; }
5868
……@@ -74,7 +84,9 @@ internal sealed class FfmpegFramePipe : IDisposable
7484 /// The full argument list. Input geometry is fixed by the render, so it is ours to set;
7585 /// everything about the encode comes from the user.
7686 /// </summary>
77 internal static List<string> BuildArgs(int width, int height, int fps, string userArgs, string outputPath)
87 internal static List<string> BuildArgs(int width, int height, int fps, string userArgs,
88 string outputPath, string audioPath = null,
89 double? durationSeconds = null)
7890 {
7991 var args = new List<string>
8092 {
……@@ -86,6 +98,22 @@ internal sealed class FfmpegFramePipe : IDisposable
8698 "-framerate", fps.ToString(),
8799 "-i", "-",
88100 };
101
102 // The animation panel can load an audio clip and drive channels from it, so a user can
103 // watch a track in the scrubber, export, and get silence. The canonical muxes it as a
104 // second input; -t then cuts a longer track back to the animation rather than letting
105 // the video run on past its last frame.
106 if (!string.IsNullOrWhiteSpace(audioPath))
107 {
108 args.Add("-i");
109 args.Add(audioPath);
110 }
111 if (durationSeconds is > 0)
112 {
113 args.Add("-t");
114 args.Add(durationSeconds.Value.ToString("0.###",
115 System.Globalization.CultureInfo.InvariantCulture));
116 }
89117 // h264/h265 with yuv420p need even dimensions, and the export resolution is whatever the
90118 // user typed - the app's own preview resolution is 1407x791. Without this ffmpeg refuses
91119 // the stream outright (exit 187), so an odd resolution encodes on Windows, where
……@@ -158,7 +186,8 @@ internal sealed class FfmpegFramePipe : IDisposable
158186 RedirectStandardError = true,
159187 WorkingDirectory = Path.GetDirectoryName(OutputPath) ?? ".",
160188 };
161 foreach (string arg in BuildArgs(width, height, fps, UserArgs, OutputPath))
189 foreach (string arg in BuildArgs(width, height, fps, UserArgs, OutputPath,
190 AudioPath, DurationSeconds))
162191 psi.ArgumentList.Add(arg);
163192
164193 try
AvaloniaShell/MainWindow.axaml.cs +6 -3
……@@ -3040,7 +3040,8 @@ public partial class MainWindow : Window
30403040 {
30413041 string video = Path.Combine(dir,
30423042 "animation" + FfmpegFramePipe.ExtensionFor(_settings.FfmpegArgs));
3043 pipe = new FfmpegFramePipe(_settings.FfmpegPath, _settings.FfmpegArgs, video);
3043 pipe = new FfmpegFramePipe(_settings.FfmpegPath, _settings.FfmpegArgs, video,
3044 AnimationView.AudioPath, f.Dopesheet.Length.TotalSeconds);
30443045
30453046 // Before frame 0: a bad ffmpeg path must not cost an entire render.
30463047 pipe.Start(w, h, Math.Max(f.Dopesheet.Fps, 1));
……@@ -3114,7 +3115,8 @@ public partial class MainWindow : Window
31143115 bool exr = _settings.IsRawFrameExportEnabled;
31153116
31163117 Dialogs.VideoExportChoice choice =
3117 await Dialogs.ShowVideoExport(this, _settings, f.Title, w, h, exr);
3118 await Dialogs.ShowVideoExport(this, _settings, f.Title, w, h, exr,
3119 AnimationView.AudioPath);
31183120 if (choice is null)
31193121 return;
31203122
……@@ -3136,7 +3138,8 @@ public partial class MainWindow : Window
31363138 {
31373139 if (!choice.KeepFrames)
31383140 {
3139 pipe = new FfmpegFramePipe(_settings.FfmpegPath, choice.Args, choice.OutputPath);
3141 pipe = new FfmpegFramePipe(_settings.FfmpegPath, choice.Args, choice.OutputPath,
3142 AnimationView.AudioPath, f.Dopesheet.Length.TotalSeconds);
31403143 // Before frame 0: a bad ffmpeg path must not cost an entire render.
31413144 pipe.Start(w, h, fps);
31423145 OverlayText.Text = "Encoding clip…";