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:
@@ -39,6 +39,7 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
|
||||
private readonly Func<string, IMediaFrameSource?> _sourceFactory;
|
||||
private readonly Dispatcher? _uiDispatcher;
|
||||
private readonly Func<string, bool>? _loopProvider;
|
||||
private readonly Dictionary<string, MediaSession> _sessions = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
@@ -48,10 +49,14 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
/// <summary>Raised when a media decoder cannot be created (bad path).</summary>
|
||||
public event Action<string, string>? MediaFailed;
|
||||
|
||||
public MediaVideoSourceManager(Func<string, IMediaFrameSource?> sourceFactory, Dispatcher? uiDispatcher = null)
|
||||
public MediaVideoSourceManager(
|
||||
Func<string, IMediaFrameSource?> sourceFactory,
|
||||
Dispatcher? uiDispatcher = null,
|
||||
Func<string, bool>? loopProvider = null)
|
||||
{
|
||||
_sourceFactory = sourceFactory;
|
||||
_uiDispatcher = uiDispatcher;
|
||||
_loopProvider = loopProvider;
|
||||
}
|
||||
|
||||
/// <summary>Increments the refcount for a file, starting the decoder the first time.</summary>
|
||||
@@ -90,6 +95,10 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
session = new MediaSession(key, source);
|
||||
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||
session.Source.FrameAvailable += session.FrameHandler;
|
||||
// Loop intent lives on the Source (per scene), so the caller supplies the
|
||||
// per-path answer ("any live reference loops"). Applied before Start so a
|
||||
// zero-length or instantly-EOF file cannot slip past a later assignment.
|
||||
session.Source.Looping = _loopProvider?.Invoke(key) ?? false;
|
||||
_sessions[key] = session;
|
||||
shouldStart = true;
|
||||
}
|
||||
@@ -158,6 +167,32 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
|
||||
}
|
||||
|
||||
/// <summary>True while a decode session exists for the file — the caller uses this to
|
||||
/// keep one acquire per path instead of stacking refcounts on every layout pass.</summary>
|
||||
public bool HasSession(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>The files with a live session, for reconciling a layout load.</summary>
|
||||
public IReadOnlyList<string> GetActiveKeys()
|
||||
{
|
||||
lock (_gate)
|
||||
return new List<string>(_sessions.Keys);
|
||||
}
|
||||
|
||||
/// <summary>Applies a loop change to a running session (the creator flipped the
|
||||
/// toggle). No-op when the file has no session.</summary>
|
||||
public void SetLooping(string key, bool looping)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_sessions.TryGetValue(key, out var session))
|
||||
session.Source.Looping = looping;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The shared preview bitmap for a file (so a config created after the
|
||||
/// first frame can adopt the running bitmap), or null.</summary>
|
||||
public WriteableBitmap? GetPreviewBitmap(string key)
|
||||
|
||||
Reference in New Issue
Block a user