diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index 57311cc..9ecfed0 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -70,6 +70,8 @@ IsEnabled="{Binding CanAddWebcam}" ToolTip="{Binding WebcamAddToolTip}"/> + + + + + + + + + + + + + + + + diff --git a/HANDOFF.md b/HANDOFF.md index dd703e6..c7f8b84 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,19 +1,18 @@ -# HANDOFF — 2026-11-04 (TASK 3·17 — static text layers) +# HANDOFF — 2026-11-05 (TASK 21 — video file layer) ## State -- `main`; TASK 53 is complete and TASK 3·17 is complete in three commits: `60c4a00`, `58eb640`, - and the final output/compositing phase. -- Text layers persist independently, edit as static multiline text, and share one cached raster between - preview and output. The frame pump only reads the raster cache; no WPF render occurs on its thread. -- The app clean build has 0 warnings. 524 tests passed with the documented physical mouse-injection test - and known audio test excluded; the new text renderer, preview/output, and pump-cache tests pass. -- The four stray `Assets/llamacasty-*` exports were deleted 2026-11-04 — unreferenced by the csproj, - XAML or scripts (recipe in `MyMistakes.md`). +- `main`; TASK 21 is complete: a video file added from the (+) menu decodes, composites into preview + and stream, loops on toggle, and stops with its last scene reference. 3·17 (text layers) completed + 2026-11-04. +- New test `MediaSourceAcquisitionTests` (add → one decoder starts + frames reach the layer + loop flip + reaches the decoder + remove → stops). App build is 0 warnings. +- Volume is deliberately unwired: media audio rides the desktop loopback, so a slider would do nothing. ## 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. ## Landmines @@ -21,4 +20,6 @@ - `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical 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`. +- 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"`). diff --git a/Models/Source.cs b/Models/Source.cs index 8f71ade..a52d775 100644 --- a/Models/Source.cs +++ b/Models/Source.cs @@ -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 /// (creator ruling 2026-11-01). 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; /// Live captures, chat, web sources and media are dynamic; static art is fixed. public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture @@ -238,10 +239,17 @@ public class Source : SceneElement set { if (Set(ref _mediaPath, value)) + { Raise(nameof(IsMediaSource)); + Raise(nameof(MediaFileDisplay)); + } } } + /// The chosen file's name for the properties panel (path stays in the model). + public string MediaFileDisplay => + string.IsNullOrWhiteSpace(_mediaPath) ? "No video chosen" : System.IO.Path.GetFileName(_mediaPath); + private bool _mediaIsLooping; public bool MediaIsLooping { diff --git a/Services/MediaVideoSourceManager.cs b/Services/MediaVideoSourceManager.cs index f39c0a3..7e44ccc 100644 --- a/Services/MediaVideoSourceManager.cs +++ b/Services/MediaVideoSourceManager.cs @@ -39,6 +39,7 @@ public sealed class MediaVideoSourceManager : IDisposable private readonly Func _sourceFactory; private readonly Dispatcher? _uiDispatcher; + private readonly Func? _loopProvider; private readonly Dictionary _sessions = new(); private readonly object _gate = new(); @@ -48,10 +49,14 @@ public sealed class MediaVideoSourceManager : IDisposable /// Raised when a media decoder cannot be created (bad path). public event Action? MediaFailed; - public MediaVideoSourceManager(Func sourceFactory, Dispatcher? uiDispatcher = null) + public MediaVideoSourceManager( + Func sourceFactory, + Dispatcher? uiDispatcher = null, + Func? loopProvider = null) { _sourceFactory = sourceFactory; _uiDispatcher = uiDispatcher; + _loopProvider = loopProvider; } /// Increments the refcount for a file, starting the decoder the first time. @@ -90,6 +95,10 @@ public sealed class MediaVideoSourceManager : IDisposable session = new MediaSession(key, source); session.FrameHandler = frame => OnFrameAvailable(session, frame); session.Source.FrameAvailable += session.FrameHandler; + // Loop intent lives on the Source (per scene), so the caller supplies the + // per-path answer ("any live reference loops"). Applied before Start so a + // zero-length or instantly-EOF file cannot slip past a later assignment. + session.Source.Looping = _loopProvider?.Invoke(key) ?? false; _sessions[key] = session; shouldStart = true; } @@ -158,6 +167,32 @@ public sealed class MediaVideoSourceManager : IDisposable return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null; } + /// 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. + public bool HasSession(string key) + { + lock (_gate) + return _sessions.ContainsKey(key); + } + + /// The files with a live session, for reconciling a layout load. + public IReadOnlyList GetActiveKeys() + { + lock (_gate) + return new List(_sessions.Keys); + } + + /// Applies a loop change to a running session (the creator flipped the + /// toggle). No-op when the file has no session. + public void SetLooping(string key, bool looping) + { + lock (_gate) + { + if (_sessions.TryGetValue(key, out var session)) + session.Source.Looping = looping; + } + } + /// The shared preview bitmap for a file (so a config created after the /// first frame can adopt the running bitmap), or null. public WriteableBitmap? GetPreviewBitmap(string key) diff --git a/TASKS.md b/TASKS.md index a6d6630..f6de53e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | **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 | -| **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 | | **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) -**TASK 3·17 — the Text layer** is scheduled in the surge (day 4). Placeholder detail retained for -the unit itself: `AddSource` creates it 0×0, all four compositor paths `continue` past it, -`FramePump`'s hash skips it, it is not draggable, and it has no text/font/colour properties or -persistence. Text content, font family/size/weight/style, colour, alignment, drag/resize, -persisted; renderer mirrors `Services/ChatBoxRenderer.cs`. +**Day 4 remainder:** TASK 49 (chat profanity filter) then TASK 34 (scheduled streams — its two research +flags must be cited first). Day 3's resilience row (TASK 32/33) is still outstanding and is a launch +blocker; the smallest slice is 32·4 (fragmented MP4 on the record block). ## 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) | | — | ✅ | 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) | -| 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) | +| 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: 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) | | 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) | diff --git a/TASKS/task-21-media-source.md b/TASKS/task-21-media-source.md index b4d07e2..a35d66f 100644 --- a/TASKS/task-21-media-source.md +++ b/TASKS/task-21-media-source.md @@ -5,22 +5,33 @@ **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) 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) 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. -5. ✅ `IMediaFrameSource` + `MediaVideoSourceManager` (`Services/IMediaFrameSource.cs`, `Services/MediaVideoSourceManager.cs`): app-wide decode-session ownership refcounted by `MediaPath` with a `Func` 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. -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 (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) -13. ☐ Tests: video frame extraction, loop behavior, volume scaling, file validation +5. ✅ `IMediaFrameSource` + `MediaVideoSourceManager` (`Services/IMediaFrameSource.cs`, `Services/MediaVideoSourceManager.cs`): app-wide decode-session ownership refcounted by `MediaPath` with a `Func` 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? 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, and the same + resolver serves preview and thumbnails (`FramePump` defaults `resolver` to the frame resolver). +8. ✅ Wire into `SceneCompositor` — media renders through the generic `frameFor(element)` path at the + element's rect, so no compositor change was needed. +9. ✅ Loop control — `IMediaFrameSource.Looping`; `MediaVideoSource` restarts the decode on natural EOF + when `Looping` (fresh process per pass). `MediaIsLooping` reaches the session through the manager's + 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 @@ -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. - **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." - -### UI picker slice — spec (imported from HANDOFF 2026-09-01 so it can't be lost by a rewrite) - -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. - +- **Layers are defined in Live first** (creator 2026-11-05): this unit adds the layer and the picker in + 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. diff --git a/ViewModels/MainViewModel.Media.cs b/ViewModels/MainViewModel.Media.cs index 3e634a1..f1aebe4 100644 --- a/ViewModels/MainViewModel.Media.cs +++ b/ViewModels/MainViewModel.Media.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Windows.Media.Imaging; +using Microsoft.Win32; using ytLive.Models; using ytLive.Services; @@ -17,8 +21,15 @@ public partial class MainViewModel private const int MediaDecodeWidth = 1920; 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; + // 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 _mediaHandlers = new(); + // MediaVideoSourceManager publishes one shared WriteableBitmap per file; every // media Source pointing at that path picks it up for the preview/canvas. private void OnMediaPreviewBitmapChanged(string path, WriteableBitmap bitmap) @@ -28,8 +39,135 @@ public partial class MainViewModel source.VideoImageSource = bitmap; } - // Called from the encoder error path is out of scope (TASK 21 sees the decoder - // fail at startup); log to the debug console for now, surfaced via the UI later. - private static void OnMediaFailed(string path, string message) - => System.Diagnostics.Debug.WriteLine($"MediaSource: '{path}' failed: {message}"); + private void OnMediaFailed(string path, string message) + { + Helpers.AppLog.Write($"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; + } + + /// Adds a MediaSource layer for to the staged scene. + /// Internal so the Good Dog test drives the real add path without a file dialog. + 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) ---------- + + /// 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. + 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); + } + + /// 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). + private bool AnyMediaReferenceLoops(string path) + => Scenes.SelectMany(s => s.Elements).OfType() + .Any(s => s.IsMediaSource && s.MediaPath == path && s.MediaIsLooping); + + private bool HasMediaReference(string path) + => Scenes.SelectMany(s => s.Elements).OfType() + .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); + } + + /// 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. + private void ReacquireMediaSources() + { + var sources = Scenes.SelectMany(s => s.Elements).OfType() + .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); + } } diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs index e550d8a..21700be 100644 --- a/ViewModels/MainViewModel.Sources.cs +++ b/ViewModels/MainViewModel.Sources.cs @@ -15,14 +15,19 @@ namespace ytLive.ViewModels; public partial class MainViewModel { private void AddSource(object? parameter) + { + var sourceType = parameter is SourceType typed + ? typed + : Enum.TryParse(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image; + AddSource(sourceType); + } + + private void AddSource(SourceType sourceType, string? mediaPath = null) { var scene = StagedScene; if (scene == null) return; PushPreviewUndo(); - var sourceType = parameter is SourceType typed - ? typed - : Enum.TryParse(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image; // Privacy blur is a Live-scene tool: it exists to hide the creator's desktop, and every // other scene's art is authored. Rejecting here (not only in the (+) menu) keeps a stale // menu item or a scripted call from planting one on BRB. @@ -40,6 +45,7 @@ public partial class MainViewModel SourceType.AlertBox => "Stream Alerts", SourceType.Ticker => "Ticker", SourceType.Scoreboard => "Scoreboard", + SourceType.MediaSource => "Video", _ => "Layer", }; @@ -62,6 +68,7 @@ public partial class MainViewModel ? $"{baseName}-{webCount}" : NextSourceName(scene, baseName), Type = sourceType, + MediaPath = mediaPath, X = sourceType == SourceType.TextOverlay ? 160 : sourceType == SourceType.ChatBox ? 1400 : sourceType == SourceType.AlertBox ? 620 @@ -79,6 +86,7 @@ public partial class MainViewModel : sourceType == SourceType.AlertBox ? 680 : sourceType == SourceType.Scoreboard ? 1920 : sourceType == SourceType.Ticker ? 1920 + : sourceType == SourceType.MediaSource ? 1920 : sourceType == SourceType.Blur ? 240 : 0, Height = sourceType == SourceType.TextOverlay ? 140 : sourceType == SourceType.WebSource ? 480 @@ -86,6 +94,7 @@ public partial class MainViewModel : sourceType == SourceType.AlertBox ? 200 : sourceType == SourceType.Scoreboard ? 56 : sourceType == SourceType.Ticker ? 48 + : sourceType == SourceType.MediaSource ? 1080 : sourceType == SourceType.Blur ? 160 : 0, }; @@ -104,6 +113,9 @@ public partial class MainViewModel }; } + if (sourceType == SourceType.MediaSource) + RegisterMediaSource(source); + if (sourceType == SourceType.ChatBox) { source.PropertyChanged += (_, e) => @@ -286,7 +298,18 @@ public partial class MainViewModel } if (element is Source { Type: SourceType.WebSource } webSource) _webView2Manager?.Unregister(webSource.Id); + + // Media: the layer stops listening, and its decode session stops once no scene + // references the file any more (sessions are shared per path). + string? mediaPath = null; + if (element is Source mediaSource && mediaSource.IsMediaSource) + { + UnregisterMediaSource(mediaSource); + mediaPath = mediaSource.MediaPath; + } + _sceneGraph.RemoveElement(scene, element); + ReleaseMediaIfUnused(mediaPath); if (element is WebcamSceneConfig) { diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index a04fa47..69529cc 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -188,6 +188,8 @@ public partial class MainViewModel : ViewModelBase public ICommand AddSceneCommand { get; } public ICommand AddSourceCommand { get; } public ICommand AddImageCommand { get; } + public ICommand AddMediaCommand { get; } + public ICommand ChangeMediaFileCommand { get; } public ICommand RemoveSourceCommand { get; } public ICommand EditElementCommand { get; } public ICommand ToggleElementVisibilityCommand { get; } @@ -260,6 +262,10 @@ public partial class MainViewModel : ViewModelBase AddSourceCommand = new RelayCommand(parameter => AddSource(parameter)); AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync()); 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); }); EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement)); ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); }); @@ -396,12 +402,14 @@ public partial class MainViewModel : ViewModelBase _screenCaptureManager.CaptureFailed += OnScreenCaptureFailed; _mediaManager = new MediaVideoSourceManager( - path => new MediaVideoSource( + MediaFrameSourceFactoryOverride ?? (path => new MediaVideoSource( path, MediaDecodeWidth, MediaDecodeHeight, new FfmpegLocator(), () => new FfmpegDecodeProcess(), frameRateProbe: new FfmpegFrameRateProbe( - new FfmpegLocator(), () => new FfmpegDecodeProcess())), - System.Windows.Application.Current?.Dispatcher); + new FfmpegLocator(), () => new FfmpegDecodeProcess()))), + System.Windows.Application.Current?.Dispatcher, + // Loop intent is per Source; the manager needs the per-*path* answer. + AnyMediaReferenceLoops); _mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged; _mediaManager.MediaFailed += OnMediaFailed; @@ -466,6 +474,10 @@ public partial class MainViewModel : ViewModelBase /// real ONNX model. Mirrors . internal static Services.BackgroundEffects.IPersonSegmenter? PersonSegmenterOverride { get; set; } + /// Test seam (TASK 21): a fake media decoder so a test can prove add/release + /// lifecycle without spawning ffmpeg. Mirrors . + internal static Func? MediaFrameSourceFactoryOverride { get; set; } + private void LoadLayout() { _isLoading = true; @@ -518,6 +530,7 @@ public partial class MainViewModel : ViewModelBase UpdateBackgroundImage(); ReacquireWebcam(); ReacquireScreenCaptures(); + ReacquireMediaSources(); RegisterLoadedWebSources(); _socials = _layoutStore.Socials; RenderSocialBarFrame(); diff --git a/docs/ai/frame-pipeline.md b/docs/ai/frame-pipeline.md index fe9471b..45b80c6 100644 --- a/docs/ai/frame-pipeline.md +++ b/docs/ai/frame-pipeline.md @@ -790,6 +790,17 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t () => 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). + **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? 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 `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/MediaSourceAcquisitionTests.cs b/ytLive.Tests/MediaSourceAcquisitionTests.cs new file mode 100644 index 0000000..d266651 --- /dev/null +++ b/ytLive.Tests/MediaSourceAcquisitionTests.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; + +/// +/// 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 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. +/// +[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(); + 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().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 */ } + } + }); + } + + /// Stands in for the ffmpeg decode process (the manager's factory seam). + 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? 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() + { + } + } +}