TASK 18: local (record-only) recording pipeline + top-bar pills/light/Start-End button UX

Encoder dual-output: StreamEnabled/RecordEnabled/RecordPath options; per-output
ffmpeg block (stream ->flv, record ->mp4); throw unless at least one output.

RecordingFile: auto-name ty-<yyyymmdd>-<start hhmm>-0000.mp4, rename-on-stop to
ty-...-<len hhmmss>.mp4 with numeric-suffix on collision. LayoutStore persists
record folder. MainViewModel: REC/ON-AIR pills, status light, single Start<->End
button (replaces Start/Stop variants), StopStream no longer signs out, explicit
session timer. About overlay widened. Sign In label + spacing before Start.

Tests: 244 passed / 246 (2 pre-existing: AudioPipeline gains-mute, RoundClip).
Build 0 warnings. Scope check clean.

Memory-map correction (derived-solution rule, 2026-08-29): image-shrink recipe was
never recorded when first done, so it was re-derived. Added Derived-solution/recipes
rule to AGENTS.md; MyMistakes.md now a permanent recipes registry (records the
verified WPF-imaging shrink recipe); schema.md rows MyMistakes as registry; ai.md
points to the rule. README hero image replaced: 3.2MB screenshot -> 1400x794 ->
188KB docs/ytLlive-preview.jpg (JPEG q82).
This commit is contained in:
2026-08-29 09:02:55 -07:00
parent 1e633f9bd9
commit a9eb360288
20 changed files with 708 additions and 151 deletions
+9
View File
@@ -53,6 +53,15 @@ at a problem.
- **The first time a fact costs a hunt (secrets path, DB path, port, recovery - **The first time a fact costs a hunt (secrets path, DB path, port, recovery
source), record it** in `ai.md`/indexes/`HANDOFF.md` so the next session never source), record it** in `ai.md`/indexes/`HANDOFF.md` so the next session never
re-hunts it. re-hunts it.
- **Derived-solution rule (record once, then grep):** the moment you work out a
reusable solution — a recipe, workaround, or how-to (e.g. how to shrink and
re-encode an image for the README) — write it into `MyMistakes.md` **in the same
session**, before you finish the task. And always **grep `MyMistakes.md` first**
when you hit a "I've done this before but have to figure it out again" wall. A
solution recorded once ends the re-derivation loop; an un-recorded solution is a
guarantee you will re-derive it and make the user sit through it again. This is
the fix for the image-shrink incident (2026-08-29): the recipe was never written
down, so it had to be worked out from scratch a second time.
- **Follow existing conventions** — MVVM, `RelayCommand` for actions, - **Follow existing conventions** — MVVM, `RelayCommand` for actions,
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml` `ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
(merged once in `App.xaml`; never duplicate per-window). (merged once in `App.xaml`; never duplicate per-window).
+57 -49
View File
@@ -2,66 +2,74 @@
## Branch / Commit State ## Branch / Commit State
**`main`**, working tree clean. Web-source bounding work is **DONE, verified, committed, and **`main`**, working tree has an **uncommitted TASK 18 work unit** (record-only pipeline + top-bar
PUSHED (2026-08-28)** — the first push in a while; the web feature is complete and on origin. pills/light/button UX + About resize + now button-label Start/End simplification) **plus a docs/image
No feature branches pre-1.0: all work lands on `main` per work unit. memory-map fix** (derived-solution rule + README hero image). `origin/main` and head are both at
**`1e633f9`** (spin-guard rule), already pushed. This session's changes are **NOT committed** yet — see
"Next steps".
## The web-source rendering work (2026-08-28) — DONE: canvas-size viewport + ALPHA-BBOX crop No feature branches pre-1.0: all work lands on `main` per work unit (pending the creator's go-ahead to
commit + push).
**VERDICT (creator, 2026-08-28):** bounding is PERFECT. Verified with a second widget resource that ## Memory-map corrections (2026-08-29 — "don't re-derive" fix)
has no glow effect. The earlier "remaining defect / dead-space gap" was the WIDGET'S OWN GLOW
effect pushing out its perceived borders — NOT a code bug. The alpha-bbox was already hugging the
glow halo; with a plain widget the dashed selection border sits exactly on the widget edge.
**History of failures (log wiped from MyMistakes.md per task-complete rule; see git history):** - **Incident:** the image-shrink recipe (for `docs/ytLlive-preview.jpg`) was never recorded when first
box-size rendering clipped full-bleed widgets to their upper-left chunk; `JSON.stringify` return done, so it was re-derived from scratch. Fixed by:
double-encoded under `ExecuteScriptAsync` (crop silently disabled); `scrollWidth/scrollHeight` at - **`AGENTS.md`** → Working rules: new **🔬 Derived-solution rule** (record a one-off solution in
canvas size = whole viewport for full-canvas wrappers (no trim); DOM-element-union measured an `MyMistakes.md` the same session; grep it first on any "did this before?" wall).
unsettled layout on NavigationCompleted (BROKE the image); sizing the container to the crop - **`MyMistakes.md`** → contract expanded: per-task failure log **+ RECIPES REGISTRY** (permanent, NOT
(`ab29ec8`) made things worse (reverted). 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).
**The model that shipped:**
- `Services/WebView2Manager.cs` — page renders at 1920×1080 (stable, never tracked); `overflow:hidden`
+ transparent-bg injection. `FindContentBounds` scans the Bgra32 capture per frame and crops to the
bounding box of NON-TRANSPARENT pixels — the widget's true rendered extent, measured from the frame
itself. No DOM query, no layout timing: it can never truncate visible content (worst case = full
frame = the confirmed-good image). `Stretch="Fill"` maps the cropped frame flush under the selection
box → the box hugs the widget on all four sides. `QueryContentBoundsAsync` + the JS script are
DELETED.
- `MainWindow.xaml` — web `<Image>` Stretch set to Fill via the IsWebSource DataTrigger (unchanged).
- Invariant: **render at canvas size; crop to the alpha extent; Fill to the box.**
- Unit tests: 7/7 (3 `FindContentBounds`, 4 control-size.)
**Property panel / sliders (2026-08-28):** web URI ✕ clears the textbox; web ✓/✕ icon buttons ## TASK 18 — Local recording (in progress, 2026-08-29)
`IsTabStop="False"` so Tab walks X→Y→W→H→URI; Slider style `IsMoveToPointEnabled="True"`
restores click-anywhere-on-bar. All three confirmed fixed by the creator.
Build: 0 warnings; tests 7/7 (WebView2Manager). Rejected for the record: box-sized viewport (upper-left **Shipped in this (uncommitted) change:**
truncation), fixed 1920×1080 + `Uniform` (letterbox dead space), CSS margin/100% reset (enlarged gap), - `EncoderOptions` + `FfmpegArgs` + `FfmpegEncoder`: independent `StreamEnabled`/`RecordEnabled`/
viewport==box (reflow/truncation/scrollbars), DOM-union crop (broke the image), container==content `RecordPath`; per-output blocks (`-f flv` stream, `-f mp4` record); encoder throws unless ≥1 output.
auto-fit sized to the crop (`ab29ec8`, reverted). - `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 ## Key facts / landmines
- **Why the web-box saga spun (2026-08):** nine commits of in-head theorizing for a problem - **Pre-existing test failures (do not chase):** `AudioPipelineTests.Mix_HonorsProviderGains…` +
OBS already solved (browser source = fixed canvas, box = user-sized, content clips at the `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…` — fail on `main` with this change unapplied.
edge). The apparent gap was the widget's CSS glow, not a bug. → AGENTS.md now mandates All other tests pass (incl. the 7 new recording tests).
external research on the second failed fix and proactive scans for new features. - **App running locks `ytLive.exe`** → build fails MSB3027 until the creator exits the app.
- **Startup diagnostics:** `%APPDATA%\ytLlive\startup.log` (`Helpers/AppLog.cs`).
- **App running locks `ytLive.exe`** → build fails MSB3027 until creator exits the app.
- **Startup diagnostics:** unhandled exceptions + checkpoints go to `%APPDATA%\ytLlive\startup.log`
(`Helpers/AppLog.cs`). On a silent startup crash, read that.
- **Pre-existing test failures (unrelated to web source, do not chase):**
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` +
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`.
- **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 new feature branches pre-1.0. - **Good Dog Rule:** ONE integration test per change; no feature branches pre-1.0.
- **Scope Lock:** declare the file list, run `scripts/scope-check.sh` before every commit. - **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. - **Every feature change ships memory updates in the SAME commit** — `TASKS.md` status + this file.
- No `CODING.md`; conventions live in `AGENTS.md` + `ai.md`. - No `CODING.md`; conventions live in `AGENTS.md` + `ai.md`.
## Commit scope (declared)
`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)`
## Next steps ## Next steps
1. Done (2026-08-28): web-source bounding verified PERFECT and pushed. If an animated widget causes
visible crop jitter later, add hysteresis as a follow-up — not queued. 1. Creator to decide: commit + push this TASK 18 work unit (scope list above), or refine first
2. Graceful handling of navigation errors / invalid URIs (TASK 17 ×9) — only if the creator asks. (verify the rename + dual output in the running app before committing).
3. Continue queue: TASK 18 local recording, TASK 21 media source, TASK 22 audio sync offset. 2. Continue queue: TASK 21 media source, TASK 22 audio sync offset.
+34 -14
View File
@@ -111,10 +111,23 @@
</TextBlock> </TextBlock>
</Button> </Button>
<!-- REC sign (center): dark offline, red live, darker red when private --> <!-- REC / ON-AIR pills (intent) + status signs (reality) -->
<StackPanel Grid.Column="1" Orientation="Horizontal" <StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Center" VerticalAlignment="Center">
<Ellipse Width="12" Height="12" VerticalAlignment="Center" <!-- REC pill: local recording needs no sign-in -->
<TextBlock Text="REC" Foreground="#e94560" FontWeight="Bold" FontSize="12"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
IsChecked="{Binding RecordPillOn}"/>
<!-- ON-AIR pill: greyed out until signed in -->
<TextBlock Text="ON-AIR" Foreground="#e94560" FontWeight="Bold" FontSize="12"
VerticalAlignment="Center" Margin="16,0,6,0"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
IsChecked="{Binding OnAirPillOn}"
IsEnabled="{Binding CanToggleOnAir}"
ToolTip="{Binding AccountStatusLightToolTip}"/>
<!-- Status signs: REC dot + ON-AIR dot -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/> Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/> <TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
<!-- ON-AIR sign: gray offline, red public, green private --> <!-- ON-AIR sign: gray offline, red public, green private -->
@@ -134,7 +147,9 @@
<!-- Three-state top bar: logged-out / idle / live --> <!-- Three-state top bar: logged-out / idle / live -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Status light --> <!-- Status light -->
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center"> <Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountStatusLightToolTip}"
x:Name="AccountLight">
<Ellipse.Style> <Ellipse.Style>
<Style TargetType="Ellipse"> <Style TargetType="Ellipse">
<Setter Property="Fill" Value="#ef4444"/> <Setter Property="Fill" Value="#ef4444"/>
@@ -162,26 +177,30 @@
Visibility="Collapsed"/> Visibility="Collapsed"/>
</Grid> </Grid>
</Border> </Border>
<!-- Log In button (disconnected) --> <!-- Sign In button (disconnected + offline) -->
<Button Content="Log In" Style="{StaticResource YtButton}" <Button Content="Sign In" Style="{StaticResource YtButton}"
Margin="0,0,12,0"
Command="{Binding StartStreamCommand}" Command="{Binding StartStreamCommand}"
Visibility="{Binding IsConnected, Converter={StaticResource InverseBoolToVis}}"/> Visibility="{Binding ShowLogInButton, Converter={StaticResource BoolToVis}}"/>
<!-- Start Stream button (connected + offline) --> <!-- Primary Start button (offline): dynamic label from the pills -->
<Button Content="Start Stream" Style="{StaticResource YtButton}" <Button Content="{Binding PrimaryButtonText}" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}" Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"> Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu> <Button.ContextMenu>
<ContextMenu> <ContextMenu>
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
<Separator/>
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/> <MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
<Separator/> <Separator/>
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/> <MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu> </ContextMenu>
</Button.ContextMenu> </Button.ContextMenu>
</Button> </Button>
<!-- End Stream button (live) --> <!-- End button (live OR recording) — the only button whose
<Button Content="End Stream" Style="{StaticResource YtButton}" label changes; "Stop Recording" while recording -->
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}" Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding ShowEndStreamButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -1521,9 +1540,9 @@
</Button.Template> </Button.Template>
</Button> </Button>
<Border Background="#0A0C1A" CornerRadius="8" Width="520" MaxHeight="560" <Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center"
Padding="24" BorderBrush="#333" BorderThickness="1"> Padding="28" BorderBrush="#333" BorderThickness="1">
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
@@ -1588,6 +1607,7 @@
<!-- About --> <!-- About -->
<Grid Grid.Row="1" Margin="0,16,0,0" <Grid Grid.Row="1" Margin="0,16,0,0"
Background="#171928"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}"> Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<!-- Hub view --> <!-- Hub view -->
+44 -3
View File
@@ -1,5 +1,46 @@
# MyMistakes.md # MyMistakes.md
> One task at a time. Updated before every commit touching that task: current iteration + > **Two jobs**, distinguished by heading:
> why the last one failed. On task complete, committed AND pushed → truncate to this stub. >
> A new task does NOT seed this file until its first failure. > 1. **Per-task failure log** — updated before every commit touching that task:
> current iteration + why the last one failed. On task complete, committed AND
> pushed → truncate to this stub. A new task does NOT seed this file until its
> first failure.
> 2. **Recipes registry** (DERIVED-SOLUTION RULE, see `AGENTS.md` 🔬) — the durable
> home for one-off derived solutions, recipes, and how-tos. The moment you work
> out a reusable solution, write it here **in the same session**. GREP THIS FILE
> FIRST when you hit a "I've done this before but have to figure it out again"
> wall. Recipe entries stay permanently (they are NOT truncated on task
> completion) — only the failure log truncates.
## 🔬 Recipes registry
### Shrink / re-encode an image for the README (screenshots → small hero image)
Worked out 2026-08-29 (the recipe was NEVER recorded the first time it was done, so
it had to be re-derived from scratch — that's the incident this entry exists to end).
**Approach:** a throwaway Windows-dotnet console app uses WPF's imaging stack
(`System.Windows.Media.Imaging`) — same framework the app runs on, zero NuGet
packages, high-quality downscale via `TransformedBitmap`. Screenshots compress
**far smaller as JPEG than PNG** (PNG 1400px = ~1.2MB; JPEG q82 1400px = ~188KB).
**Recipe (run via the Windows dotnet host from WSL):**
1. Create `imgresize.csproj` targeting `net8.0-windows` with `<UseWPF>true</UseWPF>`
(SDK controller). Put it in a Windows-visible temp path, e.g.
`C:\Users\gramp\AppData\Local\Temp\imgresize` — NOT `/tmp` (Windows dotnet can't
reach a Linux-only path reliably).
2. `Program.cs`: load `BitmapImage` (`CacheOption=OnLoad` → `Freeze()`), downscale
with `TransformedBitmap(src, new ScaleTransform(scale, scale))` to max width
(1400 for the README hero), encode with `JpegBitmapEncoder { QualityLevel = 82 }`,
save.
3. Run:
```bash
"/mnt/c/Program Files/dotnet/dotnet.exe" run -c Release --project .
-- "C:\Users\gramp\Downloads\Screenshot 2026-08-29 075626.png"
"C:\Users\gramp\Documents\Code\projects\ytLive\docs\ytLlive-preview.jpg" 1400
```
4. Point `README.md` at the `.jpg` (not `.png`).
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
+1 -1
View File
@@ -8,7 +8,7 @@ A creator-proof live-streaming and recording app for YouTube — native Windows
## Screenshot ## Screenshot
![ytLlive — main window with scenes, sources, and live preview](docs/ytLlive-preview.png) ![ytLlive — main window with scenes, sources, and live preview](docs/ytLlive-preview.jpg)
## Design Principle ## Design Principle
+13
View File
@@ -19,6 +19,19 @@ public sealed class EncoderOptions
public int Fps { get; init; } = 60; public int Fps { get; init; } = 60;
public int BitrateKbps { get; init; } = 8000; public int BitrateKbps { get; init; } = 8000;
/// <summary>Push to <see cref="RtmpUrl"/> (the ON-AIR side). Defaults on so
/// the pre-recording callers behave unchanged; the VM sets it from the ON-AIR
/// pill.</summary>
public bool StreamEnabled { get; init; } = true;
/// <summary>Write a local file at <see cref="RecordPath"/> (the REC side).
/// False unless the REC pill is on.</summary>
public bool RecordEnabled { get; init; }
/// <summary>Local MP4 output path for recording. Required when
/// <see cref="RecordEnabled"/> is true.</summary>
public string? RecordPath { get; init; }
/// <summary>H.264 encoder name for <c>-c:v</c>; the encoder probes and prefers /// <summary>H.264 encoder name for <c>-c:v</c>; the encoder probes and prefers
/// nvenc → qsv → amf → libopenh264 when not forced.</summary> /// nvenc → qsv → amf → libopenh264 when not forced.</summary>
public string? VideoEncoder { get; init; } public string? VideoEncoder { get; init; }
+39 -29
View File
@@ -12,40 +12,50 @@ public static class FfmpegArgs
{ {
public static IReadOnlyList<string> Build(EncoderOptions options, string videoEncoder) public static IReadOnlyList<string> Build(EncoderOptions options, string videoEncoder)
{ {
var gop = options.GopSize; var args = new List<string>
return {
[ "-hide_banner", "-loglevel", "info",
"-hide_banner", "-stats", "-stats_period", "0.5",
"-loglevel", "info",
"-stats",
"-stats_period", "0.5",
"-re", "-re",
"-f", "rawvideo", "-f", "rawvideo", "-pix_fmt", "bgra",
"-pix_fmt", "bgra",
"-video_size", $"{options.Width}x{options.Height}", "-video_size", $"{options.Width}x{options.Height}",
"-framerate", options.Fps.ToString(), "-framerate", options.Fps.ToString(),
"-i", "pipe:0", "-i", "pipe:0",
"-f", "f32le", "-f", "f32le", "-ar", options.AudioSampleRate.ToString(),
"-ar", options.AudioSampleRate.ToString(),
"-ac", options.AudioChannels.ToString(), "-ac", options.AudioChannels.ToString(),
"-i", $@"\\.\pipe\{options.AudioPipeName}", "-i", $@"\\.\pipe\{options.AudioPipeName}",
"-map", "0:v", };
"-map", "1:a",
"-c:v", videoEncoder, // Each output is a self-contained block: its own -map, codec options,
"-b:v", $"{options.BitrateKbps}k", // format, and destination. Record+stream encodes the video twice off the
"-maxrate", $"{options.BitrateKbps}k", // one raw input pipe (cheap on NVENC).
"-bufsize", $"{options.BitrateKbps * 2}k", if (options.StreamEnabled)
"-g", gop.ToString(), {
"-keyint_min", gop.ToString(), AddVideoTags(args, options, videoEncoder);
"-sc_threshold", "0", args.AddRange(["-c:a", "aac", "-b:a", "128k",
"-bf", "0", "-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
"-pix_fmt", "yuv420p", args.AddRange(["-f", "flv", options.RtmpUrl]);
"-c:a", "aac", }
"-b:a", "128k",
"-ar", options.AudioSampleRate.ToString(), if (options.RecordEnabled)
"-ac", options.AudioChannels.ToString(), {
"-f", "flv", AddVideoTags(args, options, videoEncoder);
options.RtmpUrl, args.AddRange(["-c:a", "aac",
]; "-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
args.AddRange(["-f", "mp4", options.RecordPath!]);
}
return args;
}
private static void AddVideoTags(List<string> args, EncoderOptions options, string videoEncoder)
{
var gop = options.GopSize;
args.AddRange(["-map", "0:v", "-map", "1:a"]);
args.AddRange(["-c:v", videoEncoder]);
args.AddRange(["-b:v", $"{options.BitrateKbps}k", "-maxrate", $"{options.BitrateKbps}k",
"-bufsize", $"{options.BitrateKbps * 2}k"]);
args.AddRange(["-g", gop.ToString(), "-keyint_min", gop.ToString(),
"-sc_threshold", "0", "-bf", "0", "-pix_fmt", "yuv420p"]);
} }
} }
+6 -2
View File
@@ -43,8 +43,12 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default) public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{ {
if (options == null) throw new ArgumentNullException(nameof(options)); if (options == null) throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(options.RtmpUrl)) var hasStream = options.StreamEnabled && !string.IsNullOrWhiteSpace(options.RtmpUrl);
throw new ArgumentException("An RTMP ingestion URL is required.", nameof(options)); var hasRecord = options.RecordEnabled && !string.IsNullOrWhiteSpace(options.RecordPath);
if (!hasStream && !hasRecord)
throw new ArgumentException(
"At least one output is required — a stream needs an RTMP URL, a recording needs an output path.",
nameof(options));
lock (_gate) lock (_gate)
{ {
+1 -1
View File
@@ -87,7 +87,7 @@ public sealed class FramePump : IDisposable
var options = _encoderOptions(); var options = _encoderOptions();
if (options == null) if (options == null)
{ {
_log?.Invoke("FramePump: no RTMP URL available (live-stream create lands in TASK 5) — encoder skipped"); _log?.Invoke("FramePump: no output configured (neither streaming nor recording) — encoder skipped");
lock (_gate) _started = false; lock (_gate) _started = false;
return; return;
} }
+24
View File
@@ -735,6 +735,30 @@ public class LayoutStore : IDisposable
insert.ExecuteNonQuery(); insert.ExecuteNonQuery();
} }
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
public string? LoadRecordFolder()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'RecordFolder';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveRecordFolder(string? folder)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'RecordFolder';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(folder))
return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('RecordFolder', $folder);";
insert.Parameters.AddWithValue("$folder", folder);
insert.ExecuteNonQuery();
}
/// <summary>The cached reusable stream (TASK 5), or null if never created. /// <summary>The cached reusable stream (TASK 5), or null if never created.
/// Survives restarts so the pump has its RTMP URL the moment go-live starts /// Survives restarts so the pump has its RTMP URL the moment go-live starts
/// instead of waiting on the YouTube round-trip.</summary> /// instead of waiting on the YouTube round-trip.</summary>
+27
View File
@@ -0,0 +1,27 @@
namespace ytLive.Services;
/// <summary>
/// Pure filename logic for local recordings (TASK 18). The user-requested naming
/// is <c>ty-yyyymmdd-hhmm-hh2mm2.mp4</c>: <c>ty</c> is the brand marker, the first
/// <c>hhmm</c> is the hour+minute the recording STARTED (saved), and the second
/// <c>hh2mm2</c> is the hour+minute of the recording's LENGTH. Length is only known
/// when recording ends, so the file is created with a <c>0000</c> length and renamed
/// on stop (see <see cref="FinalizeFileName"/>).
/// </summary>
public static class RecordingFile
{
/// <summary>A start placeholder — the length is filled in on stop.</summary>
public const string StartLengthSuffix = "0000";
public static string BuildStartName(DateTime start)
=> $"ty-{start:yyyyMMdd}-{start:HHmm}-{StartLengthSuffix}";
/// <summary>Final name with the real recording length (fractional hour+minute),
/// e.g. 75 minutes → <c>ty-20260829-1405-0115.mp4</c>.</summary>
public static string BuildFinalName(DateTime start, TimeSpan length)
{
var hh2 = (int)length.TotalHours;
var mm2 = length.Minutes;
return $"ty-{start:yyyyMMdd}-{start:HHmm}-{hh2:00}{mm2:00}";
}
}
+10 -11
View File
@@ -951,18 +951,17 @@ click (volume sliders keep their manual `SetSliderValueFromClick`, harmless dupl
**Goal:** record the stream output to a local file, with or without simultaneously streaming. **Goal:** record the stream output to a local file, with or without simultaneously streaming.
### Status: ☐ Not started — required for v1 ### Status: 🔄 In progress — encoder/CLI + VM + top-bar UX shipped (2026-08-29)
1. ☐ Extend `EncoderOptions` with `OutputPath?` and recording mode flags 1. ✅ `EncoderOptions` extended with `StreamEnabled` / `RecordEnabled` / `RecordPath` (independent intent flags)
2. ☐ `FfmpegArgs.Build` gains a local-file branch: MP4/MKV container for file output 2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags`
3. ☐ Three modes: 3. ✅ Three modes via pills: record-only / stream-only / stream+record (single ffmpeg, dual output)
- **Record only** — no RTMP push, just local file (for pre-recorded content) 4. ✅ Top-bar REC + ON-AIR pill toggles, status lights (REC green when recording, ON-AIR green when live), dynamic primary-button text, account status-light tooltip
- **Stream only** — RTMP push, no local file (current behavior) 5. ✅ Record-folder persistence (`LayoutStore` `RecordFolder` key) + picker (`ChooseRecordFolderCommand`)
- **Stream + record** — dual output via `-f flv rtmp://... -f mp4 file.mp4` 6. 🟡 Branding flash carries into local recordings — same frame path as streaming (verify in the running app)
4. ☐ Recording controls in UI (REC button, file path picker, duration display) 7. ✅ Output folder `%APPDATA%\ytLlive\recordings\` default; auto-name `ty-<yyyymmdd>-<hhmm start>-0000.mp4` at start, **rename-on-stop** to `ty-…-<hh2mm2 length>.mp4` (numeric suffix on collision)
5. ☐ Schema migration for recording preferences (default output path, format)
6. ☐ Branding flash appears in local recordings too (free tier) Remaining: manual rename dialog for the user (toast actions unsupported — modal window was planned but parked), running-app verification of the rename + dual output.
7. ☐ Output folder: `%APPDATA%\ytLlive\recordings\` with timestamped filenames
### Design decisions ### Design decisions
+296 -32
View File
@@ -78,6 +78,16 @@ public class MainViewModel : ViewModelBase
private double _recDotPulse = 1.0; private double _recDotPulse = 1.0;
private TimeSpan _liveElapsed; private TimeSpan _liveElapsed;
// ─── Recording (TASK 18): independent REC / ON-AIR pills. The pills are
// ─── intent; IsRecording / IsLive are reality. ON-AIR needs a sign-in.
private bool _recordPillOn;
private bool _onAirPillOn;
private bool _isRecording;
private string? _recordFolder;
private string? _activeRecordPath;
private DateTime _recordStartTime;
private TimeSpan _recordLength;
private bool _isSettingsOpen; private bool _isSettingsOpen;
private bool _isBugOpen; private bool _isBugOpen;
private bool _isFeatureOpen; private bool _isFeatureOpen;
@@ -242,7 +252,7 @@ public class MainViewModel : ViewModelBase
/// <summary>True when offline — edit mode is available. When live/recording, /// <summary>True when offline — edit mode is available. When live/recording,
/// all edit operations are locked out (gun safety).</summary> /// all edit operations are locked out (gun safety).</summary>
public bool IsEditMode => StreamStatus == StreamStatus.Offline; public bool IsEditMode => StreamStatus == StreamStatus.Offline && !IsRecording;
public ImageSource? ActiveBackgroundImage public ImageSource? ActiveBackgroundImage
{ {
@@ -375,7 +385,7 @@ public class MainViewModel : ViewModelBase
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break; case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break; case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
case Services.HotkeyId.StartStopStream: case Services.HotkeyId.StartStopStream:
if (IsLive) if (IsLive || IsRecording)
{ {
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null); if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
} }
@@ -509,7 +519,13 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(OnAirBrush)); OnPropertyChanged(nameof(OnAirBrush));
OnPropertyChanged(nameof(IsLivePrivate)); OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowStartStream)); OnPropertyChanged(nameof(IsStreamingStart));
OnPropertyChanged(nameof(PrimaryButtonText));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(ShowLogInButton));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(AccountStatusLightToolTip));
OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowPreviewPlaceholder));
UpdateLiveVisuals(); UpdateLiveVisuals();
@@ -523,7 +539,16 @@ public class MainViewModel : ViewModelBase
set set
{ {
if (SetProperty(ref _isConnected, value)) if (SetProperty(ref _isConnected, value))
OnPropertyChanged(nameof(ShowStartStream)); {
// ON-AIR requires a sign-in; dropping the session turns the pill
// off so the state can never offer a stream without an account.
if (!value) OnAirPillOn = false;
OnPropertyChanged(nameof(CanToggleOnAir));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryButtonText));
OnPropertyChanged(nameof(IsStreamingStart));
OnPropertyChanged(nameof(AccountStatusLightToolTip));
}
} }
} }
@@ -967,11 +992,89 @@ public class MainViewModel : ViewModelBase
public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming; public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase); public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560"; public string RecDotBrush => !IsRecording ? "#555555" : "#e94560";
public string RecTextBrush => IsLive ? "#ffffff" : "#888888"; public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888";
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55; public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55;
public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560"; public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560";
public bool ShowStartStream => IsOffline && IsConnected;
// ─── REC / ON-AIR pills (TASK 18) ───
public bool RecordPillOn
{
get => _recordPillOn;
set
{
if (!SetProperty(ref _recordPillOn, value)) return;
OnPropertyChanged(nameof(PrimaryButtonText));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(CanStartSession));
}
}
public bool OnAirPillOn
{
get => _onAirPillOn;
set
{
if (value && !CanToggleOnAir) return; // needs a sign-in
if (!SetProperty(ref _onAirPillOn, value)) return;
OnPropertyChanged(nameof(PrimaryButtonText));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(CanStartSession));
}
}
/// <summary>The ON-AIR pill (and thus live streaming) needs a YouTube sign-in.</summary>
public bool CanToggleOnAir => IsConnected;
/// <summary>Whether the session is actively recording — the REC status light
/// turns green only when this is true (the pill is intent, this is reality).</summary>
public bool IsRecording
{
get => _isRecording;
private set
{
if (!SetProperty(ref _isRecording, value)) return;
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(EndButtonText));
UpdateSessionTimer();
}
}
/// <summary>"Start Streaming" unless ONLY the record pill is on → "Start Recording".</summary>
public string PrimaryButtonText => IsStreamingStart ? "Start Streaming" : "Start Recording";
/// <summary>Streaming (not record-only) — the ON-AIR pill is on OR we're already live.</summary>
public bool IsStreamingStart => OnAirPillOn || IsLive;
/// <summary>The primary button shows when there's something to start, but never
/// while a session is running — once recording/live the ONLY control is the End
/// button.</summary>
public bool ShowPrimaryStartButton =>
IsOffline && !IsRecording && !IsLive
&& (IsConnected || (RecordPillOn && !OnAirPillOn));
/// <summary>Either output selected makes the button actionable.</summary>
public bool CanStartSession => RecordPillOn || OnAirPillOn;
/// <summary>The End button shows while recording OR live.</summary>
public bool ShowEndStreamButton => IsLive || IsRecording;
/// <summary>The one button whose label changes: while recording it reads
/// "Stop Recording", while live "End Stream".</summary>
public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream";
public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
/// <summary>Tooltip for the account status light explaining the color.</summary>
public string AccountStatusLightToolTip =>
IsLive ? "Live on YouTube — the top bar is red to show the stream is on the air."
: IsConnected ? "You're signed in to YouTube. The light is green; ON-AIR is available."
: "Signed out — the light is red and the ON-AIR pill is greyed out. You can still record locally.";
public bool ShowChatInactiveMessage => !IsLive; public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0; public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
@@ -1509,6 +1612,7 @@ public class MainViewModel : ViewModelBase
public ICommand OpenSocialDialogCommand { get; } public ICommand OpenSocialDialogCommand { get; }
public ICommand StartStreamCommand { get; } public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; } public ICommand EndStreamCommand { get; }
public ICommand ChooseRecordFolderCommand { get; }
public ICommand OpenSettingsCommand { get; } public ICommand OpenSettingsCommand { get; }
public ICommand OpenBugCommand { get; } public ICommand OpenBugCommand { get; }
public ICommand OpenFeatureCommand { get; } public ICommand OpenFeatureCommand { get; }
@@ -1595,8 +1699,9 @@ public class MainViewModel : ViewModelBase
OpenCoffeeCommand = new RelayCommand(_ => OpenUrl(CoffeeUrl)); OpenCoffeeCommand = new RelayCommand(_ => OpenUrl(CoffeeUrl));
OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable); OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
OpenLicensesCommand = new RelayCommand(_ => ShowLicenses()); OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
StartStreamCommand = new RelayCommand(_ => BeginGoLive()); StartStreamCommand = new RelayCommand(_ => StartSession());
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive); EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow()); SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs()); SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile()); OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
@@ -1608,6 +1713,7 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath); _layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath; _activeLayoutPath = _layoutStore.ActivePath;
_recordFolder = _layoutStore.LoadRecordFolder();
// Load saved hotkey bindings (filled with defaults for any unbound actions). // Load saved hotkey bindings (filled with defaults for any unbound actions).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings(); _hotkeyBindings = _layoutStore.LoadHotkeyBindings();
@@ -3003,7 +3109,44 @@ public class MainViewModel : ViewModelBase
_chatFadeTimer.Start(); _chatFadeTimer.Start();
} }
private void BeginGoLive() /// <summary>Entry point for the primary Start button (and the start/stop hotkey):
/// routes to a record session or the YouTube go-live dialog by the pill state.</summary>
private void StartSession()
{
if (IsLive || IsRecording) return;
var streaming = OnAirPillOn;
var recording = RecordPillOn;
if (!streaming && !recording) return;
if (!streaming)
{
BeginRecordOnly();
return;
}
BeginGoLive(recording);
}
/// <summary>Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording
/// is local and works signed-out. Generates the auto-named file and starts the
/// pipeline immediately.</summary>
private async void BeginRecordOnly()
{
var path = StartRecordFile();
if (path == null)
{
_notifications.Error("Couldn't record", "No record output path could be created.");
return;
}
_recordStartTime = DateTime.Now;
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
ResetHealth(StreamStatus.Offline);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
IsRecording = true;
await _framePump.StartAsync();
}
private void BeginGoLive(bool alsoRecord)
{ {
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel) var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{ {
@@ -3023,10 +3166,50 @@ public class MainViewModel : ViewModelBase
StreamStatus = StreamStatus.Streaming; StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming); ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
var recordStarted = false;
if (alsoRecord)
{
if (StartRecordFile() == null)
{
_notifications.Error("Couldn't record",
"No record output path could be created — streaming continues without recording.");
}
else
{
_recordStartTime = DateTime.Now;
recordStarted = true;
}
}
IsRecording = recordStarted;
_ = PrepareAndStartLiveAsync(); _ = PrepareAndStartLiveAsync();
} }
} }
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "recordings");
Directory.CreateDirectory(folder);
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
_activeRecordPath = path;
return path;
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: failed to prepare output path");
return null;
}
}
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a /// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP /// broadcast bound to it, then start the frame pump. The pump reads the RTMP
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set /// URL once at startup (FramePump.StartAsync), so the stream URL must be set
@@ -3150,9 +3333,13 @@ public class MainViewModel : ViewModelBase
HealthIssueBanner = report.Text; HealthIssueBanner = report.Text;
} }
private void StopStream() private async void StopStream()
{ {
var wasLive = IsLive;
var wasRecording = IsRecording;
StreamStatus = StreamStatus.Offline; StreamStatus = StreamStatus.Offline;
IsRecording = false;
WindowTitle = "LlamaCasty"; WindowTitle = "LlamaCasty";
ResetHealth(StreamStatus.Offline); ResetHealth(StreamStatus.Offline);
// Stop chat polling first (TASK 9.4) — no more messages arriving while // Stop chat polling first (TASK 9.4) — no more messages arriving while
@@ -3160,20 +3347,82 @@ public class MainViewModel : ViewModelBase
_youtubeChat.Stop(); _youtubeChat.Stop();
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input, // Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
// then stop the pump (stdin EOF) — both inputs end and the encoder // then stop the pump (stdin EOF) — both inputs end and the encoder
// finalizes the FLV. Audio capture itself is always-on (preview // finalizes the output. Audio capture itself is always-on (preview
// monitoring); only the live pipe, the pump, and the session stop here. // monitoring); only the live/report pipe, the pump, and the session stop here.
_audioMixer.StopLive(); _audioMixer.StopLive();
_ = _framePump.StopAsync(); await _framePump.StopAsync();
_currentBroadcastId = null; _currentBroadcastId = null;
OnPropertyChanged(nameof(CurrentBroadcastId)); OnPropertyChanged(nameof(CurrentBroadcastId));
// Graceful end completes the session = signs out (the DPAPI token is
// cleared so the next Start Stream requires a fresh sign-in). A crash if (wasRecording) await FinalizeRecordingAsync();
// never runs this, so the token survives and the creator stays signed in.
_youtubeAuth.ClearSession(); // Explicit sign-out only (TASK 18): stopping the stream does NOT clear
TokenStore.Clear(); // the session/token — the creator signs out deliberately via the
IsConnected = false; // Logout / Change Account commands.
SyncConnectedAccount(); AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
AppLog.Write("Stream ended; session signed out"); }
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
/// name collisions. Runs only after the pump has fully closed the file.</summary>
private async Task FinalizeRecordingAsync()
{
var startPath = _activeRecordPath;
_activeRecordPath = null;
if (string.IsNullOrEmpty(startPath)) return;
try
{
while (_framePump.IsRunning) await Task.Delay(50);
if (!File.Exists(startPath))
{
AppLog.Write($"Recording: output not found for rename: {startPath}");
return;
}
_recordLength = _liveElapsed;
var finalName = RecordingFile.BuildFinalName(_recordStartTime, _recordLength) + ".mp4";
var dir = Path.GetDirectoryName(startPath)!;
var finalPath = UniquePath(Path.Combine(dir, finalName));
File.Move(startPath, finalPath);
AppLog.Write($"Recording saved: {finalPath}");
_notifications.Info("Recording saved",
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: rename-on-stop failed");
_notifications.Error("Recording saved",
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
}
}
private static string UniquePath(string path)
{
if (!File.Exists(path)) return path;
var dir = Path.GetDirectoryName(path)!;
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 2; ; i++)
{
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
if (!File.Exists(candidate)) return candidate;
}
}
private void ChooseRecordFolder()
{
var dialog = new Microsoft.Win32.OpenFolderDialog
{
Title = "Choose a folder for local recordings",
InitialDirectory = _recordFolder ??
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
};
if (dialog.ShowDialog() == true)
{
_recordFolder = dialog.FolderName;
_layoutStore.SaveRecordFolder(_recordFolder);
_notifications.Info("Record folder set", _recordFolder);
}
} }
private void OnMicLevelChanged(float level) private void OnMicLevelChanged(float level)
@@ -3389,11 +3638,13 @@ public class MainViewModel : ViewModelBase
private EncoderOptions? BuildEncoderOptions() private EncoderOptions? BuildEncoderOptions()
{ {
var url = _rtmpUrlProvider(); var url = _rtmpUrlProvider();
if (string.IsNullOrWhiteSpace(url)) return null;
var quality = SelectedQuality; var quality = SelectedQuality;
return new EncoderOptions return new EncoderOptions
{ {
RtmpUrl = url, RtmpUrl = url ?? string.Empty,
StreamEnabled = OnAirPillOn,
RecordEnabled = RecordPillOn,
RecordPath = _activeRecordPath,
Width = quality.Width, Width = quality.Width,
Height = quality.Height, Height = quality.Height,
Fps = quality.Fps, Fps = quality.Fps,
@@ -3458,24 +3709,36 @@ public class MainViewModel : ViewModelBase
if (live) if (live)
{ {
_liveElapsed = TimeSpan.Zero;
LiveElapsedText = "00:00:00";
_recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
_liveTimer.Start();
if (BrandFlashEnabled) StartBrandFlashTimer(); if (BrandFlashEnabled) StartBrandFlashTimer();
} }
else else
{ {
_liveTimer.Stop();
_healthPollTimer.Stop(); _healthPollTimer.Stop();
_brandFlashTimer.Stop(); _brandFlashTimer.Stop();
_brandFlashOffTimer.Stop(); _brandFlashOffTimer.Stop();
BrandFlashActive = false; BrandFlashActive = false;
}
UpdateSessionTimer();
}
/// <summary>Runs the elapsed/rec-dot-pulse timer while a session is active —
/// live (Streaming) OR recording. Reset the elapsed when a new session starts.</summary>
private void UpdateSessionTimer()
{
var active = IsLive || IsRecording;
if (active)
{
_recDotPulse = 1.0;
_liveTimer.Start();
}
else
{
_liveTimer.Stop();
LiveElapsedText = "00:00:00"; LiveElapsedText = "00:00:00";
_recDotPulse = 1.0; _recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
} }
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(LiveElapsedText));
} }
// First flash shortly after go-live, then every 300s (the cadence resets on // First flash shortly after go-live, then every 300s (the cadence resets on
@@ -3507,6 +3770,7 @@ public class MainViewModel : ViewModelBase
{ {
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1)); _liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss"); LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0; _recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(RecDotOpacity));
} }
+46 -8
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

+1
View File
@@ -21,6 +21,7 @@ that every other memory file follows. Read `ai.md` first; this file explains
| `ai.md` | **AI guide + session handoff** — architecture, patterns, decisions, the cognitive map home | **yes — start here** | | `ai.md` | **AI guide + session handoff** — architecture, patterns, decisions, the cognitive map home | **yes — start here** |
| `TASKS.md` | Task queue + authoritative YouTube API research facts + task statuses | yes — for status | | `TASKS.md` | Task queue + authoritative YouTube API research facts + task statuses | yes — for status |
| `HANDOFF.md` | Current operational state: what's in flight, landmines, next step, secret/DB/port locations | yes — trust it as current state | | `HANDOFF.md` | Current operational state: what's in flight, landmines, next step, secret/DB/port locations | yes — trust it as current state |
| `MyMistakes.md` | Per-task failure log **+ RECIPES REGISTRY** (derived-solution rule): one-off solutions / how-tos get recorded here the same session, then grepp'd before ever re-deriving them (see `AGENTS.md` 🔬) | grep first on any "did this before?" wall |
| `schema.md` | This file: the conventions below | when in doubt | | `schema.md` | This file: the conventions below | when in doubt |
| `<dir>/index.md` | Per-directory map (progressive disclosure): what lives there + links | when diving into code | | `<dir>/index.md` | Per-directory map (progressive disclosure): what lives there + links | when diving into code |
| `Views/` | Reserved for Views; currently empty | — | | `Views/` | Reserved for Views; currently empty | — |
+50
View File
@@ -175,6 +175,56 @@ public class FfmpegEncoderTests
await Assert.ThrowsAsync<ArgumentException>(() => encoder.StartAsync(new EncoderOptions())); await Assert.ThrowsAsync<ArgumentException>(() => encoder.StartAsync(new EncoderOptions()));
} }
[Fact]
public void Args_RecordOnly_SkipsRtmp_WritesMp4()
{
var args = FfmpegArgs.Build(
new EncoderOptions
{
StreamEnabled = false,
RecordEnabled = true,
RecordPath = @"C:\videos\ty-20260829-1405-0000.mp4",
Width = 1920,
Height = 1080,
Fps = 60,
BitrateKbps = 8000,
},
"libopenh264").ToArray();
Assert.DoesNotContain("flv", args);
Assert.DoesNotContain("rtmp", args);
var idx = Array.IndexOf(args, "mp4");
Assert.True(idx > -1, "record-only must emit a -f mp4 output");
Assert.Equal("-f", args[idx - 1]);
Assert.Equal(@"C:\videos\ty-20260829-1405-0000.mp4", args[idx + 1]);
}
[Fact]
public void Args_DualOutput_HasTwoMapBlocks_FlvAndMp4()
{
var args = FfmpegArgs.Build(
new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
StreamEnabled = true,
RecordEnabled = true,
RecordPath = @"C:\videos\ty-20260829-1405-0000.mp4",
Width = 1280,
Height = 720,
Fps = 30,
BitrateKbps = 4000,
},
"h264_nvenc").ToArray();
// two video maps (one per output block)
Assert.Equal(2, args.Count(a => a == "0:v"));
Assert.Equal(2, args.Count(a => a == "-c:v"));
Assert.Contains("flv", args);
Assert.Contains("mp4", args);
Assert.Contains("rtmp://a.rtmp.youtube.com/live2/key", args);
Assert.Contains(@"C:\videos\ty-20260829-1405-0000.mp4", args);
}
[Fact] [Fact]
public async Task SubmitFrame_WhenNotRunning_Throws() public async Task SubmitFrame_WhenNotRunning_Throws()
{ {
+1 -1
View File
@@ -196,7 +196,7 @@ public class FramePumpTests
Assert.False(pump.IsRunning); Assert.False(pump.IsRunning);
Assert.Equal(0, encoder.StartCount); Assert.Equal(0, encoder.StartCount);
Assert.Contains(log, m => m.Contains("RTMP")); Assert.Contains(log, m => m.Contains("no output configured"));
await pump.StopAsync(); // no-op after a skipped start await pump.StopAsync(); // no-op after a skipped start
Assert.Equal(0, encoder.StopCount); Assert.Equal(0, encoder.StopCount);
+49
View File
@@ -0,0 +1,49 @@
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// Local recording filename rules (TASK 18): <c>ty-&lt;yyyyMMdd&gt;-&lt;HHmm start&gt;-&lt;hh2mm2 length&gt;.mp4</c>.
/// Recordings start with a <c>0000</c> length placeholder and are renamed on stop.
/// </summary>
public class RecordingFileTests
{
[Fact]
public void BuildStartName_UsesDateHourAndMinute_WithZeroLength()
{
var start = new DateTime(2026, 8, 29, 14, 5, 0);
Assert.Equal("ty-20260829-1405-0000", RecordingFile.BuildStartName(start));
}
[Fact]
public void BuildStartName_PadsSingleDigitHourAndMinute()
{
var start = new DateTime(2026, 1, 2, 9, 7, 0);
Assert.Equal("ty-20260102-0907-0000", RecordingFile.BuildStartName(start));
}
[Fact]
public void BuildFinalName_ShortRecording_IsHhmmOfLength()
{
var start = new DateTime(2026, 8, 29, 14, 5, 0);
var length = TimeSpan.FromMinutes(2);
Assert.Equal("ty-20260829-1405-0002", RecordingFile.BuildFinalName(start, length));
}
[Fact]
public void BuildFinalName_HourAndMinutes_ConcatenatesHh2Mm2()
{
var start = new DateTime(2026, 8, 29, 14, 5, 0);
var length = new TimeSpan(1, 15, 0); // 1h 15m
Assert.Equal("ty-20260829-1405-0115", RecordingFile.BuildFinalName(start, length));
}
[Fact]
public void BuildFinalName_OverFlowHours_ShowsTotalHours()
{
var start = new DateTime(2026, 8, 29, 14, 5, 0);
var length = new TimeSpan(12, 30, 0); // 12h 30m
Assert.Equal("ty-20260829-1405-1230", RecordingFile.BuildFinalName(start, length));
}
}