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:
@@ -70,6 +70,8 @@
|
|||||||
IsEnabled="{Binding CanAddWebcam}"
|
IsEnabled="{Binding CanAddWebcam}"
|
||||||
ToolTip="{Binding WebcamAddToolTip}"/>
|
ToolTip="{Binding WebcamAddToolTip}"/>
|
||||||
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
||||||
|
<MenuItem Header="Video File" Command="{Binding AddMediaCommand}"
|
||||||
|
ToolTip="Play a video file in the scene (mp4, mov, avi, mkv, webm)"/>
|
||||||
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
|
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
|
||||||
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
|
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
|
||||||
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
|
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
|
||||||
@@ -725,6 +727,29 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Media (video file) config — TASK 21 -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsMediaSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Video File" Style="{StaticResource PropertyHeader}"/>
|
||||||
|
<Grid Margin="10,0,0,2">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding SelectedElement.MediaFileDisplay}"
|
||||||
|
Style="{StaticResource PropertyFieldLabel}"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||||
|
ToolTip="{Binding SelectedElement.MediaPath}"/>
|
||||||
|
<Button Grid.Column="1" Content="Change…" Command="{Binding ChangeMediaFileCommand}"
|
||||||
|
CommandParameter="{Binding SelectedElement}" Style="{StaticResource PropertyButton}"
|
||||||
|
Margin="4,0,0,0" Padding="8,2" FontSize="11"/>
|
||||||
|
</Grid>
|
||||||
|
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,4,0,4"
|
||||||
|
Content="Loop" IsChecked="{Binding SelectedElement.MediaIsLooping, Mode=TwoWay}"
|
||||||
|
ToolTip="Restart from the beginning at the end of the file — for starting-soon and BRB loops"/>
|
||||||
|
<TextBlock Text="Audio plays through your desktop audio, like TRAX."
|
||||||
|
Style="{StaticResource PropertyHint}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Chat box config -->
|
<!-- Chat box config -->
|
||||||
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
<TextBlock Text="Chat Overlay" Style="{StaticResource PropertyHeader}"/>
|
<TextBlock Text="Chat Overlay" Style="{StaticResource PropertyHeader}"/>
|
||||||
|
|||||||
+11
-10
@@ -1,19 +1,18 @@
|
|||||||
# HANDOFF — 2026-11-04 (TASK 3·17 — static text layers)
|
# HANDOFF — 2026-11-05 (TASK 21 — video file layer)
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
- `main`; TASK 53 is complete and TASK 3·17 is complete in three commits: `60c4a00`, `58eb640`,
|
- `main`; TASK 21 is complete: a video file added from the (+) menu decodes, composites into preview
|
||||||
and the final output/compositing phase.
|
and stream, loops on toggle, and stops with its last scene reference. 3·17 (text layers) completed
|
||||||
- Text layers persist independently, edit as static multiline text, and share one cached raster between
|
2026-11-04.
|
||||||
preview and output. The frame pump only reads the raster cache; no WPF render occurs on its thread.
|
- New test `MediaSourceAcquisitionTests` (add → one decoder starts + frames reach the layer + loop flip
|
||||||
- The app clean build has 0 warnings. 524 tests passed with the documented physical mouse-injection test
|
reaches the decoder + remove → stops). App build is 0 warnings.
|
||||||
and known audio test excluded; the new text renderer, preview/output, and pump-cache tests pass.
|
- Volume is deliberately unwired: media audio rides the desktop loopback, so a slider would do nothing.
|
||||||
- The four stray `Assets/llamacasty-*` exports were deleted 2026-11-04 — unreferenced by the csproj,
|
|
||||||
XAML or scripts (recipe in `MyMistakes.md`).
|
|
||||||
|
|
||||||
## Next action
|
## Next action
|
||||||
|
|
||||||
- Next action: select the next queued task from `TASKS.md`.
|
- Next queued unit: TASK 49 (chat profanity filter). Day 3's TASK 32/33 resilience slices are still
|
||||||
|
outstanding — 32·4 (fragmented MP4) is the smallest.
|
||||||
- Do not push absent a checkpoint request.
|
- Do not push absent a checkpoint request.
|
||||||
|
|
||||||
## Landmines
|
## Landmines
|
||||||
@@ -21,4 +20,6 @@
|
|||||||
- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
|
- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
|
||||||
mouse input. Do not run it while the desktop is in use.
|
mouse input. Do not run it while the desktop is in use.
|
||||||
- Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`.
|
- Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`.
|
||||||
|
- Media sessions are keyed per **path**, not per layer: acquire through `EnsureMediaSession`, release
|
||||||
|
through `ReleaseMediaIfUnused`, never `AcquireAsync` twice for one path.
|
||||||
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
||||||
|
|||||||
+9
-1
@@ -118,7 +118,8 @@ public class Source : SceneElement
|
|||||||
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
|
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
|
||||||
/// (creator ruling 2026-11-01).</summary>
|
/// (creator ruling 2026-11-01).</summary>
|
||||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
|
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
|
||||||
or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker;
|
or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker
|
||||||
|
or SourceType.MediaSource;
|
||||||
|
|
||||||
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
|
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
|
||||||
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
|
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
|
||||||
@@ -238,10 +239,17 @@ public class Source : SceneElement
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (Set(ref _mediaPath, value))
|
if (Set(ref _mediaPath, value))
|
||||||
|
{
|
||||||
Raise(nameof(IsMediaSource));
|
Raise(nameof(IsMediaSource));
|
||||||
|
Raise(nameof(MediaFileDisplay));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The chosen file's name for the properties panel (path stays in the model).</summary>
|
||||||
|
public string MediaFileDisplay =>
|
||||||
|
string.IsNullOrWhiteSpace(_mediaPath) ? "No video chosen" : System.IO.Path.GetFileName(_mediaPath);
|
||||||
|
|
||||||
private bool _mediaIsLooping;
|
private bool _mediaIsLooping;
|
||||||
public bool MediaIsLooping
|
public bool MediaIsLooping
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public sealed class MediaVideoSourceManager : IDisposable
|
|||||||
|
|
||||||
private readonly Func<string, IMediaFrameSource?> _sourceFactory;
|
private readonly Func<string, IMediaFrameSource?> _sourceFactory;
|
||||||
private readonly Dispatcher? _uiDispatcher;
|
private readonly Dispatcher? _uiDispatcher;
|
||||||
|
private readonly Func<string, bool>? _loopProvider;
|
||||||
private readonly Dictionary<string, MediaSession> _sessions = new();
|
private readonly Dictionary<string, MediaSession> _sessions = new();
|
||||||
private readonly object _gate = 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>
|
/// <summary>Raised when a media decoder cannot be created (bad path).</summary>
|
||||||
public event Action<string, string>? MediaFailed;
|
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;
|
_sourceFactory = sourceFactory;
|
||||||
_uiDispatcher = uiDispatcher;
|
_uiDispatcher = uiDispatcher;
|
||||||
|
_loopProvider = loopProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Increments the refcount for a file, starting the decoder the first time.</summary>
|
/// <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 = new MediaSession(key, source);
|
||||||
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||||
session.Source.FrameAvailable += session.FrameHandler;
|
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;
|
_sessions[key] = session;
|
||||||
shouldStart = true;
|
shouldStart = true;
|
||||||
}
|
}
|
||||||
@@ -158,6 +167,32 @@ public sealed class MediaVideoSourceManager : IDisposable
|
|||||||
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
|
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
|
/// <summary>The shared preview bitmap for a file (so a config created after the
|
||||||
/// first frame can adopt the running bitmap), or null.</summary>
|
/// first frame can adopt the running bitmap), or null.</summary>
|
||||||
public WriteableBitmap? GetPreviewBitmap(string key)
|
public WriteableBitmap? GetPreviewBitmap(string key)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ corrected. No new scope mid-week; anything discovered goes on a list for day 6.
|
|||||||
| **1 · Mon** — licensing | Paid-app ownership check (`BIsSubscribedApp`) + Debug fake + app-wide `IsAppOwned` · greyed tier entries · About conversion copy + llamacasty.com link · current-launch Logs tab (replaces the separate system-message strip) · Debug-only `InternalsVisibleTo` | A locked tier opens About without changing the selected free tier or leaving the app; the site opens only from its link |
|
| **1 · Mon** — licensing | Paid-app ownership check (`BIsSubscribedApp`) + Debug fake + app-wide `IsAppOwned` · greyed tier entries · About conversion copy + llamacasty.com link · current-launch Logs tab (replaces the separate system-message strip) · Debug-only `InternalsVisibleTo` | A locked tier opens About without changing the selected free tier or leaving the app; the site opens only from its link |
|
||||||
| **2 · Tue** — release blockers | Visibility flow (creator-selected at release; development/test stays Private) · `OutputProfile` entitlement: 30 fps free / 60 fps paid regardless of resolution · ~~`StreamConfig` defaults~~ retired (no production call sites) · TASK 53 (vertical resolution check ✅; remaining output contract open) · 9·5 webcam identity key · Move transitions (per-layer carry-across-scenes) | Visibility is selectable at creation and live-editable; the 30/60-fps gate is tested; remaining Day 2 items stay queued |
|
| **2 · Tue** — release blockers | Visibility flow (creator-selected at release; development/test stays Private) · `OutputProfile` entitlement: 30 fps free / 60 fps paid regardless of resolution · ~~`StreamConfig` defaults~~ retired (no production call sites) · TASK 53 (vertical resolution check ✅; remaining output contract open) · 9·5 webcam identity key · Move transitions (per-layer carry-across-scenes) | Visibility is selectable at creation and live-editable; the 30/60-fps gate is tested; remaining Day 2 items stay queued |
|
||||||
| **3 · Wed** — resilience | TASK 32: blip retry → measured grace → amber RECONNECTING → one-click Back on air → fragmented MP4 → ingest pre-flight · 33 bandwidth step-down | Killing the network mid-broadcast recovers without ending it |
|
| **3 · Wed** — resilience | TASK 32: blip retry → measured grace → amber RECONNECTING → one-click Back on air → fragmented MP4 → ingest pre-flight · 33 bandwidth step-down | Killing the network mid-broadcast recovers without ending it |
|
||||||
| **4 · Thu** — content & media | 3·17 Text layer · 21 media acquisition wiring + file picker + loop/volume UI · 49 chat profanity filter · 34 scheduled streams | Text layer renders/persists; media file plays, loops, has volume; scheduled stream created |
|
| **4 · Thu** — content & media | ~~3·17 Text layer~~ ✅ · ~~21 media acquisition wiring~~ ✅ (picker, loop, lifecycle) · 49 chat profanity filter · 34 scheduled streams | Text layer renders/persists; media file plays, loops; scheduled stream created |
|
||||||
| **5 · Fri** — data & settings | 3·20 RewardEvent persistence + `superChatEvents.list` backfill · 39·2 YPP slice 2 · 37 defaults-vs-current split · 35 scene-linked audio · 40·A/C/D settings round · 55·2–3 disk hygiene | Session report reads persisted events; defaults can be reverted; temp clips cleaned |
|
| **5 · Fri** — data & settings | 3·20 RewardEvent persistence + `superChatEvents.list` backfill · 39·2 YPP slice 2 · 37 defaults-vs-current split · 35 scene-linked audio · 40·A/C/D settings round · 55·2–3 disk hygiene | Session report reads persisted events; defaults can be reverted; temp clips cleaned |
|
||||||
| **6 · Sat** — finish & prove | 50 publish recording · preview undo (Ctrl+Z) · 14 creator feedback batch · 36 gold pass · full sweep: clean build, whole per-class suite, `publish.sh` artifact, EULA/privacy/notices in the artifact · commit + push | Gold pass list empty; artifact stages; suite green; pushed |
|
| **6 · Sat** — finish & prove | 50 publish recording · preview undo (Ctrl+Z) · 14 creator feedback batch · 36 gold pass · full sweep: clean build, whole per-class suite, `publish.sh` artifact, EULA/privacy/notices in the artifact · commit + push | Gold pass list empty; artifact stages; suite green; pushed |
|
||||||
|
|
||||||
@@ -29,11 +29,9 @@ in the middle.
|
|||||||
|
|
||||||
## Next up (after the surge slice)
|
## Next up (after the surge slice)
|
||||||
|
|
||||||
**TASK 3·17 — the Text layer** is scheduled in the surge (day 4). Placeholder detail retained for
|
**Day 4 remainder:** TASK 49 (chat profanity filter) then TASK 34 (scheduled streams — its two research
|
||||||
the unit itself: `AddSource` creates it 0×0, all four compositor paths `continue` past it,
|
flags must be cited first). Day 3's resilience row (TASK 32/33) is still outstanding and is a launch
|
||||||
`FramePump`'s hash skips it, it is not draggable, and it has no text/font/colour properties or
|
blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
|
||||||
persistence. Text content, font family/size/weight/style, colour, alignment, drag/resize,
|
|
||||||
persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
|
|
||||||
|
|
||||||
## Queue
|
## Queue
|
||||||
|
|
||||||
@@ -43,8 +41,8 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 53 | ✅ | Output-format contract — six size/FPS modes, vertical YouTube playback, and recordings visually accepted | [`task-53`](TASKS/task-53-output-formats.md) |
|
| 53 | ✅ | Output-format contract — six size/FPS modes, vertical YouTube playback, and recordings visually accepted | [`task-53`](TASKS/task-53-output-formats.md) |
|
||||||
| — | ✅ | Output entitlement code: free at 30 fps / paid at 60 fps regardless of resolution; Steam App ID is still pending | [`commercial`](docs/ai/commercial.md) |
|
| — | ✅ | Output entitlement code: free at 30 fps / paid at 60 fps regardless of resolution; Steam App ID is still pending | [`commercial`](docs/ai/commercial.md) |
|
||||||
| 3·17 | ☐ **NEXT** | **Text layer** — content, font family/size/weight/style, colour, alignment, drag/resize, persisted | [`task-03`](TASKS/task-03-capture-pipeline.md) |
|
| 3·17 | ✅ | **Text layer** — content, font family/size/weight/style, colour, alignment, drag/resize, persisted (done 2026-11-04) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
|
||||||
| 21 | 🔶 | Media source: acquisition wiring + file picker + loop/volume UI (decoder, manager, pacing, loop all shipped — **no `AcquireAsync` caller exists**) | [`task-21`](TASKS/task-21-media-source.md) |
|
| 21 | ✅ | Media source: video file added from the (+) menu decodes, composites, loops, and stops with its last reference (done 2026-11-05) | [`task-21`](TASKS/task-21-media-source.md) |
|
||||||
| — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
|
| — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||||
| 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
|
| 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
|
||||||
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
|
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
|
||||||
|
|||||||
@@ -5,22 +5,33 @@
|
|||||||
|
|
||||||
**Goal:** play video files (MP4, MOV, AVI) into scenes — starting soon videos, BRB loops, intro/outro clips.
|
**Goal:** play video files (MP4, MOV, AVI) into scenes — starting soon videos, BRB loops, intro/outro clips.
|
||||||
|
|
||||||
### Status: 🔶 In progress — Increment A (model + persistence) + Increment B (decoder) + slice 1 (manager + resolver/preview wire-in) + slice 2a/2b (ffprobe probe + native-FPS pacing) + slice 3 (loop mechanism) shipped. **Remaining: the UI picker slice (AddMedia command + file dialog + Acquire/Release wiring + loop-flag wiring) — GUI, handed off to build/verify natively on Windows (see HANDOFF → "UI picker slice — handoff spec").**
|
### Status: ✅ Complete 2026-11-05 — a video file added in the (+) menu decodes, composites into the
|
||||||
|
preview and the stream, loops on the creator's toggle, and stops when the layer or its last scene
|
||||||
|
reference goes away. Audio is the desktop loopback (TRAX-style); a per-file volume route is not built.
|
||||||
|
|
||||||
1. ✅ `MediaSourceType` enum: `Video`, `Audio` (audio-only files via media source)
|
1. ✅ `MediaSourceType` enum: `Video`, `Audio` (audio-only files via media source)
|
||||||
2. ✅ `SourceType.MediaSource` addition to the enum
|
2. ✅ `SourceType.MediaSource` addition to the enum
|
||||||
3. ✅ `MediaSourceModel`: `MediaPath`, `MediaIsLooping`, `MediaVolume` (0-1), `MediaPlaybackState` — persisted in LayoutStore (schema migration + SELECT/INSERT + round-trip test)
|
3. ✅ `MediaSourceModel`: `MediaPath`, `MediaIsLooping`, `MediaVolume` (0-1), `MediaPlaybackState` — persisted in LayoutStore (schema migration + SELECT/INSERT + round-trip test)
|
||||||
4. ✅ `MediaVideoSource` implements `IMediaFrameSource`: FFmpeg raw video decoder → `VideoFrame` pipeline — spawns ffmpeg `-f rawvideo -pix_fmt bgra`, drains the pipe via pure `RawVideoFrameReader` (`Services/RawVideoFrameReader.cs`), raises `FrameAvailable`/`Completed`; lifecycle is `StartAsync`/`StopAsync`; decode process behind `IDecodeProcess`/`FfmpegDecodeProcess` seam (binary-stdout mirror of `IEncoderProcess`). Shipped 2026-08-31; refactored onto `IMediaFrameSource` with this slice.
|
4. ✅ `MediaVideoSource` implements `IMediaFrameSource`: FFmpeg raw video decoder → `VideoFrame` pipeline — spawns ffmpeg `-f rawvideo -pix_fmt bgra`, drains the pipe via pure `RawVideoFrameReader` (`Services/RawVideoFrameReader.cs`), raises `FrameAvailable`/`Completed`; lifecycle is `StartAsync`/`StopAsync`; decode process behind `IDecodeProcess`/`FfmpegDecodeProcess` seam (binary-stdout mirror of `IEncoderProcess`). Shipped 2026-08-31; refactored onto `IMediaFrameSource` with this slice.
|
||||||
4b. ✅ Slice 2a — native-FPS probe seam: `FfmpegFrameRateParser` (pure, prefers `avg_frame_rate=` then `r_frame_rate=`, rational N/N/M, unknown→null) + `IFrameRateProbe`/`FfmpegFrameRateProbe` (derives sibling `ffprobe.exe` from the located ffmpeg dir, reuses the `IDecodeProcess` seam; null if ffprobe absent) + `FfmpegLocator.ProbeFileName` now also extracts `ffprobe.exe` from the pinned archive (conditional). Tests: `FfmpegFrameRateParserTests` (6 pure units + 1 probe integration via fake locator/process) + `FfmpegLocatorTests` still green — 15/15.
|
4b. ✅ Slice 2a — native-FPS probe seam: `FfmpegFrameRateParser` (pure, prefers `avg_frame_rate=` then `r_frame_rate=`, rational N/N/M, unknown→null) + `IFrameRateProbe`/`FfmpegFrameRateProbe` (derives sibling `ffprobe.exe` from the located ffmpeg dir, reuses the `IDecodeProcess` seam; null if ffprobe absent) + `FfmpegLocator.ProbeFileName` now also extracts `ffprobe.exe` from the pinned archive (conditional). Tests: `FfmpegFrameRateParserTests` (6 pure units + 1 probe integration via fake locator/process) + `FfmpegLocatorTests` still green — 15/15.
|
||||||
5. ✅ `IMediaFrameSource` + `MediaVideoSourceManager` (`Services/IMediaFrameSource.cs`, `Services/MediaVideoSourceManager.cs`): app-wide decode-session ownership refcounted by `MediaPath` with a `Func<string, IMediaFrameSource?>` factory seam; `AcquireAsync`/`ReleaseAsync`/`ReleaseAllAsync`/`GetLatestFrame`; coalesces frames onto the UI dispatcher onto a single shared `WriteableBitmap` per file (mirror of `ScreenCaptureManager`); `MediaFailed` + `PreviewBitmapChanged`. Unit tests + one integration test (bitmap share/coalesce) — `MediaVideoSourceManagerTests`, 5/5 pass.
|
5. ✅ `IMediaFrameSource` + `MediaVideoSourceManager` (`Services/IMediaFrameSource.cs`, `Services/MediaVideoSourceManager.cs`): app-wide decode-session ownership refcounted by `MediaPath` with a `Func<string, IMediaFrameSource?>` factory seam; `AcquireAsync`/`ReleaseAsync`/`ReleaseAllAsync`/`GetLatestFrame`/`HasSession`/`GetActiveKeys`/`SetLooping`; coalesces frames onto the UI dispatcher onto a single shared `WriteableBitmap` per file (mirror of `ScreenCaptureManager`); `MediaFailed` + `PreviewBitmapChanged`. Unit tests + one integration test (bitmap share/coalesce) — `MediaVideoSourceManagerTests`.
|
||||||
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`.
|
||||||
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, and the same
|
||||||
8. ☐ Wire into `SceneCompositor` — render media source as an image element at its position/size
|
resolver serves preview and thumbnails (`FramePump` defaults `resolver` to the frame resolver).
|
||||||
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.
|
8. ✅ Wire into `SceneCompositor` — media renders through the generic `frameFor(element)` path at the
|
||||||
10. ☐ Volume control — per-source volume slider for audio playback
|
element's rect, so no compositor change was needed.
|
||||||
11. ☐ UI: file picker (filtered to video formats), loop toggle, volume slider
|
9. ✅ Loop control — `IMediaFrameSource.Looping`; `MediaVideoSource` restarts the decode on natural EOF
|
||||||
12. ☐ Schema migration for media source settings (file path, loop, volume)
|
when `Looping` (fresh process per pass). `MediaIsLooping` reaches the session through the manager's
|
||||||
13. ☐ Tests: video frame extraction, loop behavior, volume scaling, file validation
|
per-path `loopProvider` at start and `SetLooping` on a live toggle. Rule (recorded): the path's
|
||||||
|
session loops if **any** live media reference has it on, because one decoder is shared per path.
|
||||||
|
10. ⏸ Volume control — deliberately not wired: media audio is captured by the desktop loopback like
|
||||||
|
TRAX, so a per-file slider would be a control with no audible effect. Revisit only with a local
|
||||||
|
audio route for the file's own stream.
|
||||||
|
11. ✅ UI: `AddMediaCommand` (+ menu → "Video File", video-filtered `OpenFileDialog`), `ChangeMediaFileCommand`
|
||||||
|
("Change…" in the properties panel), loop toggle, video name display.
|
||||||
|
12. ✅ Schema migration for media source settings (file path, loop, volume) — shipped with item 3.
|
||||||
|
13. ✅ Tests: decode/loop/pacing at the unit level, and `MediaSourceAcquisitionTests` (the Good Dog for the
|
||||||
|
wiring): add a file → one decoder starts, frames reach the layer, loop flip reaches the decoder, remove → stops.
|
||||||
|
|
||||||
### Design decisions
|
### Design decisions
|
||||||
|
|
||||||
@@ -28,31 +39,6 @@
|
|||||||
- **Audio plays through the desktop channel** — media source audio is captured by the WASAPI loopback (like TRAX/game audio). No separate audio routing needed.
|
- **Audio plays through the desktop channel** — media source audio is captured by the WASAPI loopback (like TRAX/game audio). No separate audio routing needed.
|
||||||
- **This pairs with TRAX** — TRAX is background music, media source is background video. Together they make non-Live scenes (Starting/BRB/Ending) feel polished.
|
- **This pairs with TRAX** — TRAX is background music, media source is background video. Together they make non-Live scenes (Starting/BRB/Ending) feel polished.
|
||||||
- **Not a full NLE** — no trimming, no multi-track, no effects. Just "play this video in the scene."
|
- **Not a full NLE** — no trimming, no multi-track, no effects. Just "play this video in the scene."
|
||||||
|
- **Layers are defined in Live first** (creator 2026-11-05): this unit adds the layer and the picker in
|
||||||
### UI picker slice — spec (imported from HANDOFF 2026-09-01 so it can't be lost by a rewrite)
|
the ordinary (+) menu, with no per-scene availability rules. Which scenes may host a video layer is a
|
||||||
|
later pass, and scene-only layers (an ending credit roll, for instance) are defined after that pass.
|
||||||
Nothing `AcquireAsync`s a media path yet, so **no frames flow in a running app** — decoder, manager,
|
|
||||||
pacing, and loop mechanism are shipped and unit-tested, but a media Source has no way to start a
|
|
||||||
session. Deliverables, in order:
|
|
||||||
|
|
||||||
1. **File picker + add/remove commands** (mirror `MainViewModel.Trax.cs:68` `OpenFileDialog` usage).
|
|
||||||
A "Media" source action opens an `OpenFileDialog` filtered to video (mp4/mov/avi); on OK set
|
|
||||||
`Source.MediaPath`, add to scene, and **`AcquireAsync(MediaPath)`**; on remove
|
|
||||||
**`ReleaseAsync(MediaPath)`** (refcounted — not `ReleaseAllAsync`, unless the path leaves the
|
|
||||||
layout entirely). On `StagedScene` layout loads/teardown, release every media path no longer
|
|
||||||
present and acquire new ones so sessions track the live layout.
|
|
||||||
2. **Wire `Source.MediaIsLooping` → `IMediaFrameSource.Looping`** — needs a manager-level per-**path**
|
|
||||||
loop provider. Ambiguity to rule + record: sessions are refcounted per `MediaPath` and shared
|
|
||||||
across scenes, but `MediaIsLooping` is per-`Source`. Chosen rule: the path's session `Looping` =
|
|
||||||
**any** live reference has it on (shared behaviour). Record it in the slice commit.
|
|
||||||
3. **Loop toggle + volume slider** in the source context menu / inspector (items 10/11).
|
|
||||||
4. `MediaVolume`/`MediaPlaybackState` wiring — audio path for media is future work (desktop loopback
|
|
||||||
already carries it, TRAX-style; `MediaVolume` slider can drive the local file's audio separately
|
|
||||||
later — out of this slice).
|
|
||||||
|
|
||||||
**Design notes to preserve:** `ResolveOutputFrame` reads `_mediaManager.GetLatestFrame(MediaPath)`;
|
|
||||||
`OnMediaPreviewBitmapChanged` adopts the shared `WriteableBitmap` per path; `MediaFailed` →
|
|
||||||
`OnMediaFailed` (currently `Debug.WriteLine` — surface in UI via toast, TASK 24 stack). The compositor
|
|
||||||
scales any frame size — media renders through the generic `frameFor(element)` path, no compositor
|
|
||||||
change. First GUI smoke test: add a short real mp4 on native Windows, confirm it plays and previews.
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
|
using Microsoft.Win32;
|
||||||
using ytLive.Models;
|
using ytLive.Models;
|
||||||
using ytLive.Services;
|
using ytLive.Services;
|
||||||
|
|
||||||
@@ -17,8 +21,15 @@ public partial class MainViewModel
|
|||||||
private const int MediaDecodeWidth = 1920;
|
private const int MediaDecodeWidth = 1920;
|
||||||
private const int MediaDecodeHeight = 1080;
|
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;
|
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
|
// MediaVideoSourceManager publishes one shared WriteableBitmap per file; every
|
||||||
// media Source pointing at that path picks it up for the preview/canvas.
|
// media Source pointing at that path picks it up for the preview/canvas.
|
||||||
private void OnMediaPreviewBitmapChanged(string path, WriteableBitmap bitmap)
|
private void OnMediaPreviewBitmapChanged(string path, WriteableBitmap bitmap)
|
||||||
@@ -28,8 +39,135 @@ public partial class MainViewModel
|
|||||||
source.VideoImageSource = bitmap;
|
source.VideoImageSource = bitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called from the encoder error path is out of scope (TASK 21 sees the decoder
|
private void OnMediaFailed(string path, string message)
|
||||||
// fail at startup); log to the debug console for now, surfaced via the UI later.
|
{
|
||||||
private static void OnMediaFailed(string path, string message)
|
Helpers.AppLog.Write($"MediaSource: '{path}' failed: {message}");
|
||||||
=> System.Diagnostics.Debug.WriteLine($"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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,19 @@ namespace ytLive.ViewModels;
|
|||||||
public partial class MainViewModel
|
public partial class MainViewModel
|
||||||
{
|
{
|
||||||
private void AddSource(object? parameter)
|
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;
|
var scene = StagedScene;
|
||||||
if (scene == null) return;
|
if (scene == null) return;
|
||||||
PushPreviewUndo();
|
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
|
// 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
|
// 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.
|
// menu item or a scripted call from planting one on BRB.
|
||||||
@@ -40,6 +45,7 @@ public partial class MainViewModel
|
|||||||
SourceType.AlertBox => "Stream Alerts",
|
SourceType.AlertBox => "Stream Alerts",
|
||||||
SourceType.Ticker => "Ticker",
|
SourceType.Ticker => "Ticker",
|
||||||
SourceType.Scoreboard => "Scoreboard",
|
SourceType.Scoreboard => "Scoreboard",
|
||||||
|
SourceType.MediaSource => "Video",
|
||||||
_ => "Layer",
|
_ => "Layer",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +68,7 @@ public partial class MainViewModel
|
|||||||
? $"{baseName}-{webCount}"
|
? $"{baseName}-{webCount}"
|
||||||
: NextSourceName(scene, baseName),
|
: NextSourceName(scene, baseName),
|
||||||
Type = sourceType,
|
Type = sourceType,
|
||||||
|
MediaPath = mediaPath,
|
||||||
X = sourceType == SourceType.TextOverlay ? 160
|
X = sourceType == SourceType.TextOverlay ? 160
|
||||||
: sourceType == SourceType.ChatBox ? 1400
|
: sourceType == SourceType.ChatBox ? 1400
|
||||||
: sourceType == SourceType.AlertBox ? 620
|
: sourceType == SourceType.AlertBox ? 620
|
||||||
@@ -79,6 +86,7 @@ public partial class MainViewModel
|
|||||||
: sourceType == SourceType.AlertBox ? 680
|
: sourceType == SourceType.AlertBox ? 680
|
||||||
: sourceType == SourceType.Scoreboard ? 1920
|
: sourceType == SourceType.Scoreboard ? 1920
|
||||||
: sourceType == SourceType.Ticker ? 1920
|
: sourceType == SourceType.Ticker ? 1920
|
||||||
|
: sourceType == SourceType.MediaSource ? 1920
|
||||||
: sourceType == SourceType.Blur ? 240 : 0,
|
: sourceType == SourceType.Blur ? 240 : 0,
|
||||||
Height = sourceType == SourceType.TextOverlay ? 140
|
Height = sourceType == SourceType.TextOverlay ? 140
|
||||||
: sourceType == SourceType.WebSource ? 480
|
: sourceType == SourceType.WebSource ? 480
|
||||||
@@ -86,6 +94,7 @@ public partial class MainViewModel
|
|||||||
: sourceType == SourceType.AlertBox ? 200
|
: sourceType == SourceType.AlertBox ? 200
|
||||||
: sourceType == SourceType.Scoreboard ? 56
|
: sourceType == SourceType.Scoreboard ? 56
|
||||||
: sourceType == SourceType.Ticker ? 48
|
: sourceType == SourceType.Ticker ? 48
|
||||||
|
: sourceType == SourceType.MediaSource ? 1080
|
||||||
: sourceType == SourceType.Blur ? 160 : 0,
|
: sourceType == SourceType.Blur ? 160 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +113,9 @@ public partial class MainViewModel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceType == SourceType.MediaSource)
|
||||||
|
RegisterMediaSource(source);
|
||||||
|
|
||||||
if (sourceType == SourceType.ChatBox)
|
if (sourceType == SourceType.ChatBox)
|
||||||
{
|
{
|
||||||
source.PropertyChanged += (_, e) =>
|
source.PropertyChanged += (_, e) =>
|
||||||
@@ -286,7 +298,18 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
if (element is Source { Type: SourceType.WebSource } webSource)
|
if (element is Source { Type: SourceType.WebSource } webSource)
|
||||||
_webView2Manager?.Unregister(webSource.Id);
|
_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);
|
_sceneGraph.RemoveElement(scene, element);
|
||||||
|
ReleaseMediaIfUnused(mediaPath);
|
||||||
|
|
||||||
if (element is WebcamSceneConfig)
|
if (element is WebcamSceneConfig)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public ICommand AddSceneCommand { get; }
|
public ICommand AddSceneCommand { get; }
|
||||||
public ICommand AddSourceCommand { get; }
|
public ICommand AddSourceCommand { get; }
|
||||||
public ICommand AddImageCommand { get; }
|
public ICommand AddImageCommand { get; }
|
||||||
|
public ICommand AddMediaCommand { get; }
|
||||||
|
public ICommand ChangeMediaFileCommand { get; }
|
||||||
public ICommand RemoveSourceCommand { get; }
|
public ICommand RemoveSourceCommand { get; }
|
||||||
public ICommand EditElementCommand { get; }
|
public ICommand EditElementCommand { get; }
|
||||||
public ICommand ToggleElementVisibilityCommand { get; }
|
public ICommand ToggleElementVisibilityCommand { get; }
|
||||||
@@ -260,6 +262,10 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
|
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
|
||||||
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync());
|
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync());
|
||||||
AddImageCommand = new RelayCommand(_ => AddImage());
|
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); });
|
RemoveSourceCommand = new RelayCommand(element => { PushPreviewUndo(); RemoveElement(element as SceneElement); });
|
||||||
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
|
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
|
||||||
ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); });
|
ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); });
|
||||||
@@ -396,12 +402,14 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
|
_screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
|
||||||
|
|
||||||
_mediaManager = new MediaVideoSourceManager(
|
_mediaManager = new MediaVideoSourceManager(
|
||||||
path => new MediaVideoSource(
|
MediaFrameSourceFactoryOverride ?? (path => new MediaVideoSource(
|
||||||
path, MediaDecodeWidth, MediaDecodeHeight,
|
path, MediaDecodeWidth, MediaDecodeHeight,
|
||||||
new FfmpegLocator(), () => new FfmpegDecodeProcess(),
|
new FfmpegLocator(), () => new FfmpegDecodeProcess(),
|
||||||
frameRateProbe: new FfmpegFrameRateProbe(
|
frameRateProbe: new FfmpegFrameRateProbe(
|
||||||
new FfmpegLocator(), () => new FfmpegDecodeProcess())),
|
new FfmpegLocator(), () => new FfmpegDecodeProcess()))),
|
||||||
System.Windows.Application.Current?.Dispatcher);
|
System.Windows.Application.Current?.Dispatcher,
|
||||||
|
// Loop intent is per Source; the manager needs the per-*path* answer.
|
||||||
|
AnyMediaReferenceLoops);
|
||||||
_mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged;
|
_mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged;
|
||||||
_mediaManager.MediaFailed += OnMediaFailed;
|
_mediaManager.MediaFailed += OnMediaFailed;
|
||||||
|
|
||||||
@@ -466,6 +474,10 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
/// real ONNX model. Mirrors <see cref="CameraFrameSourceFactoryOverride"/>.</summary>
|
/// real ONNX model. Mirrors <see cref="CameraFrameSourceFactoryOverride"/>.</summary>
|
||||||
internal static Services.BackgroundEffects.IPersonSegmenter? PersonSegmenterOverride { get; set; }
|
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()
|
private void LoadLayout()
|
||||||
{
|
{
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -518,6 +530,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
UpdateBackgroundImage();
|
UpdateBackgroundImage();
|
||||||
ReacquireWebcam();
|
ReacquireWebcam();
|
||||||
ReacquireScreenCaptures();
|
ReacquireScreenCaptures();
|
||||||
|
ReacquireMediaSources();
|
||||||
RegisterLoadedWebSources();
|
RegisterLoadedWebSources();
|
||||||
_socials = _layoutStore.Socials;
|
_socials = _layoutStore.Socials;
|
||||||
RenderSocialBarFrame();
|
RenderSocialBarFrame();
|
||||||
|
|||||||
@@ -790,6 +790,17 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t
|
|||||||
() => new FfmpegDecodeProcess())` as the probe.
|
() => new FfmpegDecodeProcess())` as the probe.
|
||||||
Still open: session acquisition on add/remove (UI picker) + wiring `Source.MediaIsLooping` into
|
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).
|
`IMediaFrameSource.Looping` (needs a manager-level per-path loop provider, comes with the picker slice).
|
||||||
|
**Acquisition shipped 2026-11-05:** `AddMediaCommand` (`MainViewModel.Media.cs`) opens a video-filtered
|
||||||
|
`OpenFileDialog` and `AddMediaSource(path)` adds a `MediaSource` layer through the ordinary `AddSource`
|
||||||
|
path, defaulting to a full-canvas 1920x1080 rect, then `RegisterMediaSource` subscribes the layer and
|
||||||
|
`EnsureMediaSession` starts one decoder per **distinct path** (`HasSession` guard keeps refcounts at 0/1).
|
||||||
|
Remove (`RemoveElement`) unsubscribes and releases only when no scene references the file any more;
|
||||||
|
`ReacquireMediaSources` runs after `LoadLayout` so persisted layers get sessions and stale files are
|
||||||
|
released (`GetActiveKeys`). Loop is a manager-level per-path answer: the ctor takes
|
||||||
|
`Func<string,bool>? loopProvider` (production: "any live media reference loops"), applied before
|
||||||
|
`StartAsync`, and `SetLooping(key, bool)` applies a live toggle. A file swap releases the old path after
|
||||||
|
the layer no longer references it. Volume stays unwired on purpose — sound rides the desktop loopback
|
||||||
|
(TRAX-style), so a per-file slider would be a control with no effect.
|
||||||
- **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`
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TASK 21 Good Dog (2026-11-05): a MediaSource layer exists but nothing ever started
|
||||||
|
/// a decode session, so a video file layer rendered nothing in a running app. This
|
||||||
|
/// drives the real add path through <see cref="MainWindow"/> and asserts the piece the
|
||||||
|
/// unit adds — the session lifecycle: adding a video file starts exactly one decoder,
|
||||||
|
/// the decoded frames reach the layer, a loop flip reaches that decoder, and removing
|
||||||
|
/// the layer stops it.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("RealApp")]
|
||||||
|
public sealed class MediaSourceAcquisitionTests
|
||||||
|
{
|
||||||
|
private readonly RealAppHost _app;
|
||||||
|
|
||||||
|
public MediaSourceAcquisitionTests(RealAppHost app) => _app = app;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddingAVideoLayer_StartsTheDecoder_AndRemovingItStopsTheDecoder()
|
||||||
|
{
|
||||||
|
_app.Run(() =>
|
||||||
|
{
|
||||||
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
||||||
|
var videoPath = Path.Combine(Path.GetTempPath(), "starting-soon.mp4");
|
||||||
|
var decoders = new List<FakeMediaFrameSource>();
|
||||||
|
MainViewModel.LayoutPathOverride = tempDb;
|
||||||
|
MainViewModel.MediaFrameSourceFactoryOverride = key =>
|
||||||
|
{
|
||||||
|
var decoder = new FakeMediaFrameSource(key);
|
||||||
|
decoders.Add(decoder);
|
||||||
|
return decoder;
|
||||||
|
};
|
||||||
|
|
||||||
|
var window = new MainWindow();
|
||||||
|
var vm = (MainViewModel)window.DataContext;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
window.UpdateLayout();
|
||||||
|
var scene = vm.StagedScene!;
|
||||||
|
|
||||||
|
vm.AddMediaSource(videoPath);
|
||||||
|
|
||||||
|
var layer = scene.Elements.OfType<Source>().Single(s => s.IsMediaSource);
|
||||||
|
Assert.Equal(videoPath, layer.MediaPath);
|
||||||
|
Assert.Equal(SourceType.MediaSource, layer.Type);
|
||||||
|
Assert.Single(decoders);
|
||||||
|
|
||||||
|
var decoder = decoders[0];
|
||||||
|
Assert.Equal(videoPath, decoder.Key);
|
||||||
|
Assert.Equal(1, decoder.StartCount);
|
||||||
|
|
||||||
|
// A decoded frame reaches the layer the preview/renderer draws from —
|
||||||
|
// otherwise the session would be running and still invisible.
|
||||||
|
Assert.Null(layer.DisplaySource);
|
||||||
|
decoder.Emit(new VideoFrame(64, 36, new byte[64 * 36 * 4]));
|
||||||
|
Assert.NotNull(layer.DisplaySource);
|
||||||
|
|
||||||
|
// Loop intent is per Source; the shared session is the per-path answer.
|
||||||
|
Assert.False(decoder.Looping);
|
||||||
|
layer.MediaIsLooping = true;
|
||||||
|
Assert.True(decoder.Looping);
|
||||||
|
|
||||||
|
vm.RemoveSourceCommand.Execute(layer);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(layer, scene.Elements);
|
||||||
|
Assert.Equal(1, decoder.StopCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
MainViewModel.MediaFrameSourceFactoryOverride = null;
|
||||||
|
MainViewModel.LayoutPathOverride = null;
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stands in for the ffmpeg decode process (the manager's factory seam).</summary>
|
||||||
|
private sealed class FakeMediaFrameSource : IMediaFrameSource
|
||||||
|
{
|
||||||
|
public FakeMediaFrameSource(string key) => Key = key;
|
||||||
|
|
||||||
|
public string Key { get; }
|
||||||
|
public bool Looping { get; set; }
|
||||||
|
public int StartCount { get; private set; }
|
||||||
|
public int StopCount { get; private set; }
|
||||||
|
|
||||||
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
|
public event Action? Completed { add { } remove { } }
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
StartCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync()
|
||||||
|
{
|
||||||
|
StopCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Emit(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user