From e37215d9c4db19626c5d1292975d33f5f15f23bb Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 5 Nov 2026 08:58:48 -0800 Subject: [PATCH] feat(record): fragment the MP4 so a crash cannot destroy the whole take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK 32·4. A plain MP4 keeps its index (moov) at the end, so a power cut or a killed process left the entire recording unreadable even though every video byte was on disk. The record block now carries `-movflags +frag_keyframe+empty_moov+default_base_moof`: index first, a playable fragment flushed at every keyframe, so an interrupted take plays to the last fragment. Record block only — the FLV stream output has no index and never gets the flags. Rename-on-stop is untouched. Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` asserts the flags on the record block and their absence from the streaming block. Not yet proven on a real interrupted take (kill ffmpeg mid-recording, play the result); the args test is by construction. Docs: task-32 slice 4 ticked with that caveat, TASKS row, output.md record note, HANDOFF. verify.sh: 550 passed (mouse-injection test excluded, desktop in use), 0 warnings. --- Services/Encoder/FfmpegArgs.cs | 6 +++++ TASKS.md | 2 +- TASKS/task-32-stream-resilience.md | 6 +++-- docs/ai/output.md | 5 ++++- ytLive.Tests/FfmpegEncoderTests.cs | 35 ++++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/Services/Encoder/FfmpegArgs.cs b/Services/Encoder/FfmpegArgs.cs index 15108e7..6078c26 100644 --- a/Services/Encoder/FfmpegArgs.cs +++ b/Services/Encoder/FfmpegArgs.cs @@ -43,6 +43,12 @@ public static class FfmpegArgs AddVideoTags(args, options, videoEncoder); args.AddRange(["-c:a", "aac", "-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]); + // TASK 32·4: FRAGMENTED mp4. A plain MP4 writes its index (moov) at the end, so a + // power cut or a killed process leaves the video bytes on disk in a file nothing can + // open — the whole recording, not the tail. Fragmented writes the index first and + // flushes a playable fragment at every keyframe, so an interrupted file still plays up + // to the last fragment. Record block only: the FLV stream output has no index. + args.AddRange(["-movflags", "+frag_keyframe+empty_moov+default_base_moof"]); args.AddRange(["-f", "mp4", options.RecordPath!]); } diff --git a/TASKS.md b/TASKS.md index 864f809..f5ae666 100644 --- a/TASKS.md +++ b/TASKS.md @@ -48,7 +48,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block). | 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) | | 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | | 3·20 | ☐ | RewardEvent persistence: canonical `RewardEvents` table + `superChatEvents.list` 30-day backfill (the session report and journey read it) | [`task-03`](TASKS/task-03-capture-pipeline.md) | -| 32 | ☐ | Stream resilience: blip retry → measured grace + amber "RECONNECTING" → one-click Back on air → fragmented MP4 → ingest pre-flight | [`task-32`](TASKS/task-32-stream-resilience.md) | +| 32 | 🔶 | Stream resilience: **fragmented MP4 done 2026-11-05**; remaining — blip retry → measured grace + amber "RECONNECTING" → one-click Back on air → ingest pre-flight | [`task-32`](TASKS/task-32-stream-resilience.md) | | 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) | | 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) | | 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) | diff --git a/TASKS/task-32-stream-resilience.md b/TASKS/task-32-stream-resilience.md index e82c070..1a7edc6 100644 --- a/TASKS/task-32-stream-resilience.md +++ b/TASKS/task-32-stream-resilience.md @@ -26,11 +26,13 @@ the API — creator ruling 2026-09-01): no lying in the copy. 3. ☐ **One-click Back on air** — the relabeled button fires the existing go-live path with saved `Broadcast.*` + cached stream, dialog skipped: new broadcast, new VOD (accepted cost, stated in-product). Test: seeded saved form + fake stream service → click creates + pushes, no dialog. -4. ☐ **Crash-safe recording** — fragmented MP4 on the record block (`-movflags +4. ✅ **Crash-safe recording** (shipped 2026-11-05) — fragmented MP4 on the record block (`-movflags frag_keyframe+empty_moov+default_base_moof`): plain MP4's trailing moov atom means a power loss kills the WHOLE file. Rename-on-stop unchanged. Closes the TASK 18 "MKV as an option" design-note remnant (fragmented MP4 chosen over MKV: single container, YouTube-upload-native — verify player/ - editor tolerance, cite the research). Test: `FfmpegArgs` emits the movflags only on the record block. + editor tolerance, cite the research). Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` — the flags are on the record block and + absent from the streaming block. Verified by construction only: the file still needs one real + interrupted-take check on Windows (kill ffmpeg mid-recording, play the result). 5. ☐ **Pre-flight** — before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout). Unreachable → Error toast, no insert, no ghost broadcast ("check VPN/network"). The "forgot the VPN" story, solved as prevention instead of recovery. Test: fake probe failure aborts go-live before any API call. diff --git a/docs/ai/output.md b/docs/ai/output.md index 2a0c729..3f39872 100644 --- a/docs/ai/output.md +++ b/docs/ai/output.md @@ -249,7 +249,10 @@ devices, no timers). (Before this wiring the seams defaulted to unity and the controls were decorative.) `StopStream` → `StopLive()` (closes the pipe → ffmpeg audio EOF) **BEFORE** stopping the frame pump (video EOF) — the reverse order stalls on pipe backpressure. `FfmpegEncoder` itself is untouched; the VM owns the - pipe lifecycle. + pipe lifecycle. **Recordings are fragmented MP4 (TASK 32·4, 2026-11-05):** the record block carries + `-movflags +frag_keyframe+empty_moov+default_base_moof`, so the index is written first and a playable + fragment is flushed at every keyframe. A plain MP4 puts its index at the end, which is why a power cut + or a killed process previously left the whole take unreadable. The FLV stream block never gets it. **Two clip-audio rings join that mix (2026-11-05):** `EnqueueAlertAudio` (TASK 47) is pre-scaled by the alert layer and mixed at **unity, never ducked** — an alert must be heard over the mic. `EnqueueDesktopMediaAudio` (TASK 21's audio slice) is the opposite on purpose: a video layer's diff --git a/ytLive.Tests/FfmpegEncoderTests.cs b/ytLive.Tests/FfmpegEncoderTests.cs index cdf0e25..388316d 100644 --- a/ytLive.Tests/FfmpegEncoderTests.cs +++ b/ytLive.Tests/FfmpegEncoderTests.cs @@ -207,6 +207,41 @@ public class FfmpegEncoderTests Assert.Equal(@"C:\videos\ty-20260829-1405-0000.mp4", args[idx + 1]); } + /// TASK 32·4: a recording must survive a crash. A plain MP4 keeps its index at the + /// end, so an interrupted file is unreadable — the whole take, not the tail. Fragmented MP4 + /// writes the index first and flushes a fragment per keyframe, so the file plays to the last + /// fragment. The flags belong to the RECORD block only; the FLV stream has no index. + [Fact] + public void Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile() + { + var recordOnly = FfmpegArgs.Build( + new EncoderOptions + { + StreamEnabled = false, + RecordEnabled = true, + RecordPath = @"C:\videos\take.mp4", + Width = 1920, Height = 1080, Fps = 30, BitrateKbps = 6000, + }, + "h264_nvenc").ToArray(); + + var flagsAt = Array.IndexOf(recordOnly, "-movflags"); + Assert.True(flagsAt > -1, "a recording must be fragmented or a crash destroys the whole file"); + Assert.Equal("+frag_keyframe+empty_moov+default_base_moof", recordOnly[flagsAt + 1]); + + // …and the stream output must never carry it. + var streaming = FfmpegArgs.Build( + new EncoderOptions + { + RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key", + StreamEnabled = true, + RecordEnabled = false, + Width = 1920, Height = 1080, Fps = 30, BitrateKbps = 6000, + }, + "h264_nvenc").ToArray(); + + Assert.DoesNotContain("-movflags", streaming); + } + [Fact] public void Args_DualOutput_HasTwoMapBlocks_FlvAndMp4() {