diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 838ef6e77..dce4db3b7 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -181,6 +181,8 @@ interface Window { discarded?: boolean; /** The take ended before it was stopped, but its recording was kept. */ warning?: string; + /** The stop failed and the recording was recovered from what was on disk. */ + recovered?: boolean; error?: string; }>; attachNativeMacWebcamRecording: (payload: { diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 5837502da..7b6d64d2d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -81,6 +81,11 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/ import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; +import { + describeSalvagedTake, + nativeMacSalvageTarget, + salvageNativeMacCapture, +} from "../recording/nativeMacCaptureSalvage"; import { type NativeMacCaptureExit, nativeMacDiscardTargets, @@ -3230,31 +3235,74 @@ export function registerIpcHandlers( } return { success: true, discarded: true }; } - if (!stopResult.ok) { - pendingCursorRecordingData = null; - console.error("Failed to stop native macOS recording:", { - reason: stopResult.reason, - message: stopResult.message, - helperExited: stopResult.exited, - output: (nativeMacCaptureOutputs.get(proc) ?? "").trim(), - }); - return { success: false, error: stopResult.message }; - } - const screenVideoPath = stopResult.screenVideoPath; - nativeMacRecordingWarning = stopResult.warning - ? { screenVideoPath, message: stopResult.warning } - : null; - if (stopResult.warning) { - console.warn("[native-sck] the take ended before it was stopped; its recording was kept", { - warning: stopResult.warning, + let screenVideoPath: string; + let warning: string | undefined; + let recovered = false; + if (stopResult.ok) { + screenVideoPath = stopResult.screenVideoPath; + warning = stopResult.warning; + if (warning) { + console.warn( + "[native-sck] the take ended before it was stopped; its recording was kept", + { + warning, + path: screenVideoPath, + }, + ); + } + } else { + // A helper that exited left a file nothing writes to any more, and what its + // writer finished before the failure is usually a playable fragmented take. + // One still running may be mid-write, so it is left alone. + const salvageTarget = nativeMacSalvageTarget(stopResult, preferredPath); + const salvage = salvageTarget ? await salvageNativeMacCapture(salvageTarget) : null; + if (!salvage || !salvage.ok) { + pendingCursorRecordingData = null; + console.error("Failed to stop native macOS recording:", { + reason: stopResult.reason, + message: stopResult.message, + helperExited: stopResult.exited, + salvage: salvage ? salvage.reason : "not attempted: the helper had not exited", + output: (nativeMacCaptureOutputs.get(proc) ?? "").trim(), + }); + return { success: false, error: stopResult.message }; + } + screenVideoPath = salvage.screenVideoPath; + warning = describeSalvagedTake(stopResult.message, salvage.durationSec); + recovered = true; + console.warn("[native-sck] recovered the part of the take written before its stop failed", { + stopFailure: stopResult.message, path: screenVideoPath, + videoSamples: salvage.videoSamples, + durationSec: salvage.durationSec, + truncatedBytes: salvage.truncatedBytes, }); } + nativeMacRecordingWarning = warning ? { screenVideoPath, message: warning } : null; + + // A recovered take most often follows a disk that filled up, and these writes + // go to the same volume. The video is already safe on disk, so for a recovered + // take a failed side write is logged, and its partial file removed, instead of + // turning the recovery back into a lost take. + const writeAlongside = async (label: string, target: string, write: () => Promise) => { + if (!recovered) { + await write(); + return; + } + try { + await write(); + } catch (error) { + console.warn(`[native-sck] could not write the recovered take's ${label}:`, error); + await fs.rm(target, { force: true }).catch(() => undefined); + } + }; if (cursorCaptureMode === "editable-overlay") { compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges); shiftPendingCursorTelemetry(nativeMacCursorOffsetMs); - await writePendingCursorTelemetry(screenVideoPath); + await writeAlongside("cursor telemetry", `${screenVideoPath}.cursor.json`, () => + writePendingCursorTelemetry(screenVideoPath), + ); } const session: RecordingSession = { @@ -3269,15 +3317,20 @@ export function registerIpcHandlers( RECORDINGS_DIR, `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); - await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + await writeAlongside("session manifest", sessionManifestPath, () => + fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"), + ); await registerRecordingMediaLinks(screenVideoPath, { cursorCaptureMode }); return { success: true, path: screenVideoPath, session, - message: "Native macOS recording session stored successfully", - ...(stopResult.warning ? { warning: stopResult.warning } : {}), + message: recovered + ? "Native macOS recording recovered from a failed stop" + : "Native macOS recording session stored successfully", + ...(warning ? { warning } : {}), + ...(recovered ? { recovered: true } : {}), }; } catch (error) { console.error("Failed to stop native macOS recording:", error); diff --git a/electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz new file mode 100644 index 000000000..2cae6a953 Binary files /dev/null and b/electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz differ diff --git a/electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz new file mode 100644 index 000000000..5bf2fbcba Binary files /dev/null and b/electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz differ diff --git a/electron/recording/__fixtures__/macos-fmp4/helper-killed-9s-main.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/helper-killed-9s-main.mp4.gz new file mode 100644 index 000000000..c4790ba61 Binary files /dev/null and b/electron/recording/__fixtures__/macos-fmp4/helper-killed-9s-main.mp4.gz differ diff --git a/electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz new file mode 100644 index 000000000..fb246582d Binary files /dev/null and b/electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz differ diff --git a/electron/recording/__fixtures__/macos-fmp4/writer-died-4s.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/writer-died-4s.mp4.gz new file mode 100644 index 000000000..4841f397d Binary files /dev/null and b/electron/recording/__fixtures__/macos-fmp4/writer-died-4s.mp4.gz differ diff --git a/electron/recording/nativeMacCaptureSalvage.test.ts b/electron/recording/nativeMacCaptureSalvage.test.ts new file mode 100644 index 000000000..1cf39180c --- /dev/null +++ b/electron/recording/nativeMacCaptureSalvage.test.ts @@ -0,0 +1,290 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { gunzipSync } from "node:zlib"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + describeSalvagedTake, + inspectNativeMacCapture, + nativeMacSalvageTarget, + salvageNativeMacCapture, +} from "./nativeMacCaptureSalvage"; + +/** + * The fixtures are real takes from the macOS helper (Mac mini M1, macOS 26.5) with + * every `mdat` payload zeroed and then gzipped, so they keep AVAssetWriter's exact + * box structure — sample tables, fragments, the size-0 tail — in a few KB. The + * expected frame counts are ffmpeg's packet counts on the ORIGINAL files; zeroing + * payloads does not move a single box. + * + * - writer-died-4s: shipped 1.11.0-rc.1 helper, writer failed with -16364 (3 moof). + * - writer-died-1s-no-moof: the same death within the first second (mvex, 0 moof). + * - helper-killed-29s: main helper SIGKILLed 30 s in (28 moof, size-0 tail mdat). + * - helper-killed-9s-main: main helper after #661, SIGKILLed 10 s in. + * - clean-flat-8s: a clean stop, which finishWriting rewrites flat. + */ +const FIXTURES = path.join(__dirname, "__fixtures__", "macos-fmp4"); + +/** Box offsets in helper-killed-29s, read off the original file. */ +const KILLED_29S = { + moov: 1_201_426, + firstMoof: 2_371_612, + lastMoof: 35_918_587, + wide: 35_919_355, + tailMdat: 35_919_363, +}; + +let dir: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "native-mac-salvage-")); +}); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +/** A private copy of a fixture, optionally cut to its first `bytes` bytes. */ +async function take(fixture: string, bytes?: number) { + const data = gunzipSync(await fs.readFile(path.join(FIXTURES, `${fixture}.mp4.gz`))); + const target = path.join( + dir, + `${fixture}-${bytes ?? "whole"}-${Math.random().toString(36).slice(2)}.mp4`, + ); + await fs.writeFile(target, bytes === undefined ? data : data.subarray(0, bytes)); + return target; +} + +async function sizeOf(filePath: string) { + return (await fs.stat(filePath)).size; +} + +describe("inspectNativeMacCapture on real helper output", () => { + it.each([ + ["writer-died-4s", 231, 4], + ["writer-died-1s-no-moof", 58, 1], + ["helper-killed-29s", 1652, 29], + ["helper-killed-9s-main", 513, 9], + ["clean-flat-8s", 461, 8], + ])("finds every frame ffmpeg reads in %s", async (fixture, frames, seconds) => { + const inspection = await inspectNativeMacCapture(await take(fixture)); + + expect(inspection).toMatchObject({ ok: true, videoSamples: frames }); + if (!inspection.ok) throw new Error("unreachable"); + expect(inspection.validBytes).toBe(inspection.fileBytes); + expect(Math.abs(inspection.durationSec - seconds)).toBeLessThan(0.2); + }); + + /** + * #571's check rejected this file at random: its last `mdat` has a size field of 0, + * and mp4box aborted whenever a read-chunk boundary fell inside it. + */ + it("reads a take whose last mdat declares size 0, with no read-size dependence", async () => { + const file = await take("helper-killed-29s", KILLED_29S.tailMdat + 8); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1652, + }); + }); + + /** The fragment still being written when the take died: a final mdat cut short. */ + it("accepts a final mdat cut short, keeping the fragments indexed before it", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof - 1000); + const inspection = await inspectNativeMacCapture(file); + expect(inspection).toMatchObject({ ok: true, videoSamples: 1596 }); + if (!inspection.ok) throw new Error("unreachable"); + expect(inspection.validBytes).toBe(inspection.fileBytes); + }); + + it("marks where a torn moof starts without changing the file", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof + 300); + const inspection = await inspectNativeMacCapture(file); + + expect(inspection).toMatchObject({ + ok: true, + validBytes: KILLED_29S.lastMoof, + videoSamples: 1596, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.lastMoof + 300); + }); + + it("rejects a file whose movie header is torn", async () => { + const file = await take("helper-killed-29s", KILLED_29S.moov + 500); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: false, + reason: "no movie header in the readable part of the file", + }); + }); + + it("rejects a file cut before its movie header", async () => { + const file = await take("helper-killed-29s", 600_000); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ ok: false }); + }); + + it("rejects something that is not an MP4 at all", async () => { + const file = path.join(dir, "notes.txt"); + await fs.writeFile(file, "this is not a recording\n".repeat(10)); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: false, + reason: "not an MP4 file", + }); + }); + + it("rejects a file that is not there", async () => { + await expect(inspectNativeMacCapture(path.join(dir, "missing.mp4"))).resolves.toMatchObject({ + ok: false, + }); + }); +}); + +describe("salvageNativeMacCapture", () => { + it("leaves a readable take exactly as it is", async () => { + const file = await take("writer-died-4s"); + const before = await sizeOf(file); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + screenVideoPath: file, + videoSamples: 231, + truncatedBytes: 0, + }); + expect(await sizeOf(file)).toBe(before); + }); + + /** + * A file cut inside a moof opens nowhere (ffmpeg, libavformat and Chromium all + * refuse it), though mp4box accepts it. Cut back to that moof it opens with every + * earlier fragment: ffmpeg reads 1596 frames from exactly this cut. + */ + it("cuts a torn last moof off and keeps every fragment before it", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof + 300); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1596, + truncatedBytes: 300, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.lastMoof); + }); + + it("keeps the first second when the first moof is the torn one", async () => { + const file = await take("helper-killed-29s", KILLED_29S.firstMoof + 300); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 58, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.firstMoof); + }); + + /** Players skip these 4 bytes anyway; the cut is a precaution, and it loses nothing. */ + it("cuts off a box header left half-written at the end", async () => { + const file = await take("helper-killed-29s", KILLED_29S.wide + 4); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1652, + truncatedBytes: 4, + }); + }); + + /** Nothing is cut from a file that cannot be recovered anyway. */ + it("does not touch a file it cannot recover", async () => { + const file = await take("helper-killed-29s", KILLED_29S.moov + 500); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ ok: false }); + expect(await sizeOf(file)).toBe(KILLED_29S.moov + 500); + }); +}); + +describe("nativeMacSalvageTarget", () => { + const TARGET = "/rec/recording-1.mp4"; + + it("salvages the requested file once the helper has exited", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "helper-failed", message: "x", exited: true }, + TARGET, + ), + ).toBe(TARGET); + }); + + /** Salvage truncates; a helper that did not exit may still be inside finishWriting. */ + it("never salvages a file a helper that did not exit may still be writing", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "stop-timeout", message: "x", exited: false }, + TARGET, + ), + ).toBeNull(); + }); + + it("has nothing to salvage after a stop that worked", () => { + expect(nativeMacSalvageTarget({ ok: true, screenVideoPath: TARGET }, TARGET)).toBeNull(); + }); + + it("has nothing to salvage when no file was requested", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "helper-failed", message: "x", exited: true }, + null, + ), + ).toBeNull(); + }); +}); + +describe("describeSalvagedTake", () => { + /** Verbatim from the helper, in the disk-full end-to-end run. */ + it("keeps the helper's sentence and adds a description that says something", () => { + expect( + describeSalvagedTake( + 'Recording stopped: the video file could not be written (writer status: Error Domain=AVFoundationErrorDomain Code=-11807 "Disk Full" UserInfo={NSLocalizedDescription=Disk Full, NSUnderlyingError=0x9e326c930 {Error Domain=NSPOSIXErrorDomain Code=28 "No space left on device"}, NSLocalizedRecoverySuggestion=Make room by deleting existing files and try again., NSLocalizedFailureReason=There is not enough available space to continue the file writing.}).', + 35.01, + ), + ).toBe( + "Recording stopped after 0:35: the video file could not be written (Disk Full). The part recorded until then was saved.", + ); + }); + + /** The -16364 writer death this whole path started from; its description is generic. */ + it("does not replace the helper's sentence with a generic NSError description", () => { + expect( + describeSalvagedTake( + 'Recording stopped: the video file could not be written (video append: Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSLocalizedFailureReason=An unknown error occurred (-16364), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x1 {Error Domain=NSOSStatusErrorDomain Code=-16364 "(null)"}}).', + 73.01, + ), + ).toBe( + "Recording stopped after 1:13: the video file could not be written. The part recorded until then was saved.", + ); + }); + + it("reads a bare NSError by its description", () => { + expect( + describeSalvagedTake( + 'Error Domain=com.apple.ScreenCaptureKit.SCStreamErrorDomain Code=-3815 "The stream was stopped by the system." UserInfo={NSLocalizedDescription=The stream was stopped by the system.}', + 12, + ), + ).toBe( + "Recording stopped after 0:12: The stream was stopped by the system. The part recorded until then was saved.", + ); + }); + + it("uses the whole message when there is no NSError in it", () => { + expect(describeSalvagedTake("The recorder stopped unexpectedly (signal SIGKILL).", 9)).toBe( + "Recording stopped after 0:09: The recorder stopped unexpectedly (signal SIGKILL). The part recorded until then was saved.", + ); + }); + + /** Measured: a 35 s disk-full take whose frame durations sum to 34.98 s. */ + it("rounds the length rather than cutting a second off it", () => { + expect(describeSalvagedTake("Disk Full", 34.983333)).toBe( + "Recording stopped after 0:35: Disk Full. The part recorded until then was saved.", + ); + }); + + it("counts hours on a long take", () => { + expect(describeSalvagedTake("Disk Full", 3725)).toBe( + "Recording stopped after 1:02:05: Disk Full. The part recorded until then was saved.", + ); + }); +}); diff --git a/electron/recording/nativeMacCaptureSalvage.ts b/electron/recording/nativeMacCaptureSalvage.ts new file mode 100644 index 000000000..90f3d6dc6 --- /dev/null +++ b/electron/recording/nativeMacCaptureSalvage.ts @@ -0,0 +1,543 @@ +import fs from "node:fs/promises"; + +/** + * Recovering what a macOS take left on disk when its stop failed. + * + * # What a failed take leaves behind + * + * The helper's AVAssetWriter writes fragmented MP4 (`movieFragmentInterval` = 1 s). + * A clean stop rewrites it flat — `ftyp mdat moov` — but a writer that died, or a + * helper that was killed, leaves the fragmented shape as it was: + * + * ftyp mdat moov[… mvex] (mdat moof)* wide mdat + * + * The first `mdat` holds the first second, indexed by the `moov` sample table. + * Each later fragment writes its `mdat` BEFORE the `moof` that indexes it, with an + * absolute data offset (`tfhd` flag 0x1). The final `mdat` is the fragment that was + * still open, and no `moof` indexes it. ffmpeg, libavformat, Chromium and the editor + * all open such a file as it is, with the right duration (measured on real takes: + * 4 s, 10 s, 29 s, 73 s). + * + * # Why this is not mp4box + * + * PR #571 fed mp4box in 1 MiB chunks, and that last `mdat` has a size field of 0: + * whenever a multiple of the chunk size fell inside it, mp4box aborted with "Invalid + * box type", so whether a take was kept depended on its byte length. mp4box also + * accepted a file cut inside a `moof`, which nothing can open. This walk reads box + * headers at their own offsets, so the read size never matters. + * + * # The one layout that has to be repaired + * + * A file cut inside a `moof` opens nowhere: ffmpeg, libavformat and Chromium all + * refuse it. Cut back to the start of that `moof` it opens, and keeps every fragment + * before it. So a torn tail is truncated and the file inspected again. The same cut + * is applied to anything else left unfinished at the end, such as a header of a few + * bytes, which players skip anyway: it costs nothing, and a file is never reported + * recoverable while bytes that could break it are still on disk. + */ + +import type { NativeMacCaptureStopResult } from "./nativeMacCaptureStop"; + +/** Guards against a corrupt size field making a structural box look enormous. */ +const MAX_INDEX_BOX_BYTES = 64 * 1024 * 1024; + +type Box = { type: string; start: number; headerSize: number; size: number }; + +export type NativeMacCaptureInspection = + | { + ok: true; + fileBytes: number; + /** Bytes from the start of the file up to the first box that is torn or unreadable. */ + validBytes: number; + /** Video samples whose bytes lie entirely inside `validBytes`. */ + videoSamples: number; + durationSec: number; + fragments: number; + } + | { ok: false; reason: string; fileBytes: number; validBytes: number }; + +export type NativeMacSalvageResult = + | { + ok: true; + screenVideoPath: string; + videoSamples: number; + durationSec: number; + /** Bytes cut off a torn tail before the file could be opened; 0 when none. */ + truncatedBytes: number; + } + | { ok: false; reason: string }; + +function boxType(buffer: Buffer, offset: number) { + return buffer.toString("latin1", offset, offset + 4); +} + +function isPrintableType(type: string) { + return /^[\x20-\x7e]{4}$/.test(type); +} + +/** Complete child boxes of `[start, end)` inside an in-memory box. */ +function childBoxes(buffer: Buffer, start: number, end: number): Box[] { + const boxes: Box[] = []; + let position = start; + while (position + 8 <= end) { + let size = buffer.readUInt32BE(position); + const type = boxType(buffer, position + 4); + let headerSize = 8; + if (size === 1) { + if (position + 16 > end) { + break; + } + size = Number(buffer.readBigUInt64BE(position + 8)); + headerSize = 16; + } else if (size === 0) { + size = end - position; + } + if (size < headerSize || position + size > end) { + break; + } + boxes.push({ type, start: position, headerSize, size }); + position += size; + } + return boxes; +} + +function childBox(buffer: Buffer, parent: Box, type: string) { + return childBoxes(buffer, parent.start + parent.headerSize, parent.start + parent.size).find( + (box) => box.type === type, + ); +} + +/** Offset of the first byte after a full box's version and flags. */ +function fullBoxBody(box: Box) { + return box.start + box.headerSize + 4; +} + +async function readAt(handle: fs.FileHandle, position: number, length: number) { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, position); + return buffer.subarray(0, bytesRead); +} + +type TopLevel = { boxes: Box[]; validBytes: number }; + +/** + * Walks the top-level boxes by their own offsets. A final `mdat` may run past the end + * of the file — that is the fragment still being written — but any other box that + * does, a header cut short, or bytes that are not a box header end the readable part + * where they start. + */ +async function walkTopLevel(handle: fs.FileHandle, fileBytes: number): Promise { + const boxes: Box[] = []; + let position = 0; + while (position < fileBytes) { + if (fileBytes - position < 8) { + return { boxes, validBytes: position }; + } + const header = await readAt(handle, position, 16); + const type = boxType(header, 4); + if (!isPrintableType(type)) { + return { boxes, validBytes: position }; + } + let size = header.readUInt32BE(0); + let headerSize = 8; + if (size === 1) { + if (header.length < 16) { + return { boxes, validBytes: position }; + } + size = Number(header.readBigUInt64BE(8)); + headerSize = 16; + } else if (size === 0) { + size = fileBytes - position; + } + if (size < headerSize) { + return { boxes, validBytes: position }; + } + if (position + size > fileBytes) { + if (type !== "mdat") { + return { boxes, validBytes: position }; + } + boxes.push({ type, start: position, headerSize, size: fileBytes - position }); + return { boxes, validBytes: fileBytes }; + } + boxes.push({ type, start: position, headerSize, size }); + position += size; + } + return { boxes, validBytes: position }; +} + +type VideoTrack = { + trackId: number; + timescale: number; + defaultSampleDuration: number; + defaultSampleSize: number; +}; + +type SampleTally = { samples: number; duration: number }; + +/** Reads the video `trak`: its id, timescale and flat sample table, counting samples in the file. */ +function readVideoTrack( + moov: Buffer, + validBytes: number, +): { track: VideoTrack; flat: SampleTally } | null { + const root: Box = { type: "moov", start: 0, headerSize: 8, size: moov.length }; + for (const trak of childBoxes(moov, 8, moov.length).filter((box) => box.type === "trak")) { + const tkhd = childBox(moov, trak, "tkhd"); + const mdia = childBox(moov, trak, "mdia"); + if (!tkhd || !mdia) { + continue; + } + const hdlr = childBox(moov, mdia, "hdlr"); + if (!hdlr || boxType(moov, fullBoxBody(hdlr) + 4) !== "vide") { + continue; + } + const mdhd = childBox(moov, mdia, "mdhd"); + const minf = childBox(moov, mdia, "minf"); + const stbl = minf ? childBox(moov, minf, "stbl") : undefined; + const stsd = stbl ? childBox(moov, stbl, "stsd") : undefined; + if (!mdhd || !stbl || !stsd) { + continue; + } + // A visual sample entry: 8-byte header, 8 reserved/data-reference bytes, 16 + // pre-defined bytes, then width and height. + const entry = fullBoxBody(stsd) + 4; + if (moov.readUInt32BE(fullBoxBody(stsd)) === 0 || entry + 36 > stsd.start + stsd.size) { + continue; + } + const width = moov.readUInt16BE(entry + 32); + const height = moov.readUInt16BE(entry + 34); + if (width === 0 || height === 0) { + continue; + } + + const tkhdVersion = moov[tkhd.start + tkhd.headerSize]; + const trackId = moov.readUInt32BE(fullBoxBody(tkhd) + (tkhdVersion === 1 ? 16 : 8)); + const mdhdVersion = moov[mdhd.start + mdhd.headerSize]; + const timescale = moov.readUInt32BE(fullBoxBody(mdhd) + (mdhdVersion === 1 ? 16 : 8)); + if (timescale === 0) { + continue; + } + + let defaultSampleDuration = 0; + let defaultSampleSize = 0; + const mvex = childBox(moov, root, "mvex"); + if (mvex) { + for (const trex of childBoxes(moov, mvex.start + mvex.headerSize, mvex.start + mvex.size)) { + if (trex.type === "trex" && moov.readUInt32BE(fullBoxBody(trex)) === trackId) { + defaultSampleDuration = moov.readUInt32BE(fullBoxBody(trex) + 8); + defaultSampleSize = moov.readUInt32BE(fullBoxBody(trex) + 12); + } + } + } + + return { + track: { trackId, timescale, defaultSampleDuration, defaultSampleSize }, + flat: tallyFlatSamples(moov, stbl, validBytes), + }; + } + return null; +} + +function tallyFlatSamples(moov: Buffer, stbl: Box, validBytes: number): SampleTally { + const stsz = childBox(moov, stbl, "stsz"); + const stsc = childBox(moov, stbl, "stsc"); + const stco = childBox(moov, stbl, "stco") ?? childBox(moov, stbl, "co64"); + const stts = childBox(moov, stbl, "stts"); + if (!stsz || !stsc || !stco) { + return { samples: 0, duration: 0 }; + } + + const uniformSize = moov.readUInt32BE(fullBoxBody(stsz)); + const sampleCount = moov.readUInt32BE(fullBoxBody(stsz) + 4); + const sizeOf = (index: number) => + uniformSize !== 0 ? uniformSize : moov.readUInt32BE(fullBoxBody(stsz) + 8 + index * 4); + + const chunkCount = moov.readUInt32BE(fullBoxBody(stco)); + const chunkOffset = (index: number) => + stco.type === "co64" + ? Number(moov.readBigUInt64BE(fullBoxBody(stco) + 4 + index * 8)) + : moov.readUInt32BE(fullBoxBody(stco) + 4 + index * 4); + + const deltas: number[] = []; + if (stts) { + const entries = moov.readUInt32BE(fullBoxBody(stts)); + for (let entry = 0; entry < entries && deltas.length < sampleCount; entry += 1) { + const count = moov.readUInt32BE(fullBoxBody(stts) + 4 + entry * 8); + const delta = moov.readUInt32BE(fullBoxBody(stts) + 8 + entry * 8); + for (let index = 0; index < count && deltas.length < sampleCount; index += 1) { + deltas.push(delta); + } + } + } + + const runs = moov.readUInt32BE(fullBoxBody(stsc)); + let sample = 0; + const tally: SampleTally = { samples: 0, duration: 0 }; + for (let run = 0; run < runs && sample < sampleCount; run += 1) { + const firstChunk = moov.readUInt32BE(fullBoxBody(stsc) + 4 + run * 12); + const samplesPerChunk = moov.readUInt32BE(fullBoxBody(stsc) + 8 + run * 12); + const nextFirstChunk = + run + 1 < runs ? moov.readUInt32BE(fullBoxBody(stsc) + 4 + (run + 1) * 12) : chunkCount + 1; + for (let chunk = firstChunk; chunk < nextFirstChunk && chunk <= chunkCount; chunk += 1) { + let position = chunkOffset(chunk - 1); + for (let index = 0; index < samplesPerChunk && sample < sampleCount; index += 1) { + const size = sizeOf(sample); + if (position + size <= validBytes) { + tally.samples += 1; + tally.duration += deltas[sample] ?? 0; + } + position += size; + sample += 1; + } + } + } + return tally; +} + +/** Counts the video samples one `moof` indexes whose bytes are inside the file. */ +function tallyFragmentSamples( + moof: Buffer, + moofStart: number, + track: VideoTrack, + validBytes: number, +): SampleTally { + const tally: SampleTally = { samples: 0, duration: 0 }; + for (const traf of childBoxes(moof, 8, moof.length).filter((box) => box.type === "traf")) { + const children = childBoxes(moof, traf.start + traf.headerSize, traf.start + traf.size); + const tfhd = children.find((box) => box.type === "tfhd"); + if (!tfhd) { + continue; + } + const tfhdFlags = moof.readUInt32BE(tfhd.start + tfhd.headerSize) & 0xffffff; + if (moof.readUInt32BE(fullBoxBody(tfhd)) !== track.trackId) { + continue; + } + let cursor = fullBoxBody(tfhd) + 4; + let base = moofStart; + if (tfhdFlags & 0x1) { + base = Number(moof.readBigUInt64BE(cursor)); + cursor += 8; + } + if (tfhdFlags & 0x2) cursor += 4; + let defaultDuration = track.defaultSampleDuration; + if (tfhdFlags & 0x8) { + defaultDuration = moof.readUInt32BE(cursor); + cursor += 4; + } + let defaultSize = track.defaultSampleSize; + if (tfhdFlags & 0x10) { + defaultSize = moof.readUInt32BE(cursor); + } + + let dataPosition = base; + for (const trun of children.filter((box) => box.type === "trun")) { + const end = trun.start + trun.size; + const flags = moof.readUInt32BE(trun.start + trun.headerSize) & 0xffffff; + const count = moof.readUInt32BE(fullBoxBody(trun)); + let field = fullBoxBody(trun) + 4; + if (flags & 0x1) { + dataPosition = base + moof.readInt32BE(field); + field += 4; + } + if (flags & 0x4) field += 4; + const perSample = + (flags & 0x100 ? 4 : 0) + + (flags & 0x200 ? 4 : 0) + + (flags & 0x400 ? 4 : 0) + + (flags & 0x800 ? 4 : 0); + for (let index = 0; index < count && field + perSample <= end; index += 1) { + let duration = defaultDuration; + let size = defaultSize; + if (flags & 0x100) { + duration = moof.readUInt32BE(field); + field += 4; + } + if (flags & 0x200) { + size = moof.readUInt32BE(field); + field += 4; + } + if (flags & 0x400) field += 4; + if (flags & 0x800) field += 4; + if (dataPosition >= 0 && dataPosition + size <= validBytes) { + tally.samples += 1; + tally.duration += duration; + } + dataPosition += size; + } + } + } + return tally; +} + +/** What a capture file on disk can still give back, without changing it. */ +export async function inspectNativeMacCapture( + filePath: string, +): Promise { + let handle: fs.FileHandle | null = null; + let fileBytes = 0; + try { + handle = await fs.open(filePath, "r"); + fileBytes = (await handle.stat()).size; + const { boxes, validBytes } = await walkTopLevel(handle, fileBytes); + const fail = (reason: string): NativeMacCaptureInspection => ({ + ok: false, + reason, + fileBytes, + validBytes, + }); + + if (boxes[0]?.type !== "ftyp") { + return fail("not an MP4 file"); + } + const moovBox = boxes.find((box) => box.type === "moov"); + if (!moovBox) { + return fail("no movie header in the readable part of the file"); + } + if (moovBox.size > MAX_INDEX_BOX_BYTES) { + return fail("the movie header is implausibly large"); + } + const video = readVideoTrack(await readAt(handle, moovBox.start, moovBox.size), validBytes); + if (!video) { + return fail("no video track"); + } + + let samples = video.flat.samples; + let duration = video.flat.duration; + let fragments = 0; + for (const moof of boxes.filter((box) => box.type === "moof")) { + if (moof.size > MAX_INDEX_BOX_BYTES) { + continue; + } + fragments += 1; + const tally = tallyFragmentSamples( + await readAt(handle, moof.start, moof.size), + moof.start, + video.track, + validBytes, + ); + samples += tally.samples; + duration += tally.duration; + } + if (samples === 0) { + return fail("no complete video frame in the file"); + } + return { + ok: true, + fileBytes, + validBytes, + videoSamples: samples, + durationSec: duration / video.track.timescale, + fragments, + }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + fileBytes, + validBytes: 0, + }; + } finally { + await handle?.close().catch(() => undefined); + } +} + +/** + * Makes a failed take's file openable if it can be, and says what it holds. + * + * Only for a file no process is writing to any more: the helper must have exited. + * A torn tail is cut off and the result inspected again, so success always means + * the file on disk opens as it is. + */ +export async function salvageNativeMacCapture(filePath: string): Promise { + const first = await inspectNativeMacCapture(filePath); + if (!first.ok) { + return { ok: false, reason: first.reason }; + } + if (first.validBytes === first.fileBytes) { + return { + ok: true, + screenVideoPath: filePath, + videoSamples: first.videoSamples, + durationSec: first.durationSec, + truncatedBytes: 0, + }; + } + + try { + await fs.truncate(filePath, first.validBytes); + } catch (error) { + return { + ok: false, + reason: `could not cut off the torn end: ${error instanceof Error ? error.message : String(error)}`, + }; + } + const second = await inspectNativeMacCapture(filePath); + if (!second.ok) { + return { ok: false, reason: second.reason }; + } + if (second.validBytes !== second.fileBytes) { + return { ok: false, reason: "the file is still torn after cutting off its end" }; + } + return { + ok: true, + screenVideoPath: filePath, + videoSamples: second.videoSamples, + durationSec: second.durationSec, + truncatedBytes: first.fileBytes - second.fileBytes, + }; +} + +function formatDuration(seconds: number) { + // Rounded, not floored: the frame durations of a 35 s take sum to 34.98 s, and + // "0:34" would contradict the length the editor then shows. + const total = Math.max(0, Math.round(seconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const rest = String(total % 60).padStart(2, "0"); + return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`; +} + +/** + * The file a failed stop may be salvaged from, or null. + * + * Only once the helper has exited: a stop that timed out leaves a helper that may + * still be inside finishWriting, and salvaging truncates. + */ +export function nativeMacSalvageTarget( + result: NativeMacCaptureStopResult, + targetPath: string | null, +): string | null { + return !result.ok && result.exited && targetPath ? targetPath : null; +} + +/** NSError descriptions that say nothing a person can act on. */ +const GENERIC_ERROR_DESCRIPTIONS = new Set(["The operation could not be completed"]); + +/** + * The warning for a take whose stop failed but whose file was recovered. + * + * The helper's messages are a sentence followed by a raw NSError, e.g. "Recording + * stopped: the video file could not be written (video append: Error Domain=… + * NSLocalizedDescription=Disk Full …)". The sentence is kept; the NSError is + * reduced to its localized description, and only when that says something — + * AVFoundation's -11800 says "The operation could not be completed". + */ +export function describeSalvagedTake(failureMessage: string, durationSec: number) { + const text = failureMessage.trim().replace(/^Recording stopped:\s*/i, ""); + const description = /NSLocalizedDescription=([^,}]+)/.exec(text)?.[1]?.trim(); + const usefulDescription = + description && !GENERIC_ERROR_DESCRIPTIONS.has(description) ? description : undefined; + + let reason = text; + const errorStart = text.indexOf("Error Domain="); + if (errorStart !== -1) { + const openParen = text.lastIndexOf("(", errorStart); + const sentence = text.slice(0, openParen === -1 ? errorStart : openParen).trim(); + if (sentence && usefulDescription) { + reason = `${sentence} (${usefulDescription})`; + } else { + reason = sentence || usefulDescription || description || "the recorder failed"; + } + } + reason = reason.replace(/[.\s]+$/, ""); + return `Recording stopped after ${formatDuration(durationSec)}: ${reason}. The part recorded until then was saved.`; +}