Convert fragmented MP4 to traditional MP4 at the end of the video recording - #2983
Convert fragmented MP4 to traditional MP4 at the end of the video recording#2983rafaellehmkuhl wants to merge 1 commit into
Conversation
|
| # | 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 exactlyfrag_keyframe+empty_moov+default_base_moofand with nofaststart,global_sidxor 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_fragmentedwrites 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_sidxon 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.
finalizeVideoRecordinggenerates it atsrc/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-recording ← src/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
- The FFmpeg binary Cockpit runs accepts
hybrid_fragmented. Violators: the three separately pinned binaries inscripts/download-ffmpeg.js:20-56(BtbNrelease/7.1builds for Windows/Linux, osxexpertsffmpeg71for macOS) plus any stale binary already inbinaries/ffmpeg/, which the installer keeps rather than re-downloading ("FFmpeg binary already exists. Skipping download.").src/electron/services/ffmpeg-path.ts:38-49runs 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 - 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 - 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.asstelemetry 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 ships — major
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 theclosehandler at :132, writes the first chunk with a callback that onlyconsole.errors on EPIPE (:145-149), and returns{ id, outputPath }. The renderer therefore believes recording started.- Every subsequent chunk hits
appendChunkToVideoRecording(:180-183), which throwsLive stream process … not found or already finalizedbecause theclosehandler already deleted the entry. src/stores/video.ts:1105-1112turns 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:
- State the FFmpeg version that first supports
hybrid_fragmentedand 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. - Make an FFmpeg that dies at spawn a start failure. The
closehandler at :132 already knows; failing the pending start when it fires before any chunk was accepted routes the error intoLiveVideoProcessorInitializationError, 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 success — major
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-259SIGKILLs 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 user — minor
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 wrong — minor
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-safetyon 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 fragment — minor
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.
b227789 to
6cd9d56
Compare
6cd9d56 to
1e4ff60
Compare
Review follow-up — round 1The review was composed against Done
Won't change (with reasoning)
|
|
/review |
|
| # | 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 b227789 → 1e4ff60
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_moofand 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 > 0branch (: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 typecheckclean" — contradicted. See 1.4;fs.statfs(:234) is not in the@types/nodethis 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
- 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.
- A spawned FFmpeg's stderr must be drained, or not piped, for the child to run to completion. Enumerated spawns in this file:
startVideoRecordingattaches a stderr listener (:113-124),generateThumbnailFromMP4attaches one (:641), and the new remux (:241) attaches none. Covered by the PR: none → 1.3. - Every Node API used under
src/must exist in the pinned@types/node.tsconfig.app.jsonincludessrc/**/*,tsconfig.vitest.jsonresetsexcludeto[]and setstypes: ["node"], and CI runsyarn typecheckat.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 kill — major
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 file — major
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 removes — minor
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 runs — minor
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 600000 — nit
: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.
2ae0c85 to
f281aa8
Compare
Review follow-up — round 2All five open findings implemented. No dispute this round. Done
Done differently
|
|
/review |
📝 MINOR SUGGESTIONS (Automated PR Review — round 3)
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
Since round 2 — 5 closed, comparing 1e4ff60 → f281aa8Range and method. 1.3 — Remux child's stderr piped and never drained — ✅ Addressed. Both halves of the ask landed. 1.4 — 2.2 — Orphaned 6.1 — Silent wait after pressing stop — ✅ Addressed. 11.1 — Bare 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 Change map — what was established before judgingAll line numbers are head-revision numbers for Claims
Failure site
Entry points
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. Correctness & Implementation Bugs — 1 finding1.5 — The startup sweep reports a missing videos folder as a failure, so a fresh install logs a warning at every launch — 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.
That warning is not just console noise. This repository already has the correct shape for exactly this operation, one file away: Ask: give 2. Persistence & User Data — inventory, no findingsInventory
No 11. Nitpicks / Optional — 1 finding11.2 — "no remux can be in flight yet" is true of this process, not of this machine —
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 — 4. Security — ✅ (96 added lines, all plain ASCII — grepped 5. Performance — ✅ (the added work is one read-and-write of the finished file behind an explicit stop action — traced in pass 4 to 6. UI / UX — ✅ (the one added surface is a snackbar through the existing composable: sentence case, no protocol or implementation jargon, dismissible because 7. Code Quality & Style — ✅ ( 8. Commit Hygiene — ✅ (one commit in 9. Tests — ✅ (no test file touched, removed or weakened; 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 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.
f281aa8 to
7101401
Compare
Review follow-up — round 3Both open findings implemented. No dispute this round. Done
Done differently
|
|
/review |
✅ READY TO MERGE (Automated PR Review — round 4)
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 attentionNothing. Every finding this PR has had is closed. Since round 3 — 2 closed, comparing f281aa8 → 7101401Range and method. 1.5 — Startup sweep logs
11.2 — "no remux can be in flight yet" stated as an absolute — ✅ Addressed. Discussion. rafaellehmkuhl posted a structured follow-up ("Both open findings implemented. No dispute this round.") and then the bare Change map — what was established before judgingAll line numbers are head-revision numbers for Claims
Failure site
Entry points
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
2. Persistence & User Data — inventory, no findingsInventory
No Sections with nothing to report (10)1. Correctness & Implementation Bugs — ✅ (re-traced the whole remux lifecycle on 3. AGENTS.md Adherence — ✅ (this round's fix climbs the reuse ladder rather than adding code — it deletes a 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 5. Performance — ✅ (the added work is one read-and-write of the finished file behind an explicit stop — traced in pass 4 to 6. UI / UX — ✅ (the one added surface is a snackbar through the existing composable: sentence case, no protocol or implementation jargon, dismissible because 7. Code Quality & Style — ✅ ( 8. Commit Hygiene — ✅ (one commit in 9. Tests — ✅ (no test file touched, removed or weakened; 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 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. |
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:mvhddeclares 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:
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_sidxand 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:+hybrid_fragmentedhybrid_fragmentedabsorbs themoofboxes into a singlemdat, so once that header is rewritten there is no fragmented structure left to fall back on — if themoovdoes not land, the whole recording is gone. The current layout degrades gracefully instead: you lose only themfra, which measurement shows is worthless anyway (it does not reduce the seek count at all).+faststartcosts +45 s on 4.76 GiB and does not help either, because a conventional MP4'smoovscales 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
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
yarn lint:fixclean at--max-warnings=0; type-check clean on the changed filesTest plan
.remuxing.tmpis gone