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
+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)
{