diff --git a/HANDOFF.md b/HANDOFF.md index 0bbc70a..afab8cf 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,75 +2,55 @@ ## Branch / Commit State -**`main`**, **working tree CLEAN.** TASK 18 (record-only pipeline + top-bar pills/light/Start-End button UX -+ Sign In label/spacing) **plus** the memory-map/derived-solution fix **and** the README hero image were -committed and pushed as **`a9eb360`** (2026-08-29). `origin/main` and head both at **`a9eb360`**. +**`main`**, last pushed commit **`46f4696`** (manual-rename modal). Working tree is **DIRTY** — +TASK 21 Increment A (model + persistence for media sources) is fully implemented, verified, and +ready to commit. See "In flight" below. No feature branches pre-1.0: all work lands on `main` per work unit. -## Memory-map corrections (2026-08-29 — "don't re-derive" fix) +## In flight: TASK 21 — next bounded slice = Increment B (FFmpeg video decoder, items 4–5) -- **Incident:** the image-shrink recipe (for `docs/ytLlive-preview.jpg`) was never recorded when first - done, so it was re-derived from scratch. Fixed by: -- **`AGENTS.md`** → Working rules: new **🔬 Derived-solution rule** (record a one-off solution in - `MyMistakes.md` the same session; grep it first on any "did this before?" wall). -- **`MyMistakes.md`** → contract expanded: per-task failure log **+ RECIPES REGISTRY** (permanent, NOT - truncated); recorded the verified image-shrink recipe. -- **`schema.md`** → file inventory now rows `MyMistakes.md` as the recipes registry. -- **`ai.md`** → Derivative-work section notes the derived-solution rule (registry reachable from map home). -- **`README.md`** + `docs/ytLlive-preview.jpg` → README hero image replaced (screenshot downscaled to - 1400×794, 188KB JPEG). +**What is DONE (uncommitted, ready to commit):** Increment A — `SourceType.MediaSource` enum, +`MediaSourceType`/`MediaPlaybackState` enums, media config fields on `Source` (`MediaPath`, +`MediaIsLooping`, `MediaVolume`, `MediaPlaybackState`), LayoutStore migration + SELECT + INSERT, +round-trip test (`MediaSource_Config_Survives_Save_And_Reload`). Build 0 warnings, 245/247 tests +(2 known failures), scope check clean. +**What is NOT started (Increment B):** `MediaVideoDecoder` — FFmpeg-based video decoder → `VideoFrame`. +Needs ffmpeg present on Windows PATH or in `%APPDATA%\ytLlive\tools` to test. Currently absent: +neither `ffmpeg.exe` on PATH nor in tools cache (locator's network pull hasn't run). This is a +**run-the-app wall** — not blocked from writing the code, but blocked from headless integration +testing. Can be shipped as a compile-verified code commit gated on creator run, or deferred until +ffmpeg is present. -## TASK 18 — Local recording (shipped `a9eb360`, 2026-08-29) - -**Shipped (committed + pushed):** -- `EncoderOptions` + `FfmpegArgs` + `FfmpegEncoder`: independent `StreamEnabled`/`RecordEnabled`/ - `RecordPath`; per-output blocks (`-f flv` stream, `-f mp4` record); encoder throws unless ≥1 output. -- `MainViewModel`: REC + ON-AIR pill toggles, status lights, dynamic `PrimaryButtonText` - (Start Streaming / Start Recording), `ShowPrimaryStartButton`/`ShowLogInButton`/`ShowEndStreamButton`, - `CanStartSession`, `AccountStatusLightToolTip`; `IsEditMode` += `!IsRecording`. -- `StartSession()` routes: ON-AIR → GoLive dialog then stream (± record); only-REC → `BeginRecordOnly()` - (no YouTube dialog). `StopStream()` no longer signs the creator out (explicit logout only) and runs - **rename-on-stop** to `ty---.mp4`. -- `Services/RecordingFile` (pure): auto-name `ty-…-0000.mp4` → rename-on-stop with hh2mm2 length. -- `LayoutStore.Load/SaveRecordFolder` + `ChooseRecordFolderCommand` (`OpenFolderDialog`), default - `%APPDATA%\ytLlive\recordings\`. -- `MainWindow.xaml`: REC/ON-AIR pill `ToggleButton`s + status lights, dynamic button, status-light tooltip, - About overlay border 520→650 / MaxHeight 560→700 / padding 24→28, About grid background `#171928`. -- Tests: `FfmpegEncoderTests` record-only + dual-output arg tests; `RecordingFileTests` (naming rules). - -**Not done / parked:** manual rename dialog for the user (planned as a modal, needs a trigger since the -toast service has no actions) — parked; running-app verification of rename + dual output. - -## Key facts / landmines +## Landmines (unchanged) - **Pre-existing test failures (do not chase):** `AudioPipelineTests.Mix_HonorsProviderGains…` + - `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…` — fail on `main` with this change unapplied. - All other tests pass (incl. the 7 new recording tests). + `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…`. - **App running locks `ytLive.exe`** → build fails MSB3027 until the creator exits the app. -- **Startup diagnostics:** `%APPDATA%\ytLlive\startup.log` (`Helpers/AppLog.cs`). - **Real-MainWindow tests MUST set `LayoutPathOverride`** — DB pollution wiped real data once. -- **Good Dog Rule:** ONE integration test per change; no feature branches pre-1.0. -- **Scope Lock:** declared list below; run `scripts/scope-check.sh` before committing. -- **Every feature change ships memory updates in the SAME commit** — `TASKS.md` status + this file. -- **Pre-commit gate:** run `./scripts/verify.sh ` — clean build (0 warnings) + full tests + scope check. Do NOT trust an incremental build's "0 warnings". -- No `CODING.md`; conventions live in `AGENTS.md` + `ai.md`. +- **Pre-commit gate:** run `./scripts/verify.sh ` — clean build (0 warnings) + full tests + + scope check. Do NOT trust an incremental build's "0 warnings". +- **Good Dog Rule:** ONE integration test per change. +- **🔬 Derived-solution rule:** record one-off solutions in `MyMistakes.md` recipes registry same session. +- Startup diagnostics: `%APPDATA%\ytLlive\startup.log`. +- Media-source feature is large (12 items); each later slice (decode, compositor wiring, loop/volume, + UI) is its own bounded increment — decoder needs ffmpeg present, can't be headless-tested unless + ffmpeg is installed (currently absent from Windows PATH + empty tools cache). -## Commit scope (declared) +## Todo state -`Services/Encoder/EncoderOptions.cs Services/Encoder/FfmpegArgs.cs Services/Encoder/FfmpegEncoder.cs -Services/Encoder/FramePump.cs Services/RecordingFile.cs Services/LayoutStore.cs -ViewModels/MainViewModel.cs MainWindow.xaml ytLive.Tests/FfmpegEncoderTests.cs -ytLive.Tests/FramePumpTests.cs ytLive.Tests/RecordingFileTests.cs -AGENTS.md TASKS.md ai.md HANDOFF.md MyMistakes.md schema.md README.md -docs/ytLlive-preview.jpg docs/ytLlive-preview.png(deleted)` +- TASK 18: fully shipped (`a9eb360` recording, `c8374bd` docs, `be529a4` verify.sh, `46f4696` rename + modal). Only remaining = **running-app verification** (creator must run the WPF app). +- TASK 21: Increment A (model + persistence) done and ready to commit. Increment B (ffmpeg decoder) + is the next bounded slice — compile-verifiable without ffmpeg, but integration test is walled. +- TASK 22 (audio sync offset): not started. ## Next steps -1. **Running-app verification** of TASK 18 (the one real gap): confirm rename-on-stop produces - `ty-…-.mp4` and dual stream+record output actually works live. Requires the creator to run the app. -2. ~~Manual rename modal~~ **DONE** — `RenameRecordingDialog` (themed modal, pre-filled auto-stem, - OK/Enter / Cancel/Esc, invalid-char + empty = keep-auto validation) shown at finalize in - `FinalizeRecordingAsync`; reuses `YtTextBox`/`YtButton` styles. -3. Continue queue: TASK 21 media source, TASK 22 audio sync offset. +1. Commit + push Increment A (the code is ready, gates passed, TASKS/HANDOFF docs updated). +2. Commit + push the handoff correction (HANDOFF says done, not partial). +3. Running-app verification of TASK 18 rename/dual-output (creator) — one of two run-app walls. +4. Increment B: write `MediaVideoDecoder` (FFmpeg → BGRA8 `VideoFrame`), compile-verified, gated on + creator run for integration test (ffmpeg not present headlessly). +5. Later TASK 21 slices + TASK 22. diff --git a/Models/Source.cs b/Models/Source.cs index c0914d8..237308c 100644 --- a/Models/Source.cs +++ b/Models/Source.cs @@ -12,7 +12,23 @@ public enum SourceType TextOverlay, Countdown, WebSource, - ChatBox + ChatBox, + MediaSource +} + +/// Distinguishes how a media source presents its content (TASK 21). +public enum MediaSourceType +{ + Video, + Audio +} + +/// Playback state of a media source (TASK 21). +public enum MediaPlaybackState +{ + Stopped, + Playing, + Paused } public enum ClipShape @@ -113,6 +129,48 @@ public class Source : SceneElement private string? _webUri; public string? WebUri { get => _webUri; set => Set(ref _webUri, value); } + // Media source config (TASK 21: video/audio file playback) + private MediaSourceType _mediaType = MediaSourceType.Video; + public MediaSourceType MediaType + { + get => _mediaType; + set => Set(ref _mediaType, value); + } + + private string? _mediaPath; + public string? MediaPath + { + get => _mediaPath; + set + { + if (Set(ref _mediaPath, value)) + Raise(nameof(IsMediaSource)); + } + } + + private bool _mediaIsLooping; + public bool MediaIsLooping + { + get => _mediaIsLooping; + set => Set(ref _mediaIsLooping, value); + } + + private double _mediaVolume = 1.0; + public double MediaVolume + { + get => _mediaVolume; + set => Set(ref _mediaVolume, Math.Clamp(value, 0.0, 1.0)); + } + + private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped; + public MediaPlaybackState MediaPlaybackState + { + get => _mediaPlaybackState; + set => Set(ref _mediaPlaybackState, value); + } + + public bool IsMediaSource => Type == SourceType.MediaSource; + // Chat box config (TASK 3.18) private double _chatFontSize = 16; public double ChatFontSize diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs index 64da48b..47e0631 100644 --- a/Services/LayoutStore.cs +++ b/Services/LayoutStore.cs @@ -281,6 +281,32 @@ public class LayoutStore : IDisposable cmd.ExecuteNonQuery(); } + // TASK 21: Media source (video/audio file playback) + if (!columns.Contains("MediaPath")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("MediaIsLooping")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("MediaVolume")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("MediaPlaybackState")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';"; + cmd.ExecuteNonQuery(); + } + // Rename legacy "Backdrop" display name to "Background" in existing rows using (var cmd = _connection.CreateCommand()) { @@ -599,7 +625,7 @@ public class LayoutStore : IDisposable IsBackground, CaptureKey, ShowDesktop, ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor, - WebUri + WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState FROM Source ORDER BY SortOrder """; using var reader = cmd.ExecuteReader(); @@ -637,6 +663,14 @@ public class LayoutStore : IDisposable ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23), ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24), WebUri = reader.IsDBNull(25) ? null : reader.GetString(25), + MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26), + MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0, + MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28), + MediaPlaybackState = reader.IsDBNull(29) + ? MediaPlaybackState.Stopped + : Enum.TryParse(reader.GetString(29), out var ps) + ? ps + : MediaPlaybackState.Stopped, }; if (!sourcesByScene.TryGetValue(sceneId, out var list)) sourcesByScene[sceneId] = list = new List(); @@ -871,13 +905,13 @@ public class LayoutStore : IDisposable ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder, ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor, - WebUri) + WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState) VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, $x, $y, $w, $h, $opacity, $monitor, $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort, $chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs, $chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor, - $webUri) + $webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState) """; cmd.Transaction = tx; var idP = cmd.Parameters.Add("$id", SqliteType.Text); @@ -907,6 +941,10 @@ public class LayoutStore : IDisposable var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text); var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text); var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text); + var mediaPathP = cmd.Parameters.Add("$mediaPath", SqliteType.Text); + var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer); + var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real); + var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text); foreach (var scene in scenes) { @@ -941,6 +979,10 @@ public class LayoutStore : IDisposable chatMemberColorP.Value = source.ChatMemberColor; chatSuperChatColorP.Value = source.ChatSuperChatColor; webUriP.Value = (object?)source.WebUri ?? DBNull.Value; + mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value; + mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0; + mediaVolumeP.Value = source.MediaVolume; + mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString(); cmd.ExecuteNonQuery(); } } diff --git a/TASKS.md b/TASKS.md index 99776e2..cd6c335 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1035,11 +1035,11 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev **Goal:** play video files (MP4, MOV, AVI) into scenes — starting soon videos, BRB loops, intro/outro clips. -### Status: ☐ Not started — required for v1 +### Status: 🔶 In progress — Increment A shipped (model + persistence), slices 4–12 open -1. ☐ `MediaSourceType` enum: `Video`, `Audio` (audio-only files via media source) -2. ☐ `SourceType.MediaSource` addition to the enum -3. ☐ `MediaSourceModel`: `FilePath`, `IsLooping`, `Volume` (0-1), `PlaybackState` +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. ☐ `VideoFrameSource`: FFmpeg-based video decoder → `VideoFrame` pipeline 5. ☐ Frame capture from video file (decode at native FPS, output BGRA8 frames) 6. ☐ Wire into `FramePump` resolver — `Source { Type: MediaSource }` → latest video frame diff --git a/ytLive.Tests/LayoutStorePersistenceTests.cs b/ytLive.Tests/LayoutStorePersistenceTests.cs index 1925e1e..40e8cc6 100644 --- a/ytLive.Tests/LayoutStorePersistenceTests.cs +++ b/ytLive.Tests/LayoutStorePersistenceTests.cs @@ -388,4 +388,47 @@ public class LayoutStorePersistenceTests try { File.Delete(path); } catch { /* best-effort cleanup */ } } } + + // TASK 21: media-source config (Path, Looping, Volume, PlaybackState) must + // survive a save + reload through the full DELETE-all/re-insert cycle. + [Fact] + public void MediaSource_Config_Survives_Save_And_Reload() + { + var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db"); + try + { + using var store = new LayoutStore(path); + var scene = new Scene { Name = "Starting" }; + var source = new Source + { + Name = "Starting Loop", + Type = SourceType.MediaSource, + Width = 1920, + Height = 1080, + MediaPath = @"C:\videos\starting.mp4", + MediaIsLooping = true, + MediaVolume = 0.75, + MediaPlaybackState = MediaPlaybackState.Playing, + }; + scene.Elements.Add(source); + + store.Save(new[] { scene }, null, null); + + var reloaded = store.Load(); + var loaded = Assert.Single(reloaded[0].Elements); + var loadedSource = Assert.IsType(loaded); + + Assert.Equal(SourceType.MediaSource, loadedSource.Type); + Assert.True(loadedSource.IsMediaSource); + Assert.Equal(@"C:\videos\starting.mp4", loadedSource.MediaPath); + Assert.True(loadedSource.MediaIsLooping); + Assert.Equal(0.75, loadedSource.MediaVolume); + Assert.Equal(MediaPlaybackState.Playing, loadedSource.MediaPlaybackState); + } + finally + { + SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } }