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
+142 -4
View File
@@ -1,5 +1,9 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Windows.Media.Imaging;
using Microsoft.Win32;
using ytLive.Models;
using ytLive.Services;
@@ -17,8 +21,15 @@ public partial class MainViewModel
private const int MediaDecodeWidth = 1920;
private const int MediaDecodeHeight = 1080;
private const string VideoFileFilter =
"Video files (*.mp4;*.mov;*.avi;*.mkv;*.webm)|*.mp4;*.mov;*.avi;*.mkv;*.webm|All files (*.*)|*.*";
private readonly MediaVideoSourceManager _mediaManager;
// One subscription per media Source, so removing a layer stops the listener as
// well as the decoder (a scene element outlives its layer list row).
private readonly Dictionary<string, PropertyChangedEventHandler> _mediaHandlers = new();
// MediaVideoSourceManager publishes one shared WriteableBitmap per file; every
// media Source pointing at that path picks it up for the preview/canvas.
private void OnMediaPreviewBitmapChanged(string path, WriteableBitmap bitmap)
@@ -28,8 +39,135 @@ public partial class MainViewModel
source.VideoImageSource = bitmap;
}
// Called from the encoder error path is out of scope (TASK 21 sees the decoder
// fail at startup); log to the debug console for now, surfaced via the UI later.
private static void OnMediaFailed(string path, string message)
=> System.Diagnostics.Debug.WriteLine($"MediaSource: '{path}' failed: {message}");
private void OnMediaFailed(string path, string message)
{
Helpers.AppLog.Write($"MediaSource: '{path}' failed: {message}");
_notifications.Warning("Couldn't play that video", System.IO.Path.GetFileName(path));
}
// ---------- add / change the file ----------
private void AddMedia()
{
if (PickVideoFile() is { } path)
AddMediaSource(path);
}
private string? PickVideoFile(string title = "Choose a video file")
{
var dialog = new OpenFileDialog
{
Title = title,
Filter = VideoFileFilter,
};
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
/// <summary>Adds a MediaSource layer for <paramref name="path"/> to the staged scene.
/// Internal so the Good Dog test drives the real add path without a file dialog.</summary>
internal void AddMediaSource(string path)
{
if (string.IsNullOrWhiteSpace(path)) return;
AddSource(SourceType.MediaSource, path);
}
private void ChangeMediaFile(Source? source)
{
if (source is not { IsMediaSource: true }) return;
if (PickVideoFile("Replace the video") is not { } path) return;
PushPreviewUndo();
source.MediaPath = path;
}
// ---------- session lifecycle (one session per distinct path) ----------
/// <summary>Starts decoding the file if nothing else already is, and subscribes to the
/// layer so a file swap or a loop flip reaches the session.</summary>
private void RegisterMediaSource(Source source)
{
if (_mediaHandlers.ContainsKey(source.Id)) return;
string? lastPath = source.MediaPath;
void OnSourceChanged(object? _, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(Source.MediaPath))
{
var previous = lastPath;
lastPath = source.MediaPath;
if (string.Equals(previous, source.MediaPath, StringComparison.OrdinalIgnoreCase)) return;
// The layer now points elsewhere, so the old file may be unreferenced —
// release it before acquiring the replacement.
ReleaseMediaIfUnused(previous);
EnsureMediaSession(source.MediaPath);
}
else if (e.PropertyName == nameof(Source.MediaIsLooping) && source.MediaPath is { } path)
{
_mediaManager.SetLooping(path, AnyMediaReferenceLoops(path));
}
}
_mediaHandlers[source.Id] = OnSourceChanged;
source.PropertyChanged += OnSourceChanged;
EnsureMediaSession(source.MediaPath);
}
private void UnregisterMediaSource(Source source)
{
if (_mediaHandlers.Remove(source.Id, out var handler))
source.PropertyChanged -= handler;
}
private void EnsureMediaSession(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return;
if (_mediaManager.HasSession(path)) return;
_ = AcquireMediaAsync(path);
}
private async System.Threading.Tasks.Task AcquireMediaAsync(string path)
{
if (!await _mediaManager.AcquireAsync(path)) return;
// A session can be running before this layer existed (shared path), in which
// case the bitmap is already published and will not be raised again for us.
if (_mediaManager.GetPreviewBitmap(path) is { } bitmap)
OnMediaPreviewBitmapChanged(path, bitmap);
}
/// <summary>Shared sessions or not, the loop state is the path's: any live media
/// reference asking for a loop makes the one decoder loop (recorded rule, TASK 21).</summary>
private bool AnyMediaReferenceLoops(string path)
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Any(s => s.IsMediaSource && s.MediaPath == path && s.MediaIsLooping);
private bool HasMediaReference(string path)
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Any(s => s.IsMediaSource && s.MediaPath == path);
private void ReleaseMediaIfUnused(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return;
if (!_mediaManager.HasSession(path)) return;
if (HasMediaReference(path)) return;
_ = _mediaManager.ReleaseAsync(path);
}
/// <summary>After a layout load: subscribe every persisted media layer and start one
/// session per distinct file; release sessions for files the layout no longer uses.</summary>
private void ReacquireMediaSources()
{
var sources = Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.IsMediaSource && !string.IsNullOrWhiteSpace(s.MediaPath))
.ToList();
foreach (var source in sources)
RegisterMediaSource(source);
var wanted = sources.Select(s => s.MediaPath!).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
foreach (var stale in _mediaManager.GetActiveKeys()
.Where(key => !wanted.Contains(key, StringComparer.OrdinalIgnoreCase))
.ToList())
_ = _mediaManager.ReleaseAllAsync(stale);
}
}
+26 -3
View File
@@ -15,14 +15,19 @@ namespace ytLive.ViewModels;
public partial class MainViewModel
{
private void AddSource(object? parameter)
{
var sourceType = parameter is SourceType typed
? typed
: Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
AddSource(sourceType);
}
private void AddSource(SourceType sourceType, string? mediaPath = null)
{
var scene = StagedScene;
if (scene == null) return;
PushPreviewUndo();
var sourceType = parameter is SourceType typed
? typed
: Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
// Privacy blur is a Live-scene tool: it exists to hide the creator's desktop, and every
// other scene's art is authored. Rejecting here (not only in the (+) menu) keeps a stale
// menu item or a scripted call from planting one on BRB.
@@ -40,6 +45,7 @@ public partial class MainViewModel
SourceType.AlertBox => "Stream Alerts",
SourceType.Ticker => "Ticker",
SourceType.Scoreboard => "Scoreboard",
SourceType.MediaSource => "Video",
_ => "Layer",
};
@@ -62,6 +68,7 @@ public partial class MainViewModel
? $"{baseName}-{webCount}"
: NextSourceName(scene, baseName),
Type = sourceType,
MediaPath = mediaPath,
X = sourceType == SourceType.TextOverlay ? 160
: sourceType == SourceType.ChatBox ? 1400
: sourceType == SourceType.AlertBox ? 620
@@ -79,6 +86,7 @@ public partial class MainViewModel
: sourceType == SourceType.AlertBox ? 680
: sourceType == SourceType.Scoreboard ? 1920
: sourceType == SourceType.Ticker ? 1920
: sourceType == SourceType.MediaSource ? 1920
: sourceType == SourceType.Blur ? 240 : 0,
Height = sourceType == SourceType.TextOverlay ? 140
: sourceType == SourceType.WebSource ? 480
@@ -86,6 +94,7 @@ public partial class MainViewModel
: sourceType == SourceType.AlertBox ? 200
: sourceType == SourceType.Scoreboard ? 56
: sourceType == SourceType.Ticker ? 48
: sourceType == SourceType.MediaSource ? 1080
: sourceType == SourceType.Blur ? 160 : 0,
};
@@ -104,6 +113,9 @@ public partial class MainViewModel
};
}
if (sourceType == SourceType.MediaSource)
RegisterMediaSource(source);
if (sourceType == SourceType.ChatBox)
{
source.PropertyChanged += (_, e) =>
@@ -286,7 +298,18 @@ public partial class MainViewModel
}
if (element is Source { Type: SourceType.WebSource } webSource)
_webView2Manager?.Unregister(webSource.Id);
// Media: the layer stops listening, and its decode session stops once no scene
// references the file any more (sessions are shared per path).
string? mediaPath = null;
if (element is Source mediaSource && mediaSource.IsMediaSource)
{
UnregisterMediaSource(mediaSource);
mediaPath = mediaSource.MediaPath;
}
_sceneGraph.RemoveElement(scene, element);
ReleaseMediaIfUnused(mediaPath);
if (element is WebcamSceneConfig)
{
+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();