Skip to content

Convert fragmented MP4 to traditional MP4 at the end of the video recording - #2983

Draft
rafaellehmkuhl wants to merge 1 commit into
bluerobotics:masterfrom
rafaellehmkuhl:video-http-seekable-recordings
Draft

Convert fragmented MP4 to traditional MP4 at the end of the video recording#2983
rafaellehmkuhl wants to merge 1 commit into
bluerobotics:masterfrom
rafaellehmkuhl:video-http-seekable-recordings

Conversation

@rafaellehmkuhl

@rafaellehmkuhl rafaellehmkuhl commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

Recordings are muxed as fragmented MP4 for crash-safety (frag_keyframe+empty_moov), which leaves the file without an index a player can use cheaply: mvhd declares no duration, and the only index (mfra) sits in the last few hundred bytes. A demuxer therefore walks every fragment to build a seek table — one seek per fragment.

Measured on a real 4.76 GiB / 42-minute recording:

before after
seeks to determine duration 1,343 2
bytes read to determine duration 45.5 MB 1.2 MB

On local disk those seeks are invisible, which is why recordings play fine when opened locally. Over HTTP each one becomes a range request, so playback never starts. The reported symptom was a video that plays locally but spins forever in Chrome when served from S3 (also reproducible behind nginx/Apache), while downloading the same file works — a download is sequential and never seeks.

This remuxes the finished recording into a regular MP4 on finalize.

Why a separate remux instead of a muxer flag

Recording is left exactly as it was, so the crash-safety property is untouched. The remux writes a sibling temp file and renames over the original only once FFmpeg succeeds, so an interruption at any point leaves the original recording intact.

Doing it in the muxer was tried first, with +global_sidx and with +hybrid_fragmented. Both reach 2 seeks, but both rewrite the finished file in place, and that is a real regression. Measured by cutting each variant at the point an interruption would leave it:

interrupted state current behaviour +hybrid_fragmented
final index never written 63.362 s, 1943 packets UNPLAYABLE, 0 packets
final index half written 63.362 s, 1943 packets UNPLAYABLE, 0 packets

hybrid_fragmented absorbs the moof boxes into a single mdat, so once that header is rewritten there is no fragmented structure left to fall back on — if the moov does not land, the whole recording is gone. The current layout degrades gracefully instead: you lose only the mfra, which measurement shows is worthless anyway (it does not reduce the seek count at all).

+faststart costs +45 s on 4.76 GiB and does not help either, because a conventional MP4's moov scales with sample count rather than fragment count, so it still will not fit in an early read.

Spending the disk space to avoid that failure mode is deliberate.

Behaviour notes

  • A volume without room for the second copy makes FFmpeg exit non-zero, which is handled like any other remux failure: the temp is removed and the fragmented recording is kept — it plays fine locally and is merely slow over HTTP.
  • A remux failure never fails the recording: the fragmented file is kept and the error logged.
  • Remux takes ~12 s for 4.76 GiB and ~0.5 s for 135 MB. It runs after the finalization timeout is cleared, so it is not bounded by that timeout.
  • The output is a conventional MP4, which also improves playback in Firefox and Safari.
  • Stopping a recording now returns only once the copy has finished, so the "Video processing completed" snackbar arrives later than it used to. A persistent snackbar covers the interval.
  • A temp left behind by a crash or power loss mid-remux is swept from the videos folder at startup.

Not fixed here

Recordings that never finalize get no remux and no index, so they still cost one range request per fragment. That is a separate defect in the finalization path.

Recordings already on disk keep the fragmented layout — the remux only runs when a recording finalizes, and Cockpit offers no way to convert an existing file. Operators who hit this need to re-record or remux those files themselves.

Verification

  • Interrupted remux → original intact (63.362 s / 1943 packets), temp file removed
  • Completed remux → all 1943 packets preserved, 2 seeks, temp file removed
  • yarn lint:fix clean at --max-warnings=0; type-check clean on the changed files

Test plan

  • Record in Standalone and confirm the resulting file plays locally
  • Serve that file over HTTP and confirm it plays and seeks in Chrome
  • Confirm the thumbnail is still generated on finalize
  • Kill Cockpit mid-recording and confirm the partial file is still playable
  • Run with a nearly-full disk and confirm the recording survives when the remux fails
  • Kill Cockpit mid-remux, restart, and confirm the leftover .remuxing.tmp is gone

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 1)

5 open findings: 2 major (1.1, 1.2) and 3 minor.

The desktop app records video by piping it through a bundled media tool, which writes the file in a streaming-friendly shape so a crash still leaves something playable. This PR adds one option to that tool so that, when a recording is stopped normally, the finished file is converted into the ordinary shape instead — which is what lets a player start a video quickly when it is fetched from a web server rather than opened from disk. Nothing about how the file is written during recording changes; the conversion happens at the moment recording stops.

What still needs attention

# Problem What it means Severity Status
1.1 Option never verified against the bundled tool If the media tool shipped with Cockpit does not recognise the new option, desktop recordings produce no video file at all, and the user only learns this from a stream of error pop-ups. major
1.2 New window where stopping can ruin the file If Cockpit or the computer stops while a recording is being closed, the entire recording can be left unplayable while the app still reports that it saved. major
2.1 Existing recordings not covered Videos recorded before this update still will not play from a web server, and nothing in the app or the notes tells their owner why the fix did not reach them. minor
3.1 Two comments now contradict each other The next person reading this code finds neighbouring comments saying opposite things about what the recorder produces. minor
5.1 Memory during long recordings unmeasured Very long dives may now make the recording process use steadily more of the operator's memory, and nobody has checked how much. minor
Change map — what was established before judging

Claims

  • Symptom — "plays locally, spins forever in Chrome when served over HTTP, downloads fine": unverified here. It rests on a 4.76 GiB recording that is not in this repo, and there is no network access to check it. Nothing in the code contradicts it.
  • Cause — "recordings are muxed as fragmented MP4, so a demuxer walks every fragment": verified. src/electron/services/video-recording.ts:98-99 (base) invokes the muxer with exactly frag_keyframe+empty_moov+default_base_moof and with no faststart, global_sidx or index-writing flag. The seek/byte counts in the table (1,343 → 2) are the author's measurement, not something reproducible from this checkout.
  • Mechanism — "hybrid_fragmented writes the fragmented layout during recording and converts on finalize": the diff does exactly, and only, that — one flag appended, no code path changed. Whether the FFmpeg Cockpit actually ships accepts that flag is unverified and is finding 1.1.
  • "No second pass and the file is never rewritten in place"partly contradicted as a safety claim. True that the payload is not moved; the conversion still performs writes over the finished file's structure after the stream ends, which is what finding 1.2 is about. The PR argues against +global_sidx on precisely the grounds of "a window in which an interruption could corrupt an otherwise-good recording"; that window exists here too, smaller.
  • "Confirm the thumbnail is still generated on finalize"verified unaffected. finalizeVideoRecording generates it at src/electron/services/video-recording.ts:261-289, on exit code 0, by re-decoding the finished file; the diff does not touch that path and a regular MP4 decodes the same way.
  • "Crash-safety during recording is unchanged" — consistent with the diff: the recording-time flags are untouched and the new flag only adds trailer behaviour. Not independently checkable here.

Failure site

src/electron/services/video-recording.ts:98-99 — the muxer flags themselves — and it is in the diff. This is the single origin: a search for movflags across the tree returns only this call and scripts/uicast/record.mjs:337, a developer screen-recording script unrelated to vehicle recordings. So the fix is at the root rather than at a call site, which is the right shape.

Entry points

Function Reached from Frequency
startVideoRecording (src/electron/services/video-recording.ts:63) — the only changed function ipc start-video-recording (:787) ← LiveVideoProcessor.initializeOutputFile (src/libs/live-video-processor.ts:169) on chunk 0 of a recording; also the chunk-ZIP recovery path (src/libs/live-video-processor.ts:260) and leftover-chunk processing (src/composables/videoChunkManager.ts:612) per user action (once per recording started, or per recovery run)

The changed flags take effect in a second function the diff does not touch: finalizeVideoRecording (:223), reached from ipc finalize-video-recordingsrc/libs/live-video-processor.ts:210 when the user stops recording — also per user action. That is where the new conversion runs and where finding 1.2 lives.

Invariants

  1. The FFmpeg binary Cockpit runs accepts hybrid_fragmented. Violators: the three separately pinned binaries in scripts/download-ffmpeg.js:20-56 (BtbN release/7.1 builds for Windows/Linux, osxexperts ffmpeg71 for macOS) plus any stale binary already in binaries/ffmpeg/, which the installer keeps rather than re-downloading ("FFmpeg binary already exists. Skipping download."). src/electron/services/ffmpeg-path.ts:38-49 runs only that bundled binary and throws rather than falling back to a system one, so there is exactly one chokepoint — and nothing checks it at build or run time. Covered by the PR: none. → 1.1
  2. FFmpeg must be allowed to complete its trailer for the output to be a valid file of either layout. Violators: the flat 60 s finalization SIGKILL at src/electron/services/video-recording.ts:246-259, app quit, machine power-off. Covered by the PR: none. → 1.2
  3. Nothing downstream may depend on the fragmented layout. Enumerated consumers of the output file: generateThumbnailFromMP4 (:274, plain decode), the renderer's video library (name/size/thumbnail only), and the .ass telemetry sidecar (independent file). None of them depends on fragmentation — invariant holds, no finding.
1. Correctness & Implementation Bugs — 2 findings

1.1 — hybrid_fragmented is never verified against the FFmpeg binaries Cockpit shipsmajor

Consequence: if the FFmpeg bundled with Cockpit does not recognise the new option, desktop recordings produce no video file at all, and the user only learns this from a stream of error pop-ups.

The recorder runs only the bundled binary (src/electron/services/ffmpeg-path.ts:38-49 throws if it is missing; there is no system-FFmpeg fallback), and that binary is pinned to 7.1 in scripts/download-ffmpeg.js:14-56 — BtbN release/7.1 builds for Windows and Linux, and the osxexperts 7.1 build for macOS. Three different builds, from two sources, of a release branch that by definition does not receive new muxer features. hybrid_fragmented is a late addition to the mov muxer's movflags set; the PR does not state which FFmpeg version first supports it, and nothing in the repo pins a minimum or probes for the capability. If any one of those builds rejects the option, FFmpeg fails while parsing options and exits before it ever opens the output file — and it can fail on one platform while working on another, from the same merge.

What that failure looks like to a user is worse than a crash, because the code treats a dead FFmpeg as a live one:

  • startVideoRecording (:63-167) never waits for the process to be alive. It registers the close handler at :132, writes the first chunk with a callback that only console.errors on EPIPE (:145-149), and returns { id, outputPath }. The renderer therefore believes recording started.
  • Every subsequent chunk hits appendChunkToVideoRecording (:180-183), which throws Live stream process … not found or already finalized because the close handler already deleted the entry.
  • src/stores/video.ts:1105-1112 turns each of those into an error snackbar and keeps recording. The initialization dialog that would actually stop the recording (src/stores/video.ts:1113-1117) never fires, because initialization "succeeded".

So the user records the whole dive, collects one snackbar per chunk, and ends with no MP4 — only the raw WebM chunk backups. And the recovery route for those chunks (src/libs/live-video-processor.ts:260, src/composables/videoChunkManager.ts:612) goes through the same startVideoRecording with the same flag, so it fails identically.

Two asks, the first mandatory:

  1. State the FFmpeg version that first supports hybrid_fragmented and bump the three pinned URLs if 7.1 predates it. The macOS entry is a plain 7.1 release build and is the most likely to be short.
  2. Make an FFmpeg that dies at spawn a start failure. The close handler at :132 already knows; failing the pending start when it fires before any chunk was accepted routes the error into LiveVideoProcessorInitializationError, which already shows a dialog and stops the recording, instead of one snackbar per chunk for the length of a dive.

1.2 — The finalize-time conversion adds a window where a killed FFmpeg leaves an unreadable file, and the code reports it as successmajor

Consequence: if Cockpit or the computer stops while a recording is being closed, the entire recording can be left unplayable while the app still reports that it saved.

Before this change, closing stdin made FFmpeg append mfra and exit; anything that killed it at that moment still left every already-flushed fragment on disk and the file playable — that is the whole point of the fragmented layout. With hybrid_fragmented, stopping a recording starts a conversion pass that rewrites the file's mdat header so it absorbs the moof boxes and appends a moov. Between those writes the file is neither a valid fragmented recording nor a complete regular one.

Cockpit kills FFmpeg inside exactly that window, on its own:

  • src/electron/services/video-recording.ts:246-259 SIGKILLs the process 60 s after stdin closes. That timeout is a flat constant; it does not scale with the size of the recording, and the conversion is new work that did not exist when the constant was chosen.
  • When FFmpeg exits nonzero, :297-305 stats the output and treats any non-empty file as "partial success", resolving the promise. A half-converted file is non-empty, so the user is told the recording finished.
  • Quitting the app or losing power during finalization has the same shape and no guard at all.

The PR body's "the file is never rewritten in place" is a statement about not moving the payload, which is true and is not the same as there being no window. Asks: before resolving on a nonzero exit or a timeout kill, confirm the output is readable — the bundled binary is right there for a cheap probe — and tell the user when it is not, rather than resolving on file size alone; and make the finalization timeout a function of the recording rather than a flat 60 s.

2. Persistence & User Data — inventory, 1 finding

Inventory

What Backend What happened to it
Cockpit settings keys (cockpit-*, settings-management.ts, useBlueOsStorage) none added, reshaped or removed by this PR
Recording output file <Cockpit folder>/videos/<fileName>.mp4 machine-local filesystem (src/electron/services/video-recording.ts:75-79) on-disk layout of newly written files changes — fragmented until finalize, regular MP4 after; filename, location and extension unchanged
Video thumbnail (filesystemStorage, ['videos'], :280) machine-local unchanged; still generated from the finished file
Telemetry .ass sidecar machine-local unchanged
Raw WebM chunk backups (temp dir, :139, :188) machine-local unchanged

No migration is introduced, nothing already on disk is rewritten, and no machine-specific value is vehicle-synced. The one entry that needs judgement is the recording file itself.

2.1 — Recordings made before this change keep the reported problem, with nothing said to the userminor

Consequence: videos recorded before this update still will not play from a web server, and nothing in the app or the notes tells their owner why the fix did not reach them.

The change applies to files written from now on. Every recording already in a user's Cockpit folder — including the one that produced the bug report — keeps the old layout, and Cockpit offers no way to convert it: the chunk-recovery path rebuilds an MP4 from WebM chunks, which are deleted once a recording is processed, so it is not a remux route for an existing MP4. The PR body's "Not fixed here" section names only recordings that never finalize, so this gap is currently unstated.

Ask: add it to that section and to the release notes, so an operator who hit this understands they need to re-record or remux their existing files. Building a remux affordance is separate work and should not hold this PR.

3. AGENTS.md Adherence — 1 finding

3.1 — The same fact is now stated four times, and one of the copies became wrongminor

Consequence: the next person reading this code finds neighbouring comments saying opposite things about what the recorder produces.

After the diff, src/electron/services/video-recording.ts says the following in four places:

  • :27 (module JSDoc, unchanged) — "Fragmented MP4 output (frag_keyframe + empty_moov) for crash-safety"
  • the new JSDoc bullet after it — "Converted to a regular non-fragmented MP4 on finalize, so HTTP playback needs 2 range requests"
  • the new two-line inline comment above the flags
  • the retained trailing // Fragmented MP4 for crash-safety on the changed line itself — which now sits directly beneath a comment stating the output is not fragmented once finalized

AGENTS.md:37 ("Avoid repeated comments; describe reasoning once only") and :40 ("One sentence is the target for a new comment") are both breached by the two-line inline block restating the JSDoc bullet. AGENTS.md:35 asks to keep the original comment over rewriting it "unless the comment has become factually wrong" — the trailing one has, so leaving it verbatim is the one option that is not available.

Smallest fix: keep the new JSDoc bullet, drop the two-line inline comment, and make the trailing comment say what the argument now does.

5. Performance — 1 finding

5.1 — Memory during a recording is no longer bounded by the fragmentminor

Consequence: very long dives may now make the recording process use steadily more of the operator's memory, and nobody has checked how much.

A non-fragmented moov is the sample table for the whole file, so writing one at finalize requires the muxer to still hold per-sample bookkeeping for every video and audio sample of the entire recording — it is not re-read from the file. In the fragmented layout that bookkeeping is flushed with each fragment, so the recording process' footprint is bounded; with the conversion it grows monotonically for as long as the recording runs. Per the entry-point walk this sits behind a deliberate user action (start recording), but unlike the other costs in the PR's table it lasts for the whole recording rather than for the finalize step, and Cockpit is regularly run on modest topside laptops for multi-hour dives.

The PR's measurements cover mux time, seek count and bytes read, all on a 42-minute file. Ask: measure the FFmpeg process' resident memory across a long recording (a few hours, the case that matters) and put the number next to the timing ones. If it stays flat, this finding is answered by the measurement.

Sections with nothing to report (7)

4. Security — ✅ (diff is 4 lines of plain ASCII — checked for non-ASCII, no matches; no new dependency, no network call, no change to spawn arguments other than one literal option string passed to the already-bundled binary; no build script, workflow or Dockerfile touched)

6. UI / UX — ✅ (no component, dialog, label or user-facing string touched; the recorder's failure-feedback path in src/stores/video.ts:1105-1118 was traced end to end and what it does badly is reported under 1.1)

7. Code Quality & Style — ✅ (complexity-report.json reports 67 functions measured across the 1 changed file with 0 triggers and no truncation, so nothing is flagged there; no any, no new scoped CSS, no re-implemented helper — the comment duplication is reported under 3.1)

8. Commit Hygiene — ✅ (single commit video: make recordings seekable when served over http, matching the scope-prefixed style in git log on master; body states the why, no issue or PR references, no fixup/wip noise, nothing to split)

9. Tests — ✅ (no test file touched, removed or weakened; there is no existing suite covering src/electron/services)

10. Documentation — ✅ (README.md:106's Lite-vs-Standalone video row still holds — this PR does not change what either build can do; the module JSDoc was updated, and its accuracy is covered by 3.1)

11. Nitpicks / Optional — ✅ (nothing beyond the comment cleanup already raised as 3.1)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the video-http-seekable-recordings branch from b227789 to 6cd9d56 Compare August 28, 2026 13:12
@rafaellehmkuhl rafaellehmkuhl changed the title video: make recordings seekable when served over http Convert fragmented MP4 to traditional MP4f at the end of the video recording Aug 28, 2026
@rafaellehmkuhl
rafaellehmkuhl force-pushed the video-http-seekable-recordings branch from 6cd9d56 to 1e4ff60 Compare August 28, 2026 13:56
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 1

The review was composed against b227789, which appended +hybrid_fragmented to the recording muxer flags. That approach was replaced before this round: the branch now leaves the recording flags untouched and remuxes the finished file into a sibling temp, renaming over the original only on a clean FFmpeg exit. Three of the five findings are about the flag and no longer have a subject.

Done

  • src/electron/services/video-recording.ts:23 (3.1 — contradictory comments): the four repetitions the finding listed are already gone at HEAD — there is no inline comment above the flags, and the trailing // Fragmented MP4 for crash-safety is still true, since recording is still fragmented. What remained was the module JSDoc claiming the pipeline "eliminates the need for post-processing finalization" while the new bullet three lines below says the file is converted on finalize. Reworded to what it actually meant: no reassembly from chunks once recording ends.
  • PR body (2.1 — existing recordings): "Not fixed here" now states that files already on disk keep the fragmented layout and that Cockpit offers no conversion route for them.

Won't change (with reasoning)

  • 1.1 — hybrid_fragmented never verified against the pinned FFmpeg 7.1 binaries: obsolete. No movflag is added anywhere now; the remux runs -i <file> -c copy -f mp4 -y <temp>, which every FFmpeg accepts. The second ask — treat a spawn-time FFmpeg death as a start failure rather than one snackbar per chunk — is a real defect, but it lives in startVideoRecording, which this diff no longer touches at all. Pre-existing, and its own PR.
  • 1.2 — the conversion window leaves an unreadable file, reported as success: obsolete in its mechanism. remuxToSeekableMp4 (:230-268) never writes to the recording; it writes <file>.remuxing.tmp and fs.renames over the original only after exit code 0, unlinking the temp on any failure. A kill at any point leaves the original fragmented recording byte-identical. The 60 s finalization timeout is cleared at :313 before the remux starts, so it does not bound it, and the remux carries its own timeout. The stats.size > 0 "partial success" branch (:352-367) sits on the non-zero-exit path, where no remux runs. The one residue is an orphaned .remuxing.tmp if the app dies mid-remux; it does not end in a video extension, so isVideoFilename (src/stores/video.ts:1237) keeps it out of the library, and a startup sweep for it is more machinery than the case is worth.
  • 5.1 — recording-time memory no longer bounded by the fragment: obsolete. The recording muxer still writes frag_keyframe+empty_moov+default_base_moof (:99-100), so its per-sample bookkeeping is still flushed per fragment and the footprint during a multi-hour dive is what it was before this PR. The whole-file sample table now lives in the separate remux process, which starts after recording has ended and exits seconds later. At roughly 64 bytes per index entry a 6-hour 30 fps recording lands on the order of 100 MB — an estimate rather than a measurement, but transient and in a process that no longer overlaps the dive.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 2)

5 open findings: 2 major (1.3, 1.4), 2 minor and 1 nit. 5 closed this round — every finding from round 1.

When a desktop recording stops, the app now makes a second, plain copy of the finished video file with the bundled media tool and swaps it in for the original. The recording itself is written exactly as before, in the crash-resistant form that survives a power cut, and the swap happens only once the copy has completed, so an interruption leaves the original file untouched. The point of the copy is that the plain form carries an index a player can fetch in one go, which is what lets the video start playing when it is served from a web server instead of opened from disk. This is a different approach from the one round 1 reviewed, and it closes every finding that review raised.

What still needs attention

# Problem What it means Severity Status
1.3 Conversion can stall on a long recording Converting a long recording can stop making progress and be abandoned ten minutes later, so the video stays in the slow-to-play form and the log does not say why. major
1.4 Free-space check does not compile The project's type check — one of the gates every change has to pass — fails on the new code, so this cannot merge as it stands. major
2.2 Half-finished copy left behind after a crash If the app or the computer dies while a recording is being converted, a file as large as the recording stays in the video folder, hidden from the app and never cleaned up. minor
6.1 Longer silent wait after pressing stop After stopping a recording the operator waits longer for the "finished" message, with nothing on screen saying the app is still working on the file. minor
11.1 Unlabelled ten-minute timeout Someone reading the code has to work out what the timeout value means, where the two timeouts next to it say so. nit
Since round 1 — 5 closed, comparing b2277891e4ff60

Range and method. incremental.diff was not usable for this round: it reproduces pr.diff verbatim instead of the difference between the two revisions. It presents the finalize bullet in the module JSDoc as an addition, although round 1 recorded that bullet as already present at b227789, and it contains no trace of +hybrid_fragmented being withdrawn from the muxer flags, which must have happened between the two revisions. Every transition below was therefore worked out from pr.diff against the checked-out base, not from the increment. pr.json lists a single commit at head, so this arrived as a force-push amending the previous one. Line numbers below are head-revision numbers.

1.1 — hybrid_fragmented never verified against the pinned FFmpeg binaries — ⚪ No longer applicable. There is no movflags change anywhere in pr.diff; the recording muxer line is untouched at src/electron/services/video-recording.ts:99-100, so nothing asks the three pinned 7.1 binaries for an option they may not have. The remux spawns -i <file> -c copy -f mp4 -y <temp> (:241), none of which is new syntax. The finding's second ask — make an FFmpeg that dies at spawn a start failure rather than one error snackbar per chunk — is still a real defect, but it lives in startVideoRecording, which this revision no longer touches at all; it is pre-existing and belongs to its own PR. Closed on the code, not on the author's say-so.

1.2 — Finalize-time conversion could leave an unreadable file reported as success — ✅ Addressed. What it asked for was two things: probe the output before resolving on a non-zero exit or a timeout kill, and stop bounding finalization with a flat 60 s. Neither landed literally, and neither needs to, because the window itself is gone rather than guarded. Nothing rewrites the recording now: remuxToSeekableMp4 writes <file>.remuxing.tmp (:231) and fs.renames over the original only after exit code 0 (:263) — both paths in the same folder, so the rename is atomic — and every failure path unlinks the temp and leaves the recording byte-identical (:264-266). The two supporting claims check out too: clearTimeout(timeout) is the first statement of the close handler (:313) with the remux called after it (:319), so the 60 s timer does not bound the copy; and the stats.size > 0 "partial success" branch (:348-356) sits on the non-zero-exit path, where no remux ever runs.

2.1 — Existing recordings keep the problem, unstated — ✅ Addressed, with a deviation worth naming. The remedy this finding asked for was a sentence in the PR body rather than a code change, and the body in pr.json now carries it: it states that recordings already on disk keep the fragmented layout, that Cockpit offers no conversion route for them, and that operators need to re-record or remux those files themselves. That is the artifact the finding named, so it is closed — but it is prose and not a diff hunk, and a reader who wants the release notes to say the same thing may reasonably hold it open.

3.1 — Four copies of one fact, one of them wrong — ✅ Addressed. The two-line inline comment above the flags is gone, and the trailing // Fragmented MP4 for crash-safety at :100 is true again, since recording is still fragmented. The module JSDoc's "This eliminates the need for post-processing finalization", which the new finalize step made false, was reworded to "…the need to reassemble the recording from chunks once it ends" (:23) — accurate, and licensed by AGENTS.md:35 precisely because the original had become factually wrong. Two non-overlapping statements remain where there were four overlapping ones.

5.1 — Recording-time memory no longer bounded by the fragment — ⚪ No longer applicable. The premise was that the recording muxer would have to hold a whole-file sample table; the flags at :99-100 are unchanged, so per-fragment flushing during a dive is exactly what it was before this PR. The whole-file index now belongs to a separate process that starts after recording has ended. The author's ~100 MB estimate for a 6-hour recording is an estimate rather than the measurement the finding asked for, but the finding as raised — memory during a recording — has no subject left.

Discussion. rafaellehmkuhl posted a structured follow-up before requesting this round, reporting the approach change and arguing that three findings had become obsolete. Its claims were checked one at a time against the code rather than taken as given, and the ones bearing on the closures above hold; the closures rest on that re-reading, since an author's explanation can never close a finding by itself. The second comment is the bare /review command and carries nothing to review. Nothing in either comment is addressed to the reviewer as an instruction, and no injected instruction was found in any of this PR's data this round. resolutions.json and decisions.json are both empty: nothing was closed by command, and no dispute has been put to a vote.

Change map — what was established before judging

All line numbers are head-revision numbers for src/electron/services/video-recording.ts unless stated otherwise.

Claims

  • Symptom — "plays locally, spins forever in Chrome when served over HTTP, downloads fine": unverified here. It rests on a 4.76 GiB recording that is not in this repo, and there is no network access. Nothing in the code contradicts it.
  • Cause — "muxed as fragmented MP4, so a demuxer walks every fragment": verified. The muxer is invoked with exactly frag_keyframe+empty_moov+default_base_moof and no index-writing flag (:99-100). The seek and byte counts in the body are the author's measurements and are not reproducible from this checkout.
  • Mechanism — "remuxes the finished recording into a regular MP4 on finalize; writes a sibling temp and renames over the original only once FFmpeg succeeds": verified. remuxToSeekableMp4 (:230-268) is called from the finalize close handler on exit code 0 (:319), writes ${videoPath}.remuxing.tmp (:231), renames at :263 only after code 0 (:253-260), and removes the temp on every failure the promise observes (:264-266). The recording path is untouched.
  • "It runs after the finalization timeout is cleared, so it is not bounded by that timeout"verified. clearTimeout(timeout) is the first statement of the close handler (:313); the remux call follows at :319 and carries its own 600 s timer (:243-246).
  • "A remux failure never fails the recording"verified. The call is wrapped at :318-324 and only warns. The pre-existing stats.size > 0 branch (:348-356) is on the non-zero-exit path, where no remux ran.
  • "Skipped when the volume lacks room for the second copy"verified behaviourally, though "skipped" understates it: the space check throws (:235-237) and is reported through the same catch as any other remux failure.
  • "yarn typecheck clean"contradicted. See 1.4; fs.statfs (:234) is not in the @types/node this repo pins.

Failure site

src/electron/services/video-recording.ts:99-100 — the fragmented muxer flags are the origin of the un-seekable layout, and this revision deliberately leaves them alone, adding a separate pass after recording ends instead. A search for movflags across the tree returns only this call and scripts/uicast/record.mjs:337, a developer screen-recording script unrelated to vehicle recordings, so there is one chokepoint and the diff is at it.

Entry points

Function Reached from Frequency
remuxToSeekableMp4 (:230, new) only finalizeVideoRecording's close handler (:319) ← ipc finalize-video-recording (:869) ← LiveVideoProcessor.stopProcessing (src/libs/live-video-processor.ts:210) ← mediaRecorder.onstop (src/stores/video.ts:1169); also the chunk-ZIP recovery path (src/libs/live-video-processor.ts:287) and leftover-chunk processing (src/composables/videoChunkManager.ts) per user action
finalizeVideoRecording (:274, changed) same chain as above per user action

Both are once per recording stopped, or once per recovery run — never on a timer, a watcher or a message path. That is what keeps the cost of a whole-file copy acceptable, and it is why 6.1 is minor rather than more.

Invariants

  1. The finished recording must never be observable half-converted. Sites that could violate it: the rename (temp and target are both in the videos folder, so it is same-filesystem and atomic); the promise's failure paths (temp unlinked, original untouched, :264-266); the 600 s SIGKILL (:244); app quit or power loss mid-remux. Covered by the PR: all of them for the recording itself. What is not covered is the leftover temp in the last two cases → 2.2.
  2. A spawned FFmpeg's stderr must be drained, or not piped, for the child to run to completion. Enumerated spawns in this file: startVideoRecording attaches a stderr listener (:113-124), generateThumbnailFromMP4 attaches one (:641), and the new remux (:241) attaches none. Covered by the PR: none → 1.3.
  3. Every Node API used under src/ must exist in the pinned @types/node. tsconfig.app.json includes src/**/*, tsconfig.vitest.json resets exclude to [] and sets types: ["node"], and CI runs yarn typecheck at .github/workflows/ci.yml:141, so this file is in the checked program. Violator: fs.statfs (:234) → 1.4.
1. Correctness & Implementation Bugs — 2 findings

1.3 — The remux child's stderr is piped and never read, so a long remux can block until the 600 s killmajor

Consequence: converting a long recording can stop making progress and be abandoned ten minutes later, so the video stays in the slow-to-play form and nothing in the log says why.

spawn(getFFmpegPath(), [...]) at :241 uses Node's default stdio, which creates a pipe for stdout and stderr. Neither is ever read: no data listener, no stdio override, nothing resumes the streams. FFmpeg writes its banner, the input and output stream dumps, and a progress line roughly twice a second to stderr. This repository already knows that — startVideoRecording exists to filter exactly those lines, testing for frame=, size=, time= and bitrate= at :113-124, on a spawn made the same way. Once the OS pipe buffer (64 KiB on Linux and macOS, comparable on Windows) fills with nobody draining it, the child blocks on its next write to stderr and stops muxing.

At the throughput the PR body measures — 12 s for 4.76 GiB — the output stays a few KiB and nothing happens. The case that matters is the one this PR is aimed at: a multi-hour recording, or any recording on an external or network volume where the copy takes minutes rather than seconds. Several hundred progress lines reach the buffer, the remux wedges, the timer at :243-246 SIGKILLs it, :265 removes the temp, and :323 logs a warning. The recording survives — that part of the design holds — but the feature silently does not apply, and the user's stop-recording flow waits out the full ten minutes before the completion snackbar (src/stores/video.ts:1170) and the telemetry overlay (src/stores/video.ts:1186) can run.

There is a second, smaller cost that is certain rather than conditional: when the remux exits non-zero, the only thing recorded is FFmpeg remux exited with code ${code} (:258). FFmpeg's own explanation went into the pipe nobody read. Both other spawns in this file capture stderr for precisely that reason.

Ask: attach a stderr listener in the same shape as :113-124, keep the last few lines, and put them in the rejection message so a failed remux is diagnosable. Draining the pipe is what removes the stall; the tail is what makes the failure readable.

1.4 — fs.statfs is not in the @types/node this repo pins, so yarn typecheck fails on this filemajor

Consequence: the type check that gates every change fails on the new code, so this cannot be merged as it stands — and the PR body reports that check as passing.

:234 calls await fs.statfs(dirname(videoPath)), where fs is promises from fs (:3). fs.statfs first shipped in Node 18.15 / 19.6. This repo pins "@types/node": "^17.0.29" (package.json:115), which yarn.lock:3359-3361 resolves to 17.0.45 — a type package that predates the API and therefore cannot declare it. That is the copy the compiler will use: @types/node is a direct devDependency, so it is the one hoisted to node_modules/@types/node, and tsconfig.vitest.json sets types: ["node"], extends tsconfig.app.json's include: ["src/**/*"], and resets exclude to [], putting this file in the program. .github/workflows/ci.yml:141 runs yarn typecheck (vue-tsc --noEmit -p tsconfig.vitest.json) in the same step as lint and build.

At runtime the call is fine — Electron 29 (package.json:129) ships Node 20 — and vite build will not catch it either, since esbuild strips types without checking them. It is only the typecheck that fails, which is the one that gates the merge. The PR body's Verification section states yarn typecheck clean, which contradicts this; if it really is clean on a --frozen-lockfile install, that single command settles it and this finding is wrong.

Smallest fix: delete the pre-check. The failure it guards against is already handled, and handled safely — a volume that fills mid-remux makes FFmpeg exit non-zero, the temp is removed and the original recording is kept, exactly like every other remux failure. The check is also racy in a way that matters here: two streams stopped together each measure the free space independently and can both pass before either copy starts, so it never was a guarantee. Removing it drops four lines, the race and the type problem together. If an explicit check is worth keeping, bumping @types/node is a dependency change that needs to be a deliberate, separate commit rather than a side effect of this one.

2. Persistence & User Data — inventory, 1 finding

Inventory

What Backend What happened to it
Cockpit settings keys (cockpit-*, settings-management.ts, useBlueOsStorage) none added, reshaped or removed by this PR
Recording <Cockpit folder>/videos/<fileName>.mp4 machine-local filesystem (:75-79) replaced at finalize — the fragmented file is swapped for a regular MP4 of the same name by an atomic rename (:263); the crash-safe original is discarded once the copy succeeds. Location, name and extension unchanged
<fileName>.mp4.remuxing.tmp machine-local, same folder (:231) new transient artifact — renamed away on success, unlinked on any observed failure, orphaned when the process dies mid-remux → 2.2
Video thumbnail (filesystemStorage, ['videos'], :331) machine-local unchanged; now generated from the remuxed file, which decodes the same way
Telemetry .ass sidecar machine-local unchanged
Raw WebM chunk backups (temp dir) machine-local unchanged

No cockpit- key is touched, no migration is introduced, and nothing here is vehicle-synced. The one entry that needed judgement is the recording itself: it is replaced rather than edited, and the replacement is committed by a same-directory rename after a clean exit, so there is no state in which the user's recording is neither the old file nor the new one. That is the right shape, and it is what closed 1.2.

2.2 — A remux killed with the process leaves a full-size temp file that nothing ever removesminor

Consequence: if Cockpit or the computer dies while a recording is being converted, a file as large as the recording is left in the user's video folder, where the app never shows it and never deletes it.

The temp lives beside the recording as ${videoPath}.remuxing.tmp (:231). It is cleaned on success (renamed over the original) and on every failure the promise observes (:265). It is not cleaned when the process holding that catch goes away: app quit, crash, or power loss during the copy. On Windows there is a second route — the SIGKILL path calls fs.rm on a file the dying child may still have open, and force: true swallows a missing file but not an EPERM, so that failure replaces the original error and the temp stays.

Nothing surfaces it. The library lists the videos folder through filesystemStorage.keys(['videos']) and filters with isVideoFilename (src/components/VideoLibraryModal.vue:1107-1109, src/stores/video.ts:1237-1242), whose extension set is mkv/mp4/webm (src/types/video.ts:173-177), so a .remuxing.tmp never appears in the UI and the user finds it only by opening the folder. On the recording the PR body measures, that is 4.76 GiB of invisible disk.

Ask: sweep *.remuxing.tmp out of the videos folder once at startup, in setupVideoRecordingService. A readdir and a filter is not machinery, and startup is the right moment because no remux can be in flight — doing it in startVideoRecording instead would delete the temp of another stream that is still being converted.

6. UI / UX — 1 finding

6.1 — Stopping a recording now waits out a full copy of the file, with nothing shown while it runsminor

Consequence: after pressing stop, the operator waits longer before Cockpit says the recording is finished, with no sign that the app is still working on the file.

mediaRecorder.onstop awaits stopProcessing() and only then opens the "Video processing completed." snackbar (src/stores/video.ts:1169-1175); the telemetry overlay is generated after that (src/stores/video.ts:1186). The remux sits inside that await, so the gap grows by the time it takes to copy the whole recording — the PR body measures 12 s for 4.76 GiB on the author's disk, and an external or network volume is a different number — bounded only by the 600 s timer at :243-246, or by 1.3 above.

Nothing in the interface marks the interval. The recorder button has already returned to its idle state, since toggleRecording calls stopRecording and returns without awaiting anything (src/components/mini-widgets/MiniVideoRecorder.vue:336-343), so an operator who quits Cockpit in that window silently ends up with the un-converted file and the leftover of 2.2. The recovery path is better served by comparison: processVideoChunksFromZips holds a progress bar at "Finalizing video…" across the call (src/libs/live-video-processor.ts:284-287), though that bar now sits at 85% for the whole copy rather than a moment.

Ask: cover the interval with feedback the operator can see — the store already has openSnackbar in hand at the call site, and a "still finishing the recording" state on the recorder is the unambiguous UI state change AGENTS.md:231 asks for. At the very least, the PR body's Behaviour notes should record that stopping a recording now returns only after the copy, so the added wait is a stated decision rather than a surprise.

11. Nitpicks / Optional — 1 finding

11.1 — The remux timeout is a bare 600000nit

:246 ends the timer with }, 600000) and nothing says what that is. The two timeouts either side of it in this same file both do: const timeoutMs = 60000 // 1 minute timeout for finalization (:297) and }, 300000) // 5 minutes timeout for thumbnail generation (:677). Match one of those two forms.

Sections with nothing to report (7)

3. AGENTS.md Adherence — ✅ (climbed the minimalism ladder: no new dependency, no new file, the remux reuses the bundled binary through the existing getFFmpegPath() and follows the spawn-promise shape already in this file; the added JSDoc has a real summary and typed @param/@returns; the single added inline comment at :322 explains why the failure is swallowed rather than what the line does; the module JSDoc reword at :23 is the case AGENTS.md:35 permits, since the old sentence had become factually wrong; no rename, reorder, helper move or formatter reflow anywhere in the diff)

4. Security — ✅ (60 added lines, all plain ASCII — grepped for non-ASCII and for encoded blobs, no matches; no new dependency, no network call, no secret or environment variable; the one new spawn runs the already-bundled binary with fixed literal flags and no shell, and its two path arguments derive from join(getCockpitFolderPath(), 'videos', …) at :75-79, so both are absolute and neither can be read as an option; no build script, workflow or Dockerfile touched)

5. Performance — ✅ (the added work is one read-and-write of the finished file behind an explicit stop action — traced to mediaRecorder.onstop in pass 4, never a timer, watcher or message path — and it runs after recording has ended, so the recording path itself is unchanged at :99-100; two streams stopped together spawn one copy each and both failure paths keep the original; the new timer at :243 is cleared on both the error and close branches, :249 and :254)

7. Code Quality & Style — ✅ (complexity-report.json for this head reports 72 functions measured across the 1 changed file, 0 triggers and no truncation, so nothing is flagged there; no any, no new scoped CSS, no locally re-implemented helper — the remux mirrors generateThumbnailFromMP4 at :621 rather than inventing a second pattern; the one bare literal is at 11.1)

8. Commit Hygiene — ✅ (one commit in pr.json, video: make recordings seekable when served over http, matching the scope-prefixed style dominant in git log on master and describing this change; the earlier muxer-flag attempt was amended away rather than left on the branch as the self-correcting commit AGENTS.md:191 forbids; no issue or PR reference, no fixup/wip noise, nothing bundled that should be split)

9. Tests — ✅ (no test file touched, removed or weakened; src/tests/ contains no coverage of src/electron/services, and this section does not ask for new tests)

10. Documentation — ✅ (README.md:106's Lite-vs-Standalone video row still holds — neither build gains or loses a capability here, only the on-disk layout of a Standalone recording changes; the module JSDoc records the new finalize step and the added helper documents its own contract)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the video-http-seekable-recordings branch 2 times, most recently from 2ae0c85 to f281aa8 Compare August 28, 2026 14:31
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 2

All five open findings implemented. No dispute this round.

Done

  • src/electron/services/video-recording.ts:240-245 (1.3 — remux stderr piped and never drained): the remux now attaches a stderr listener that keeps the last 1000 characters, so the pipe drains and the child cannot block on a full buffer. The tail goes into the rejection message, so a non-zero exit carries FFmpeg's own explanation instead of just the code.
  • src/electron/services/video-recording.ts:233 (1.4 — fs.statfs not in the pinned @types/node): the pre-check is deleted, as the finding suggested. It was racy anyway — two streams stopped together both measure free space before either copy starts — and the case it guarded is already handled: a full volume makes FFmpeg exit non-zero, the temp is removed and the original recording is kept. Confirmed the removal is what fixes it: tsc -p tsconfig.vitest.json reports TS2551: Property 'statfs' does not exist at :234 on the previous head and nothing on this file now.
  • src/electron/services/video-recording.ts:844-868 (2.2 — orphaned .remuxing.tmp): removeOrphanRemuxTempFiles runs once from setupVideoRecordingService, readdirs the videos folder and removes anything ending in the suffix. Startup, as asked, because no remux can be in flight there. The suffix is now a module const shared by the remux and the sweep.
  • src/electron/services/video-recording.ts:269-270 (2.2, Windows branch): the cleanup on the failure path is fs.rm(...).catch(() => undefined), so an EPERM on a file the dying child still holds open no longer replaces the real error. The leftover is the sweep's job.
  • src/stores/video.ts:1168-1187 (6.1 — silent wait after stop): a persistent snackbar — "Finishing the recording. This can take a while for long videos." — opens before stopProcessing() and is closed in the finally, so it covers the whole interval including the failure path. PR body's Behaviour notes also state that stopping now returns only after the copy.
  • src/electron/services/video-recording.ts:237 (11.1 — bare 600000): const timeoutMs = 600000 // 10 minutes timeout for the remux, matching :302.

Done differently

  • PR body (part of 1.4): the Behaviour notes bullet claiming the remux is "skipped when the volume lacks room" was wrong once the pre-check went, and so was the commit message's last sentence. Both now say a failed remux — full disk included — keeps the fragmented recording. Verification no longer claims yarn typecheck clean; it says type-check clean on the changed files, which is what was actually run.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
📝 MINOR SUGGESTIONS (Automated PR Review — round 3)

2 open findings: 1 minor and 1 nit, both raised this round. 5 closed this round — every finding round 2 left open.

When a desktop recording stops, the app makes a second, plain copy of the finished video file with the bundled media tool and swaps it in for the original. The recording itself is still written in the crash-resistant form that survives a power cut, and the swap happens only once the copy has completed, so an interruption leaves the original file untouched. The point of the copy is that the plain form carries an index a player can fetch in one go, which is what lets the video start playing when it is served from a web server instead of opened from disk. This round adds the two things that were missing around it: a message on screen while the copy runs, so stopping a recording no longer looks like it hung, and a startup sweep that removes a half-finished copy left behind by a crash.

What still needs attention

# Problem What it means Severity Status
1.5 Fresh installs log a startup failure that never happened On a computer that has never recorded anything, Cockpit writes a "failed to remove leftover files" warning into its log file at every launch, which makes real problems harder to spot when someone reads that log for support. minor
11.2 A comment claims the sweep is always safe If a second copy of Cockpit is started while the first is still converting a recording, it deletes the file that conversion is writing, so the conversion quietly fails. nit
Since round 2 — 5 closed, comparing 1e4ff60f281aa8

Range and method. incremental.diff is unusable again, in exactly the way it was last round: its hunks are byte-for-byte the hunks of pr.diff (96 added and 2 removed lines in both), so it is a base-to-head diff wearing an increment's header. Two things prove it cannot be 1e4ff60...f281aa8: it presents the whole of remuxToSeekableMp4 as newly added, although round 2 reviewed that function line by line at 1e4ff60, and it presents the module-JSDoc reword at :23 as new, although round 2 closed finding 3.1 on that reword already being present. Every transition below was therefore worked out from pr.diff against the checked-out base, not from the increment. pr.json lists one commit at head, so this arrived as a force-push amending the previous one. Line numbers are head-revision numbers.

1.3 — Remux child's stderr piped and never drained — ✅ Addressed. Both halves of the ask landed. src/electron/services/video-recording.ts:240-245 now attaches a stderr listener on the remux child, in the same shape as the one startVideoRecording has carried all along at :115-126, so the pipe is drained and FFmpeg cannot block on a full OS buffer part-way through a long copy. The tail is kept — stderrTail = (stderrTail + data.toString()).slice(-1000) — and goes into the rejection at :262, so FFmpeg remux exited with code ${code}: … now carries FFmpeg's own explanation instead of a bare number. Invariant 2 from round 2's change map ("a spawned FFmpeg's stderr must be drained or not piped") is now satisfied at all three spawn sites in this file: :115, :654, :243. One residual worth naming rather than reopening: the timeout path at :249 rejects with a bare Remux timed out and does not include the tail, so a genuine stall is still the least diagnosable failure — but the cause of that stall is what this fix removed.

1.4 — fs.statfs absent from the pinned @types/node — ✅ Addressed. statfs does not appear anywhere in pr.diff; the four-line pre-check is gone, which is the smaller of the two fixes the finding named and the one it recommended. package.json:115 still pins "@types/node": "^17.0.29", and no dependency change rode along, so nothing else in the diff can reach an API that package does not declare. The three downstream statements the finding flagged as contradicting the code were corrected too: the PR body's Behaviour notes now say a volume without room makes FFmpeg exit non-zero and is handled like any other remux failure, the commit message's closing sentence says the same, and Verification no longer claims yarn typecheck clean — it claims type-check clean on the changed files, which is a narrower and checkable statement.

2.2 — Orphaned .remuxing.tmp after a crash — ✅ Addressed. Both routes the finding named are closed. removeOrphanRemuxTempFiles (:850) readdirs the videos folder, filters on the new module const remuxTempSuffix (:35) and removes every match, and it is called once from setupVideoRecordingService (:868) — at startup, where no remux of this process can be in flight, which is precisely where the finding asked for it and not in startVideoRecording, where it would have deleted a sibling stream's live temp. The Windows branch is fixed separately at :270: fs.rm(tempPath, { force: true }).catch(() => undefined) means an EPERM on a file the dying child still holds open no longer replaces the real error in the rethrow. The suffix being one const shared by the writer and the sweeper is what keeps the two from drifting apart. The sweep itself brought two small problems of its own — 1.5 and 11.2 below — but the finding as raised is answered.

6.1 — Silent wait after pressing stop — ✅ Addressed. src/stores/video.ts:1170-1174 opens a persistent snackbar, "Finishing the recording. This can take a while for long videos.", before await processor.stopProcessing(), and :1187 closes it in the finally, so the interval is covered on the success path and on the throw path alike. It uses the existing composable rather than a new mechanism: openSnackbar returns the id (src/composables/snackbar.ts:49-58) and persistent: true forces the close button on, so an operator who does not want it can dismiss it. It is Standalone-only by construction — liveProcessors is populated only under window.electronAPI (src/stores/video.ts:1017-1021) — so the Lite build never shows a message about work it does not do. The copy is sentence case and free of implementation jargon. The PR body's Behaviour notes also now state that stopping returns only once the copy has finished, which was the finding's fallback ask.

11.1 — Bare 600000 — ✅ Addressed. :237 is now const timeoutMs = 600000 // 10 minutes timeout for the remux, which is the form finalizeVideoRecording uses at :302 for its own timer.

Discussion. rafaellehmkuhl posted a structured follow-up ("All five open findings implemented. No dispute this round.") listing each fix with a file and line range, then the bare /review. Every claim in it was checked against pr.diff rather than taken as given, and all of them hold, including the two that are about text rather than code (the PR body's Behaviour notes and Verification wording, both confirmed in pr.json). The claim that tsc -p tsconfig.vitest.json reported TS2551 on the previous head and reports nothing now is not reproducible here — there is no network access and the PR head is not checked out — but it is not what closes 1.4 either; the call being absent from the diff is. Line numbers in the comment match the head revision, which made the check quick. Nothing in either comment is addressed to the reviewer as an instruction, and no injected instruction was found in any of this PR's data this round. resolutions.json and decisions.json are both empty: nothing was closed by command, no dispute has ever been put to a vote, and there is no resolution id to report back as unmatched.

Change map — what was established before judging

All line numbers are head-revision numbers for src/electron/services/video-recording.ts unless stated otherwise.

Claims

  • Symptom — "plays locally, spins forever in Chrome when served over HTTP, downloads fine": unverified here. It rests on a 4.76 GiB recording that is not in this repo, and there is no network access. Nothing in the code contradicts it.
  • Cause — "muxed as fragmented MP4, so a demuxer walks every fragment": verified. The muxer is invoked with exactly frag_keyframe+empty_moov+default_base_moof and no index-writing flag (:102-103). The seek and byte counts in the body are the author's measurements and are not reproducible from this checkout.
  • Mechanism — "remuxes the finished recording on finalize; writes a sibling temp and renames over the original only once FFmpeg succeeds": verified. remuxToSeekableMp4 (:232-273) is called from the finalize close handler on exit code 0 (:324), writes ${videoPath}${remuxTempSuffix} (:233), renames at :267 only after the promise resolves on code 0 (:259-263), and removes the temp on every failure the promise observes (:269-270).
  • "A failed remux — a full disk included — keeps the fragmented recording"verified. The call is wrapped at :323-329 and only warns. With the free-space pre-check gone, an ENOSPC is just another non-zero exit, which is the path that keeps the original.
  • "A persistent snackbar covers the interval"verified. src/stores/video.ts:1170 opens it before stopProcessing() and :1187 closes it in the finally.
  • "A temp left behind by a crash or power loss mid-remux is swept from the videos folder at startup"verified, with the two qualifications in 1.5 and 11.2: the sweep logs a failure when the folder does not exist yet, and "startup" is only single-process-safe.
  • "type-check clean on the changed files"consistent with the code. The construct that contradicted the previous, broader claim is gone; nothing in the diff now reaches outside the pinned @types/node 17.

Failure site

:102-103 — the fragmented muxer flags are the origin of the un-seekable layout, and this revision deliberately leaves them alone, adding a separate pass after recording ends instead. A search for movflags across the tree returns only this call and scripts/uicast/record.mjs, a developer screen-recording script unrelated to vehicle recordings, so there is one chokepoint and the diff is at it.

Entry points

Function Reached from Frequency
remuxToSeekableMp4 (:232, new) finalizeVideoRecording's close handler (:324) ← ipc finalize-video-recording (:904) ← LiveVideoProcessor.stopProcessing (src/libs/live-video-processor.ts:210) ← mediaRecorder.onstop (src/stores/video.ts:1176); also LiveVideoProcessor.processZipFiles (src/libs/live-video-processor.ts:287) on the chunk-ZIP recovery path per user action
finalizeVideoRecording (:279, changed) same chain as above per user action
removeOrphanRemuxTempFiles (:850, new) setupVideoRecordingService (:868) ← src/electron/main.ts:139, at main-process module evaluation, before app.whenReady() one-shot
mediaRecorder.onstop (src/stores/video.ts:1145, changed) the MediaRecorder stop event — the Stop button, stream teardown, or a dropped link per user action

Nothing added here runs on a timer, a watcher or a message path. The whole-file copy is once per recording stopped or once per recovery run, and the sweep is once per app launch; that is what keeps the cost acceptable and what makes 11.2 a nit rather than more.

Invariants

  1. The finished recording must never be observable half-converted. Sites that could violate it: the rename (temp and target are both in the videos folder, so it is same-filesystem and atomic, :267); the promise's failure paths (:269-270); the 600 s SIGKILL (:248); app quit or power loss mid-remux. Covered by the PR: all of them. The leftover temp in the last case, which round 2 raised as 2.2, is now swept.
  2. A spawned FFmpeg's stderr must be drained, or not piped, for the child to run to completion. Enumerated spawns in this file: startVideoRecording (:115-126), generateThumbnailFromMP4 (:654-660), and the remux (:243-245). Covered by the PR: all three — the third is what this round added.
  3. Every .remuxing.tmp is eventually removed. Sites: success (renamed away, :267); observed failure (:270); process death (startup sweep, :868). Covered: all three, for a single running Cockpit. The enumeration is exhaustive only per process — there is no requestSingleInstanceLock anywhere in the tree, so a second instance's sweep can reach the first instance's live temp → 11.2.
  4. The persistent "Finishing the recording" snackbar must always be closed. Opened at src/stores/video.ts:1170, inside if (processor); closed at :1187 in the finally of the only try that follows it, so the resolve, the reject and the delete liveProcessors path all pass through it. Covered. A user dismissal is harmless: closeSnackbar on an id already gone is a no-op (src/composables/snackbar.ts:44-47).
1. Correctness & Implementation Bugs — 1 finding

1.5 — The startup sweep reports a missing videos folder as a failure, so a fresh install logs a warning at every launchminor

Consequence: a computer that has not recorded anything yet writes a "failed to remove leftover files" warning into Cockpit's log on every start, so someone reading that log for a real problem has to first work out that this one is not real.

removeOrphanRemuxTempFiles (src/electron/services/video-recording.ts:850) wraps the whole sweep in one try, and its catch at :859-861 treats every error the same way:

const fileNames = await fs.readdir(videosPath)
…
} catch (error) {
  console.warn('Failed to remove leftover remux temporary files:', error)
}

videosPath is join(getCockpitFolderPath(), 'videos') (:851). getCockpitFolderPath() guarantees the Cockpit folder exists (src/electron/services/storage.ts:220-223ensureCockpitFolder, which mkdirSyncs it), but nothing creates the videos subfolder at startup. It is created lazily, by startVideoRecording (:78-79) and by filesystemStorage.setItem(…, ['videos']). So on a fresh install, and on any machine where the user has just pointed Cockpit at a new folder, readdir throws ENOENT and the sweep logs a failure on every launch until the first recording is made.

That warning is not just console noise. setupElectronLogService() runs first in src/electron/main.ts:23 and does Object.assign(console, taggedLoggerFunctions) (src/electron/services/electron-log.ts:78), so every main-process console.warn after that point is written into the session .syslog file — the one users export and attach to support threads, readable in-app through the get-electron-log-content handler (src/electron/services/electron-log.ts:146). setupVideoRecordingService() is called at src/electron/main.ts:139, well after the override.

This repository already has the correct shape for exactly this operation, one file away: filesystemStorage.keys lists the same folder and returns [] on ENOENT, rethrowing everything else (src/electron/services/storage.ts:84-94).

Ask: give ENOENT its own exit before the warning — if (error.code === 'ENOENT') return at the top of the catch, matching storage.ts:91. Nothing needs sweeping when the folder does not exist, and a real failure (a permissions problem on the videos folder, say) still gets logged.

2. Persistence & User Data — inventory, no findings

Inventory

What Backend What happened to it
Cockpit settings keys (cockpit-*, settings-management.ts, useBlueOsStorage) none added, reshaped or removed by this PR
Recording <Cockpit folder>/videos/<fileName>.mp4 machine-local filesystem (:78-82) replaced at finalize — the fragmented file is swapped for a regular MP4 of the same name by an atomic rename (:267); the crash-safe original is discarded only once the copy succeeds. Location, name and extension unchanged
<fileName>.mp4.remuxing.tmp machine-local, same folder (:233, suffix const at :35) new transient artifact — renamed away on success, unlinked on any observed failure (:270), and, new this round, swept from the videos folder at startup when a crash orphaned it (:850, :868)
Video thumbnail (filesystemStorage, ['videos'], :344) machine-local unchanged; now generated from the remuxed file, which decodes the same way
Telemetry .ass sidecar machine-local unchanged
Raw WebM chunk backups (temp dir) machine-local unchanged

No cockpit- key is touched, no migration is introduced, nothing is vehicle-synced, and nothing machine-specific is written anywhere it could reach another topside computer. The one entry that needed judgement is the recording itself: it is replaced rather than edited, and the replacement is committed by a same-directory rename after a clean exit, so there is no state in which the user's recording is neither the old file nor the new one. The temp entry, which was round 2's only finding here, now has a complete lifecycle — the sweep at :850 is the third and last of the three removal sites enumerated as invariant 3 in the change map. The sweep's own filter is exact (fileName.endsWith(remuxTempSuffix), :854), so it cannot reach a recording, a thumbnail or a sidecar, and its two remaining rough edges (1.5, 11.2) cost nobody their data.

11. Nitpicks / Optional — 1 finding

11.2 — "no remux can be in flight yet" is true of this process, not of this machinenit

removeOrphanRemuxTempFiles's JSDoc (src/electron/services/video-recording.ts:847) justifies the sweep's placement with "Startup is the only safe moment to sweep, since no remux can be in flight yet." The reasoning for choosing startup is right, and that is why this is a nit rather than a finding against the design. The absolute is not: there is no requestSingleInstanceLock and no second-instance handler anywhere in the tree, so on Windows and Linux a user can launch a second Cockpit while the first is mid-remux, and the newcomer's fs.rm at :855 takes out the temp the first one is still writing. The outcome is benign — the fs.rename at :267 fails ENOENT, the catch rethrows, :328 warns, and the recording is kept in its fragmented form — but the remux silently does not apply to that recording, and the comment is what a future reader will trust when deciding whether the sweep needs a guard.

Ask: drop the absolute from the sentence, or make the sweep skip a temp whose mtime is recent enough that another process could still own it. The comment edit is the smaller of the two and is enough for a nit.

Sections with nothing to report (8)

3. AGENTS.md Adherence — ✅ (climbed the minimalism ladder: no new dependency, no new file, the remux and the sweep both reuse what is already here — getFFmpegPath(), the spawn-promise shape of generateThumbnailFromMP4, and openSnackbar/closeSnackbar from src/composables/snackbar.ts rather than a new progress mechanism; this round's net addition over round 2 is 43 lines and it deletes the four-line pre-check; both added JSDoc blocks have real summaries and typed @returns; all four added inline comments explain why, not what; no rename, reorder, helper move or formatter reflow anywhere in the diff, and nothing added is left without a call site)

4. Security — ✅ (96 added lines, all plain ASCII — grepped pr.diff for non-ASCII and found none, no encoded blob, no committed binary; no new dependency, no network call, no secret or environment variable; the one new spawn runs the already-bundled binary with fixed literal flags and no shell, and its two path arguments derive from join(getCockpitFolderPath(), 'videos', …), so both are absolute and neither can be read as an option; the new fs.rm loop is confined to one folder and gated on an exact .remuxing.tmp suffix; no build script, workflow or Dockerfile touched)

5. Performance — ✅ (the added work is one read-and-write of the finished file behind an explicit stop action — traced in pass 4 to mediaRecorder.onstop and to the ZIP recovery path, never a timer, watcher or message path — and it runs after recording has ended, so the recording path at :102-103 is untouched; the new stderr listener holds at most 1000 characters and runs on a per-second-ish stream, not a hot path; the startup sweep is one readdir of a folder the app already lists elsewhere; the remux timer at :247 is cleared on both the error and close branches, :253 and :258, and the snackbar id is released in a finally)

6. UI / UX — ✅ (the one added surface is a snackbar through the existing composable: sentence case, no protocol or implementation jargon, dismissible because persistent forces the close button on at src/composables/snackbar.ts:52, closed programmatically in the finally at src/stores/video.ts:1187 so it cannot outlive the work it describes, and Standalone-only because liveProcessors is populated only under window.electronAPI; no dialog, overlay-teleporting control, button, icon control or footer is added anywhere in the diff, so the anatomy, theme="dark", token, padding, glass and stacking clauses have no subject; the sweep and the remux are not user interactions, so logUserAction is not owed)

7. Code Quality & Style — ✅ (complexity-report.json for this head reports 188 functions measured across the 2 changed files, 0 triggers and no truncation, so nothing is flagged there; no any, no new scoped CSS, no x && x.y where x?.y would do — ffmpegProcess.stderr?.on at :243 matches the existing :115; the timeout literal round 2 flagged is now named; no comment was deleted or reworded whose code is unchanged; nothing here is domain logic that belongs in src/libs/, since both added functions are Electron main-process I/O, and neither file grew by anything near the file-growth threshold)

8. Commit Hygiene — ✅ (one commit in pr.json, video: make recordings seekable when served over http, matching the scope-prefixed style dominant in git log on master and describing this change; round 2's work was amended into it rather than left as the "address review" commit AGENTS.md:195-196 forbids, and the body was updated in the same amend so it no longer contradicts the code; no issue or PR reference, no fixup/wip noise, no AI-authorship trailer; the sweep and the snackbar are consequences of the remux rather than independent changes, so one commit is right here)

9. Tests — ✅ (no test file touched, removed or weakened; src/tests/ contains no coverage of src/electron/services or of the video store's onstop path, and this section does not ask for new tests)

10. Documentation — ✅ (README.md's Lite-vs-Standalone video row still holds — neither build gains or loses a capability, only the on-disk layout of a Standalone recording changes, and the new snackbar appears only where the work happens; the module JSDoc bullet at :30 records the finalize conversion, both added helpers document their own contract, and the PR body's Behaviour notes now state the added wait and the startup sweep)

Generated by Claude. This is advisory; a human reviewer must still approve.

Recordings are muxed as fragmented MP4 for crash-safety, which leaves the
file without a usable index: mvhd declares no duration and the only index,
mfra, sits at the very end. A demuxer therefore walks every fragment to
build a seek table, one seek per fragment - 1343 of them on a 42-minute
recording. On local disk that is invisible; over HTTP each one becomes a
range request, so playback never starts.

Remux the finished recording into a regular MP4, which brings that down to
2 seeks. The remux writes a sibling temp file and renames over the original
only on success, so an interruption leaves the original recording intact
rather than a half-rewritten one. A failed remux - a full disk included -
keeps the fragmented recording, which plays fine locally.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the video-http-seekable-recordings branch from f281aa8 to 7101401 Compare August 28, 2026 15:02
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 3

Both open findings implemented. No dispute this round.

Done

  • src/electron/services/video-recording.ts:846 (11.2 — the sweep's "no remux can be in flight" absolute): reworded to "Startup is the right moment to sweep, since no remux of this process can be in flight yet." A second instance launched mid-remux is the case the absolute hid, and the sentence no longer claims otherwise.

Done differently

  • src/electron/services/video-recording.ts:853 (1.5 — ENOENT on a videos folder that does not exist yet): rather than adding an if (error.code === 'ENOENT') return branch to the catch, the sweep now lists the folder through filesystemStorage.keys(['videos']) — the helper the finding itself named as the right shape, which already returns [] on ENOENT and rethrows everything else (src/electron/services/storage.ts:84-94). Same one-line cost, and it removes the error class instead of special-casing it, so the two copies of the branch cannot drift. fs.rm still runs against join(getCockpitFolderPath(), 'videos', …), and a real failure — a permissions problem on the folder — still reaches the warning.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
READY TO MERGE (Automated PR Review — round 4)

No open findings. 2 closed this round (1.5, 11.2), which leaves all 12 findings raised across the four rounds closed.

When a desktop recording stops, Cockpit makes a second, plain copy of the finished video file with the bundled media tool and swaps it in for the original. The recording itself is still written in the crash-resistant form that survives a power cut, and the swap happens only once the copy has completed, so an interruption leaves the original file untouched. The point of the copy is that the plain form carries an index a player can fetch in one go, which is what lets the video start playing when it is served from a web server instead of opened from disk. A message on screen covers the wait while the copy runs, and a startup sweep removes a half-finished copy left behind by a crash. This round changed two lines of that sweep: it now lists the videos folder through the shared storage helper, so a computer that has never recorded anything no longer logs a failure at every launch, and its comment no longer claims more safety than the code has.

What still needs attention

Nothing. Every finding this PR has had is closed.

Since round 3 — 2 closed, comparing f281aa87101401

Range and method. incremental.diff is unusable for the third round running, in the same way as before: it carries 96 added and 2 removed lines, which is exactly pr.json's additions/deletions for the whole PR, so it is a base-to-head diff wearing an increment's header. Two things prove it cannot be f281aa8...7101401. It presents the whole of remuxToSeekableMp4 as newly added, although round 3 reviewed that function line by line at f281aa8. And it presents the sweep's JSDoc sentence in its new wording as a pure addition (incremental.diff:106) with no removal of the old sentence anywhere in the file — a genuine f281aa8...7101401 diff would have to show that line as a modification, since round 3 quoted the old wording at that same position. Every transition below was therefore worked out from pr.diff against the checked-out base, not from the increment. pr.json lists one commit at head, so this arrived as another force-push amending the previous one. Line numbers are head-revision numbers.

1.5 — Startup sweep logs ENOENT as a failure — ✅ Addressed, differently from the ask. src/electron/services/video-recording.ts:853 is now const fileNames = await filesystemStorage.keys(['videos']) in place of fs.readdir(videosPath). filesystemStorage.keys returns [] on ENOENT and rethrows everything else (src/electron/services/storage.ts:84-94, unchanged by this PR), so a fresh install — or a machine freshly pointed at a new Cockpit folder — sweeps nothing and logs nothing, and a real failure such as a permissions problem on the videos folder still reaches the console.warn at :860. The finding asked for an if (error.code === 'ENOENT') return branch and named that helper as the correct shape; using the helper instead removes the error class rather than special-casing it, so there is no second copy of the branch to drift. Three things checked before accepting the substitution, since it is a different call and not the one requested:

  • Coverage is unchanged for what the sweep is for. keys filters to entries.filter((e) => e.isFile()) (storage.ts:89), so every regular file in the folder is still listed and a .remuxing.tmp is still found; the only entries it drops are directories, which is strictly correct here — <Cockpit>/videos/temporary-video-chunks (storage.ts:133) can no longer be considered at all.
  • The two paths cannot diverge. keys joins the module-private cockpitFolderPath, while the removal at :855 joins videosPath from getCockpitFolderPath() on the preceding line; both go through ensureCockpitFolder() (storage.ts:15, :85, :221) and getCockpitFolderPath() runs first, so the folder listed and the folder deleted from are the same one.
  • The suffix filter and the fs.rm are untouched, so the removal is still exact and still confined to that one folder.

11.2 — "no remux can be in flight yet" stated as an absolute — ✅ Addressed. :847 now reads "Startup is the right moment to sweep, since no remux of this process can be in flight yet." Both absolutes the finding named are gone: "the only safe moment" became "the right moment", and "no remux" became "no remux of this process", which is the claim the code actually supports given there is no requestSingleInstanceLock anywhere in the tree. The finding said the comment edit was enough for a nit, and it is what landed.

Discussion. rafaellehmkuhl posted a structured follow-up ("Both open findings implemented. No dispute this round.") and then the bare /review, which is noise. Both claims in the follow-up were checked against the code rather than taken as given, including the "Done differently" one — that filesystemStorage.keys "already returns [] on ENOENT and rethrows everything else (src/electron/services/storage.ts:84-94)" — which is verifiable in the checked-out base and holds exactly as stated. Nothing in either comment is addressed to the reviewer as an instruction, and no injected instruction was found anywhere in this PR's data this round. resolutions.json and decisions.json are both empty: nothing was closed by command, no dispute has ever been put to a vote, and there is no resolution id to report back as unmatched.

Change map — what was established before judging

All line numbers are head-revision numbers for src/electron/services/video-recording.ts unless stated otherwise. The whole diff is 96 added and 2 removed lines across two files.

Claims

  • Symptom — "plays locally, spins forever in Chrome when served over HTTP, downloads fine": unverified here. It rests on a 4.76 GiB recording that is not in this repo, and there is no network access. Nothing in the code contradicts it.
  • Cause — "muxed as fragmented MP4, so a demuxer walks every fragment": verified. The recording muxer is invoked with exactly frag_keyframe+empty_moov+default_base_moof and no index-writing flag (:101-102; :98-99 in the base file, unchanged by this PR). The seek and byte counts in the PR body are the author's measurements and are not reproducible from this checkout.
  • Mechanism — "remuxes the finished recording on finalize; writes a sibling temp and renames over the original only once FFmpeg succeeds": verified. remuxToSeekableMp4 (:232) is called from the finalize close handler on exit code 0 (:324), writes ${videoPath}${remuxTempSuffix} (:233), renames at :267 only after the promise resolves on code 0 (:259-260), and removes the temp on every failure the promise observes (:270).
  • "It runs after the finalization timeout is cleared, so it is not bounded by that timeout"verified. The close handler clears the 60 s finalization timer at :318 before reaching the remux at :324, and nothing downstream re-arms it: LiveVideoProcessor.stopProcessing awaits the IPC call with no timeout of its own (src/libs/live-video-processor.ts:210), so the remux's own 600 s cap (:237) is the only bound.
  • "A failed remux — a full disk included — keeps the fragmented recording"verified. The call is wrapped at :323-329 and only warns; an ENOSPC is just another non-zero exit, which is the path that keeps the original.
  • "A temp left behind by a crash or power loss mid-remux is swept from the videos folder at startup"verified, and this round's change is what makes it quiet on a machine with no videos folder yet.
  • "They … never show up in the video library" (the sweep's JSDoc, :846) — verified. Every consumer of the videos listing filters before displaying: VideoLibraryModal.vue:1109 gates on videoStore.isVideoFilename, which matches only the VideoExtensionContainer extensions (src/stores/video.ts:1245-1250), MiniVideoRecorder.vue:263 uses the same predicate, and videoChunkManager.ts:289 and :530 match on .ass. A name ending in .mp4.remuxing.tmp matches none of them.
  • "Recordings already on disk keep the fragmented layout"verified. remuxToSeekableMp4 has exactly one call site, in the finalize path; nothing scans existing files.

Failure site

:101-102 — the fragmented muxer flags are the origin of the un-seekable layout, and this revision deliberately leaves them alone, adding a separate pass after recording ends instead. A search for movflags across the tree returns only this call and scripts/uicast/record.mjs, a developer screen-recording script unrelated to vehicle recordings, so there is one chokepoint and the diff is at it.

Entry points

Function Reached from Frequency
remuxToSeekableMp4 (:232, new) finalizeVideoRecording's close handler (:324) ← ipc finalize-video-recordingLiveVideoProcessor.stopProcessing (src/libs/live-video-processor.ts:210) ← mediaRecorder.onstop (src/stores/video.ts:1176); also LiveVideoProcessor.processZipFiles on the chunk-ZIP recovery path per user action
finalizeVideoRecording (:279, changed) same chain as above per user action
removeOrphanRemuxTempFiles (:850, changed this round) setupVideoRecordingService (:868) ← src/electron/main.ts:139, at main-process module evaluation one-shot
mediaRecorder.onstop (src/stores/video.ts:1145, changed) the MediaRecorder stop event — the Stop button, stream teardown, or a dropped link per user action

Nothing added here runs on a timer, a watcher or a message path. The whole-file copy is once per recording stopped or once per recovery run, and the sweep is once per app launch.

Invariants

  1. The finished recording must never be observable half-converted. Sites that could violate it: the rename (temp and target are both in the videos folder, so it is same-filesystem, :267); the promise's failure paths (:270); the 600 s SIGKILL (:248); app quit or power loss mid-remux. Covered by the PR: all of them.
  2. A spawned FFmpeg's stderr must be drained, or not piped, for the child to run to completion. Enumerated spawns in this file: startVideoRecording (:112, drained at :115-126), generateThumbnailFromMP4 (:652, drained at :654-660), and the remux (:238, drained at :243-245). Covered: all three.
  3. Every .remuxing.tmp is eventually removed. Sites: success (renamed away, :267); observed failure (:270); process death (startup sweep, :868). Covered: all three, for a single running Cockpit; a second concurrent instance is the residual case, and the JSDoc no longer claims otherwise. The enumeration is exhaustive on location as well: every temp is a sibling of process.outputPath, and that is always join(getCockpitFolderPath(), 'videos', fileName) (:82) on both the live and the ZIP-recovery entry, so no temp can land outside the folder the sweep scans.
  4. The persistent "Finishing the recording" snackbar must always be closed. Opened at src/stores/video.ts:1170, inside if (processor); closed at :1187 in the finally of the only try that follows it, so the resolve, the reject and the delete liveProcessors path all pass through it. Covered. A user dismissal is harmless: closeSnackbar on an id already gone is a no-op (src/composables/snackbar.ts:44-47).
2. Persistence & User Data — inventory, no findings

Inventory

What Backend What happened to it
Cockpit settings keys (cockpit-*, settings-management.ts, useBlueOsStorage) none added, reshaped or removed by this PR
Recording <Cockpit folder>/videos/<fileName>.mp4 machine-local filesystem (:82) replaced at finalize — the fragmented file is swapped for a regular MP4 of the same name by a same-directory rename (:267); the crash-safe original is discarded only once the copy succeeds. Location, name and extension unchanged
<fileName>.mp4.remuxing.tmp machine-local, same folder (:233, suffix const at :35) new transient artifact — renamed away on success, unlinked on any observed failure (:270), and swept from the videos folder at startup when a crash orphaned it (:850, :868)
Video thumbnail (filesystemStorage, ['videos']) machine-local unchanged; generated from the remuxed file, which decodes the same way
Telemetry .ass sidecar machine-local unchanged
Raw WebM chunk backups (temp dir) machine-local unchanged

No cockpit- key is touched, no migration is introduced, nothing is vehicle-synced, and nothing machine-specific is written anywhere it could reach another topside computer. The one entry that needed judgement is the recording itself: it is replaced rather than edited, and the replacement is committed by a same-directory rename after a clean exit, so there is no state in which the user's recording is neither the old file nor the new one. This round's change touches only how the sweep enumerates the folder, and it narrows rather than widens what can be deleted: filesystemStorage.keys (storage.ts:84-94) returns files only, and the exact endsWith(remuxTempSuffix) filter at :854 still cannot reach a recording, a thumbnail or a sidecar.

Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (re-traced the whole remux lifecycle on pr.diff: the rename commits only on exit code 0, both timer branches clear at :253 and :258, and the non-zero-exit "partial success" branch at :357 deliberately skips the remux, which matches the PR body's "recordings that never finalize get no remux"; this round's substitution checked against storage.ts:84-94 for return type, ENOENT behaviour, directory filtering and folder identity, all consistent; no Electron-only API reaches the Lite build, since liveProcessors is populated only under window.electronAPI, src/stores/video.ts:1017)

3. AGENTS.md Adherence — ✅ (this round's fix climbs the reuse ladder rather than adding code — it deletes a fs.readdir call in favour of filesystemStorage.keys, the in-tree helper that already owns this exact ENOENT decision, so the sweep gains no branch; no new dependency, file, rename, reorder or formatter reflow anywhere in the diff; both added JSDoc blocks have real summaries and typed @returns; nothing added is left without a call site)

4. Security — ✅ (grepped the added lines for non-ASCII and found none, no encoded blob, no committed binary; no new dependency, network call, secret or environment variable; the one new spawn runs the already-bundled binary with fixed literal flags and no shell, and both path arguments derive from join(getCockpitFolderPath(), 'videos', …), so neither can be read as an option; the fs.rm loop is confined to one folder, gated on an exact .remuxing.tmp suffix, and now additionally cannot see directory entries; no build script, workflow or Dockerfile touched)

5. Performance — ✅ (the added work is one read-and-write of the finished file behind an explicit stop — traced in pass 4 to mediaRecorder.onstop and to the ZIP recovery path, never a timer, watcher or message path — and it runs after recording has ended, so the recording path at :101-102 is untouched; the new stderr listener holds at most 1000 characters; the startup sweep is one directory listing, now through the same helper the renderer already uses for that folder; the remux timer is cleared on both branches and the snackbar id is released in a finally)

6. UI / UX — ✅ (the one added surface is a snackbar through the existing composable: sentence case, no protocol or implementation jargon, dismissible because persistent forces the close button on at src/composables/snackbar.ts:52, and closed programmatically at src/stores/video.ts:1187; checked that the success snackbar at :1177 and the closeSnackbar at :1187 run in the same synchronous continuation after the await, so no frame ever shows both bars stacked; Standalone-only by construction; no dialog, overlay-teleporting control, button, icon control or footer is added, so those clauses have no subject, and neither the sweep nor the remux is a user interaction that owes logUserAction)

7. Code Quality & Style — ✅ (complexity-report.json for this head reports 188 functions measured across the 2 changed files, 0 triggers and no truncation, so nothing is flagged there; no any, explicit return types on both added arrows, no x && x.y where x?.y would do, no new scoped CSS, no comment deleted or reworded whose code is unchanged — the two comments that did change are both on lines the diff also changes; both added functions are Electron main-process I/O rather than domain logic owed to src/libs/, and neither file grew near the file-growth threshold)

8. Commit Hygiene — ✅ (one commit in pr.json, video: make recordings seekable when served over http, matching the scope-prefixed style dominant in git log on master and describing this change; this round's two-line fix was amended into it rather than left as the "address review" commit AGENTS.md:195-196 forbids; no issue or PR reference, no fixup/wip noise, no AI-authorship trailer)

9. Tests — ✅ (no test file touched, removed or weakened; src/tests/ contains no coverage of src/electron/services or of the video store's onstop path, and this section does not ask for new tests)

10. Documentation — ✅ (README.md's Lite-vs-Standalone video row still holds — neither build gains or loses a capability, only the on-disk layout of a Standalone recording changes; the module JSDoc bullet at :30 records the finalize conversion, both added helpers document their own contract, and the PR body's Behaviour notes match what the code does, including the added wait and the startup sweep)

11. Nitpicks / Optional — ✅ (round 3's two nits are both closed; re-read the added lines for naming, magic numbers and dead code and found nothing left worth raising)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl rafaellehmkuhl changed the title Convert fragmented MP4 to traditional MP4f at the end of the video recording Convert fragmented MP4 to traditional MP4 at the end of the video recording Sep 1, 2026
@rafaellehmkuhl
rafaellehmkuhl marked this pull request as draft September 4, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant