From 881addb5b4e3127a9692e045b52a4eba943bd3ee Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 31 Aug 2026 19:52:10 -0700 Subject: [PATCH] TASK 21 slice 3: loop control in MediaVideoSource (process factory + Loop flag) - IMediaFrameSource gains bool Looping. - MediaVideoSource ctor takes Func processFactory instead of a single IDecodeProcess: a System.Diagnostics.Process can't be re-Start()ed, so each loop pass creates a fresh decoder. Decode wrapped in do-while(Looping): restart on natural EOF instead of raising Completed. - Production wiring (MainViewModel media factory): passes the process factory AND FfmpegFrameRateProbe -- closes the slice-2b gap where production had no probe and therefore no pacing. - Tests: loop test (single frame re-emits across passes, Completed only when loop cleared); fakes updated for the new interface member. Media tests 12/12, build 0 warnings. Wiring Source.MediaIsLooping into the flag needs a manager-level per-path loop provider -> lands with the UI-picker (acquisition) slice. Derivative reference: looping media by restarting decode on EOF, standard in playback/overlay tooling (OBS media source repeat). --- HANDOFF.md | 16 +++++-- Services/IMediaFrameSource.cs | 7 ++- Services/MediaVideoSource.cs | 50 +++++++++++++------- TASKS.md | 2 +- ViewModels/MainViewModel.cs | 4 +- ai.md | 10 +++- ytLive.Tests/MediaVideoSourceManagerTests.cs | 2 + ytLive.Tests/MediaVideoSourceTests.cs | 35 +++++++++++++- 8 files changed, 98 insertions(+), 28 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 269e656..8c46b87 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -63,7 +63,17 @@ absent probe → no pacing). New test `MediaVideoSource_PacesFramesByProbedFps` (fake probe + recording delay, one delay per frame ≈1ms). Media tests 5/5, build 0 warnings. -Next (slice 3): loop control (restart decode on `Completed` when `MediaIsLooping`). +Next (slice 3): loop control — SHIPPED. `MediaVideoSource` takes a +`Func` process factory (a single `Process` can't be re-`Start()`ed, +so each loop pass creates a fresh decoder) and wraps the decode in a +`do…while (Looping)` restart, keyed on the new `IMediaFrameSource.Looping` flag; +Production wiring in `MainViewModel` (media factory) now passes the factory AND +`FfmpegFrameRateProbe` (closing the slice-2b gap where production had no probe → +no pacing). Loop test `MediaVideoSource_LoopsUntilLoopDisabled` (single frame +re-emits, `Completed` only when loop cleared) — media tests 12/12 (6 source + 6 +manager), build 0 warnings. Open: wiring `Source.MediaIsLooping` into +`IMediaFrameSource.Looping` needs a manager-level per-path loop provider — lands +with the UI picker (acquisition) slice. **Landmine:** a stale `testhost` can lock `ytLive.Tests`'s `ytLive.dll` and break `dotnet build` of the test project — kill it first (`cmd.exe /c "taskkill /PID N /F"`) @@ -73,8 +83,8 @@ pure tests run in WSL; user verifies the GUI suites on native Windows PowerShell ## Next -1. Commit slice 2b (this unit): native-FPS pacing in `MediaVideoSource` + pacing test + docs. -2. Slice 3: loop control (restart decode on `Completed` when `MediaIsLooping`). +1. Commit slice 3 (this unit): loop mechanism + process factory + probe wiring + tests + docs. +2. UI picker slice: acquire/release media sessions on add/remove AND wire `Source.MediaIsLooping` → `IMediaFrameSource.Looping` (manager-level per-path loop provider). 3. Slice 3: loop control (restart decode on `Completed` when `MediaIsLooping`). 4. UI picker slice: acquire/release media sessions on add/remove (the remaining slice-1-step-4 open end). 5. Await user push checkpoint for the pending TASK 22/21 commits. diff --git a/Services/IMediaFrameSource.cs b/Services/IMediaFrameSource.cs index e01ecdb..fb26de5 100644 --- a/Services/IMediaFrameSource.cs +++ b/Services/IMediaFrameSource.cs @@ -10,10 +10,15 @@ public interface IMediaFrameSource : IDisposable { string Key { get; } + /// When true, the decoder restarts from the beginning on natural EOF + /// instead of raising (used for BRB/starting-soon loops). + bool Looping { get; set; } + /// Raised (on a worker thread) for each decoded frame. event Action? FrameAvailable; - /// Raised when the file finishes decoding (natural EOF). + /// Raised when the file finishes decoding (natural EOF) — not raised + /// while is true. event Action? Completed; Task StartAsync(CancellationToken cancellationToken = default); diff --git a/Services/MediaVideoSource.cs b/Services/MediaVideoSource.cs index 750805f..62912b3 100644 --- a/Services/MediaVideoSource.cs +++ b/Services/MediaVideoSource.cs @@ -8,8 +8,9 @@ namespace ytLive.Services; /// TASK 21: decodes a local media file (any format ffmpeg handles) into /// s for a MediaSource. Implements /// : raises per -/// frame and on natural EOF. Spawns -/// ffmpeg with -f rawvideo -pix_fmt bgra and drains the raw BGRA pipe via +/// frame and on natural EOF (unless +/// ). Spawns ffmpeg with +/// -f rawvideo -pix_fmt bgra and drains the raw BGRA pipe via /// . The decoder process and emitters are seams /// so tests run without a real codec or audio device. /// @@ -20,22 +21,25 @@ public sealed class MediaVideoSource : IMediaFrameSource public string Key => _path; + public bool Looping { get; set; } + private readonly string _path; private readonly int _width; private readonly int _height; private readonly IFfmpegLocator _locator; - private readonly IDecodeProcess _process; + private readonly Func _processFactory; private readonly IFrameRateProbe? _probe; private readonly Func _delay; private readonly CancellationTokenSource _cts = new(); private IDisposable? _run; + private IDecodeProcess? _active; public MediaVideoSource( string path, int width, int height, IFfmpegLocator locator, - IDecodeProcess process, + Func processFactory, IFrameRateProbe? frameRateProbe = null, Func? delay = null) { @@ -43,7 +47,7 @@ public sealed class MediaVideoSource : IMediaFrameSource _width = width; _height = height; _locator = locator; - _process = process; + _processFactory = processFactory; _probe = frameRateProbe; _delay = delay ?? ((t, ct) => Task.Delay(t, ct)); } @@ -67,7 +71,7 @@ public sealed class MediaVideoSource : IMediaFrameSource { _cts.Cancel(); _cts.Dispose(); - _process.Dispose(); + _active?.Dispose(); } private void StopInternal() => KillProcess(); @@ -94,22 +98,32 @@ public sealed class MediaVideoSource : IMediaFrameSource RedirectStandardError = true, CreateNoWindow = true, }; - _process.Start(startInfo); - var reader = new RawVideoFrameReader(_width, _height); - var buffer = new byte[65536]; - var stream = _process.StandardOutput; - while (!_cts.IsCancellationRequested && !_process.HasExited) + do { - var read = await stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false); - if (read <= 0) break; - foreach (var frame in reader.Feed(buffer.AsSpan(0, read).ToArray())) + using (var process = _processFactory()) { - FrameAvailable?.Invoke(frame); - if (frameDuration > TimeSpan.Zero) - await _delay(frameDuration, _cts.Token).ConfigureAwait(false); + _active = process; + process.Start(startInfo); + + var reader = new RawVideoFrameReader(_width, _height); + var buffer = new byte[65536]; + var stream = process.StandardOutput; + while (!_cts.IsCancellationRequested && !process.HasExited) + { + var read = await stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false); + if (read <= 0) break; + foreach (var frame in reader.Feed(buffer.AsSpan(0, read).ToArray())) + { + FrameAvailable?.Invoke(frame); + if (frameDuration > TimeSpan.Zero) + await _delay(frameDuration, _cts.Token).ConfigureAwait(false); + } + } } + _active = null; } + while (Looping && !_cts.IsCancellationRequested); } catch (OperationCanceledException) { @@ -128,6 +142,6 @@ public sealed class MediaVideoSource : IMediaFrameSource private void KillProcess() { - try { _process.Kill(); } catch { /* already exited */ } + try { _active?.Kill(); } catch { /* already exited */ } } } diff --git a/TASKS.md b/TASKS.md index 9757030..ebb4025 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1047,7 +1047,7 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev 6. ✅ Native-FPS pacing (slice 2b): `MediaVideoSource` takes optional `IFrameRateProbe?` + `Func? delay` seams, probes FPS once in `RunAsync`, and delays by 1/fps after each emitted frame; unknown/absent probe → no pacing. Test: `MediaVideoSource_PacesFramesByProbedFps` (fake probe + recording delay, one delay per frame ≈ 1ms). 7. ☐ Wire into `FramePump` resolver — `Source { Type: MediaSource }` → latest video frame — **resolver + preview routing + manager wired; session acquisition (start/stop on add/remove) still open (comes with the UI picker)** 8. ☐ Wire into `SceneCompositor` — render media source as an image element at its position/size -9. ☐ Loop control — `IsLooping` property, restart on end +9. ✅ Loop control (mechanism) — `IMediaFrameSource.Looping`; `MediaVideoSource` takes a `Func` process factory and restarts the decode on natural EOF when `Looping` (fresh process per pass, since a `Process` can't be re-`Start()`ed). Test: `MediaVideoSource_LoopsUntilLoopDisabled` (single frame re-emits across passes, `Completed` only after loop cleared). Wiring `Source.MediaIsLooping` into the flag lands with the UI-picker (acquisition) slice. 10. ☐ Volume control — per-source volume slider for audio playback 11. ☐ UI: file picker (filtered to video formats), loop toggle, volume slider 12. ☐ Schema migration for media source settings (file path, loop, volume) diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index cd2ad5d..4a3af03 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -285,7 +285,9 @@ public partial class MainViewModel : ViewModelBase _mediaManager = new MediaVideoSourceManager( path => new MediaVideoSource( path, MediaDecodeWidth, MediaDecodeHeight, - new FfmpegLocator(), new FfmpegDecodeProcess()), + new FfmpegLocator(), () => new FfmpegDecodeProcess(), + frameRateProbe: new FfmpegFrameRateProbe( + new FfmpegLocator(), () => new FfmpegDecodeProcess())), System.Windows.Application.Current?.Dispatcher); _mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged; _mediaManager.MediaFailed += OnMediaFailed; diff --git a/ai.md b/ai.md index ba7ab37..765c765 100644 --- a/ai.md +++ b/ai.md @@ -690,8 +690,14 @@ seam:** `Func`, `Func` resolver, `Func? delay` (default `Task.Delay`) seams, probes FPS once in `RunAsync`, and delays by 1/fps after each emitted frame; no probe/unknown → no - pacing (ffmpeg's own pipe backpressure already throttles the decode). - Still open: session acquisition on add/remove (UI picker), loop control. + pacing (ffmpeg's own pipe backpressure already throttles the decode). Slice 3 added loop control: + `IMediaFrameSource.Looping` (bool); `MediaVideoSource` takes a `Func` process factory (a single + `Process` can't be re-`Start()`ed, so each loop pass creates a fresh decoder) and wraps the decode in a + `do…while (Looping)` — restart on natural EOF instead of raising `Completed`. Production wiring (MainViewModel + media factory): passes `() => new FfmpegDecodeProcess()` as the factory, `FfmpegFrameRateProbe(new FfmpegLocator(), + () => new FfmpegDecodeProcess())` as the probe. + Still open: session acquisition on add/remove (UI picker) + wiring `Source.MediaIsLooping` into + `IMediaFrameSource.Looping` (needs a manager-level per-path loop provider, comes with the picker slice). - **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam, re-read **every frame** (so a mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs` diff --git a/ytLive.Tests/MediaVideoSourceManagerTests.cs b/ytLive.Tests/MediaVideoSourceManagerTests.cs index cca3b83..e37f0af 100644 --- a/ytLive.Tests/MediaVideoSourceManagerTests.cs +++ b/ytLive.Tests/MediaVideoSourceManagerTests.cs @@ -20,6 +20,7 @@ public class MediaVideoSourceManagerTests private readonly List? _stopped; public string Key { get; } + public bool Looping { get; set; } public event Action? FrameAvailable; public event Action? Completed; @@ -50,6 +51,7 @@ public class MediaVideoSourceManagerTests private sealed class FailingMediaSource : IMediaFrameSource { public string Key { get; } + public bool Looping { get; set; } public event Action? FrameAvailable; public event Action? Completed; public FailingMediaSource(string key) => Key = key; diff --git a/ytLive.Tests/MediaVideoSourceTests.cs b/ytLive.Tests/MediaVideoSourceTests.cs index a5588ec..b8f0967 100644 --- a/ytLive.Tests/MediaVideoSourceTests.cs +++ b/ytLive.Tests/MediaVideoSourceTests.cs @@ -62,7 +62,7 @@ public class MediaVideoSourceTests using var source = new MediaVideoSource( "clip.mp4", w, h, new FakeLocator(), - new FakeDecodeProcess(payload)); + () => new FakeDecodeProcess(payload)); var seen = new List(); var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -100,7 +100,7 @@ public class MediaVideoSourceTests using var source = new MediaVideoSource( "clip.mp4", w, h, new FakeLocator(), - new FakeDecodeProcess(payload), + () => new FakeDecodeProcess(payload), frameRateProbe: new FakeFrameRateProbe(1000.0), delay: (_d, _) => { delays.Add(_d); return Task.CompletedTask; }); @@ -117,6 +117,37 @@ public class MediaVideoSourceTests $"expected ~1ms, got {d.TotalMilliseconds}ms"); } + [Fact] + public async Task MediaVideoSource_LoopsUntilLoopDisabled() + { + const int w = 1, h = 1; // frame bytes = 4 + var payload = new byte[4]; + var passCount = 0; + var frames = 0; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var source = new MediaVideoSource( + "clip.mp4", w, h, + new FakeLocator(), + () => { passCount++; return new FakeDecodeProcess(payload); }); + source.Looping = true; + source.Completed += () => completed.TrySetResult(true); + source.FrameAvailable += _ => + { + frames++; + if (frames >= 3) source.Looping = false; + }; + + await source.StartAsync(); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await source.StopAsync(); + + // The single frame re-emits across passes (restart) and Completed only + // fires once Looping is cleared. + Assert.True(frames >= 3, $"expected the single frame to re-emit across passes, got {frames}"); + Assert.True(passCount >= 3, "Looping should have restarted the decode process"); + } + private sealed class FakeFrameRateProbe : IFrameRateProbe { private readonly double? _fps;