| … | … | @@ -157,6 +157,115 @@ public class FfmpegPipeTests |
| 157 | 157 | finally { Directory.Delete(dir, true); } |
| 158 | 158 | } |
| 159 | 159 | |
| 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 | ||
| 160 | 269 | /// <summary>A cancelled export must still leave something playable.</summary> |
| 161 | 270 | [Fact] |
| 162 | 271 | public void ClosingEarlyLeavesAValidPartialFile() |
| … | … | @@ -11,6 +11,7 @@ using Avalonia.Controls; |
| 11 | 11 | using Avalonia.Layout; |
| 12 | 12 | using Avalonia.Media; |
| 13 | 13 | using Avalonia.Media.Imaging; |
| 14 | using Avalonia.Platform.Storage; | |
| 14 | 15 | |
| 15 | 16 | using IFSEngine.Model; |
| 16 | 17 | |
| … | … | @@ -687,6 +688,132 @@ internal static class Dialogs |
| 687 | 688 | return choice; |
| 688 | 689 | } |
| 689 | 690 | |
| 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 | ||
| 690 | 817 | // ------------------------------------------------------------ palette picker |
| 691 | 818 | |
| 692 | 819 | /// <summary> |
| … | … | @@ -86,11 +86,66 @@ internal sealed class FfmpegFramePipe : IDisposable |
| 86 | 86 | "-framerate", fps.ToString(), |
| 87 | 87 | "-i", "-", |
| 88 | 88 | }; |
| 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 | ||
| 89 | 103 | args.AddRange(SplitArgs(string.IsNullOrWhiteSpace(userArgs) ? DefaultArgs : userArgs)); |
| 90 | 104 | args.Add(outputPath); |
| 91 | 105 | return args; |
| 92 | 106 | } |
| 93 | 107 | |
| 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 | ||
| 94 | 149 | /// <summary> |
| 95 | 150 | /// Launches ffmpeg. Call BEFORE rendering anything: a failure here must cost nothing. |
| 96 | 151 | /// </summary> |
| … | … | @@ -140,6 +140,7 @@ |
| 140 | 140 | <MenuItem Name="MenuExportPng" Header="🖼 Export to _PNG…" InputGesture="Ctrl+E" /> |
| 141 | 141 | <Separator /> |
| 142 | 142 | <MenuItem Name="MenuExportFrames" Header="🎥 Export _animation frames…" InputGesture="Ctrl+A" /> |
| 143 | <MenuItem Name="MenuExportVideo" Header="🎬 Export _video…" InputGesture="Ctrl+Shift+A" /> | |
| 143 | 144 | </MenuItem> |
| 144 | 145 | <MenuItem Header="_View"> |
| 145 | 146 | <MenuItem Name="MenuToggleRender" Header="Pause _rendering" InputGesture="Space" /> |
| … | … | @@ -778,6 +778,7 @@ public partial class MainWindow : Window |
| 778 | 778 | // Toolbar quick commands and the right-hand help menu. |
| 779 | 779 | MenuSaveQuick.Click += async (_, _) => await SaveFractal(); |
| 780 | 780 | MenuExportFrames.Click += async (_, _) => await ExportClip(); |
| 781 | MenuExportVideo.Click += async (_, _) => await ExportVideo(); | |
| 781 | 782 | BtnCancelExport.Click += (_, _) => |
| 782 | 783 | { |
| 783 | 784 | _exportCancelled = true; |
| … | … | @@ -1258,6 +1259,7 @@ public partial class MainWindow : Window |
| 1258 | 1259 | Bind("Ctrl+Shift+E", () => _ = Export(".exr")); |
| 1259 | 1260 | Bind("Ctrl+E", () => _ = Export(".png")); |
| 1260 | 1261 | Bind("Ctrl+A", () => _ = ExportClip(), beatsTextInput: false); |
| 1262 | Bind("Ctrl+Shift+A", () => _ = ExportVideo(), beatsTextInput: false); | |
| 1261 | 1263 | Bind("Ctrl+C", () => _ = CopyParamsToClipboard(), beatsTextInput: false); |
| 1262 | 1264 | Bind("Ctrl+V", () => _ = PasteParamsFromClipboard(), beatsTextInput: false); |
| 1263 | 1265 | Bind("F1", () => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/wiki")); |
| … | … | @@ -3088,6 +3090,101 @@ public partial class MainWindow : Window |
| 3088 | 3090 | } |
| 3089 | 3091 | } |
| 3090 | 3092 | |
| 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 | ||
| 3091 | 3188 | private async Task<(bool, string)> EncodeWithFfmpeg(string dir, int fps, string outputPath) |
| 3092 | 3189 | { |
| 3093 | 3190 | string exe = string.IsNullOrWhiteSpace(_settings.FfmpegPath) ? "ffmpeg" : _settings.FfmpegPath; |