OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 0f3f3fc

Video export existed but was unreachable, and refused odd resolutions
Two things: a real encode bug, and a capability nobody could find. ODD RESOLUTIONS FAILED. h264/h265 with yuv420p require even dimensions, and the export resolution is whatever the user typed - the app's own preview resolution is 1407x791. The canonical pads unconditionally (AnimationViewModel.RunFfmpegProcess, -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2"); this port did not, so ffmpeg rejected the stream outright with exit 187 and an odd resolution exported on Windows and failed here. Padding is now applied when the dimensions actually need it AND the user has not brought their own filter - a second -vf REPLACES the first rather than combining, so a user filter is theirs to get right. VIDEO HAD NO MENU ITEM. The only entry point was "Export animation frames", the folder picker said "frame sequence", and whether an mp4 appeared depended on a checkbox in Settings > Animations that defaults to false. The capability worked and was indistinguishable from one that did not exist. Adds Export > video (Ctrl+Shift+A) and a dialog carrying the encoder options, because that is where the decision is made; Settings keeps the ffmpeg path, which is a machine-level fact. The dialog defaults the filename to the fractal's title rather than a hardcoded "animation", rewrites the extension when a preset changes the codec so a prores stream cannot land in an unplayable .mp4, offers to keep the frames as well as the video (the canonical does both; the pipe alone cannot), and states plainly that EXR cannot be streamed instead of silently producing a frame sequence. The ffmpeg path stays an OVERRIDE, not a requirement. Resolve() reports what will actually run - "found on PATH" or "set in Settings" - and never gates. Copying the canonical's IsFfmpegPathSet gating would demand a browse from Linux users, where ffmpeg is a package and already on PATH. ffmpeg is not bundled on either platform. The suite caught the accelerator: Avalonia's InputGesture only DISPLAYS a shortcut, and ShortcutTests.Every_advertised_accelerator_is_bound fails any menu item that advertises one without a real binding. Tests: an odd resolution now encodes and reports even dimensions via ffprobe (a real encode, since an argument-shape test would pass against a stream ffmpeg rejects); a user-supplied -vf is left alone; even resolutions get no filter at all; a blank path resolves via PATH and a bogus one resolves to null. 153 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0f3f3fc1c496a8a609417c1dab9493948f4d2322
by plankatron <fleeky@gmail.com> · 2026-08-17T19:06:29.000Z

Changed files (5) +389 -0

AvaloniaShell.Tests/FfmpegPipeTests.cs +109 -0
……@@ -157,6 +157,115 @@ public class FfmpegPipeTests
157157 finally { Directory.Delete(dir, true); }
158158 }
159159
160 /// <summary>
161 /// h264/h265 with yuv420p require even dimensions, and the export resolution is whatever
162 /// the user typed - the canonical's own preview resolution is 1407x791. WPF pads for this
163 /// (AnimationViewModel.RunFfmpegProcess: -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2"); without it
164 /// ffmpeg refuses the stream outright, so an odd resolution exports on Windows and fails
165 /// here. A real encode is the only assertion worth making: an argument-shape test would
166 /// pass against a stream ffmpeg rejects.
167 /// </summary>
168 [Fact]
169 public void AnOddResolutionStillEncodes()
170 {
171 if (!Encoding())
172 return;
173
174 string dir = Path.Combine(Path.GetTempPath(), "ifs-odd-" + Guid.NewGuid().ToString("N"));
175 Directory.CreateDirectory(dir);
176 try
177 {
178 const int W = 101, H = 63; // both odd, as 1407x791 is
179 string output = Path.Combine(dir, "odd.mp4");
180 using var pipe = new FfmpegFramePipe(null, "-c:v libx264 -pix_fmt yuv420p", output);
181 pipe.Start(W, H, 10);
182
183 for (int i = 0; i < 6; i++)
184 pipe.Write(Frame(W, H, (byte)(i * 30)));
185
186 string failure = pipe.Finish();
187
188 Assert.True(failure is null,
189 "ffmpeg rejected an odd resolution; the pad filter is missing: " + failure);
190 Assert.Equal(6, CountFrames(output));
191
192 (int w, int h) = Dimensions(output);
193 Assert.True(w % 2 == 0 && h % 2 == 0, $"encoded dimensions {w}x{h} are not even");
194 }
195 finally { Directory.Delete(dir, true); }
196 }
197
198 private static (int, int) Dimensions(string path)
199 {
200 var psi = new ProcessStartInfo(Which("ffprobe"))
201 {
202 UseShellExecute = false,
203 RedirectStandardOutput = true,
204 RedirectStandardError = true,
205 };
206 foreach (string a in new[]
207 {
208 "-v", "error", "-select_streams", "v:0",
209 "-show_entries", "stream=width,height", "-of", "csv=p=0", path,
210 })
211 psi.ArgumentList.Add(a);
212 using Process probe = Process.Start(psi);
213 string output = probe.StandardOutput.ReadToEnd().Trim();
214 probe.WaitForExit();
215 string[] parts = output.Split(',');
216 return parts.Length == 2 && int.TryParse(parts[0], out int w) && int.TryParse(parts[1], out int h)
217 ? (w, h) : (-1, -1);
218 }
219
220 /// <summary>
221 /// A blank path means "whatever is on PATH", which is the normal case on Linux. The
222 /// canonical gates video export on an explicit path because ffmpeg is rarely on PATH under
223 /// Windows; copying that would demand a browse from users who need to do nothing, so the
224 /// path is an override and this only reports what will run.
225 /// </summary>
226 [Fact]
227 public void BlankPathResolvesToWhateverIsOnPath()
228 {
229 string resolved = FfmpegFramePipe.Resolve("");
230 if (!HasFfmpeg)
231 {
232 _output.WriteLine("SKIPPED: no ffmpeg on PATH.");
233 return;
234 }
235 Assert.NotNull(resolved);
236 Assert.True(File.Exists(resolved), $"resolved to '{resolved}', which does not exist");
237 }
238
239 [Fact]
240 public void AnExplicitPathIsUsedWhenItExistsAndRejectedWhenItDoesNot()
241 {
242 string real = Which("ffmpeg");
243 if (real is not null)
244 Assert.Equal(real, FfmpegFramePipe.Resolve(real));
245
246 Assert.Null(FfmpegFramePipe.Resolve("/nonexistent/definitely/not/ffmpeg"));
247 }
248
249 /// <summary>
250 /// The pad filter must not be bolted on top of a filter the user brought: a second -vf
251 /// REPLACES the first rather than combining, so theirs would silently stop applying.
252 /// </summary>
253 [Fact]
254 public void AUserSuppliedFilterIsLeftAlone()
255 {
256 List<string> args = FfmpegFramePipe.BuildArgs(
257 101, 63, 30, "-vf hflip -c:v libx264", "o.mp4");
258 Assert.Single(args, a => a == "-vf");
259 Assert.DoesNotContain(args, a => a.Contains("pad=", StringComparison.Ordinal));
260 }
261
262 [Fact]
263 public void EvenResolutionsAreNotFilteredAtAll()
264 {
265 List<string> args = FfmpegFramePipe.BuildArgs(1920, 1080, 30, "-c:v libx264", "o.mp4");
266 Assert.DoesNotContain(args, a => a == "-vf");
267 }
268
160269 /// <summary>A cancelled export must still leave something playable.</summary>
161270 [Fact]
162271 public void ClosingEarlyLeavesAValidPartialFile()
AvaloniaShell/Dialogs.cs +127 -0
……@@ -11,6 +11,7 @@ using Avalonia.Controls;
1111using Avalonia.Layout;
1212using Avalonia.Media;
1313using Avalonia.Media.Imaging;
14using Avalonia.Platform.Storage;
1415
1516using IFSEngine.Model;
1617
……@@ -687,6 +688,132 @@ internal static class Dialogs
687688 return choice;
688689 }
689690
691 // ------------------------------------------------------------- video export
692
693 /// <summary>What the user chose in <see cref="ShowVideoExport"/>; null if cancelled.</summary>
694 internal sealed record VideoExportChoice(string OutputPath, string Args, bool KeepFrames);
695
696 /// <summary>
697 /// The export-video dialog.
698 /// </summary>
699 /// <remarks>
700 /// Video export already worked, but was unreachable: the only entry point was a menu item
701 /// labelled "Export animation frames", and whether it produced an mp4 depended on a checkbox
702 /// buried in Settings > Animations that defaults to off. A capability nobody can find is
703 /// indistinguishable from one that does not exist.
704 ///
705 /// The encoder options live HERE rather than only in Settings because this is where the
706 /// decision is made. Settings keeps the ffmpeg PATH, which is a machine-level fact; the
707 /// codec and quality are a per-export choice.
708 /// </remarks>
709 public static async Task<VideoExportChoice> ShowVideoExport(
710 Window owner, AppSettings settings, string suggestedName, int width, int height, bool exr)
711 {
712 var window = Shell("Export video", 560, 430);
713 VideoExportChoice choice = null;
714
715 string resolved = FfmpegFramePipe.Resolve(settings.FfmpegPath);
716 string ext = FfmpegFramePipe.ExtensionFor(settings.FfmpegArgs);
717 var output = new TextBox
718 {
719 Text = System.IO.Path.Combine(
720 Environment.GetFolderPath(Environment.SpecialFolder.MyVideos) is { Length: > 0 } v
721 ? v : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
722 (string.IsNullOrWhiteSpace(suggestedName) ? "animation" : suggestedName) + ext),
723 };
724 var browse = new Button { Content = "Browse…" };
725 browse.Click += async (_, _) =>
726 {
727 var file = await owner.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
728 {
729 Title = "Save video as",
730 SuggestedFileName = System.IO.Path.GetFileName(output.Text),
731 });
732 if (file is not null)
733 output.Text = file.Path.LocalPath;
734 };
735
736 var args = new TextBox
737 {
738 Text = string.IsNullOrWhiteSpace(settings.FfmpegArgs)
739 ? FfmpegFramePipe.DefaultArgs : settings.FfmpegArgs,
740 AcceptsReturn = true,
741 TextWrapping = TextWrapping.Wrap,
742 MinHeight = 70,
743 };
744
745 var presets = new ComboBox { PlaceholderText = "▼ Presets", HorizontalAlignment = HorizontalAlignment.Stretch };
746 Dictionary<string, string> presetMap = LoadFfmpegPresets();
747 presets.ItemsSource = presetMap.Keys.ToList();
748 presets.SelectionChanged += (_, _) =>
749 {
750 if (presets.SelectedItem is string key && presetMap.TryGetValue(key, out string preset))
751 {
752 args.Text = preset;
753 // The container follows the codec, so the extension must follow the preset or
754 // a prores stream lands in a .mp4 that will not play.
755 string want = FfmpegFramePipe.ExtensionFor(preset);
756 output.Text = System.IO.Path.ChangeExtension(output.Text, want);
757 }
758 };
759
760 var keepFrames = new CheckBox
761 {
762 Content = "Also keep the rendered frames on disk",
763 IsChecked = false,
764 };
765
766 // Reported, never gated - see FfmpegFramePipe.Resolve.
767 var status = Label(
768 resolved is not null
769 ? (string.IsNullOrWhiteSpace(settings.FfmpegPath)
770 ? $"Using {resolved} (found on PATH)"
771 : $"Using {resolved} (set in Settings)")
772 : string.IsNullOrWhiteSpace(settings.FfmpegPath)
773 ? "ffmpeg was not found on PATH. Install it, or set its location in Settings > Animations."
774 : $"'{settings.FfmpegPath}' does not exist. Fix it in Settings > Animations.",
775 resolved is not null ? Muted : Accent);
776 status.TextWrapping = TextWrapping.Wrap;
777
778 var export = new Button { Content = "🎬 Export", IsDefault = true, IsEnabled = resolved is not null && !exr };
779 export.Click += (_, _) =>
780 {
781 choice = new VideoExportChoice(output.Text, args.Text, keepFrames.IsChecked == true);
782 window.Close();
783 };
784 var cancel = new Button { Content = "Cancel", IsCancel = true };
785 cancel.Click += (_, _) => window.Close();
786
787 var panel = new StackPanel { Margin = new Thickness(20), Spacing = 10 };
788 panel.Children.Add(Label($"Resolution {width}x{height}" +
789 (width % 2 != 0 || height % 2 != 0 ? " (odd — will be padded to even for the codec)" : ""), Muted));
790 if (exr)
791 {
792 // Stated rather than silently ignored: previously an exr frame format made the
793 // video request a no-op and the user got a frame sequence with no explanation.
794 panel.Children.Add(Label(
795 "Frame format is EXR, which cannot be streamed to an encoder. Switch to PNG in "
796 + "Settings > Animations to export video.", Accent));
797 }
798 panel.Children.Add(Row("Output file", "The container is chosen by the codec.", output));
799 panel.Children.Add(browse);
800 panel.Children.Add(Row("Preset", "Presets from Library/FfmpegPresets.json.", presets));
801 panel.Children.Add(Row("ffmpeg args", "IFSRenderer supplies input, size, fps and output.", args));
802 panel.Children.Add(keepFrames);
803 panel.Children.Add(status);
804 panel.Children.Add(new StackPanel
805 {
806 Orientation = Orientation.Horizontal,
807 Spacing = 8,
808 HorizontalAlignment = HorizontalAlignment.Right,
809 Children = { cancel, export },
810 });
811 window.Content = new ScrollViewer { Content = panel };
812
813 await window.ShowDialog(owner);
814 return choice;
815 }
816
690817 // ------------------------------------------------------------ palette picker
691818
692819 /// <summary>
AvaloniaShell/FfmpegFramePipe.cs +55 -0
……@@ -86,11 +86,66 @@ internal sealed class FfmpegFramePipe : IDisposable
8686 "-framerate", fps.ToString(),
8787 "-i", "-",
8888 };
89 // h264/h265 with yuv420p need even dimensions, and the export resolution is whatever the
90 // user typed - the app's own preview resolution is 1407x791. Without this ffmpeg refuses
91 // the stream outright (exit 187), so an odd resolution encodes on Windows, where
92 // AnimationViewModel.RunFfmpegProcess pads unconditionally, and failed here.
93 //
94 // Only when actually needed, and never over the user's own filter: a second -vf would
95 // silently replace theirs rather than combine with it, so if they brought one it is
96 // theirs to get right.
97 if ((width % 2 != 0 || height % 2 != 0) && !HasVideoFilter(userArgs))
98 {
99 args.Add("-vf");
100 args.Add("pad=ceil(iw/2)*2:ceil(ih/2)*2");
101 }
102
89103 args.AddRange(SplitArgs(string.IsNullOrWhiteSpace(userArgs) ? DefaultArgs : userArgs));
90104 args.Add(outputPath);
91105 return args;
92106 }
93107
108 /// <summary>
109 /// Resolves the ffmpeg the export will actually run, or null if there is none.
110 /// </summary>
111 /// <remarks>
112 /// A blank setting means "whatever is on PATH", which is the normal case on Linux where
113 /// ffmpeg is a package rather than something the user downloads. The canonical DISABLES
114 /// video export until an explicit path is set, because on Windows it rarely is on PATH —
115 /// copying that here would demand a browse from users who need to do nothing. So the path
116 /// stays an override and this exists to report what will run, not to gate on it.
117 /// </remarks>
118 public static string Resolve(string configured)
119 {
120 if (!string.IsNullOrWhiteSpace(configured))
121 return File.Exists(configured) ? configured : null;
122
123 foreach (string dir in (Environment.GetEnvironmentVariable("PATH") ?? "")
124 .Split(Path.PathSeparator))
125 {
126 if (dir.Length == 0)
127 continue;
128 foreach (string name in new[] { "ffmpeg", "ffmpeg.exe" })
129 {
130 string candidate = Path.Combine(dir, name);
131 if (File.Exists(candidate))
132 return candidate;
133 }
134 }
135 return null;
136 }
137
138 /// <summary>Whether the user already drives the video filter graph themselves.</summary>
139 internal static bool HasVideoFilter(string userArgs)
140 {
141 foreach (string token in SplitArgs(userArgs ?? ""))
142 {
143 if (token is "-vf" or "-filter:v" or "-filter_complex" or "-af" or "-filter:a")
144 return true;
145 }
146 return false;
147 }
148
94149 /// <summary>
95150 /// Launches ffmpeg. Call BEFORE rendering anything: a failure here must cost nothing.
96151 /// </summary>
AvaloniaShell/MainWindow.axaml +1 -0
……@@ -140,6 +140,7 @@
140140 <MenuItem Name="MenuExportPng" Header="🖼 Export to _PNG…" InputGesture="Ctrl+E" />
141141 <Separator />
142142 <MenuItem Name="MenuExportFrames" Header="🎥 Export _animation frames…" InputGesture="Ctrl+A" />
143 <MenuItem Name="MenuExportVideo" Header="🎬 Export _video…" InputGesture="Ctrl+Shift+A" />
143144 </MenuItem>
144145 <MenuItem Header="_View">
145146 <MenuItem Name="MenuToggleRender" Header="Pause _rendering" InputGesture="Space" />
AvaloniaShell/MainWindow.axaml.cs +97 -0
……@@ -778,6 +778,7 @@ public partial class MainWindow : Window
778778 // Toolbar quick commands and the right-hand help menu.
779779 MenuSaveQuick.Click += async (_, _) => await SaveFractal();
780780 MenuExportFrames.Click += async (_, _) => await ExportClip();
781 MenuExportVideo.Click += async (_, _) => await ExportVideo();
781782 BtnCancelExport.Click += (_, _) =>
782783 {
783784 _exportCancelled = true;
……@@ -1258,6 +1259,7 @@ public partial class MainWindow : Window
12581259 Bind("Ctrl+Shift+E", () => _ = Export(".exr"));
12591260 Bind("Ctrl+E", () => _ = Export(".png"));
12601261 Bind("Ctrl+A", () => _ = ExportClip(), beatsTextInput: false);
1262 Bind("Ctrl+Shift+A", () => _ = ExportVideo(), beatsTextInput: false);
12611263 Bind("Ctrl+C", () => _ = CopyParamsToClipboard(), beatsTextInput: false);
12621264 Bind("Ctrl+V", () => _ = PasteParamsFromClipboard(), beatsTextInput: false);
12631265 Bind("F1", () => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/wiki"));
……@@ -3088,6 +3090,101 @@ public partial class MainWindow : Window
30883090 }
30893091 }
30903092
3093 /// <summary>
3094 /// Renders the clip straight to a video file.
3095 /// </summary>
3096 /// <remarks>
3097 /// Distinct from <see cref="ExportClip"/>, which is the frame-sequence export and only
3098 /// produces a video as a side effect of a Settings checkbox. Video had no menu item at all,
3099 /// so the capability was unreachable without knowing to tick a box two dialogs away.
3100 ///
3101 /// Two routes, chosen by the dialog: pipe straight into ffmpeg (nothing touches the disk),
3102 /// or render the frames and encode from them afterwards, which is what the canonical does
3103 /// and the only way to end up with both.
3104 /// </remarks>
3105 private async Task ExportVideo()
3106 {
3107 IFS f = _service.Current;
3108 if (f is null)
3109 return;
3110
3111 int w = (int)(ExportWidth.Value ?? 1920), h = (int)(ExportHeight.Value ?? 1080);
3112 int level = (int)QualitySlider.Value;
3113 int fps = Math.Max(f.Dopesheet.Fps, 1);
3114 bool exr = _settings.IsRawFrameExportEnabled;
3115
3116 Dialogs.VideoExportChoice choice =
3117 await Dialogs.ShowVideoExport(this, _settings, f.Title, w, h, exr);
3118 if (choice is null)
3119 return;
3120
3121 string dir = Path.GetDirectoryName(choice.OutputPath);
3122 if (string.IsNullOrWhiteSpace(dir))
3123 {
3124 StatusText.Text = "Video export needs a full output path.";
3125 return;
3126 }
3127 Directory.CreateDirectory(dir);
3128
3129 OverlayText.IsVisible = true;
3130 OverlayText.Text = "Rendering clip…";
3131 _exportCancelled = false;
3132 BtnCancelExport.IsVisible = true;
3133 FfmpegFramePipe pipe = null;
3134
3135 try
3136 {
3137 if (!choice.KeepFrames)
3138 {
3139 pipe = new FfmpegFramePipe(_settings.FfmpegPath, choice.Args, choice.OutputPath);
3140 // Before frame 0: a bad ffmpeg path must not cost an entire render.
3141 pipe.Start(w, h, fps);
3142 OverlayText.Text = "Encoding clip…";
3143 }
3144
3145 await _service.ExportFramesAsync(dir, w, h, level,
3146 (frame, total) => Dispatcher.UIThread.Post(() =>
3147 OverlayText.Text = $"Rendering frame {frame}/{total} ({w}x{h}, level {level})"),
3148 () => _exportCancelled,
3149 rawExr: false, // the dialog refuses to export video while exr is selected
3150 frameSink: pipe is null ? null : (bgra, _, _) => pipe.Write(bgra));
3151
3152 if (pipe is not null)
3153 {
3154 OverlayText.Text = "Finishing video…";
3155 string failure = pipe.Finish();
3156 StatusText.Text = failure is not null
3157 ? "Video encoding failed: " + failure
3158 : _exportCancelled
3159 ? $"Cancelled — wrote the first {pipe.FramesWritten} frames to {pipe.OutputPath}"
3160 : $"Wrote {pipe.OutputPath}";
3161 return;
3162 }
3163
3164 if (_exportCancelled)
3165 {
3166 StatusText.Text = $"Cancelled — frames written so far are in {dir}";
3167 return;
3168 }
3169
3170 OverlayText.Text = "Encoding video…";
3171 (bool ok, string message) = await EncodeWithFfmpeg(dir, fps, choice.OutputPath);
3172 StatusText.Text = ok
3173 ? $"Wrote {choice.OutputPath} (frames kept in {dir})"
3174 : "Video encoding failed: " + message;
3175 }
3176 catch (Exception ex)
3177 {
3178 StatusText.Text = "Video export failed: " + ex.Message;
3179 }
3180 finally
3181 {
3182 pipe?.Dispose();
3183 OverlayText.IsVisible = false;
3184 BtnCancelExport.IsVisible = false;
3185 }
3186 }
3187
30913188 private async Task<(bool, string)> EncodeWithFfmpeg(string dir, int fps, string outputPath)
30923189 {
30933190 string exe = string.IsNullOrWhiteSpace(_settings.FfmpegPath) ? "ffmpeg" : _settings.FfmpegPath;