TASK 21 slice 3: loop control in MediaVideoSource (process factory + Loop flag)
- IMediaFrameSource gains bool Looping. - MediaVideoSource ctor takes Func<IDecodeProcess> 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).
This commit is contained in:
+13
-3
@@ -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,
|
(fake probe + recording delay, one delay per frame ≈1ms). Media tests 5/5,
|
||||||
build 0 warnings.
|
build 0 warnings.
|
||||||
|
|
||||||
Next (slice 3): loop control (restart decode on `Completed` when `MediaIsLooping`).
|
Next (slice 3): loop control — SHIPPED. `MediaVideoSource` takes a
|
||||||
|
`Func<IDecodeProcess>` 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
|
**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"`)
|
`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
|
## Next
|
||||||
|
|
||||||
1. Commit slice 2b (this unit): native-FPS pacing in `MediaVideoSource` + pacing test + docs.
|
1. Commit slice 3 (this unit): loop mechanism + process factory + probe wiring + tests + docs.
|
||||||
2. Slice 3: loop control (restart decode on `Completed` when `MediaIsLooping`).
|
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`).
|
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).
|
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.
|
5. Await user push checkpoint for the pending TASK 22/21 commits.
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ public interface IMediaFrameSource : IDisposable
|
|||||||
{
|
{
|
||||||
string Key { get; }
|
string Key { get; }
|
||||||
|
|
||||||
|
/// <summary>When true, the decoder restarts from the beginning on natural EOF
|
||||||
|
/// instead of raising <see cref="Completed"/> (used for BRB/starting-soon loops).</summary>
|
||||||
|
bool Looping { get; set; }
|
||||||
|
|
||||||
/// <summary>Raised (on a worker thread) for each decoded frame.</summary>
|
/// <summary>Raised (on a worker thread) for each decoded frame.</summary>
|
||||||
event Action<VideoFrame>? FrameAvailable;
|
event Action<VideoFrame>? FrameAvailable;
|
||||||
|
|
||||||
/// <summary>Raised when the file finishes decoding (natural EOF).</summary>
|
/// <summary>Raised when the file finishes decoding (natural EOF) — not raised
|
||||||
|
/// while <see cref="Looping"/> is true.</summary>
|
||||||
event Action? Completed;
|
event Action? Completed;
|
||||||
|
|
||||||
Task StartAsync(CancellationToken cancellationToken = default);
|
Task StartAsync(CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ namespace ytLive.Services;
|
|||||||
/// TASK 21: decodes a local media file (any format ffmpeg handles) into
|
/// TASK 21: decodes a local media file (any format ffmpeg handles) into
|
||||||
/// <see cref="VideoFrame"/>s for a MediaSource. Implements
|
/// <see cref="VideoFrame"/>s for a MediaSource. Implements
|
||||||
/// <see cref="IMediaFrameSource"/>: raises <see cref="FrameAvailable"/> per
|
/// <see cref="IMediaFrameSource"/>: raises <see cref="FrameAvailable"/> per
|
||||||
/// frame and <see cref="IMediaFrameSource.Completed"/> on natural EOF. Spawns
|
/// frame and <see cref="IMediaFrameSource.Completed"/> on natural EOF (unless
|
||||||
/// ffmpeg with <c>-f rawvideo -pix_fmt bgra</c> and drains the raw BGRA pipe via
|
/// <see cref="IMediaFrameSource.Looping"/>). Spawns ffmpeg with
|
||||||
|
/// <c>-f rawvideo -pix_fmt bgra</c> and drains the raw BGRA pipe via
|
||||||
/// <see cref="RawVideoFrameReader"/>. The decoder process and emitters are seams
|
/// <see cref="RawVideoFrameReader"/>. The decoder process and emitters are seams
|
||||||
/// so tests run without a real codec or audio device.
|
/// so tests run without a real codec or audio device.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -20,22 +21,25 @@ public sealed class MediaVideoSource : IMediaFrameSource
|
|||||||
|
|
||||||
public string Key => _path;
|
public string Key => _path;
|
||||||
|
|
||||||
|
public bool Looping { get; set; }
|
||||||
|
|
||||||
private readonly string _path;
|
private readonly string _path;
|
||||||
private readonly int _width;
|
private readonly int _width;
|
||||||
private readonly int _height;
|
private readonly int _height;
|
||||||
private readonly IFfmpegLocator _locator;
|
private readonly IFfmpegLocator _locator;
|
||||||
private readonly IDecodeProcess _process;
|
private readonly Func<IDecodeProcess> _processFactory;
|
||||||
private readonly IFrameRateProbe? _probe;
|
private readonly IFrameRateProbe? _probe;
|
||||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private IDisposable? _run;
|
private IDisposable? _run;
|
||||||
|
private IDecodeProcess? _active;
|
||||||
|
|
||||||
public MediaVideoSource(
|
public MediaVideoSource(
|
||||||
string path,
|
string path,
|
||||||
int width,
|
int width,
|
||||||
int height,
|
int height,
|
||||||
IFfmpegLocator locator,
|
IFfmpegLocator locator,
|
||||||
IDecodeProcess process,
|
Func<IDecodeProcess> processFactory,
|
||||||
IFrameRateProbe? frameRateProbe = null,
|
IFrameRateProbe? frameRateProbe = null,
|
||||||
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
||||||
{
|
{
|
||||||
@@ -43,7 +47,7 @@ public sealed class MediaVideoSource : IMediaFrameSource
|
|||||||
_width = width;
|
_width = width;
|
||||||
_height = height;
|
_height = height;
|
||||||
_locator = locator;
|
_locator = locator;
|
||||||
_process = process;
|
_processFactory = processFactory;
|
||||||
_probe = frameRateProbe;
|
_probe = frameRateProbe;
|
||||||
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
||||||
}
|
}
|
||||||
@@ -67,7 +71,7 @@ public sealed class MediaVideoSource : IMediaFrameSource
|
|||||||
{
|
{
|
||||||
_cts.Cancel();
|
_cts.Cancel();
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
_process.Dispose();
|
_active?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StopInternal() => KillProcess();
|
private void StopInternal() => KillProcess();
|
||||||
@@ -94,22 +98,32 @@ public sealed class MediaVideoSource : IMediaFrameSource
|
|||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
};
|
};
|
||||||
_process.Start(startInfo);
|
|
||||||
|
|
||||||
var reader = new RawVideoFrameReader(_width, _height);
|
do
|
||||||
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);
|
using (var process = _processFactory())
|
||||||
if (read <= 0) break;
|
|
||||||
foreach (var frame in reader.Feed(buffer.AsSpan(0, read).ToArray()))
|
|
||||||
{
|
{
|
||||||
FrameAvailable?.Invoke(frame);
|
_active = process;
|
||||||
if (frameDuration > TimeSpan.Zero)
|
process.Start(startInfo);
|
||||||
await _delay(frameDuration, _cts.Token).ConfigureAwait(false);
|
|
||||||
|
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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -128,6 +142,6 @@ public sealed class MediaVideoSource : IMediaFrameSource
|
|||||||
|
|
||||||
private void KillProcess()
|
private void KillProcess()
|
||||||
{
|
{
|
||||||
try { _process.Kill(); } catch { /* already exited */ }
|
try { _active?.Kill(); } catch { /* already exited */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TimeSpan,CancellationToken,Task>? 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).
|
6. ✅ Native-FPS pacing (slice 2b): `MediaVideoSource` takes optional `IFrameRateProbe?` + `Func<TimeSpan,CancellationToken,Task>? 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)**
|
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
|
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<IDecodeProcess>` 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
|
10. ☐ Volume control — per-source volume slider for audio playback
|
||||||
11. ☐ UI: file picker (filtered to video formats), loop toggle, volume slider
|
11. ☐ UI: file picker (filtered to video formats), loop toggle, volume slider
|
||||||
12. ☐ Schema migration for media source settings (file path, loop, volume)
|
12. ☐ Schema migration for media source settings (file path, loop, volume)
|
||||||
|
|||||||
@@ -285,7 +285,9 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_mediaManager = new MediaVideoSourceManager(
|
_mediaManager = new MediaVideoSourceManager(
|
||||||
path => new MediaVideoSource(
|
path => new MediaVideoSource(
|
||||||
path, MediaDecodeWidth, MediaDecodeHeight,
|
path, MediaDecodeWidth, MediaDecodeHeight,
|
||||||
new FfmpegLocator(), new FfmpegDecodeProcess()),
|
new FfmpegLocator(), () => new FfmpegDecodeProcess(),
|
||||||
|
frameRateProbe: new FfmpegFrameRateProbe(
|
||||||
|
new FfmpegLocator(), () => new FfmpegDecodeProcess())),
|
||||||
System.Windows.Application.Current?.Dispatcher);
|
System.Windows.Application.Current?.Dispatcher);
|
||||||
_mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged;
|
_mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged;
|
||||||
_mediaManager.MediaFailed += OnMediaFailed;
|
_mediaManager.MediaFailed += OnMediaFailed;
|
||||||
|
|||||||
@@ -690,8 +690,14 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
|
|||||||
(conditional — old caches without it just get no pacing). Slice 2b wired pacing: `MediaVideoSource`
|
(conditional — old caches without it just get no pacing). Slice 2b wired pacing: `MediaVideoSource`
|
||||||
takes optional `IFrameRateProbe?` + `Func<TimeSpan,CancellationToken,Task>? delay` (default `Task.Delay`)
|
takes optional `IFrameRateProbe?` + `Func<TimeSpan,CancellationToken,Task>? delay` (default `Task.Delay`)
|
||||||
seams, probes FPS once in `RunAsync`, and delays by 1/fps after each emitted frame; no probe/unknown → no
|
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).
|
pacing (ffmpeg's own pipe backpressure already throttles the decode). Slice 3 added loop control:
|
||||||
Still open: session acquisition on add/remove (UI picker), loop control.
|
`IMediaFrameSource.Looping` (bool); `MediaVideoSource` takes a `Func<IDecodeProcess>` 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
|
- **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
|
`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`
|
mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs`
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class MediaVideoSourceManagerTests
|
|||||||
private readonly List<string>? _stopped;
|
private readonly List<string>? _stopped;
|
||||||
|
|
||||||
public string Key { get; }
|
public string Key { get; }
|
||||||
|
public bool Looping { get; set; }
|
||||||
public event Action<VideoFrame>? FrameAvailable;
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
public event Action? Completed;
|
public event Action? Completed;
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ public class MediaVideoSourceManagerTests
|
|||||||
private sealed class FailingMediaSource : IMediaFrameSource
|
private sealed class FailingMediaSource : IMediaFrameSource
|
||||||
{
|
{
|
||||||
public string Key { get; }
|
public string Key { get; }
|
||||||
|
public bool Looping { get; set; }
|
||||||
public event Action<VideoFrame>? FrameAvailable;
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
public event Action? Completed;
|
public event Action? Completed;
|
||||||
public FailingMediaSource(string key) => Key = key;
|
public FailingMediaSource(string key) => Key = key;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class MediaVideoSourceTests
|
|||||||
using var source = new MediaVideoSource(
|
using var source = new MediaVideoSource(
|
||||||
"clip.mp4", w, h,
|
"clip.mp4", w, h,
|
||||||
new FakeLocator(),
|
new FakeLocator(),
|
||||||
new FakeDecodeProcess(payload));
|
() => new FakeDecodeProcess(payload));
|
||||||
|
|
||||||
var seen = new List<byte[]>();
|
var seen = new List<byte[]>();
|
||||||
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -100,7 +100,7 @@ public class MediaVideoSourceTests
|
|||||||
using var source = new MediaVideoSource(
|
using var source = new MediaVideoSource(
|
||||||
"clip.mp4", w, h,
|
"clip.mp4", w, h,
|
||||||
new FakeLocator(),
|
new FakeLocator(),
|
||||||
new FakeDecodeProcess(payload),
|
() => new FakeDecodeProcess(payload),
|
||||||
frameRateProbe: new FakeFrameRateProbe(1000.0),
|
frameRateProbe: new FakeFrameRateProbe(1000.0),
|
||||||
delay: (_d, _) => { delays.Add(_d); return Task.CompletedTask; });
|
delay: (_d, _) => { delays.Add(_d); return Task.CompletedTask; });
|
||||||
|
|
||||||
@@ -117,6 +117,37 @@ public class MediaVideoSourceTests
|
|||||||
$"expected ~1ms, got {d.TotalMilliseconds}ms");
|
$"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<bool>(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 sealed class FakeFrameRateProbe : IFrameRateProbe
|
||||||
{
|
{
|
||||||
private readonly double? _fps;
|
private readonly double? _fps;
|
||||||
|
|||||||
Reference in New Issue
Block a user