XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEToolBox

引入多轨道自动化测试与窗口行为优化

新增多轨道模型、渲染、界面和主程序端到端自动化测试,覆盖剪辑、轨道操作、属性、字幕、导出等功能,所有结果输出至 test-artifacts。抽离并统一工具窗口圆角与边距行为,最大化时自动去除圆角和外边距,提升窗口Chrome一致性。窗口拖拽手柄支持样式触发器,最大化或禁用调整时自动隐藏。项目排除旧测试,仅编译新用例。更新README,详述1.5.0验证范围与方法。全面提升多轨道编辑验证覆盖率与可维护性。

7ba76d2
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

9 个文件 +464 -43
Modified XFEToolBox.WpfCore/Controls/WindowResizeGrip.xaml +16 -0
@@ -4,6 +4,22 @@
4 4 Width="25" Height="25" Focusable="False"
5 5 HorizontalAlignment="Right" VerticalAlignment="Bottom"
6 6 Cursor="SizeNWSE">
7 <UserControl.Style>
8 <Style TargetType="UserControl">
9 <Style.Triggers>
10 <DataTrigger Binding="{Binding WindowState, RelativeSource={RelativeSource AncestorType={x:Type Window}}}" Value="Maximized">
11 <Setter Property="Visibility" Value="Collapsed"/>
12 <Setter Property="IsHitTestVisible" Value="False"/>
13 </DataTrigger>
14 <DataTrigger Binding="{Binding ResizeMode, RelativeSource={RelativeSource AncestorType={x:Type Window}}}" Value="NoResize">
15 <Setter Property="Visibility" Value="Collapsed"/>
16 </DataTrigger>
17 <DataTrigger Binding="{Binding ResizeMode, RelativeSource={RelativeSource AncestorType={x:Type Window}}}" Value="CanMinimize">
18 <Setter Property="Visibility" Value="Collapsed"/>
19 </DataTrigger>
20 </Style.Triggers>
21 </Style>
22 </UserControl.Style>
7 23 <Grid>
8 24 <Image x:Name="CornerImage" Width="25" Height="25" Stretch="Fill"
9 25 FocusVisualStyle="{x:Null}" IsHitTestVisible="False"/>
Added XFEToolBox.WpfCore/Windowing/ToolWindowFrameBehavior.cs +30 -0
@@ -0,0 +1,30 @@
1 using System.Windows;
2 using System.Windows.Controls;
3
4 namespace XFEToolBox.WpfCore.Windowing;
5
6 /// <summary>Changes only the outer tool chrome; interior tool padding remains owned by the tool.</summary>
7 public static class ToolWindowFrameBehavior
8 {
9 public static void Attach(Window window, Border surface, Border contentSurface)
10 {
11 var margin = surface.Margin;
12 var outerRadius = surface.CornerRadius;
13 var innerRadius = contentSurface.CornerRadius;
14 void Update(object? sender, EventArgs e)
15 {
16 bool maximized = window.WindowState == WindowState.Maximized;
17 surface.Margin = maximized ? new Thickness(0) : margin;
18 surface.CornerRadius = maximized ? new CornerRadius(0) : outerRadius;
19 contentSurface.CornerRadius = maximized ? new CornerRadius(0) : innerRadius;
20 }
21 void Closed(object? sender, EventArgs e)
22 {
23 window.StateChanged -= Update;
24 window.Closed -= Closed;
25 }
26 window.StateChanged += Update;
27 window.Closed += Closed;
28 Update(null, EventArgs.Empty);
29 }
30 }
Modified XFEToolBox/Utilities/ToolProjectRunService.cs +1 -0
@@ -613,6 +613,7 @@ internal static class ToolProjectRunService
613 613 });
614 614 }
615 615 window.Content = windowRoot;
616 ToolWindowFrameBehavior.Attach(window, windowSurface, contentSurface);
616 617 AttachWindowPlacementPersistence(window, savedPlacement);
617 618 }
618 619
Added tests/VideoStudio.Validation/MultiTrackInterfaceTests.cs +99 -0
@@ -0,0 +1,99 @@
1 using System.Diagnostics;
2 using System.Runtime.InteropServices;
3 using System.Windows;
4 using System.Windows.Controls;
5 using System.Windows.Interop;
6 using System.Windows.Media;
7 using System.Windows.Threading;
8 using XFEToolBox.Tools.VideoStudio;
9 using XFEToolBox.WpfCore.Controls;
10 using XFEToolBox.WpfCore.Windowing;
11
12 namespace VideoStudio.Validation;
13 internal static partial class Program
14 {
15 private static async Task InterfaceTests(string[] paths)
16 {
17 var trace = new BindingTrace(); PresentationTraceSources.DataBindingSource.Listeners.Add(trace); PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Error;
18 var page = new MainPage(); var vm = (MainPageViewModel)page.DataContext; vm.FfmpegPath = ffmpeg; vm.FfprobePath = ffprobe;
19 var window = new Window { Content = page, Width = 1380, Height = 840, Left = -30000, Top = -30000, ShowInTaskbar = false, ShowActivated = false, WindowStyle = WindowStyle.None };
20 window.Show(); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
21 await vm.LoadSourceAsync(paths[0]); await vm.ImportAssetsAsync(paths.Skip(1));
22 vm.AddVideoTrackCommand.Execute(null); vm.InsertAsset(vm.Assets[1], 1);
23 vm.AddAudioTrackCommand.Execute(null); vm.InsertAsset(vm.Assets[2], 0);
24 vm.SelectedTrack = vm.Tracks.First(t => t.Kind == MediaKind.Video && t.Index == 2); vm.InsertAsset(vm.Assets[3], 4);
25 var menu = (Menu)page.FindName("EditorMenu");
26 Check(menu.Items.Count == 6, "top menu bar exposes File/Edit/Track/Playback/View/Tools");
27 foreach (MenuItem top in menu.Items)
28 {
29 top.IsSubmenuOpen = true; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
30 Check(top.Items.OfType<MenuItem>().All(m => m.Command is not null), "all " + top.Header + " menu items bind to real commands");
31 top.IsSubmenuOpen = false;
32 }
33 vm.SelectedClip = vm.Clips.First(c => c.Kind == MediaKind.Audio); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
34 Check(((StackPanel)page.FindName("AudioProperties")).IsVisible && !((StackPanel)page.FindName("VisualProperties")).IsVisible, "audio selection displays audio inspector only");
35 var trackPicker = (ComboBox)page.FindName("PropertyTrack");
36 Check(trackPicker.Items.Cast<TimelineTrack>().All(t => t.Kind == MediaKind.Audio) && (string?)trackPicker.SelectedValue == vm.SelectedClip.TrackId, "property track picker lists only audio tracks and preserves selection");
37 trackPicker.IsDropDownOpen = true; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); trackPicker.IsDropDownOpen = false;
38 vm.VolumeText = "40"; vm.ApplyClipPropertiesCommand.Execute(null); Check(vm.SelectedClip.AudioVolume == 40, "WPF inspector can apply clip properties without read-only binding failures");
39 vm.SelectedClip = vm.Clips.First(c => c.Kind == MediaKind.Video); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
40 Check(!((StackPanel)page.FindName("AudioProperties")).IsVisible && ((StackPanel)page.FindName("VisualProperties")).IsVisible, "video selection switches to visual inspector");
41 Check(trackPicker.Items.Cast<TimelineTrack>().All(t => t.Kind == MediaKind.Video) && (string?)trackPicker.SelectedValue == vm.SelectedClip.TrackId, "visual inspector tracks update without losing selected track");
42 Check(Descendants<TextBlock>(trackPicker).Any(t => t.Text == "V1") && !Descendants<TextBlock>(trackPicker).Any(t => t.Text.Contains("XFEToolBox.Tools")), "track picker displays V1 name, not CLR type name");
43 var timeline = (TimelineSurface)page.FindName("Timeline"); var video = vm.SelectedClip!;
44 double y = timeline.YAt(vm.TrackFor(video)!) + 30, scale = timeline.XAt(1);
45 var tail = new Point(timeline.XAt(video.TimelineEnd) - 4, y);
46 Check(timeline.EdgeAt(tail) is { Edge: ClipEdge.End } hit && hit.Clip == video, "right handle hit testing identifies selected video track");
47 timeline.BeginDrag(tail); var trimmed = new Point(tail.X - .4 * scale, tail.Y); timeline.MoveDrag(trimmed); timeline.FinishDrag(trimmed);
48 Check(Near(video.EndSeconds, 3.6) && vm.Clips.First(c => c.AssetId == video.AssetId && c.Kind == MediaKind.Audio).EndSeconds == 4, "actual pointer gesture trims video independently of original audio"); vm.UndoCommand.Execute(null);
49 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); video = vm.Clips.First(c => c.Id == video.Id); scale = timeline.XAt(1); y = timeline.YAt(vm.TrackFor(video)!) + 30;
50 var head = new Point(timeline.XAt(video.TimelineStart) + 3, y); timeline.BeginDrag(head); timeline.MoveDrag(new Point(head.X + .5 * scale, y)); timeline.CancelDrag();
51 Check(!vm.IsTrimming && video.StartSeconds == 0 && video.TimelineStart == 0, "pointer capture cancellation restores head trim");
52 var body = new Point(timeline.XAt(1), y); var targetTrack = vm.Tracks.First(t => t.Kind == MediaKind.Video && t.Index == 2);
53 timeline.BeginDrag(body); var end = new Point(body.X + 2 * scale, timeline.YAt(targetTrack) + 30); timeline.MoveDrag(end); timeline.FinishDrag(end);
54 Check(video.TrackId == targetTrack.Id && Near(video.TimelineStart, 2), "body drag moves clip across compatible tracks"); vm.UndoCommand.Execute(null);
55 vm.SeekSequence(1.5); await Task.Delay(1500); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
56 Check(((Image)page.FindName("PausedFrame")).Source is not null && ((TextBlock)page.FindName("PreviewHint")).Visibility == Visibility.Collapsed, "WPF displays a real multi-track composite preview");
57 var player = (MediaElement)page.FindName("PreviewPlayer"); player.IsMuted = true;
58 vm.SeekSequence(0); vm.TogglePlaybackCommand.Execute(null);
59 var playback = Stopwatch.StartNew(); while (playback.Elapsed < TimeSpan.FromSeconds(25) && vm.SequencePosition < .3) await Task.Delay(100);
60 Check(vm.IsPlaying && vm.SequencePosition >= .3 && player.NaturalDuration.HasTimeSpan, "actual WPF composite playback opens and advances sequence playhead");
61 vm.TogglePlaybackCommand.Execute(null); Check(!vm.IsPlaying, "transport command pauses actual playback"); vm.SeekSequence(1.5); await Task.Delay(600);
62 Save(page, "multi-track-editor.png");
63 vm.SelectedClip = vm.Clips.First(c => c.Kind == MediaKind.Audio); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Save(page, "audio-inspector.png");
64 window.Width = 1040; window.Height = 760; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Save(page, "minimum-editor.png");
65 Check(((FrameworkElement)page.FindName("EditPage")).ActualHeight > 500 && timeline.ActualHeight >= 4 * TimelineSurface.RowHeight, "minimum-width layout retains editor and scrollable independent tracks");
66 Check(((ScrollViewer)page.FindName("TimelineScroll")).ScrollableWidth < 3, "fit timeline follows resized viewport instead of retaining stale width");
67 vm.ShowSubtitlesCommand.Execute(null); vm.Subtitles.Add(new SubtitleCue { Index = 1, StartSeconds = 0, EndSeconds = 1, Text = "字幕回归检查" }); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Save(page, "subtitles.png");
68 vm.ShowEnvironmentCommand.Execute(null); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Save(page, "sequence-settings.png");
69 Check(trace.Errors.Count == 0, "zero WPF binding errors: " + string.Join(" | ", trace.Errors));
70 vm.IsBusy = true; window.Close(); Check(window.IsVisible, "closing during active task requests cancellation and waits for cleanup"); vm.IsBusy = false; vm.HasUnsavedChanges = false; window.Close();
71 PresentationTraceSources.DataBindingSource.Listeners.Remove(trace);
72 }
73 private static IEnumerable<T> Descendants<T>(DependencyObject root) where T : DependencyObject
74 {
75 for (int i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) { var child = VisualTreeHelper.GetChild(root, i); if (child is T value) yield return value; foreach (var nested in Descendants<T>(child)) yield return nested; }
76 }
77 [StructLayout(LayoutKind.Sequential)] private struct NativeRect { public int Left, Top, Right, Bottom; }
78 [StructLayout(LayoutKind.Sequential)] private struct MonitorData { public int Size; public NativeRect Bounds, Work; public uint Flags; }
79 [DllImport("user32.dll")] private static extern bool GetWindowRect(nint hWnd, out NativeRect rect);
80 [DllImport("user32.dll")] private static extern nint MonitorFromWindow(nint hWnd, uint flags);
81 [DllImport("user32.dll")] private static extern bool GetMonitorInfo(nint monitor, ref MonitorData info);
82 private static async Task ChromeTests()
83 {
84 var content = new Border { CornerRadius = new CornerRadius(16), Background = Brushes.White };
85 var surface = new RoundedClipBorder { Margin = new Thickness(5), CornerRadius = new CornerRadius(19), Background = Brushes.Purple, Child = content };
86 var grip = new WindowResizeGrip(); var grid = new Grid(); grid.Children.Add(surface); grid.Children.Add(grip);
87 var window = new Window { Content = grid, Width = 800, Height = 600, Left = 100, Top = 100, ShowActivated = false, ShowInTaskbar = false, Opacity = 0, AllowsTransparency = true, WindowStyle = WindowStyle.None, ResizeMode = ResizeMode.CanResize };
88 ToolWindowFrameBehavior.Attach(window, surface, content); WindowWorkAreaHelper.Attach(window); window.Show(); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
89 Check(surface.Margin == new Thickness(5) && surface.CornerRadius.TopLeft == 19 && grip.IsVisible, "normal tool chrome retains rounded border and resize grip");
90 window.WindowState = WindowState.Maximized; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
91 Check(surface.Margin == new Thickness(0) && surface.CornerRadius == new CornerRadius(0) && content.CornerRadius == new CornerRadius(0), "maximized chrome removes all outer margins and both corner radii");
92 Check(grip.Visibility == Visibility.Collapsed && !grip.IsHitTestVisible, "maximized resize grip is hidden and cannot intercept pointer input");
93 nint hwnd = new WindowInteropHelper(window).Handle; GetWindowRect(hwnd, out var bounds); var monitor = new MonitorData { Size = Marshal.SizeOf<MonitorData>() }; GetMonitorInfo(MonitorFromWindow(hwnd, 2), ref monitor);
94 Check(bounds.Left == monitor.Work.Left && bounds.Top == monitor.Work.Top && bounds.Right == monitor.Work.Right && bounds.Bottom == monitor.Work.Bottom, "actual maximized Win32 bounds exactly fill monitor work area without covering taskbar");
95 window.WindowState = WindowState.Normal; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
96 Check(surface.Margin == new Thickness(5) && surface.CornerRadius.TopLeft == 19 && content.CornerRadius.TopLeft == 16 && grip.IsVisible, "restore reinstates original chrome and grip");
97 window.ResizeMode = ResizeMode.NoResize; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Check(grip.Visibility == Visibility.Collapsed, "non-resizable tool also hides resize grip"); window.Close();
98 }
99 }
Added tests/VideoStudio.Validation/MultiTrackModelTests.cs +80 -0
@@ -0,0 +1,80 @@
1 using System.IO;
2 using System.Text.Json;
3 using System.Windows.Threading;
4 using XFEToolBox.Tools.VideoStudio;
5
6 namespace VideoStudio.Validation;
7 internal static partial class Program
8 {
9 private static MainPageViewModel NewVm() => new() { FfmpegPath = ffmpeg, FfprobePath = ffprobe };
10 private static async Task ModelTests(string[] paths)
11 {
12 using var vm = NewVm();
13 int ticks = 0; var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(15) }; timer.Tick += (_, _) => ticks++; timer.Start();
14 await vm.LoadSourceAsync(paths[0]); timer.Stop();
15 Check(vm.Clips.Count == 2 && vm.Clips.Count(c => c.Kind == MediaKind.Audio) == 1, "video import creates independent video and original-audio clips");
16 Check(vm.Assets.Count == 1 && vm.Thumbnails.Count == 8 && vm.Waveform is not null && ticks > 2, "responsive FFprobe import with thumbnails and waveform");
17 Check(Near(vm.TimelineDuration, 4) && vm.Settings.Width == 640 && vm.FramesPerSecond == 30, "first video configures sequence dimensions and frame rate");
18 await vm.ImportAssetsAsync(paths.Skip(1));
19 Check(vm.Assets.Count == 4 && vm.Clips.Count == 2 && vm.Assets.Select(a => a.Kind).SequenceEqual(new[] { MediaKind.Video, MediaKind.Video, MediaKind.Audio, MediaKind.Image }), "multiple video/audio/image import keeps existing timeline intact");
20 Check(vm.Assets[2].Waveform is not null && vm.Assets[3].Thumbnail is not null, "external audio waveform and image thumbnail generated");
21 await vm.ImportAssetsAsync([paths[0], Path.Combine(Root, "absent.mp4")]);
22 Check(vm.Assets.Count == 4 && vm.Clips.Count == 2 && vm.Status.Contains("absent"), "duplicate and failed import preserve edits");
23 var video = vm.Clips.First(c => c.Kind == MediaKind.Video); var audio = vm.Clips.First(c => c.Kind == MediaKind.Audio);
24 vm.SelectedClip = video; vm.SeekSequence(2); vm.SplitClipCommand.Execute(null);
25 Check(vm.Clips.Count == 3 && audio.EndSeconds == 4 && vm.Clips.Count(c => c.Kind == MediaKind.Video) == 2, "split selected video does not split audio");
26 vm.SelectedClip = audio; vm.SeekSequence(1); vm.SplitClipCommand.Execute(null);
27 Check(vm.Clips.Count == 4 && vm.Clips.Count(c => c.Kind == MediaKind.Audio) == 2 && vm.Clips.Count(c => c.Kind == MediaKind.Video) == 2, "audio has its own split point");
28 vm.UndoCommand.Execute(null); vm.UndoCommand.Execute(null);
29 Check(vm.Clips.Count == 2 && vm.Clips.All(c => c.EndSeconds == 4), "undo independently restores both track edits");
30 vm.RedoCommand.Execute(null); Check(vm.Clips.Count == 3, "redo restores only the video split"); vm.UndoCommand.Execute(null);
31 vm.AddVideoTrackCommand.Execute(null); var v2 = vm.SelectedTrack!; vm.InsertAsset(vm.Assets[1], 1);
32 Check(vm.SelectedClip?.TrackId == v2.Id && vm.SelectedClip.TimelineStart == 1 && vm.Clips.Count == 3, "second video inserts on V2 at requested time");
33 vm.AddAudioTrackCommand.Execute(null); var a2 = vm.SelectedTrack!; vm.InsertAsset(vm.Assets[2], 0);
34 Check(vm.SelectedClip?.TrackId == a2.Id && vm.Clips.Count == 4, "external audio inserts independently on A2");
35 vm.SelectedTrack = v2; vm.InsertAsset(vm.Assets[3], 4);
36 var image = vm.SelectedClip!; vm.TrimOutText = "7"; vm.EditScale = "60"; vm.EditOpacity = "80"; vm.EditX = "-40"; vm.ApplyClipPropertiesCommand.Execute(null); image = vm.SelectedClip!;
37 Check(image.Kind == MediaKind.Image && image.DurationSeconds == 7 && image.Scale == 60 && image.Opacity == 80 && image.PositionX == -40, "image duration, size, opacity and position are individually editable");
38 var external = vm.Clips.Single(c => c.AssetId == vm.Assets[2].Id); vm.SelectedClip = external;
39 vm.VolumeText = "150"; vm.EditFadeIn = "0.5"; vm.EditFadeOut = "1"; vm.ApplyClipPropertiesCommand.Execute(null); external = vm.SelectedClip!;
40 Check(external.AudioVolume == 150 && external.FadeIn == .5 && external.FadeOut == 1 && vm.Clips.First(c => c.AssetId == vm.Assets[0].Id && c.Kind == MediaKind.Audio).AudioVolume == 100, "per-clip gain and fades do not alter another audio clip");
41 vm.VolumeText = "NaN"; vm.ApplyClipPropertiesCommand.Execute(null); Check(vm.SelectedClip == external && external.AudioVolume == 150, "invalid property edits are atomic");
42 vm.SelectedClip = image; vm.SelectedTrack = v2; vm.ToggleTrackLockCommand.Execute(null); int count = vm.Clips.Count;
43 Check(!vm.BeginTrim(image, ClipEdge.End), "locked track rejects trim"); vm.RemoveClipCommand.Execute(null); vm.MoveClip(image, 0, vm.Tracks.First(t => t.Kind == MediaKind.Video));
44 Check(vm.Clips.Count == count && image.TimelineStart == 4, "locked track rejects move and delete"); vm.ToggleTrackLockCommand.Execute(null);
45 vm.SelectedClip = image; vm.MoveClip(image, 2, a2); Check(image.TrackId == v2.Id && image.TimelineStart == 4, "video/image cannot move onto audio track");
46 vm.MoveClip(image, 3, vm.Tracks.First(t => t.Kind == MediaKind.Video)); Check(image.TimelineStart == 3 && image.TrackId != v2.Id, "drag move can change compatible track and time"); vm.UndoCommand.Execute(null);
47 video = vm.Clips.First(c => c.AssetId == vm.Assets[0].Id && c.Kind == MediaKind.Video); audio = vm.Clips.First(c => c.AssetId == vm.Assets[0].Id && c.Kind == MediaKind.Audio); vm.SelectedClip = video;
48 Check(vm.BeginTrim(video, ClipEdge.Start), "head trim begins"); for (int i = 0; i < 20; i++) vm.UpdateTrim(1.137); vm.EndTrim(true);
49 Check(Near(video.StartSeconds, 34d / 30) && Near(video.TimelineStart, 34d / 30) && video.TimelineEnd == 4 && audio.StartSeconds == 0, "head handle trims on frame boundary without moving audio or tail");
50 vm.UndoCommand.Execute(null); video = vm.Clips.First(c => c.Id == video.Id); Check(video.StartSeconds == 0 && video.TimelineStart == 0, "many pointer updates form one undo step");
51 vm.BeginTrim(video, ClipEdge.End); vm.UpdateTrim(-100); Check(Near(video.DurationSeconds, 1d / 30), "tail cannot cross head or create zero-length clip"); vm.EndTrim(false); Check(video.EndSeconds == 4, "Escape restores entire trim gesture");
52 vm.SeekSequence(2); int audioBefore = vm.Clips.Count(c => c.Kind == MediaKind.Audio); vm.SplitVideoCommand.Execute(null);
53 Check(vm.Clips.Count(c => c.Kind == MediaKind.Audio) == audioBefore && vm.Clips.Count(c => c.Kind == MediaKind.Video) == 4, "split-video command splits all unlocked visual tracks only");
54 int visualBefore = vm.Clips.Count(c => c.Kind != MediaKind.Audio); vm.SplitAudioCommand.Execute(null);
55 Check(vm.Clips.Count(c => c.Kind != MediaKind.Audio) == visualBefore && vm.Clips.Count(c => c.Kind == MediaKind.Audio) == audioBefore * 2, "split-audio command leaves all visual clips untouched");
56 vm.SeekSequence(0); vm.NextFrameCommand.Execute(null); Check(Near(vm.SequencePosition, 1d / 30), "frame stepping uses sequence frame rate");
57 vm.CanvasWidthText = "640.5"; vm.ApplySequenceSettingsCommand.Execute(null); Check(vm.Settings.Width == 640, "fractional canvas dimensions rejected");
58 vm.AudioMuted = true; vm.AddSubtitleCommand.Execute(null); vm.Subtitles[0].Text = "多轨工程字幕";
59 string project = Path.Combine(Root, "工程.xfevideo.json"); await vm.SaveProjectToAsync(project);
60 Check(!vm.HasUnsavedChanges && File.Exists(project), "save-project command persists edit model");
61 using var restored = NewVm(); Check(await restored.LoadProjectAsync(project), "load-project resolves all original media");
62 Check(restored.Clips.Count == vm.Clips.Count && restored.Tracks.Count == 4 && restored.Assets.Count == 4 && restored.AudioMuted, "project restores independent tracks, assets and global mute");
63 Check(restored.Subtitles.Single().Text == "多轨工程字幕", "project also restores source-time subtitle edits");
64 restored.Subtitles[0].Text = "changed"; Check(restored.HasUnsavedChanges, "editing subtitle text marks project as unsaved");
65 Check(JsonSerializer.Serialize(restored.Clips, Json) == JsonSerializer.Serialize(vm.Clips, Json), "all clip IDs, source ranges and per-clip properties round-trip");
66 string invalid = Path.Combine(Root, "broken-project.json"); await File.WriteAllTextAsync(invalid, "{}");
67 Check(!await restored.LoadProjectAsync(invalid) && restored.Clips.Count == vm.Clips.Count, "malformed project does not destroy current edits");
68 await Reject(() => vm.SaveProjectToAsync(paths[0]), "project cannot overwrite source media");
69 vm.IsBusy = true; count = vm.Clips.Count; vm.RemoveClipCommand.Execute(null); Check(vm.Clips.Count == count && !vm.CanEdit, "busy render prevents editing"); vm.IsBusy = false;
70 using var previewVm = NewVm(); await previewVm.LoadSourceAsync(paths[0]);
71 string? proxy = await previewVm.EnsurePreviewAsync(); Check(proxy is not null && File.Exists(proxy), "playback generates actual composite proxy");
72 var stamp = File.GetLastWriteTimeUtc(proxy!); Check(await previewVm.EnsurePreviewAsync() == proxy && File.GetLastWriteTimeUtc(proxy!) == stamp, "unchanged playback reuses cached proxy without re-encoding");
73 previewVm.SelectedClip = previewVm.Clips.First(c => c.Kind == MediaKind.Audio); previewVm.ToggleClipAudioCommand.Execute(null);
74 string? second = await previewVm.EnsurePreviewAsync(); Check(second != proxy && second is not null && await Rms(second, .2, .5) < .00005, "edit invalidates playback cache and preview reflects audio mute");
75 string coverAudio = Path.Combine(Root, "带封面音乐.mp3");
76 await Ffmpeg(["-i",paths[2],"-i",paths[3],"-map","0:a:0","-map","1:v:0","-c:a","libmp3lame","-c:v","png","-disposition:v:0","attached_pic",coverAudio]);
77 using var audioVm = NewVm(); await audioVm.LoadSourceAsync(coverAudio);
78 Check(audioVm.Assets.Single().Kind == MediaKind.Audio && audioVm.Clips.All(c => c.Kind == MediaKind.Audio), "MP3 album artwork is not incorrectly treated as a video track");
79 }
80 }
Added tests/VideoStudio.Validation/MultiTrackProgram.cs +99 -0
@@ -0,0 +1,99 @@
1 using System.Diagnostics;
2 using System.Globalization;
3 using System.IO;
4 using System.Reflection;
5 using System.Security.Cryptography;
6 using System.Text.Json;
7 using System.Windows;
8 using System.Windows.Controls;
9 using System.Windows.Media;
10 using System.Windows.Media.Imaging;
11 using System.Windows.Threading;
12 using XFEToolBox.Core.Tools;
13 using XFEToolBox.Tools.VideoStudio;
14
15 namespace VideoStudio.Validation;
16 internal static partial class Program
17 {
18 private static readonly string Workspace = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFEToolBox", "CrossVersion", "EditorWorkspaces", "VideoStudio");
19 private static readonly string Root = Path.Combine(AppContext.BaseDirectory, "test-artifacts", DateTime.Now.ToString("yyyyMMdd-HHmmss"));
20 private static readonly List<string> Passes = [];
21 private static int exitCode;
22 private static string ffmpeg = "", ffprobe = "";
23 private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
24 [STAThread] private static int Main(string[] args)
25 {
26 Directory.CreateDirectory(Root);
27 var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
28 app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/XFEToolBox.WpfCore;component/Resources/Style/ToolThemeResources.xaml") });
29 app.Startup += async (_, _) =>
30 {
31 try { await RunAsync(args); }
32 catch (Exception e) { exitCode = 1; Console.Error.WriteLine(e); }
33 finally
34 {
35 File.WriteAllText(Path.Combine(Root, "results.json"), JsonSerializer.Serialize(new { success = exitCode == 0, checks = Passes }, Json));
36 Console.WriteLine("Artifacts: " + Root); app.Shutdown();
37 }
38 };
39 app.Run(); return exitCode;
40 }
41 private static void Check(bool condition, string name)
42 { if (!condition) throw new InvalidOperationException("FAIL: " + name); Passes.Add(name); Console.WriteLine("PASS: " + name); }
43 private static bool Near(double a, double b, double tolerance = .00001) => Math.Abs(a - b) < tolerance;
44 private static async Task Reject(Func<Task> action, string name)
45 { bool failed = false; try { await action(); } catch { failed = true; } Check(failed, name); }
46 private static async Task RunAsync(string[] args)
47 {
48 var settings = JsonSerializer.Deserialize<VideoToolSettings>(File.ReadAllText(Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(Workspace)!)!, "ToolData", "xfestudio.video-studio", "settings.json")), Json)!;
49 ffmpeg = settings.FfmpegPath; ffprobe = settings.FfprobePath;
50 if (args.Contains("--host-only")) { await Host(args); return; }
51 string red = Path.Combine(Root, "红色 视频's.mp4"), blue = Path.Combine(Root, "蓝色无声.mp4"), sound = Path.Combine(Root, "外部 音频.wav"), picture = Path.Combine(Root, "静态图片.png");
52 await Ffmpeg(["-f","lavfi","-i","color=c=red:s=640x360:r=30:d=4","-f","lavfi","-i","sine=frequency=440:duration=4","-c:v","libx264","-pix_fmt","yuv420p","-c:a","aac","-shortest",red]);
53 await Ffmpeg(["-f","lavfi","-i","color=c=blue:s=320x240:r=30:d=3","-c:v","libx264","-pix_fmt","yuv420p",blue]);
54 await Ffmpeg(["-f","lavfi","-i","sine=frequency=660:duration=6","-c:a","pcm_s16le",sound]);
55 await Ffmpeg(["-f","lavfi","-i","color=c=lime:s=160x90","-frames:v","1","-threads","1",picture]);
56 var sourceFiles = new[] { red, blue, sound, picture };
57 var hashes = sourceFiles.Select(p => SHA256.HashData(File.ReadAllBytes(p))).ToArray();
58 await ModelTests(sourceFiles);
59 using var library = new MediaLibrary();
60 var assets = new List<MediaAsset>();
61 foreach (string path in sourceFiles) assets.Add(await library.ImportAsync(ffmpeg, ffprobe, path, default));
62 await RenderTests(assets.ToArray());
63 await InterfaceTests(sourceFiles);
64 await ChromeTests();
65 Check(sourceFiles.Select((p, i) => hashes[i].SequenceEqual(SHA256.HashData(File.ReadAllBytes(p)))).All(x => x), "all source media remain byte-identical");
66 if (!args.Contains("--skip-host")) await Host(args);
67 Console.WriteLine($"ALL {Passes.Count} CHECKS PASSED");
68 }
69 private static async Task Ffmpeg(string[] args)
70 { var result = await new ProcessRunner().RunAsync(ffmpeg, new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-y" }.Concat(args), Root, null, null, default); if (!result.Success) throw new InvalidOperationException(result.CombinedOutput); }
71 private static async Task<double> FrameError(string first, double a, string second, double b)
72 {
73 async Task<byte[]> Pixels(string path, double position)
74 {
75 var image = (BitmapSource)(await PreviewAssets.FrameAsync(ffmpeg, path, position, default))!;
76 var bitmap = new FormatConvertedBitmap(image, PixelFormats.Rgb24, null, 0); byte[] bytes = new byte[bitmap.PixelWidth * bitmap.PixelHeight * 3]; bitmap.CopyPixels(bytes, bitmap.PixelWidth * 3, 0); return bytes;
77 }
78 var left = await Pixels(first, a); var right = await Pixels(second, b);
79 Check(left.Length == right.Length, "exported frame dimensions match source");
80 return left.Select((value, i) => Math.Abs(value - right[i])).Average();
81 }
82 private static async Task<double> Rms(string path, double start, double duration)
83 {
84 string output = Path.Combine(Root, Guid.NewGuid() + ".f32");
85 await Ffmpeg(["-ss", start.ToString(System.Globalization.CultureInfo.InvariantCulture), "-i", path, "-t", duration.ToString(System.Globalization.CultureInfo.InvariantCulture), "-vn", "-ac", "1", "-f", "f32le", output]);
86 byte[] bytes = File.ReadAllBytes(output); double sum = 0; for (int i = 0; i + 4 <= bytes.Length; i += 4) { double v = BitConverter.ToSingle(bytes, i); sum += v * v; } return Math.Sqrt(sum / (bytes.Length / 4));
87 }
88 private static void Save(FrameworkElement page, string name) { page.UpdateLayout(); var bitmap = new RenderTargetBitmap((int)page.ActualWidth, (int)page.ActualHeight, 96, 96, PixelFormats.Pbgra32); bitmap.Render(page); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using var stream = File.Create(Path.Combine(Root, name)); encoder.Save(stream); }
89 private static async Task Host(string[] args)
90 {
91 var type = typeof(XFEToolBox.Client.Models.LauncherItem).Assembly.GetType("XFEToolBox.Client.Utilities.ToolProjectRunService", true)!;
92 var manifest = JsonSerializer.Deserialize<ToolPackageManifest>(File.ReadAllText(Path.Combine(Workspace, "manifest.json")), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
93 async Task Build(string path) { var task = (Task)type.GetMethod("BuildAsync", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, [path, manifest, CancellationToken.None, null])!; await task.WaitAsync(TimeSpan.FromMinutes(4)); var result = task.GetType().GetProperty("Result")!.GetValue(task)!; Check((bool)result.GetType().GetProperty("Success")!.GetValue(result)!, "production toolbox compilation: " + result.GetType().GetProperty("Message")!.GetValue(result)); }
94 await Build(Workspace);
95 if (args.Contains("--check-package")) { string package = Path.Combine(Path.GetDirectoryName(Workspace)!, "Packages", $"{manifest.Id}-{manifest.Version}.xfetool"), extracted = Path.Combine(Root, "package"); await (Task<ToolPackageManifest>)type.GetMethod("ExtractAndValidatePackageAsync", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, [package, extracted, manifest.Id, manifest.Version, CancellationToken.None])!; Check(Directory.GetFiles(extracted, "*", SearchOption.AllDirectories).All(p => File.ReadAllBytes(p).SequenceEqual(File.ReadAllBytes(Path.Combine(Workspace, Path.GetRelativePath(extracted, p))))), "package bytes match workspace"); await Build(extracted); }
96 }
97 private sealed class BindingTrace : TraceListener { public List<string> Errors { get; } = []; public override void Write(string? text) { if (!string.IsNullOrEmpty(text)) Errors.Add(text); } public override void WriteLine(string? text) => Write(text); }
98 }
99
Added tests/VideoStudio.Validation/MultiTrackRenderTests.cs +108 -0
@@ -0,0 +1,108 @@
1 using System.IO;
2 using System.Windows.Media;
3 using System.Windows.Media.Imaging;
4 using XFEToolBox.Tools.VideoStudio;
5
6 namespace VideoStudio.Validation;
7 internal static partial class Program
8 {
9 private static TimelineClip Clip(MediaAsset asset, TimelineTrack track, double time, double duration, double source = 0) => new(asset.Name, source, source + duration)
10 { AssetId = asset.Id, TrackId = track.Id, Kind = track.Kind == MediaKind.Audio ? MediaKind.Audio : asset.Kind, TimelineStart = time };
11 private static async Task RenderTests(MediaAsset[] a)
12 {
13 var service = new MediaProcessingService();
14 var v1 = new TimelineTrack { Kind = MediaKind.Video, Index = 1 }; var v2 = new TimelineTrack { Kind = MediaKind.Video, Index = 2 }; var v3 = new TimelineTrack { Kind = MediaKind.Video, Index = 3 };
15 var a1 = new TimelineTrack { Kind = MediaKind.Audio, Index = 1 }; var a2 = new TimelineTrack { Kind = MediaKind.Audio, Index = 2 };
16 var red = Clip(a[0], v1, 0, 4); var blue = Clip(a[1], v2, 1, 2); blue.Scale = 50;
17 var picture = Clip(a[3], v3, 2, 1); picture.Scale = 25;
18 var original = Clip(a[0], a1, 0, 4); original.AudioVolume = 50;
19 var music = Clip(a[2], a2, 1, 4); music.FadeIn = .5; music.FadeOut = 1;
20 var seq = new SequenceSnapshot(a, [v1, v2, v3, a1, a2], [red, blue, picture, original, music], new(640, 360, 30));
21 string output = Path.Combine(Root, "multi-cpu.mp4");
22 var result = await service.ExportSequenceAsync(ffmpeg, ffprobe, seq, output, null, default, new(ExportEncodingMode.Cpu));
23 var info = await service.ProbeAsync(ffprobe, output, default);
24 Check(!result.UsedGpu && info.Width == 640 && info.Height == 360 && Near(info.DurationSeconds, 5, .05), "real multi-source CPU export has expected canvas and total duration");
25 await Ffmpeg(["-i", output, "-f", "null", "-"]); Check(true, "entire multi-track output decodes without errors");
26 await CheckColor(service, seq, output, .5, 'r', "base video before overlay");
27 await CheckColor(service, seq, output, 1.5, 'b', "higher V2 overlays V1");
28 await CheckColor(service, seq, output, 2.5, 'g', "image on V3 overlays both videos");
29 await CheckColor(service, seq, output, 3.5, 'r', "ended overlays reveal base video again");
30 await CheckColor(service, seq, output, 4.5, 'k', "audio-only tail has black video, not a frozen last frame");
31 var frame = (BitmapSource)(await PreviewAssets.FrameAsync(ffmpeg, output, 1.5, default))!;
32 Check(IsColor(Pixel(frame, .1, .1), 'r') && IsColor(Pixel(frame, .5, .5), 'b'), "scaling keeps base visible outside overlay");
33 double sourceLevel = await Rms(a[0].FilePath, .2, .5), halfLevel = await Rms(output, .2, .5);
34 Check(halfLevel / sourceLevel is > .43 and < .57, "original-audio clip exports at its own 50% volume");
35 Check(await Rms(output, 4.1, .15) > await Rms(output, 4.8, .1) * 2, "external audio fade-out continues independently after base video ends");
36 Check(await EnergyAt(output, 2, .4, 440) > .001 && await EnergyAt(output, 2, .4, 660) > .001, "concurrent A1 and A2 are both present in mixed output");
37 string gpu = Path.Combine(Root, "multi-auto.mp4"); var accelerated = await service.ExportSequenceAsync(ffmpeg, ffprobe, seq, gpu, null, default);
38 Check(accelerated.UsedGpu, "actual NVIDIA/AMD/Intel hardware encoder successfully exports multi-track composite");
39 await Ffmpeg(["-i", gpu, "-f", "null", "-"]); await CheckColor(service, seq, gpu, 2.5, 'g', "GPU export preserves visual track order");
40 var noAudio = seq with { AudioMuted = true }; string mute = Path.Combine(Root, "all-muted.mp4");
41 await service.ExportSequenceAsync(ffmpeg, ffprobe, noAudio, mute, null, default);
42 Check(await Rms(mute, 2, .4) < .00005, "global mute suppresses every audio track");
43 v2.IsEnabled = false; v3.IsEnabled = false;
44 var disabledFrame = (BitmapSource)(await service.RenderSequenceFrameAsync(ffmpeg, seq, 2.5, default))!;
45 Check(IsColor(Pixel(disabledFrame, .5, .5), 'r'), "disabled visual tracks do not contribute pixels"); v2.IsEnabled = v3.IsEnabled = true;
46 music.AudioEnabled = false; string singleMuted = Path.Combine(Root, "one-muted.mp4");
47 await service.ExportSequenceAsync(ffmpeg, ffprobe, seq, singleMuted, null, default);
48 Check(await EnergyAt(singleMuted, 2, .4, 440) > .001 && await EnergyAt(singleMuted, 2, .4, 660) < .0002, "muting one clip does not mute other audio tracks"); music.AudioEnabled = true;
49 original.AudioVolume = 150; var loud = seq with { Clips = [red, original] }; string boosted = Path.Combine(Root, "gain150.mp4");
50 await service.ExportSequenceAsync(ffmpeg, ffprobe, loud, boosted, null, default);
51 Check(await Rms(boosted, .2, .5) / sourceLevel is > 1.4 and < 1.6, "150% gain bypasses old single-source 100% limit correctly"); original.AudioVolume = 100;
52 string smart = Path.Combine(Root, "smart-copy.mp4"); var smartResult = await service.ExportSequenceAsync(ffmpeg, ffprobe, loud, smart, null, default);
53 Check(smartResult.StreamCopy, "untouched single-source sequence retains lossless fast-copy export");
54 var audioOnly = seq with { Clips = [Clip(a[2], a2, 0, 1)] }; string audioOut = Path.Combine(Root, "audio-only.mp4");
55 await service.ExportSequenceAsync(ffmpeg, ffprobe, audioOnly, audioOut, null, default);
56 Check(await Rms(audioOut, .2, .5) > .01, "audio-only sequence produces a valid audible export");
57 var imageOnly = seq with { Clips = [Clip(a[3], v1, 0, 2)] }; string imageOut = Path.Combine(Root, "image-only.mp4");
58 await service.ExportSequenceAsync(ffmpeg, ffprobe, imageOnly, imageOut, null, default);
59 Check(Near((await service.ProbeAsync(ffprobe, imageOut, default)).DurationSeconds, 2, .05) && await Rms(imageOut, .2, .5) < .00005, "image duration exports with silent audio");
60 await CheckColor(service, imageOnly, imageOut, 1, 'g', "image-only render matches preview");
61 var brokenGpu = new MediaProcessingService { EncoderResolver = (_, _) => Task.FromResult(new VideoEncoder("unavailable_gpu_codec", "test GPU", true, [])) };
62 string fallback = Path.Combine(Root, "fallback.mp4"); var recovered = await brokenGpu.ExportSequenceAsync(ffmpeg, ffprobe, seq, fallback, null, default);
63 Check(recovered.FellBack && !recovered.UsedGpu && File.Exists(fallback), "hardware failure automatically retries whole composite on CPU");
64 byte[] previous = await File.ReadAllBytesAsync(output); using var cancellation = new CancellationTokenSource(); var progress = new SequenceCancelProgress(cancellation);
65 await Reject(() => service.ExportSequenceAsync(ffmpeg, ffprobe, seq, output, progress, cancellation.Token, new(ExportEncodingMode.Cpu)), "cancel an active composite export");
66 byte[] afterCancel = await File.ReadAllBytesAsync(output);
67 Check(progress.Called && previous.SequenceEqual(afterCancel) && !Directory.EnumerateDirectories(Root, ".xfe-video-*").Any(), "cancellation preserves prior output and removes private staging");
68 foreach (var asset in a.Where(x => x.Kind == MediaKind.Video)) await Reject(() => service.ExportSequenceAsync(ffmpeg, ffprobe, seq, asset.FilePath, null, default), "reject overwrite of source " + asset.Name);
69 var bad = red.Copy(); bad.EndSeconds = 50;
70 await Reject(() => service.ExportSequenceAsync(ffmpeg, ffprobe, seq with { Clips = [bad] }, output, null, default), "out-of-source trims are rejected before encoding");
71 var many = seq with { Clips = Enumerable.Range(0, 70).Select(i => Clip(a[3], v1, i / 30d, 1d / 30)).ToArray() };
72 var windows = MediaProcessingService.CreateRenderWindows(many);
73 Check(windows.Count > 2 && Near(windows.Sum(w => w.Duration), 70d / 30), "many sequential clips split into bounded frame-aligned render windows");
74 string manyOut = Path.Combine(Root, "70-images.mp4"); var batches = await service.ExportSequenceAsync(ffmpeg, ffprobe, many, manyOut, null, default);
75 Check(batches.Batches > 2 && Near((await service.ProbeAsync(ffprobe, manyOut, default)).DurationSeconds, 70d / 30, .08), "actual bounded multi-batch export preserves duration");
76 await CheckColor(service, many, manyOut, 1.1, 'g', "one-frame images survive chunk boundaries");
77 await Ffmpeg(["-i", manyOut, "-f", "null", "-"]);
78 var overload = seq with { Clips = Enumerable.Range(0, 33).Select(_ => Clip(a[3], v1, 0, 1)).ToArray() };
79 await Reject(() => Task.Run(() => MediaProcessingService.CreateRenderWindows(overload)), "simultaneous-input safety limit fails clearly rather than exhausting memory");
80 }
81 private static async Task CheckColor(MediaProcessingService service, SequenceSnapshot seq, string path, double time, char expected, string name)
82 {
83 var rendered = (BitmapSource)(await PreviewAssets.FrameAsync(ffmpeg, path, time, default))!;
84 var preview = (BitmapSource)(await service.RenderSequenceFrameAsync(ffmpeg, seq, time, default))!;
85 var actual = Pixel(rendered, .5, .5); var paused = Pixel(preview, .5, .5);
86 Check(IsColor(actual, expected) && IsColor(paused, expected), name + $" (export {actual}, preview {paused})");
87 }
88 private static (byte R, byte G, byte B) Pixel(BitmapSource source, double x, double y)
89 {
90 var image = new FormatConvertedBitmap(source, PixelFormats.Rgb24, null, 0); var bytes = new byte[3];
91 image.CopyPixels(new System.Windows.Int32Rect((int)(image.PixelWidth * x), (int)(image.PixelHeight * y), 1, 1), bytes, 3, 0); return (bytes[0], bytes[1], bytes[2]);
92 }
93 private static bool IsColor((byte R, byte G, byte B) pixel, char color) => color switch
94 { 'r' => pixel.R > 180 && pixel.G < 35 && pixel.B < 35, 'g' => pixel.G > 180 && pixel.R < 35 && pixel.B < 35, 'b' => pixel.B > 180 && pixel.R < 35 && pixel.G < 35, 'k' => pixel.R < 20 && pixel.G < 20 && pixel.B < 20, _ => false };
95 private static async Task<double> EnergyAt(string path, double start, double duration, double hz)
96 {
97 string file = Path.Combine(Root, Guid.NewGuid() + ".f32");
98 await Ffmpeg(["-ss", start.ToString(System.Globalization.CultureInfo.InvariantCulture), "-i", path, "-t", duration.ToString(System.Globalization.CultureInfo.InvariantCulture), "-vn", "-ar", "48000", "-ac", "1", "-f", "f32le", file]);
99 var bytes = File.ReadAllBytes(file); double sin = 0, cos = 0; int count = bytes.Length / 4;
100 for (int i = 0; i < count; i++) { double value = BitConverter.ToSingle(bytes, i * 4), phase = 2 * Math.PI * hz * i / 48000; sin += value * Math.Sin(phase); cos += value * Math.Cos(phase); }
101 return Math.Sqrt(sin * sin + cos * cos) / count;
102 }
103 private sealed class SequenceCancelProgress(CancellationTokenSource cancellation) : IProgress<ExportProgress>
104 {
105 public bool Called;
106 public void Report(ExportProgress value) { if (value.Message.Contains("合成/混音") && value.Value > .03) { Called = true; cancellation.Cancel(); } }
107 }
108 }
Modified tests/VideoStudio.Validation/README.md +30 -42
@@ -1,56 +1,44 @@
1 # VideoStudio editor regression validation
1 # VideoStudio 1.5.0 — multi-track regression validation
2 2
3 ## Verified release
3 Verified 2026-09-30: **106 checks passed**, exit code 0. Includes actual FFmpeg processing, WPF playback/layout/gestures, native maximized window geometry, production workspace compilation, package validation and extracted-package compilation.
4 4
5 2026-09-24: **96 checks passed**, exit code 0, including source compilation, production package validation and extracted-package compilation for VideoStudio **1.4.0**.
6
7 - Artifacts: `bin/Debug/net10.0-windows10.0.17763.0/test-artifacts/20260924-232559/`.
8 - Package: `xfestudio.video-studio-1.4.0.xfetool`, 89,719 bytes.
9 - SHA-256: `5161C2DF4D1AFF9AB43758867B70B261873278DD7A4790A7F7C4732F3A90F178`.
10 - Previous 1.3.0 package is retained; `LegacyExporter.cs` freezes that release's implementation for comparisons, not production use.
11
12 Uses actual workspace sources from `%LOCALAPPDATA%\XFEToolBox\CrossVersion\EditorWorkspaces\VideoStudio`, WPF toolbox resources, FFmpeg, and FFprobe. User videos and settings are not modified. Synthetic fixtures, exports, PCM samples and rendered screenshots are kept in a timestamped `bin/Debug/net10.0-windows10.0.17763.0/test-artifacts/` directory.
5 - Results and PNG/media artifacts: `bin/Debug/net10.0-windows10.0.17763.0/test-artifacts/20260930-034854/`.
6 - Package: `%LOCALAPPDATA%/XFEToolBox/CrossVersion/EditorWorkspaces/Packages/xfestudio.video-studio-1.5.0.xfetool`.
7 - Size: 104,700 bytes.
8 - SHA-256: `678E4F587843D9A820ABAEA6C85A949F75EB2B5CB31681A4025ABCB8855A948A`.
9 - Toolbox Release build: 0 warnings, 0 errors. Window chrome changes require the newly compiled host, not just the video package.
13 10
14 11 ## Run
15 12
16 Requires Windows, .NET 10, and the existing video tool's `ToolData/xfestudio.video-studio/settings.json` containing working FFmpeg / FFprobe paths. This full validation run explicitly asserts NVIDIA hardware use and therefore requires a working NVENC GPU. Production automatic mode also probes QSV/AMF and works CPU-only; those hardware vendors were not independently validated on other machines.
13 Requires Windows, .NET 10, and working FFmpeg / FFprobe paths in the existing tool's `ToolData/xfestudio.video-studio/settings.json`. Full validation asserts actual hardware encoding on this machine (NVIDIA NVENC). Production also probes Intel QSV / AMD AMF and supports CPU-only fallback; this does not claim independent validation on every GPU vendor.
17 14
18 15 ```powershell
19 16 dotnet run --project tests/VideoStudio.Validation/VideoStudio.Validation.csproj -- --skip-host
20 17 dotnet run --project tests/VideoStudio.Validation/VideoStudio.Validation.csproj -- --check-package
21 dotnet run --project tests/VideoStudio.Validation/VideoStudio.Validation.csproj -- --benchmark-only
18 dotnet run --project tests/VideoStudio.Validation/VideoStudio.Validation.csproj -- --host-only --check-package
22 19 ```
23 20
24 Without `--skip-host`, the tool is compiled through production `ToolProjectRunService.BuildAsync`. `--check-package` additionally uses the production package validator, compares package source bytes and compiles the extracted project.
21 Source links point at `%LOCALAPPDATA%/XFEToolBox/CrossVersion/EditorWorkspaces/VideoStudio`. No user media or saved settings are changed. Tests generate their own files under a timestamped test-artifacts directory and use only owned test windows. Playback is muted in the test view; sound is validated through decoded PCM and frequency measurements. Production build-only checks do not launch or replace an installed toolbox.
25 22
26 23 ## Coverage
27 24
28 - Actual probe/import, eight source thumbnails, waveform, dispatcher responsiveness, completed progress.
29 - Split, boundary rejection, exact fixed-frame-rate stepping, fractional frame-rate/non-drop timecode, undo/redo, trimmed/reordered sequence-to-source mapping and playback hand-off.
30 - Ripple trim/delete, per-clip and track audio mute, volume, invalid trim, failed import preservation, zoom/fit, busy-state guards.
31 - Both edge handles on both tracks, start/end extension limits, one-frame minimum, frame snapping, frozen drag scale while the timeline ripples, zoomed tail drag, cancellation and redo preservation, forty updates creating only one undo entry.
32 - Actual NVENC encoding, optimized CPU path, one-pass multi-clip output, non-mutating contiguous-clip coalescing, and smart-copy compressed audio/video SHA-256 identity.
33 - Forced FFmpeg GPU failures before encoding and after a completed batch; complete CPU restart avoids mixed H.264 headers. Unrecoverable failure retains existing destination bytes.
34 - Bounded multi-batch export, exact frame counts across batch joins, 29.97fps non-keyframe cuts, single-frame output, muted/gain-adjusted audio.
35 - Actual H.264/AAC MP4 output and full decode, expected duration, single-frame and silent-source output.
36 - Extracted source/output pixel comparison to verify actual cut intervals; decoded audio RMS to verify muted versus 50% gain segments.
37 - Source overwrite/bounds rejection, cancellation triggered by actual encoding progress, preservation of an existing destination, temporary-directory cleanup, unchanged source hash.
38 - Real WPF page/viewmodel, visible still-frame image, common proportional scale for timeline drawing and hit-testing, no error-level binding messages, empty state after split, safe close during active tasks, full/minimum-size/editor/subtitle/environment renders.
39
40 The generated media is synthetic. Drag tests invoke the same gesture methods called by the WPF pointer handlers on an instantiated, laid-out page; they do not claim an OS-level manual mouse/file-picker session. Arbitrary MediaElement codecs or long continuous hardware playback were not exhaustively tested. FFmpeg frame extraction/export is exercised end-to-end. Whisper recognition/download code is preserved, not re-downloaded/re-run for this change. No network download or source-media deletion is performed by this suite.
41
42 `results.json` records each passed assertion and success state. Native decoder `libpng` interlace warnings may appear while WPF renders; assertions also check that images load and binding errors are absent.
43
44 ## Reproducible performance comparison
45
46 `--benchmark-only` creates one 1920x1080 / 30fps / 24-second H.264 source with a sine audio track, then exports the same six reordered/noncontiguous four-second clips (including one 60%-gain clip and one muted clip). The timed export includes probe, staging, encoder selection, encoding, muxing and cleanup, not fixture creation or verification. All exports are fully decoded and duration/dimensions/muted audio are checked. The two passes reverse ordering to reduce cache/order effects. CPU and GPU comparisons disable smart copy.
47
48 Measured on Ryzen 9 9950X3D, RTX 5090 (driver 32.0.16.1692), FFmpeg 9.0.1. Raw output: `bin/Debug/net10.0-windows10.0.17763.0/test-artifacts/20260924-232117/benchmark.json`.
49
50 | Path | Round 1 | Round 2 | Mean | Speedup | Output bytes |
51 | --- | ---: | ---: | ---: | ---: | ---: |
52 | Frozen 1.3.0, x264 medium / 4 threads, per-clip intermediates | 8.3703 s | 7.8796 s | 8.1249 s | 1.00x | 22,414,326 |
53 | 1.4.0 optimized CPU, x264 fast / CRF 20 | 4.2046 s | 4.1795 s | 4.1920 s | 1.94x | 23,952,379 |
54 | 1.4.0 automatic NVENC, p4 / CQ 20 | 3.3939 s | 2.9569 s | 3.1754 s | 2.56x | 40,288,951 |
55
56 Same resolution/frame rate and edit semantics, **not** identical encoding/compression quality or output size. CQ and CRF values are not equivalent across encoders; GPU output is larger in this fixture. Measurements are local synthetic results, not a universal speed guarantee. The benchmark checks are separate from the 96-check release suite.
25 - Real video/audio/image imports, thumbnails, waveform, dispatcher responsiveness, duplicate/failed import preservation, MP3 attached-cover classification.
26 - Independent original audio and video; additional video/audio tracks, cross-track move/type rejection, separate selected/video/audio splits, locked-track guards.
27 - Head/tail frame snapping, minimum one-frame length, cancel, unchanged other tracks, many pointer updates as one undo step, undo/redo.
28 - Per-clip name/time/track, gain to 200%, mute, fade-in/out, image length, scale, position, opacity; invalid values rejected atomically.
29 - Project save/open round-trip preserving IDs, tracks, clips, source-time subtitles and settings; subtitle dirty-state tracking; malformed project preserves current edits; source overwrite rejection.
30 - Actual composite preview generation/cache/invalidation, WPF MediaElement playback advancing the playhead, and transport pause.
31 - CPU and hardware export, whole-output decode, exact expected dimensions/duration, layer pixel checks against paused previews, overlay end/black-gap behavior, scaling.
32 - Actual mixed 440Hz/660Hz audio, per-clip 50%/150% gain, fade-out, clip/global mute, audio-only and image-only exports.
33 - Existing single-source smart-copy fast path retained; forced unavailable GPU encoder successfully retries on CPU.
34 - Active-encoding cancellation preserves existing output and removes private staging.
35 - 70 one-frame image clips rendered through bounded batches, pixel comparison at a batch boundary; too many simultaneous inputs fail explicitly.
36 - Real WPF menu commands, type-specific inspector visibility, compatible-track selection and readable names, pointer gesture methods, viewport resize/fit, minimum layout, zero error-level binding traces.
37 - Tool-window maximize/restore: no margins or radii when maximized, no resize-grip rendering/input, exact Win32 bounds matching monitor work area, restored chrome, nonresizable-window grip behavior.
38 - All synthetic source hashes unchanged, production workspace and extracted-package compilation, package bytes identical to workspace.
39
40 ## Scope and historical tests
41
42 WPF gesture checks invoke the methods used by real pointer handlers on an instantiated and laid-out view. They are not a manual OS mouse/file-picker acceptance session. Short local composite playback is exercised; arbitrary formats, long playback, every display/DPI topology and GPU/driver are not exhaustively tested. Whisper download/recognition is unchanged and not re-downloaded or rerun.
43
44 `Program.cs` and `AccelerationTests.cs` are preserved 1.4 historical tests and excluded from current compilation because they exercise the old linked/ripple timeline semantics. `LegacyExporter.cs` preserves the earlier exporter for reference. The active suite is the `MultiTrack*.cs` files. Old 1.4 speed benchmarks (8.12s old CPU / 4.19s optimized CPU / 3.18s NVENC on 24s 1080p footage) are historical only, not a speed claim for multi-layer compositing. The previous 1.4.0 package is retained.
Modified tests/VideoStudio.Validation/VideoStudio.Validation.csproj +1 -1
@@ -1,4 +1,4 @@
1 1 <Project Sdk="Microsoft.NET.Sdk">
2 2 <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0-windows10.0.17763.0</TargetFramework><UseWPF>true</UseWPF><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><EnableDefaultCompileItems>false</EnableDefaultCompileItems><EnableDefaultPageItems>false</EnableDefaultPageItems><StartupObject>VideoStudio.Validation.Program</StartupObject><ToolWorkspace>$(LOCALAPPDATA)\XFEToolBox\CrossVersion\EditorWorkspaces\VideoStudio</ToolWorkspace></PropertyGroup>
3 <ItemGroup><Compile Include="*.cs"/><Compile Include="$(ToolWorkspace)\Code\**\*.cs" Link="Tool\%(RecursiveDir)%(Filename)%(Extension)"/><Page Include="$(ToolWorkspace)\Code\**\*.xaml" Link="Tool\%(RecursiveDir)%(Filename)%(Extension)"/><ProjectReference Include="..\..\XFEToolBox\XFEToolBox.Client.csproj"/><PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2"/></ItemGroup>
3 <ItemGroup><Compile Include="*.cs" Exclude="Program.cs;AccelerationTests.cs"/><Compile Include="$(ToolWorkspace)\Code\**\*.cs" Link="Tool\%(RecursiveDir)%(Filename)%(Extension)"/><Page Include="$(ToolWorkspace)\Code\**\*.xaml" Link="Tool\%(RecursiveDir)%(Filename)%(Extension)"/><ProjectReference Include="..\..\XFEToolBox\XFEToolBox.Client.csproj"/><PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2"/></ItemGroup>
4 4 </Project>