| … | … | @@ -266,6 +266,94 @@ public class FfmpegPipeTests |
| 266 | 266 | Assert.DoesNotContain(args, a => a == "-vf"); |
| 267 | 267 | } |
| 268 | 268 | |
| 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 | ||
| 269 | 357 | /// <summary>A cancelled export must still leave something playable.</summary> |
| 270 | 358 | [Fact] |
| 271 | 359 | public void ClosingEarlyLeavesAValidPartialFile() |
| … | … | @@ -154,6 +154,13 @@ public sealed class AnimationPanel : Grid |
| 154 | 154 | /// <summary>Asks the host to pick an audio file; returns null if cancelled.</summary> |
| 155 | 155 | public Func<System.Threading.Tasks.Task<string>> AudioFilePicker { get; set; } |
| 156 | 156 | |
| 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 | ||
| 157 | 164 | /// <summary>Asks the host for a CSV file to import animation channels from.</summary> |
| 158 | 165 | public Func<System.Threading.Tasks.Task<string>> ChannelCsvPicker { get; set; } |
| 159 | 166 |
| … | … | @@ -707,7 +707,8 @@ internal static class Dialogs |
| 707 | 707 | /// codec and quality are a per-export choice. |
| 708 | 708 | /// </remarks> |
| 709 | 709 | 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) | |
| 711 | 712 | { |
| 712 | 713 | var window = Shell("Export video", 560, 430); |
| 713 | 714 | VideoExportChoice choice = null; |
| … | … | @@ -795,6 +796,13 @@ internal static class Dialogs |
| 795 | 796 | "Frame format is EXR, which cannot be streamed to an encoder. Switch to PNG in " |
| 796 | 797 | + "Settings > Animations to export video.", Accent)); |
| 797 | 798 | } |
| 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)); | |
| 798 | 806 | panel.Children.Add(Row("Output file", "The container is chosen by the codec.", output)); |
| 799 | 807 | panel.Children.Add(browse); |
| 800 | 808 | panel.Children.Add(Row("Preset", "Presets from Library/FfmpegPresets.json.", presets)); |
| … | … | @@ -40,11 +40,14 @@ internal sealed class FfmpegFramePipe : IDisposable |
| 40 | 40 | private Stream _stdin; |
| 41 | 41 | private Task _drain; |
| 42 | 42 | |
| 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) | |
| 44 | 45 | { |
| 45 | 46 | ExePath = string.IsNullOrWhiteSpace(exePath) ? "ffmpeg" : exePath; |
| 46 | 47 | UserArgs = string.IsNullOrWhiteSpace(userArgs) ? DefaultArgs : userArgs; |
| 47 | 48 | OutputPath = outputPath; |
| 49 | AudioPath = audioPath; | |
| 50 | DurationSeconds = durationSeconds; | |
| 48 | 51 | } |
| 49 | 52 | |
| 50 | 53 | public const string DefaultArgs = "-c:v libx264 -pix_fmt yuv420p -crf 18"; |
| … | … | @@ -53,6 +56,13 @@ internal sealed class FfmpegFramePipe : IDisposable |
| 53 | 56 | public string UserArgs { get; } |
| 54 | 57 | public string OutputPath { get; } |
| 55 | 58 | |
| 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 | ||
| 56 | 66 | /// <summary>Frames handed to <see cref="Write"/> so far.</summary> |
| 57 | 67 | public int FramesWritten { get; private set; } |
| 58 | 68 | |
| … | … | @@ -74,7 +84,9 @@ internal sealed class FfmpegFramePipe : IDisposable |
| 74 | 84 | /// The full argument list. Input geometry is fixed by the render, so it is ours to set; |
| 75 | 85 | /// everything about the encode comes from the user. |
| 76 | 86 | /// </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) | |
| 78 | 90 | { |
| 79 | 91 | var args = new List<string> |
| 80 | 92 | { |
| … | … | @@ -86,6 +98,22 @@ internal sealed class FfmpegFramePipe : IDisposable |
| 86 | 98 | "-framerate", fps.ToString(), |
| 87 | 99 | "-i", "-", |
| 88 | 100 | }; |
| 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 | } | |
| 89 | 117 | // h264/h265 with yuv420p need even dimensions, and the export resolution is whatever the |
| 90 | 118 | // user typed - the app's own preview resolution is 1407x791. Without this ffmpeg refuses |
| 91 | 119 | // the stream outright (exit 187), so an odd resolution encodes on Windows, where |
| … | … | @@ -158,7 +186,8 @@ internal sealed class FfmpegFramePipe : IDisposable |
| 158 | 186 | RedirectStandardError = true, |
| 159 | 187 | WorkingDirectory = Path.GetDirectoryName(OutputPath) ?? ".", |
| 160 | 188 | }; |
| 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)) | |
| 162 | 191 | psi.ArgumentList.Add(arg); |
| 163 | 192 | |
| 164 | 193 | try |
| … | … | @@ -3040,7 +3040,8 @@ public partial class MainWindow : Window |
| 3040 | 3040 | { |
| 3041 | 3041 | string video = Path.Combine(dir, |
| 3042 | 3042 | "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); | |
| 3044 | 3045 | |
| 3045 | 3046 | // Before frame 0: a bad ffmpeg path must not cost an entire render. |
| 3046 | 3047 | pipe.Start(w, h, Math.Max(f.Dopesheet.Fps, 1)); |
| … | … | @@ -3114,7 +3115,8 @@ public partial class MainWindow : Window |
| 3114 | 3115 | bool exr = _settings.IsRawFrameExportEnabled; |
| 3115 | 3116 | |
| 3116 | 3117 | 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); | |
| 3118 | 3120 | if (choice is null) |
| 3119 | 3121 | return; |
| 3120 | 3122 | |
| … | … | @@ -3136,7 +3138,8 @@ public partial class MainWindow : Window |
| 3136 | 3138 | { |
| 3137 | 3139 | if (!choice.KeepFrames) |
| 3138 | 3140 | { |
| 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); | |
| 3140 | 3143 | // Before frame 0: a bad ffmpeg path must not cost an entire render. |
| 3141 | 3144 | pipe.Start(w, h, fps); |
| 3142 | 3145 | OverlayText.Text = "Encoding clip…"; |