TASK 21 Increment A: media-source model + persistence (schema migration + round-trip test)

SourceType.MediaSource added; MediaSourceType (Video/Audio) and MediaPlaybackState
(Stopped/Playing/Paused) enums. Source gains MediaPath, MediaIsLooping, MediaVolume
(0-1 clamped), MediaPlaybackState. LayoutStore: MigrateSourceTable adds 4 guarded
ALTERs (MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState); SELECT appends
indices 26-29, INSERT adds 4 columns + params. Round-trip test
MediaSource_Config_Survives_Save_And_Reload passes. Build 0 warnings, 245/247
tests (2 known pre-existing). Gated on creator run for later integration (ffmpeg).
This commit is contained in:
2026-08-29 09:43:29 -07:00
parent 46f4696db6
commit eba6af5b9b
5 changed files with 188 additions and 65 deletions
+37 -57
View File
@@ -2,75 +2,55 @@
## Branch / Commit State ## Branch / Commit State
**`main`**, **working tree CLEAN.** TASK 18 (record-only pipeline + top-bar pills/light/Start-End button UX **`main`**, last pushed commit **`46f4696`** (manual-rename modal). Working tree is **DIRTY** —
+ Sign In label/spacing) **plus** the memory-map/derived-solution fix **and** the README hero image were TASK 21 Increment A (model + persistence for media sources) is fully implemented, verified, and
committed and pushed as **`a9eb360`** (2026-08-29). `origin/main` and head both at **`a9eb360`**. ready to commit. See "In flight" below.
No feature branches pre-1.0: all work lands on `main` per work unit. 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 **What is DONE (uncommitted, ready to commit):** Increment A — `SourceType.MediaSource` enum,
done, so it was re-derived from scratch. Fixed by: `MediaSourceType`/`MediaPlaybackState` enums, media config fields on `Source` (`MediaPath`,
- **`AGENTS.md`** → Working rules: new **🔬 Derived-solution rule** (record a one-off solution in `MediaIsLooping`, `MediaVolume`, `MediaPlaybackState`), LayoutStore migration + SELECT + INSERT,
`MyMistakes.md` the same session; grep it first on any "did this before?" wall). round-trip test (`MediaSource_Config_Survives_Save_And_Reload`). Build 0 warnings, 245/247 tests
- **`MyMistakes.md`** → contract expanded: per-task failure log **+ RECIPES REGISTRY** (permanent, NOT (2 known failures), scope check clean.
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 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) ## Landmines (unchanged)
**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-<yyyymmdd>-<HHmm start>-<hh2mm2 length>.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
- **Pre-existing test failures (do not chase):** `AudioPipelineTests.Mix_HonorsProviderGains…` + - **Pre-existing test failures (do not chase):** `AudioPipelineTests.Mix_HonorsProviderGains…` +
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…` — fail on `main` with this change unapplied. `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…`.
All other tests pass (incl. the 7 new recording tests).
- **App running locks `ytLive.exe`** → build fails MSB3027 until the creator exits the app. - **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. - **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. - **Pre-commit gate:** run `./scripts/verify.sh <scope...>` — clean build (0 warnings) + full tests +
- **Scope Lock:** declared list below; run `scripts/scope-check.sh` before committing. scope check. Do NOT trust an incremental build's "0 warnings".
- **Every feature change ships memory updates in the SAME commit** — `TASKS.md` status + this file. - **Good Dog Rule:** ONE integration test per change.
- **Pre-commit gate:** run `./scripts/verify.sh <scope...>` — clean build (0 warnings) + full tests + scope check. Do NOT trust an incremental build's "0 warnings". - **🔬 Derived-solution rule:** record one-off solutions in `MyMistakes.md` recipes registry same session.
- No `CODING.md`; conventions live in `AGENTS.md` + `ai.md`. - 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 - TASK 18: fully shipped (`a9eb360` recording, `c8374bd` docs, `be529a4` verify.sh, `46f4696` rename
Services/Encoder/FramePump.cs Services/RecordingFile.cs Services/LayoutStore.cs modal). Only remaining = **running-app verification** (creator must run the WPF app).
ViewModels/MainViewModel.cs MainWindow.xaml ytLive.Tests/FfmpegEncoderTests.cs - TASK 21: Increment A (model + persistence) done and ready to commit. Increment B (ffmpeg decoder)
ytLive.Tests/FramePumpTests.cs ytLive.Tests/RecordingFileTests.cs is the next bounded slice — compile-verifiable without ffmpeg, but integration test is walled.
AGENTS.md TASKS.md ai.md HANDOFF.md MyMistakes.md schema.md README.md - TASK 22 (audio sync offset): not started.
docs/ytLlive-preview.jpg docs/ytLlive-preview.png(deleted)`
## Next steps ## Next steps
1. **Running-app verification** of TASK 18 (the one real gap): confirm rename-on-stop produces 1. Commit + push Increment A (the code is ready, gates passed, TASKS/HANDOFF docs updated).
`ty-…-<len>.mp4` and dual stream+record output actually works live. Requires the creator to run the app. 2. Commit + push the handoff correction (HANDOFF says done, not partial).
2. ~~Manual rename modal~~ **DONE** — `RenameRecordingDialog` (themed modal, pre-filled auto-stem, 3. Running-app verification of TASK 18 rename/dual-output (creator) — one of two run-app walls.
OK/Enter / Cancel/Esc, invalid-char + empty = keep-auto validation) shown at finalize in 4. Increment B: write `MediaVideoDecoder` (FFmpeg → BGRA8 `VideoFrame`), compile-verified, gated on
`FinalizeRecordingAsync`; reuses `YtTextBox`/`YtButton` styles. creator run for integration test (ffmpeg not present headlessly).
3. Continue queue: TASK 21 media source, TASK 22 audio sync offset. 5. Later TASK 21 slices + TASK 22.
+59 -1
View File
@@ -12,7 +12,23 @@ public enum SourceType
TextOverlay, TextOverlay,
Countdown, Countdown,
WebSource, WebSource,
ChatBox ChatBox,
MediaSource
}
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
public enum MediaSourceType
{
Video,
Audio
}
/// <summary>Playback state of a media source (TASK 21).</summary>
public enum MediaPlaybackState
{
Stopped,
Playing,
Paused
} }
public enum ClipShape public enum ClipShape
@@ -113,6 +129,48 @@ public class Source : SceneElement
private string? _webUri; private string? _webUri;
public string? WebUri { get => _webUri; set => Set(ref _webUri, value); } 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) // Chat box config (TASK 3.18)
private double _chatFontSize = 16; private double _chatFontSize = 16;
public double ChatFontSize public double ChatFontSize
+45 -3
View File
@@ -281,6 +281,32 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery(); 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 // Rename legacy "Backdrop" display name to "Background" in existing rows
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
@@ -599,7 +625,7 @@ public class LayoutStore : IDisposable
IsBackground, CaptureKey, ShowDesktop, IsBackground, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor, ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState
FROM Source ORDER BY SortOrder FROM Source ORDER BY SortOrder
"""; """;
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@@ -637,6 +663,14 @@ public class LayoutStore : IDisposable
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23), ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24), ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25), 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<MediaPlaybackState>(reader.GetString(29), out var ps)
? ps
: MediaPlaybackState.Stopped,
}; };
if (!sourcesByScene.TryGetValue(sceneId, out var list)) if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>(); sourcesByScene[sceneId] = list = new List<Source>();
@@ -871,13 +905,13 @@ public class LayoutStore : IDisposable
ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder, ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor, ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri) WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor, $x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort, $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs, $chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor, $chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri) $webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); 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 chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text); var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
var webUriP = cmd.Parameters.Add("$webUri", 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) foreach (var scene in scenes)
{ {
@@ -941,6 +979,10 @@ public class LayoutStore : IDisposable
chatMemberColorP.Value = source.ChatMemberColor; chatMemberColorP.Value = source.ChatMemberColor;
chatSuperChatColorP.Value = source.ChatSuperChatColor; chatSuperChatColorP.Value = source.ChatSuperChatColor;
webUriP.Value = (object?)source.WebUri ?? DBNull.Value; 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(); cmd.ExecuteNonQuery();
} }
} }
+4 -4
View File
@@ -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. **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) 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`: `FilePath`, `IsLooping`, `Volume` (0-1), `PlaybackState` 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 4. ☐ `VideoFrameSource`: FFmpeg-based video decoder → `VideoFrame` pipeline
5. ☐ Frame capture from video file (decode at native FPS, output BGRA8 frames) 5. ☐ Frame capture from video file (decode at native FPS, output BGRA8 frames)
6. ☐ Wire into `FramePump` resolver — `Source { Type: MediaSource }` → latest video frame 6. ☐ Wire into `FramePump` resolver — `Source { Type: MediaSource }` → latest video frame
@@ -388,4 +388,47 @@ public class LayoutStorePersistenceTests
try { File.Delete(path); } catch { /* best-effort cleanup */ } 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<Source>(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 */ }
}
}
} }