feat(media): add a video file layer that decodes into the scene

TASK 21's picker slice: the decoder, manager, pacing and loop mechanism were
already shipped but nothing ever acquired a session, so a media Source rendered
nothing. Adds "Video File" to the (+) menu (video-filtered OpenFileDialog),
one decode session per distinct path with a HasSession guard, release when the
last scene reference goes, and a post-load reconcile. Loop is a manager-level
per-path answer ("any live reference loops") applied before StartAsync and
updated live via SetLooping. Properties panel shows the file with Change… and
Loop. Volume stays unwired: media audio rides the desktop loopback, TRAX-style.

Reference: OBS's media source (file → decode → composite into the scene) is the
established answer for this feature; the lifecycle shape mirrors this repo's
WebView2/ScreenCapture registration rather than inventing a new one.

Test: ytLive.Tests/MediaSourceAcquisitionTests.cs — add starts one decoder and
frames reach the layer, loop flip reaches the decoder, remove stops it.

Docs: task-21 closed, TASKS row + Day-4 row + stale 3·17 row corrected,
frame-pipeline acquisition note, HANDOFF rewritten. verify.sh green.
This commit is contained in:
2026-11-05 07:17:43 -08:00
parent 785b5ff8f3
commit 77d7208bfa
11 changed files with 429 additions and 68 deletions
+16 -3
View File
@@ -188,6 +188,8 @@ public partial class MainViewModel : ViewModelBase
public ICommand AddSceneCommand { get; }
public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand AddMediaCommand { get; }
public ICommand ChangeMediaFileCommand { get; }
public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
@@ -260,6 +262,10 @@ public partial class MainViewModel : ViewModelBase
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage());
AddMediaCommand = new RelayCommand(_ => AddMedia());
ChangeMediaFileCommand = new RelayCommand(
element => ChangeMediaFile(element as Source),
element => element is Source { IsMediaSource: true });
RemoveSourceCommand = new RelayCommand(element => { PushPreviewUndo(); RemoveElement(element as SceneElement); });
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); });
@@ -396,12 +402,14 @@ public partial class MainViewModel : ViewModelBase
_screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
_mediaManager = new MediaVideoSourceManager(
path => new MediaVideoSource(
MediaFrameSourceFactoryOverride ?? (path => new MediaVideoSource(
path, MediaDecodeWidth, MediaDecodeHeight,
new FfmpegLocator(), () => new FfmpegDecodeProcess(),
frameRateProbe: new FfmpegFrameRateProbe(
new FfmpegLocator(), () => new FfmpegDecodeProcess())),
System.Windows.Application.Current?.Dispatcher);
new FfmpegLocator(), () => new FfmpegDecodeProcess()))),
System.Windows.Application.Current?.Dispatcher,
// Loop intent is per Source; the manager needs the per-*path* answer.
AnyMediaReferenceLoops);
_mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged;
_mediaManager.MediaFailed += OnMediaFailed;
@@ -466,6 +474,10 @@ public partial class MainViewModel : ViewModelBase
/// real ONNX model. Mirrors <see cref="CameraFrameSourceFactoryOverride"/>.</summary>
internal static Services.BackgroundEffects.IPersonSegmenter? PersonSegmenterOverride { get; set; }
/// <summary>Test seam (TASK 21): a fake media decoder so a test can prove add/release
/// lifecycle without spawning ffmpeg. Mirrors <see cref="CameraFrameSourceFactoryOverride"/>.</summary>
internal static Func<string, IMediaFrameSource?>? MediaFrameSourceFactoryOverride { get; set; }
private void LoadLayout()
{
_isLoading = true;
@@ -518,6 +530,7 @@ public partial class MainViewModel : ViewModelBase
UpdateBackgroundImage();
ReacquireWebcam();
ReacquireScreenCaptures();
ReacquireMediaSources();
RegisterLoadedWebSources();
_socials = _layoutStore.Socials;
RenderSocialBarFrame();