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:
@@ -53,6 +53,15 @@ at a problem.
|
||||
- **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
|
||||
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,
|
||||
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
|
||||
(merged once in `App.xaml`; never duplicate per-window).
|
||||
|
||||
+57
-49
@@ -2,66 +2,74 @@
|
||||
|
||||
## Branch / Commit State
|
||||
|
||||
**`main`**, working tree clean. Web-source bounding work is **DONE, verified, committed, and
|
||||
PUSHED (2026-08-28)** — the first push in a while; the web feature is complete and on origin.
|
||||
No feature branches pre-1.0: all work lands on `main` per work unit.
|
||||
**`main`**, working tree has an **uncommitted TASK 18 work unit** (record-only pipeline + top-bar
|
||||
pills/light/button UX + About resize + now button-label Start/End simplification) **plus a docs/image
|
||||
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
|
||||
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.
|
||||
## Memory-map corrections (2026-08-29 — "don't re-derive" fix)
|
||||
|
||||
**History of failures (log wiped from MyMistakes.md per task-complete rule; see git history):**
|
||||
box-size rendering clipped full-bleed widgets to their upper-left chunk; `JSON.stringify` return
|
||||
double-encoded under `ExecuteScriptAsync` (crop silently disabled); `scrollWidth/scrollHeight` at
|
||||
canvas size = whole viewport for full-canvas wrappers (no trim); DOM-element-union measured an
|
||||
unsettled layout on NavigationCompleted (BROKE the image); sizing the container to the crop
|
||||
(`ab29ec8`) made things worse (reverted).
|
||||
- **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).
|
||||
|
||||
**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
|
||||
`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.
|
||||
## TASK 18 — Local recording (in progress, 2026-08-29)
|
||||
|
||||
Build: 0 warnings; tests 7/7 (WebView2Manager). Rejected for the record: box-sized viewport (upper-left
|
||||
truncation), fixed 1920×1080 + `Uniform` (letterbox dead space), CSS margin/100% reset (enlarged gap),
|
||||
viewport==box (reflow/truncation/scrollbars), DOM-union crop (broke the image), container==content
|
||||
auto-fit sized to the crop (`ab29ec8`, reverted).
|
||||
**Shipped in this (uncommitted) change:**
|
||||
- `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
|
||||
|
||||
- **Why the web-box saga spun (2026-08):** nine commits of in-head theorizing for a problem
|
||||
OBS already solved (browser source = fixed canvas, box = user-sized, content clips at the
|
||||
edge). The apparent gap was the widget's CSS glow, not a bug. → AGENTS.md now mandates
|
||||
external research on the second failed fix and proactive scans for new features.
|
||||
|
||||
- **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`.
|
||||
- **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).
|
||||
- **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 new feature branches pre-1.0.
|
||||
- **Scope Lock:** declare the file list, run `scripts/scope-check.sh` before every commit.
|
||||
- **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.
|
||||
- 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
|
||||
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.
|
||||
2. Graceful handling of navigation errors / invalid URIs (TASK 17 ×9) — only if the creator asks.
|
||||
3. Continue queue: TASK 18 local recording, TASK 21 media source, TASK 22 audio sync offset.
|
||||
|
||||
1. Creator to decide: commit + push this TASK 18 work unit (scope list above), or refine first
|
||||
(verify the rename + dual output in the running app before committing).
|
||||
2. Continue queue: TASK 21 media source, TASK 22 audio sync offset.
|
||||
|
||||
+34
-14
@@ -111,10 +111,23 @@
|
||||
</TextBlock>
|
||||
</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"
|
||||
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}"/>
|
||||
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
|
||||
<!-- ON-AIR sign: gray offline, red public, green private -->
|
||||
@@ -134,7 +147,9 @@
|
||||
<!-- Three-state top bar: logged-out / idle / live -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<!-- 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>
|
||||
<Style TargetType="Ellipse">
|
||||
<Setter Property="Fill" Value="#ef4444"/>
|
||||
@@ -162,26 +177,30 @@
|
||||
Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- Log In button (disconnected) -->
|
||||
<Button Content="Log In" Style="{StaticResource YtButton}"
|
||||
<!-- Sign In button (disconnected + offline) -->
|
||||
<Button Content="Sign In" Style="{StaticResource YtButton}"
|
||||
Margin="0,0,12,0"
|
||||
Command="{Binding StartStreamCommand}"
|
||||
Visibility="{Binding IsConnected, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
<!-- Start Stream button (connected + offline) -->
|
||||
<Button Content="Start Stream" Style="{StaticResource YtButton}"
|
||||
Visibility="{Binding ShowLogInButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- Primary Start button (offline): dynamic label from the pills -->
|
||||
<Button Content="{Binding PrimaryButtonText}" Style="{StaticResource YtButton}"
|
||||
Command="{Binding StartStreamCommand}"
|
||||
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}">
|
||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
</Button>
|
||||
<!-- End Stream button (live) -->
|
||||
<Button Content="End Stream" Style="{StaticResource YtButton}"
|
||||
<!-- End button (live OR recording) — the only button whose
|
||||
label changes; "Stop Recording" while recording -->
|
||||
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
|
||||
Background="#333" Command="{Binding EndStreamCommand}"
|
||||
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
|
||||
Visibility="{Binding ShowEndStreamButton, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -1521,9 +1540,9 @@
|
||||
</Button.Template>
|
||||
</Button>
|
||||
|
||||
<Border Background="#0A0C1A" CornerRadius="8" Width="520" MaxHeight="560"
|
||||
<Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Padding="24" BorderBrush="#333" BorderThickness="1">
|
||||
Padding="28" BorderBrush="#333" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
@@ -1588,6 +1607,7 @@
|
||||
|
||||
<!-- About -->
|
||||
<Grid Grid.Row="1" Margin="0,16,0,0"
|
||||
Background="#171928"
|
||||
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||
|
||||
<!-- Hub view -->
|
||||
|
||||
+44
-3
@@ -1,5 +1,46 @@
|
||||
# MyMistakes.md
|
||||
|
||||
> One task at a time. 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.
|
||||
> **Two jobs**, distinguished by heading:
|
||||
>
|
||||
> 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/`.
|
||||
|
||||
@@ -8,7 +8,7 @@ A creator-proof live-streaming and recording app for YouTube — native Windows
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||

|
||||
|
||||
## Design Principle
|
||||
|
||||
|
||||
@@ -19,6 +19,19 @@ public sealed class EncoderOptions
|
||||
public int Fps { get; init; } = 60;
|
||||
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
|
||||
/// nvenc → qsv → amf → libopenh264 when not forced.</summary>
|
||||
public string? VideoEncoder { get; init; }
|
||||
|
||||
@@ -12,40 +12,50 @@ public static class FfmpegArgs
|
||||
{
|
||||
public static IReadOnlyList<string> Build(EncoderOptions options, string videoEncoder)
|
||||
{
|
||||
var gop = options.GopSize;
|
||||
return
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel", "info",
|
||||
"-stats",
|
||||
"-stats_period", "0.5",
|
||||
var args = new List<string>
|
||||
{
|
||||
"-hide_banner", "-loglevel", "info",
|
||||
"-stats", "-stats_period", "0.5",
|
||||
"-re",
|
||||
"-f", "rawvideo",
|
||||
"-pix_fmt", "bgra",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgra",
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
"-i", "pipe:0",
|
||||
"-f", "f32le",
|
||||
"-ar", options.AudioSampleRate.ToString(),
|
||||
"-f", "f32le", "-ar", options.AudioSampleRate.ToString(),
|
||||
"-ac", options.AudioChannels.ToString(),
|
||||
"-i", $@"\\.\pipe\{options.AudioPipeName}",
|
||||
"-map", "0:v",
|
||||
"-map", "1:a",
|
||||
"-c:v", videoEncoder,
|
||||
"-b:v", $"{options.BitrateKbps}k",
|
||||
"-maxrate", $"{options.BitrateKbps}k",
|
||||
"-bufsize", $"{options.BitrateKbps * 2}k",
|
||||
"-g", gop.ToString(),
|
||||
"-keyint_min", gop.ToString(),
|
||||
"-sc_threshold", "0",
|
||||
"-bf", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", options.AudioSampleRate.ToString(),
|
||||
"-ac", options.AudioChannels.ToString(),
|
||||
"-f", "flv",
|
||||
options.RtmpUrl,
|
||||
];
|
||||
};
|
||||
|
||||
// Each output is a self-contained block: its own -map, codec options,
|
||||
// format, and destination. Record+stream encodes the video twice off the
|
||||
// one raw input pipe (cheap on NVENC).
|
||||
if (options.StreamEnabled)
|
||||
{
|
||||
AddVideoTags(args, options, videoEncoder);
|
||||
args.AddRange(["-c:a", "aac", "-b:a", "128k",
|
||||
"-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
|
||||
args.AddRange(["-f", "flv", options.RtmpUrl]);
|
||||
}
|
||||
|
||||
if (options.RecordEnabled)
|
||||
{
|
||||
AddVideoTags(args, options, videoEncoder);
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,12 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
if (string.IsNullOrWhiteSpace(options.RtmpUrl))
|
||||
throw new ArgumentException("An RTMP ingestion URL is required.", nameof(options));
|
||||
var hasStream = options.StreamEnabled && !string.IsNullOrWhiteSpace(options.RtmpUrl);
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class FramePump : IDisposable
|
||||
var options = _encoderOptions();
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -735,6 +735,30 @@ public class LayoutStore : IDisposable
|
||||
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.
|
||||
/// Survives restarts so the pump has its RTMP URL the moment go-live starts
|
||||
/// instead of waiting on the YouTube round-trip.</summary>
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
2. ☐ `FfmpegArgs.Build` gains a local-file branch: MP4/MKV container for file output
|
||||
3. ☐ Three modes:
|
||||
- **Record only** — no RTMP push, just local file (for pre-recorded content)
|
||||
- **Stream only** — RTMP push, no local file (current behavior)
|
||||
- **Stream + record** — dual output via `-f flv rtmp://... -f mp4 file.mp4`
|
||||
4. ☐ Recording controls in UI (REC button, file path picker, duration display)
|
||||
5. ☐ Schema migration for recording preferences (default output path, format)
|
||||
6. ☐ Branding flash appears in local recordings too (free tier)
|
||||
7. ☐ Output folder: `%APPDATA%\ytLlive\recordings\` with timestamped filenames
|
||||
1. ✅ `EncoderOptions` extended with `StreamEnabled` / `RecordEnabled` / `RecordPath` (independent intent flags)
|
||||
2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags`
|
||||
3. ✅ Three modes via pills: record-only / stream-only / stream+record (single ffmpeg, dual output)
|
||||
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
|
||||
5. ✅ Record-folder persistence (`LayoutStore` `RecordFolder` key) + picker (`ChooseRecordFolderCommand`)
|
||||
6. 🟡 Branding flash carries into local recordings — same frame path as streaming (verify in the running app)
|
||||
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)
|
||||
|
||||
Remaining: manual rename dialog for the user (toast actions unsupported — modal window was planned but parked), running-app verification of the rename + dual output.
|
||||
|
||||
### Design decisions
|
||||
|
||||
|
||||
+296
-32
@@ -78,6 +78,16 @@ public class MainViewModel : ViewModelBase
|
||||
private double _recDotPulse = 1.0;
|
||||
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 _isBugOpen;
|
||||
private bool _isFeatureOpen;
|
||||
@@ -242,7 +252,7 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
/// <summary>True when offline — edit mode is available. When live/recording,
|
||||
/// 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
|
||||
{
|
||||
@@ -375,7 +385,7 @@ public class MainViewModel : ViewModelBase
|
||||
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
|
||||
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
|
||||
case Services.HotkeyId.StartStopStream:
|
||||
if (IsLive)
|
||||
if (IsLive || IsRecording)
|
||||
{
|
||||
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
|
||||
}
|
||||
@@ -509,7 +519,13 @@ public class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(OnAirBrush));
|
||||
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(ShowPreviewPlaceholder));
|
||||
UpdateLiveVisuals();
|
||||
@@ -523,7 +539,16 @@ public class MainViewModel : ViewModelBase
|
||||
set
|
||||
{
|
||||
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 IsLive => StreamStatus == StreamStatus.Streaming;
|
||||
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
||||
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
|
||||
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
|
||||
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
|
||||
public string RecDotBrush => !IsRecording ? "#555555" : "#e94560";
|
||||
public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888";
|
||||
public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55;
|
||||
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 ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
|
||||
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
||||
@@ -1509,6 +1612,7 @@ public class MainViewModel : ViewModelBase
|
||||
public ICommand OpenSocialDialogCommand { get; }
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand EndStreamCommand { get; }
|
||||
public ICommand ChooseRecordFolderCommand { get; }
|
||||
public ICommand OpenSettingsCommand { get; }
|
||||
public ICommand OpenBugCommand { get; }
|
||||
public ICommand OpenFeatureCommand { get; }
|
||||
@@ -1595,8 +1699,9 @@ public class MainViewModel : ViewModelBase
|
||||
OpenCoffeeCommand = new RelayCommand(_ => OpenUrl(CoffeeUrl));
|
||||
OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
|
||||
OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
|
||||
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
|
||||
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
|
||||
StartStreamCommand = new RelayCommand(_ => StartSession());
|
||||
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
|
||||
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
|
||||
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||||
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
|
||||
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
|
||||
@@ -1608,6 +1713,7 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
||||
_activeLayoutPath = _layoutStore.ActivePath;
|
||||
_recordFolder = _layoutStore.LoadRecordFolder();
|
||||
|
||||
// Load saved hotkey bindings (filled with defaults for any unbound actions).
|
||||
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
|
||||
@@ -3003,7 +3109,44 @@ public class MainViewModel : ViewModelBase
|
||||
_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)
|
||||
{
|
||||
@@ -3023,10 +3166,50 @@ public class MainViewModel : ViewModelBase
|
||||
StreamStatus = StreamStatus.Streaming;
|
||||
ResetHealth(StreamStatus.Streaming);
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -3150,9 +3333,13 @@ public class MainViewModel : ViewModelBase
|
||||
HealthIssueBanner = report.Text;
|
||||
}
|
||||
|
||||
private void StopStream()
|
||||
private async void StopStream()
|
||||
{
|
||||
var wasLive = IsLive;
|
||||
var wasRecording = IsRecording;
|
||||
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
IsRecording = false;
|
||||
WindowTitle = "LlamaCasty";
|
||||
ResetHealth(StreamStatus.Offline);
|
||||
// Stop chat polling first (TASK 9.4) — no more messages arriving while
|
||||
@@ -3160,20 +3347,82 @@ public class MainViewModel : ViewModelBase
|
||||
_youtubeChat.Stop();
|
||||
// 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
|
||||
// finalizes the FLV. Audio capture itself is always-on (preview
|
||||
// monitoring); only the live pipe, the pump, and the session stop here.
|
||||
// finalizes the output. Audio capture itself is always-on (preview
|
||||
// monitoring); only the live/report pipe, the pump, and the session stop here.
|
||||
_audioMixer.StopLive();
|
||||
_ = _framePump.StopAsync();
|
||||
await _framePump.StopAsync();
|
||||
_currentBroadcastId = null;
|
||||
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
|
||||
// never runs this, so the token survives and the creator stays signed in.
|
||||
_youtubeAuth.ClearSession();
|
||||
TokenStore.Clear();
|
||||
IsConnected = false;
|
||||
SyncConnectedAccount();
|
||||
AppLog.Write("Stream ended; session signed out");
|
||||
|
||||
if (wasRecording) await FinalizeRecordingAsync();
|
||||
|
||||
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
|
||||
// the session/token — the creator signs out deliberately via the
|
||||
// Logout / Change Account commands.
|
||||
AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
|
||||
}
|
||||
|
||||
/// <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)
|
||||
@@ -3389,11 +3638,13 @@ public class MainViewModel : ViewModelBase
|
||||
private EncoderOptions? BuildEncoderOptions()
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
var quality = SelectedQuality;
|
||||
return new EncoderOptions
|
||||
{
|
||||
RtmpUrl = url,
|
||||
RtmpUrl = url ?? string.Empty,
|
||||
StreamEnabled = OnAirPillOn,
|
||||
RecordEnabled = RecordPillOn,
|
||||
RecordPath = _activeRecordPath,
|
||||
Width = quality.Width,
|
||||
Height = quality.Height,
|
||||
Fps = quality.Fps,
|
||||
@@ -3458,24 +3709,36 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
if (live)
|
||||
{
|
||||
_liveElapsed = TimeSpan.Zero;
|
||||
LiveElapsedText = "00:00:00";
|
||||
_recDotPulse = 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
_liveTimer.Start();
|
||||
if (BrandFlashEnabled) StartBrandFlashTimer();
|
||||
}
|
||||
else
|
||||
{
|
||||
_liveTimer.Stop();
|
||||
_healthPollTimer.Stop();
|
||||
_brandFlashTimer.Stop();
|
||||
_brandFlashOffTimer.Stop();
|
||||
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";
|
||||
_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
|
||||
@@ -3507,6 +3770,7 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
||||
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||
_recordLength = _liveElapsed;
|
||||
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 322 KiB |
@@ -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** |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `<dir>/index.md` | Per-directory map (progressive disclosure): what lives there + links | when diving into code |
|
||||
| `Views/` | Reserved for Views; currently empty | — |
|
||||
|
||||
@@ -175,6 +175,56 @@ public class FfmpegEncoderTests
|
||||
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]
|
||||
public async Task SubmitFrame_WhenNotRunning_Throws()
|
||||
{
|
||||
|
||||
@@ -196,7 +196,7 @@ public class FramePumpTests
|
||||
|
||||
Assert.False(pump.IsRunning);
|
||||
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
|
||||
Assert.Equal(0, encoder.StopCount);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Local recording filename rules (TASK 18): <c>ty-<yyyyMMdd>-<HHmm start>-<hh2mm2 length>.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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user