返回提交历史
Modified
XFEToolBox.WpfCore/Controls/WindowResizeGrip.xaml
+16
-0
Added
XFEToolBox.WpfCore/Windowing/ToolWindowFrameBehavior.cs
+30
-0
Modified
XFEToolBox/Utilities/ToolProjectRunService.cs
+1
-0
Added
tests/VideoStudio.Validation/MultiTrackInterfaceTests.cs
+99
-0
Added
tests/VideoStudio.Validation/MultiTrackModelTests.cs
+80
-0
Added
tests/VideoStudio.Validation/MultiTrackProgram.cs
+99
-0
Added
tests/VideoStudio.Validation/MultiTrackRenderTests.cs
+108
-0
Modified
tests/VideoStudio.Validation/README.md
+30
-42
Modified
tests/VideoStudio.Validation/VideoStudio.Validation.csproj
+1
-1
XFEstudio/XFEToolBox
引入多轨道自动化测试与窗口行为优化
新增多轨道模型、渲染、界面和主程序端到端自动化测试,覆盖剪辑、轨道操作、属性、字幕、导出等功能,所有结果输出至 test-artifacts。抽离并统一工具窗口圆角与边距行为,最大化时自动去除圆角和外边距,提升窗口Chrome一致性。窗口拖拽手柄支持样式触发器,最大化或禁用调整时自动隐藏。项目排除旧测试,仅编译新用例。更新README,详述1.5.0验证范围与方法。全面提升多轨道编辑验证覆盖率与可维护性。
7ba76d2
代码差异
9 个文件
+464
-43
@@ -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"/>
@@ -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
}
@@ -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
@@ -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
}
@@ -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
}
@@ -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
@@ -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
}
@@ -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.
@@ -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>