From d8470846fb27a05fa9f60de3502c81e99528b9c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:38:50 +0000 Subject: [PATCH 01/26] chore(release): bump to 1.9.5-rc.1 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59efaa90..98c0ff6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.9.2", + "version": "1.9.5-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.9.2", + "version": "1.9.5-rc.1", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index 47a386c2..c7869e5d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.2", + "version": "1.9.5-rc.1", "type": "module", "packageManager": "npm@10.9.4", "engines": { From a44206c469e70040455656ce58d35d52f2fee87b Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 22:46:37 +0200 Subject: [PATCH 02/26] fix(recording): open the recording a failed stop left playable A failed stop stopped meaning a lost take the moment the Windows helper began writing fragmented MP4 (a6795d23), and nothing on the Electron side was told. The stop handler still tears the recording down and answers "The recording could not be saved" -- which is now false. The bytes are there, indexed, and play. Measured on installed 1.9.5-rc.1: kill wgc-capture.exe mid-recording, which is what the shutdown watchdog does via TerminateProcess in #252 / #292 / #327, and the file left behind holds 41 moof+mdat fragments with mvex present and no mfra. ffprobe reads 41.0s / 2460 packets at 1920x1080, and `ffmpeg -i f -f null -` decodes it end to end, exit 0, zero errors. Truncating the pre-fMP4 container at the same fraction leaves 59.5 MB no demuxer will touch; the fragmented one at 60% still plays 29s. The app threw the good one away anyway. So the failed-stop branch now asks whether the file is worth keeping instead of assuming it is not, and falls through into the ordinary save path when it is -- same manifest, same cursor telemetry, same media links, same editor. No new UI: from the user's side the recording simply opens, minus at most the last incomplete fragment. The question is answered by the `container` field the helper has been reporting since a6795d23 and nobody read. That is the only thing that can answer it: the fragmented sink degrades to the plain one rather than failing a recording, so the flavour is a per-run outcome, and a plain MP4 killed before Finalize() really is unreadable. Absent, as from any older helper, is not fragmented. Gated on the helper actually being dead. `exited: false` means it survived even the forced kill, and such a process still holds the MP4 open and may still be appending; handing that to the editor would trade an honest failure for a sharing violation on a moving file. The predicate lives in nativeWindowsCaptureStop.ts, next to the rest of the stop logic and for the same reason: handlers.ts calls app.getPath() at import time, so nothing in it can be reached from a test. It shares its size floor with the cleanup that deletes stubs, so the two agree by construction rather than by comment -- nothing is recovered that the tidy-up would have deleted, and nothing deleted that this would keep. Windows only. macOS fragments too and needs the same treatment, but it also has no already-exited fast path and an unguarded stdin write, so it is its own change. Linux writes a plain container on purpose and has nothing to salvage. --- electron/ipc/handlers.ts | 124 +++++++++++++----- .../nativeWindowsCaptureStop.test.ts | 41 ++++++ .../recording/nativeWindowsCaptureStop.ts | 37 ++++++ src/hooks/useScreenRecorder.ts | 6 +- 4 files changed, 172 insertions(+), 36 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 14262b11..d4c09486 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -73,6 +73,8 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/ import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; import { + isSalvageableFragmentedCapture, + NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES, terminateNativeWindowsCapture, waitForNativeWindowsCaptureStop, } from "../recording/nativeWindowsCaptureStop"; @@ -538,6 +540,12 @@ let nativeWindowsCursorRecordingStartMs = 0; let nativeWindowsPauseStartedAtMs: number | null = null; let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = []; let nativeWindowsIsPaused = false; +/** + * The MP4 flavour the helper reported for THIS run, or null if it never said. + * Read at stop, not for reporting: it is what decides whether a capture that + * failed to finalize still left a playable file behind. + */ +let nativeWindowsCaptureContainer: string | null = null; /** Cuts a surviving helper's output loose so it cannot pollute the next recording. */ let nativeWindowsCaptureDrainCleanup: (() => void) | null = null; @@ -558,14 +566,17 @@ function resetNativeWindowsCaptureState() { nativeWindowsPauseStartedAtMs = null; nativeWindowsPauseRanges = []; nativeWindowsIsPaused = false; + nativeWindowsCaptureContainer = null; } -/** - * An MP4 the helper never indexed is a few bytes of header at most. Anything - * larger might be a real recording, and deleting one of those to tidy up after - * a failed stop is a far worse outcome than leaving a stray file behind. - */ -const NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES = 64 * 1024; +/** Reads the file, then defers the judgement to the tested predicate. */ +async function salvageNativeWindowsFragmentedCapture(screenVideoPath: string | null) { + if (!screenVideoPath) { + return false; + } + const stats = await fs.stat(screenVideoPath).catch(() => null); + return isSalvageableFragmentedCapture(nativeWindowsCaptureContainer, stats?.size ?? null); +} /** * Best-effort removal of the files a failed or discarded native Windows capture @@ -1344,6 +1355,13 @@ function readNativeWindowsEncoderSelection(output: string) { try { return JSON.parse(lastLine) as { video?: string; + // Which MP4 flavour the helper actually wrote, `fragmented-mp4` or + // `mp4`. It reports this because the fragmented sink degrades to the + // plain one rather than failing a recording, so the flavour is a + // per-run outcome and not a property of the version. This is the only + // thing that can answer "was this file supposed to survive a kill?", + // which is what `salvageNativeWindowsFragmentedCapture` asks. + container?: string; preferSoftwareEncoder?: boolean; }; } catch { @@ -2433,6 +2451,9 @@ export function registerIpcHandlers( : 0; const webcamFormat = readNativeWindowsWebcamFormat(nativeWindowsCaptureOutput); const encoderSelection = readNativeWindowsEncoderSelection(nativeWindowsCaptureOutput); + // Captured now because stop may have no helper left to ask. A helper + // killed mid-recording is exactly the case where this matters most. + nativeWindowsCaptureContainer = encoderSelection?.container ?? null; console.info("[native-wgc] capture started", { captureStartedAtMs, cursorOffsetMs: nativeWindowsCursorOffsetMs, @@ -2742,6 +2763,11 @@ export function registerIpcHandlers( } } + // Set when the helper failed its stop handshake but left a playable + // fragmented file. Reported so a bug report can tell a clean stop from a + // recovered one; the user-facing path is deliberately identical. + let recovered = false; + try { completeNativeWindowsCursorPauseRange(); const stopPromise = waitForNativeWindowsCaptureStop({ @@ -2763,35 +2789,62 @@ export function registerIpcHandlers( if (!stopResult.exited) { detachNativeWindowsCaptureOutputDrain(); } - await stopCursorRecording(); - // Same as the discard path. `startCursorRecording` clears this on - // the next recording anyway, so this is not what keeps the samples - // from being written next to someone else's video -- it just stops - // a lost take's telemetry from sitting in memory until then. - pendingCursorRecordingData = null; - // The helper never announced a finalized file, so what is on disk - // is almost certainly an unindexed stub, and leaving those behind - // just accumulates unplayable recordings the user cannot explain. - // Almost: size-gate it, because throwing away a recording to tidy - // up after a failed stop is the worse mistake of the two. - await removeNativeWindowsCaptureOutputs(preferredPath, preferredWebcamPath, { - onlyIfUnusable: true, - }); - // The helper log goes to console/diagnostics above, not into this - // string: it ends up in a toast, and pasting an entire capture log - // into the HUD tells the user nothing they can act on. - return { - success: false, - reason: stopResult.reason, - error: - stopResult.reason === "stop-timeout" - ? "Timed out waiting for native Windows capture to stop. The recording could not be saved." - : stopResult.message.split(/\r?\n/).filter(Boolean).at(-1) || - "Native Windows capture failed.", - }; + + // A failed stop stopped meaning a lost take when the helper started + // writing fragmented MP4. The file on disk is already playable, so + // the only thing standing between the user and their recording is + // this function deciding to throw it away and say so. Fall through + // into the normal save path instead: same manifest, same media + // links, same editor. From the user's side it simply worked, minus + // at most the last incomplete fragment. + // + // Only once the helper is actually dead. `exited: false` means it + // survived even the forced kill -- stuck somewhere `TerminateProcess` + // could not reach -- and on Windows such a process still holds the + // MP4 open and may still be appending to it. Handing that file to + // the editor trades an honest failure for a sharing violation on a + // file that is still moving, so a wedged helper keeps the old answer. + if (stopResult.exited && (await salvageNativeWindowsFragmentedCapture(preferredPath))) { + console.warn("[native-wgc] stop failed but the fragmented output is playable", { + reason: stopResult.reason, + path: preferredPath, + }); + recovered = true; + } else { + await stopCursorRecording(); + // Same as the discard path. `startCursorRecording` clears this on + // the next recording anyway, so this is not what keeps the samples + // from being written next to someone else's video -- it just stops + // a lost take's telemetry from sitting in memory until then. + pendingCursorRecordingData = null; + // Reaching here means the container was the plain one, whose only + // index is written by the `Finalize()` this stop never reached, so + // what is on disk really is an unindexed stub and leaving those + // behind just accumulates unplayable recordings the user cannot + // explain. Size-gate it anyway: throwing away a recording to tidy + // up after a failed stop is the worse mistake of the two, and the + // gate is the same one the salvage check above uses. + await removeNativeWindowsCaptureOutputs(preferredPath, preferredWebcamPath, { + onlyIfUnusable: true, + }); + // The helper log goes to console/diagnostics above, not into this + // string: it ends up in a toast, and pasting an entire capture log + // into the HUD tells the user nothing they can act on. + return { + success: false, + reason: stopResult.reason, + error: + stopResult.reason === "stop-timeout" + ? "Timed out waiting for native Windows capture to stop. The recording could not be saved." + : stopResult.message.split(/\r?\n/).filter(Boolean).at(-1) || + "Native Windows capture failed.", + }; + } } - const screenVideoPath = stopResult.screenVideoPath || preferredPath; + // Only a successful stop names the file; the salvage path above falls + // through with `ok: false` and nothing but the path we asked for. + const screenVideoPath = (stopResult.ok ? stopResult.screenVideoPath : null) || preferredPath; if (!screenVideoPath) { throw new Error("Native Windows capture did not return an output path."); } @@ -2833,7 +2886,10 @@ export function registerIpcHandlers( success: true, path: screenVideoPath, session, - message: "Native Windows recording session stored successfully", + recovered, + message: recovered + ? "Native Windows recording recovered from a failed stop" + : "Native Windows recording session stored successfully", }; } catch (error) { console.error("Failed to stop native Windows recording:", error); diff --git a/electron/recording/nativeWindowsCaptureStop.test.ts b/electron/recording/nativeWindowsCaptureStop.test.ts index 7ba34317..900df9e1 100644 --- a/electron/recording/nativeWindowsCaptureStop.test.ts +++ b/electron/recording/nativeWindowsCaptureStop.test.ts @@ -3,6 +3,8 @@ import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + isSalvageableFragmentedCapture, + NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES, readStoppedPath, terminateNativeWindowsCapture, waitForNativeWindowsCaptureStop, @@ -77,6 +79,45 @@ describe("readStoppedPath", () => { }); }); +describe("isSalvageableFragmentedCapture", () => { + const big = NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES * 8; + + // The whole point of the fragmented container, and the case that used to be + // deleted-or-disowned while the file on disk played perfectly (#252). + it("keeps a fragmented capture whose stop never finalized", () => { + expect(isSalvageableFragmentedCapture("fragmented-mp4", big)).toBe(true); + }); + + // The ablation. Same size, same failed stop, no index anywhere in the file: + // this one really is lost, and saying otherwise would open an empty editor. + it("does not pretend a plain MP4 survived the same failure", () => { + expect(isSalvageableFragmentedCapture("mp4", big)).toBe(false); + }); + + it("rejects a fragmented file too small to hold a complete fragment", () => { + expect( + isSalvageableFragmentedCapture("fragmented-mp4", NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES - 1), + ).toBe(false); + }); + + it("takes the floor itself as salvageable", () => { + expect( + isSalvageableFragmentedCapture("fragmented-mp4", NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES), + ).toBe(true); + }); + + // A helper predating the fragmented sink reports no container at all. Absent + // is not fragmented -- guessing here would resurrect the total loss. + it("refuses to guess when the helper never reported a container", () => { + expect(isSalvageableFragmentedCapture(null, big)).toBe(false); + expect(isSalvageableFragmentedCapture(undefined, big)).toBe(false); + }); + + it("rejects a file that is not there at all", () => { + expect(isSalvageableFragmentedCapture("fragmented-mp4", null)).toBe(false); + }); +}); + describe("waitForNativeWindowsCaptureStop", () => { it("resolves with the path the helper reported", async () => { let output = "Recording started\n"; diff --git a/electron/recording/nativeWindowsCaptureStop.ts b/electron/recording/nativeWindowsCaptureStop.ts index 95da2c19..badec52d 100644 --- a/electron/recording/nativeWindowsCaptureStop.ts +++ b/electron/recording/nativeWindowsCaptureStop.ts @@ -33,6 +33,43 @@ export const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 60_000; /** How long a killed helper gets to actually die before we escalate. */ const NATIVE_WINDOWS_CAPTURE_KILL_GRACE_MS = 2_000; +/** What `mf_encoder.h`'s `kContainerFormatFragmentedMp4` puts on the wire. */ +export const NATIVE_WINDOWS_FRAGMENTED_CONTAINER = "fragmented-mp4"; + +/** + * An MP4 the helper never indexed is a few bytes of header at most. Anything + * larger might be a real recording, and deleting one of those to tidy up after + * a failed stop is a far worse outcome than leaving a stray file behind. + */ +export const NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES = 64 * 1024; + +/** + * Did a stop that failed its handshake still leave a recording worth opening? + * + * Only the fragmented container can. A plain MP4 writes its one index in + * `Finalize()`, so a helper that never reached it leaves bytes no demuxer can + * read — the total loss issues #252 / #292 / #327 reported. A fragmented one + * writes `moov` up front and a self-describing `moof`+`mdat` pair about every + * second, so the same file plays up to the last complete fragment with nothing + * else needed. Which one a run used is not a property of the version: the + * fragmented sink degrades to the plain one rather than failing a recording, + * which is exactly why the helper reports the flavour it settled on. + * + * The size floor is shared with the cleanup that deletes unusable leftovers, so + * the two agree by construction: nothing is recovered that the tidy-up would + * have judged a stub, and nothing is deleted that this would have called a + * recording. + */ +export function isSalvageableFragmentedCapture( + container: string | null | undefined, + sizeBytes: number | null, +): boolean { + if (container !== NATIVE_WINDOWS_FRAGMENTED_CONTAINER) { + return false; + } + return sizeBytes !== null && sizeBytes >= NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES; +} + const RECORDING_STOPPED_PATTERN = /Recording stopped\. Output path: (.+)/; const STOP_TIMEOUT_EVENT_PATTERN = /"event":"stop-timeout"[^\n]*"step":"([^"]+)"/; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 9724c27e..a6da24d7 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -591,8 +591,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // disagreeing about whether anything was recording: the HUD kept // showing a stop button, and pressing it sent a second stop that // came back "Native Windows capture is not running." (issue #252). - // The recording is already lost either way -- what the user needs - // is to be able to start a new one. + // Reaching here now means the take really is unreadable -- a failed + // stop that left a playable fragmented file comes back `success` + // with a session and takes the editor path below, so this branch no + // longer decides the fate of a recoverable recording. clearNativeRecordingState(); return true; } From db101b915dc2ad2ed7769f1388d497bc960fd0a4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:33:50 +0200 Subject: [PATCH 03/26] fix(editor): import a recording once, so reopening keeps the project you saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD parks a finished recording in one main-process slot and opens the editor, which imports it into a fresh project on mount. Nothing ever emptied that slot, and opening the editor destroys and recreates its window — so the second open imported the same file again: a new project at the default padding, roundness and wallpaper, with everything the user had set and saved stranded in the project that was no longer on screen. Consume the hand-off once the recording lives in a project. A later mount then takes the existing 'reopen the most recent project' path, which lands on that same project. Two projects on this machine point at one recording file, created two minutes apart, both with an empty settings envelope. --- src/components/ai-edition/NewEditorShell.tsx | 78 +++++++--------- .../ai-edition/recordingImport.test.ts | 91 +++++++++++++++++++ src/components/ai-edition/recordingImport.ts | 55 +++++++++++ 3 files changed, 178 insertions(+), 46 deletions(-) create mode 100644 src/components/ai-edition/recordingImport.test.ts create mode 100644 src/components/ai-edition/recordingImport.ts diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 208942e2..4a69effe 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -41,6 +41,7 @@ import { } from "./Modals"; import { Preview } from "./Preview"; import type { TrimTarget } from "./RightPanes"; +import { importPendingRecording } from "./recordingImport"; import v4 from "./v4/EditorShellV4.module.css"; import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar"; import { type Facet, FloatingInspector } from "./v4/FloatingInspector"; @@ -91,7 +92,6 @@ export function NewEditorShell() { const projectId = useProjectStore((s) => s.projectId); const dirty = useProjectStore((s) => s.dirty); const createProject = useProjectStore((s) => s.createProject); - const addAsset = useProjectStore((s) => s.addAsset); const setCurrentTime = useProjectStore((s) => s.setCurrentTime); const setSourceDuration = useProjectStore((s) => s.setSourceDuration); const loadProject = useProjectStore((s) => s.loadProject); @@ -222,59 +222,45 @@ export function NewEditorShell() { void (async () => { if (!window.electronAPI) return; try { - const result = await window.electronAPI.getCurrentRecordingSession(); - if (!result.success || !result.session?.screenVideoPath) { - // ponytail: no active recording — try to restore the user's - // most recent project. The browser-shim's listProjects - // returns the seeded `browser-shim-projects` entries, so - // e2e tests can land directly in a populated editor; for - // real Electron users this is the expected "open last - // project on launch" UX. - try { - const projects = await nativeBridgeClient.aiEdition.listProjects(); - console.info("[editor] listProjects returned", projects); - if (projects.length > 0) { - console.info("[editor] auto-loading project", projects[0].id); - await loadProject(projects[0].id); - const state = useProjectStore.getState(); - console.info( - "[editor] post-loadProject status=", - state.status, - "error=", - JSON.stringify(state.error), - "doc=", - state.document ? "loaded" : "null", - ); - } - } catch (e) { - console.warn("[editor] auto-load failed", e); - } + if (await importPendingRecording()) { + toast.success("Recording added to a new project"); return; } - const screenPath = result.session.screenVideoPath; - const label = screenPath.split(/[\\/]/).pop() || "Recording"; - await createProject(`Recording ${new Date().toLocaleString()}`); - await addAsset(screenPath, label); - // ponytail: MediaRecorder WebMs ship with duration = NaN until - // fix-webm-duration patches the EBML header; until that flows - // through the asset, drop a default 60s clip into the timeline - // so the editor isn't stuck on "No clips yet" the moment the - // user lands in the project. Real duration overwrites this - // when handleLoadedMetadata fires with a finite value. - const doc = useProjectStore.getState().document; - if (doc && doc.timeline.clips.length === 0 && doc.assets.length > 0) { - await useProjectStore - .getState() - .replaceTimeline([{ startSec: 0, endSec: 60 }], "Auto-imported recording"); - } - toast.success("Recording added to a new project"); } catch (err) { toast.error("Could not auto-create project from recording", { description: err instanceof Error ? err.message : String(err), }); + return; + } + // ponytail: no recording waiting — restore the user's most recent + // project. The browser-shim's listProjects returns the seeded + // `browser-shim-projects` entries, so e2e tests can land directly in a + // populated editor; for real Electron users this is the expected "open + // last project on launch" UX — and, now that the recording hand-off is + // consumed on import, it is also what reopening the editor after a + // recording lands on: the project that recording went into, settings and + // all, instead of a second project on the same file. + try { + const projects = await nativeBridgeClient.aiEdition.listProjects(); + console.info("[editor] listProjects returned", projects); + if (projects.length > 0) { + console.info("[editor] auto-loading project", projects[0].id); + await loadProject(projects[0].id); + const state = useProjectStore.getState(); + console.info( + "[editor] post-loadProject status=", + state.status, + "error=", + JSON.stringify(state.error), + "doc=", + state.document ? "loaded" : "null", + ); + } + } catch (e) { + console.warn("[editor] auto-load failed", e); } })(); - }, [addAsset, createProject, loadProject]); + }, [loadProject]); // Warn on close when dirty useEffect(() => { diff --git a/src/components/ai-edition/recordingImport.test.ts b/src/components/ai-edition/recordingImport.test.ts new file mode 100644 index 00000000..2c9728d4 --- /dev/null +++ b/src/components/ai-edition/recordingImport.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { importPendingRecording } from "./recordingImport"; + +// The store's own bridge calls are never reached — every action the import uses +// is stubbed below — but importing the store pulls the client in, so stub it. +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } })); + +const createProject = vi.fn(async () => undefined); +const addAsset = vi.fn(async () => null); +const replaceTimeline = vi.fn(async () => undefined); + +/** Stands in for the main-process recording slot: one value, set and read. */ +function stubElectronApi(screenVideoPath: string | null) { + let session = screenVideoPath ? { screenVideoPath, createdAt: 0 } : null; + const api = { + getCurrentRecordingSession: vi.fn(async () => + session ? { success: true, session } : { success: false }, + ), + setCurrentRecordingSession: vi.fn(async (next: typeof session) => { + session = next; + return { success: true }; + }), + }; + // biome-ignore lint/suspicious/noExplicitAny: test-only stub of the contextBridge surface + (window as any).electronAPI = api; + return api; +} + +describe("importPendingRecording", () => { + beforeEach(() => { + vi.clearAllMocks(); + useProjectStore.setState({ + document: null, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched + createProject: createProject as any, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched + addAsset: addAsset as any, + replaceTimeline, + }); + }); + + it("does nothing when no recording is waiting", async () => { + stubElectronApi(null); + await expect(importPendingRecording()).resolves.toBe(false); + expect(createProject).not.toHaveBeenCalled(); + }); + + it("imports the recording into a new project and consumes the hand-off", async () => { + const api = stubElectronApi("C:\\recordings\\recording-1.mp4"); + + await expect(importPendingRecording()).resolves.toBe(true); + + expect(createProject).toHaveBeenCalledTimes(1); + expect(addAsset).toHaveBeenCalledWith("C:\\recordings\\recording-1.mp4", "recording-1.mp4"); + expect(api.setCurrentRecordingSession).toHaveBeenCalledWith(null); + }); + + // The regression: the editor window is destroyed and recreated on every open, + // so a session left in the slot was imported again — a second project on the + // same recording, at default settings, with the user's saved ones stranded in + // the first one. + it("imports one recording once, however often the editor mounts", async () => { + stubElectronApi("C:\\recordings\\recording-1.mp4"); + + await importPendingRecording(); + await expect(importPendingRecording()).resolves.toBe(false); + + expect(createProject).toHaveBeenCalledTimes(1); + expect(addAsset).toHaveBeenCalledTimes(1); + }); + + it("seeds a placeholder clip when the imported asset has none", async () => { + stubElectronApi("/recordings/recording-1.webm"); + addAsset.mockImplementationOnce(async () => { + useProjectStore.setState({ + // biome-ignore lint/suspicious/noExplicitAny: only the two fields the seed reads + document: { assets: [{ id: "a1" }], timeline: { clips: [] } } as any, + }); + return null; + }); + + await importPendingRecording(); + + expect(replaceTimeline).toHaveBeenCalledWith( + [{ startSec: 0, endSec: 60 }], + "Auto-imported recording", + ); + }); +}); diff --git a/src/components/ai-edition/recordingImport.ts b/src/components/ai-edition/recordingImport.ts new file mode 100644 index 00000000..0b9b656b --- /dev/null +++ b/src/components/ai-edition/recordingImport.ts @@ -0,0 +1,55 @@ +// Hand-off from the recorder to the editor. +// +// The HUD parks the recording it just finished in ONE main-process slot +// (`set/getCurrentRecordingSession`) and opens the editor, which imports it into +// a fresh project on mount. The slot has to be emptied once that project owns +// the file, because opening the editor destroys and recreates its window +// (`createEditorWindowWrapper` in electron/main.ts) — so a session left in place +// is imported AGAIN on the next open: a second project on the same recording, +// back at the default padding / roundness / wallpaper, while everything the user +// set and saved stays behind in the first project, which is no longer the one on +// screen. That reads exactly like "the editor forgot my settings" (#364). +// +// `setCurrentRecordingSession(null)` is the existing clear (it also drops the +// derived `currentVideoPath`); the only renderer that still needs the session +// after this point is the CLI runner, which lives in its own process. + +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; + +/** + * Imports the recording the HUD handed over into a new project, and consumes the + * hand-off so it is imported exactly once. + * + * Returns false when there is nothing pending — the caller then falls back to + * reopening the most recent project. Throws if the import itself fails, leaving + * the session in place so a later mount can retry it. + */ +export async function importPendingRecording(): Promise { + const api = window.electronAPI; + if (!api) return false; + + const result = await api.getCurrentRecordingSession(); + const screenPath = result.success ? result.session?.screenVideoPath : undefined; + if (!screenPath) return false; + + const label = screenPath.split(/[\\/]/).pop() || "Recording"; + await useProjectStore.getState().createProject(`Recording ${new Date().toLocaleString()}`); + await useProjectStore.getState().addAsset(screenPath, label); + // Consumed: the recording now lives in a project. Cleared here rather than + // after the timeline seed below so a failure down there can't hand the same + // recording to the next editor window. + await api.setCurrentRecordingSession(null); + + // ponytail: MediaRecorder WebMs ship with duration = NaN until + // fix-webm-duration patches the EBML header; until that flows through the + // asset, drop a default 60s clip into the timeline so the editor isn't stuck + // on "No clips yet" the moment the user lands in the project. Real duration + // overwrites this when handleLoadedMetadata fires with a finite value. + const doc = useProjectStore.getState().document; + if (doc && doc.timeline.clips.length === 0 && doc.assets.length > 0) { + await useProjectStore + .getState() + .replaceTimeline([{ startSec: 0, endSec: 60 }], "Auto-imported recording"); + } + return true; +} From 39d830f6080233c9fcffdccf3c466c6f92166959 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 20:55:20 +0200 Subject: [PATCH 04/26] docs(e2e): say what injected input can never prove about the HUD Four corrections to the computer-use E2E guidance, each one found by following the existing text and hitting the wall it does not mention. The HUD click-through note had only its positive half: move the real cursor and the control becomes clickable. The negative half is the one that costs an hour. On Windows `forward` is a global WH_MOUSE_LL hook, and only a real OS mouse move drives it; CDP-injected input arrives below the OS hit-test, fires the DOM handler, and looks like it worked while never exercising click-through at all. This repo has a green Playwright test clicking HUD testids, which reads as proof that Playwright can drive the HUD -- it proves renderer wiring and nothing else. The failure #266 actually shipped, a painted and permanently inert HUD, is invisible to injected input by construction and cannot be regression-tested there, so the spec now says so next to those clicks. `request_access` was documented as "grant electron.exe" with no timing. electron.exe is not an installed app, so the resolver only finds it once the process exists and owns a window; asking earlier fails, and one unresolvable name short-circuits the whole request. Granting Openscreen instead resolves to the installed exe and reports success while leaving the dev window masked. The worktree setup step said to copy the prebuilt native binaries without saying they are frozen. Nothing rebuilds them, so a helper older than the change under test runs silently: this pass recorded a healthy 1080p60 file whose encoder-selection event had no `container` field, because the helper predated the fragmented-MP4 commit by seventeen hours. Date the binary and grep it for a string the change introduced. --- AGENTS.md | 3 +++ tests/e2e/windows-native-checklist.spec.ts | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d7b2ec8a..fc38e073 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,10 +86,12 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). - The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. +- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and grep it for a string the change introduced (`strings -a wgc-capture.exe | grep fragmented-mp4`). If it is stale and you have no toolchain, test the CI-built artifact instead; a dev build cannot answer the question. **Granting access** - `request_access` resolves names against installed apps. A **dev build runs as `electron.exe`** (or `Electron.app`), *not* the installed `Openscreen` — grant **`electron.exe`** or the dev window stays masked in screenshots. Non-allowlisted windows are masked (solid rectangles); the screenshot note lists their process names to add. +- **Start the app before asking for it.** `electron.exe` is not an installed app, so the resolver only finds it once the process exists *and* owns a window; ask any earlier and the call fails with `doesn't match any installed or running application` — and one unresolvable name short-circuits the whole request, including the names that would have resolved. Granting `Openscreen` instead is not a workaround: it resolves to `…\programs\openscreen\openscreen.exe`, so the dev window stays masked while the grant reports success. **The HUD widget** (recording controller) @@ -97,6 +99,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. +- **Only a real OS mouse move reaches the HUD.** On Windows that `forward` option is a global `WH_MOUSE_LL` hook, and driving it is what lifts the input-transparency over a control. CDP-injected input never does: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD testids and stays green for exactly that reason; it proves renderer wiring, not reachability. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). **The tray icon** (bottom-right notification area) diff --git a/tests/e2e/windows-native-checklist.spec.ts b/tests/e2e/windows-native-checklist.spec.ts index 959b15f1..fcda17a2 100644 --- a/tests/e2e/windows-native-checklist.spec.ts +++ b/tests/e2e/windows-native-checklist.spec.ts @@ -326,6 +326,16 @@ test.describe("Windows native checklist smoke tests", () => { // input-transparent when the hook fails to install can never be clicked again, // which is what bricked the app in issue #266. Both halves matter — that nothing // asks during construction, and that the renderer still does after mount. + // + // Note what this test therefore cannot do, and what no test in this file can. + // Only a real OS cursor move drives a WH_MOUSE_LL hook; CDP-injected input + // arrives below the OS hit-test, so Playwright's own `.click()` on a HUD testid + // — above, and in the source-selector step of the checklist test — reaches the + // DOM handler whether or not click-through is installed, or even working. Those + // clicks assert renderer wiring and nothing else. The failure #266 actually shipped + // (a painted, permanently inert HUD) is invisible to injected input by construction, + // so it belongs on the manual computer-use checklist and cannot be regression-tested + // here. Do not read a green run as evidence that the HUD is clickable. test("the HUD asks for click-through instead of being born with it", async () => { const app = await launchApp(); From b146de5ee2a43e4ff9cf36d4bd53fb1f008a5686 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 21:08:06 +0200 Subject: [PATCH 05/26] docs(e2e): give a binary-string check that works on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advice I had just written recommended `strings -a … | grep`, and Git Bash has no `strings`: the pipeline returns nothing and every binary reads as missing the change. It produced five confident false negatives against the CI-built helper, which does contain the fix. Use `findstr /M /C:` (handles binaries, ships with Windows), and always search a control string the old binary also has, so a broken search cannot masquerade as a stale binary. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index fc38e073..a220da75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). - The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. -- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and grep it for a string the change introduced (`strings -a wgc-capture.exe | grep fragmented-mp4`). If it is stale and you have no toolchain, test the CI-built artifact instead; a dev build cannot answer the question. +- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced: `findstr /M /C:"fragmented-mp4" wgc-capture.exe` — `findstr` handles binaries and ships with Windows, whereas Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Always search a control string the old binary also has (`encoder-selection`) so a broken search cannot masquerade as a stale binary. If it is stale and you have no toolchain, test the CI-built artifact instead; a dev build cannot answer the question. **Granting access** From 7fe208948f6171d19d706b83c2e9f39c38911a74 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:16:01 +0200 Subject: [PATCH 06/26] docs(agents): name the thing that actually builds a capture helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review. The rebuild claim was wrong, and wrong in the direction that causes the trap the rest of this PR documents: electron-builder and `@electron/rebuild` do Node native-module ABI work, not the standalone Swift and C++ capture helpers. Those are separate executables built by `npm run build:native:` and only copied into the package as `extraResources` — `build:win` even passes `--config.npmRebuild=false`. A reader who believed the old sentence would expect a normal build to pick up a helper change. Nothing does. The staleness check quoted a bare filename, so it only worked from inside `electron/native/bin//`. Given from the repo root now, and it names the rebuild command instead of only offering the no-toolchain escape hatch. And `testids` is not a word. --- AGENTS.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a220da75..12165d63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R - Format: `npm run format` (Biome, tabs, double quotes, 100-col) - i18n check: `npm run i18n:check` (validates the 13 locale files) -**Use npm, not bun/pnpm/yarn/Deno.** Not a style preference. The native Swift (macOS) and C++ (Windows) capture helpers are rebuilt against Electron's ABI by electron-builder + `@electron/rebuild`, which resolve the tree through `package-lock.json`. Another package manager writes a different lockfile, so that rebuild breaks. `packageManager` + `engines` in `package.json` pin the versions; CI installs with `npm ci`. +**Use npm, not bun/pnpm/yarn/Deno.** Not a style preference. Node native modules are rebuilt against Electron's ABI by electron-builder + `@electron/rebuild`, which resolve the tree through `package-lock.json`. Another package manager writes a different lockfile, so that rebuild breaks. `packageManager` + `engines` in `package.json` pin the versions; CI installs with `npm ci`. Note what this does *not* cover: the standalone Swift (macOS) and C++ (Windows) capture helpers are separate executables, built by `npm run build:native:` and only *copied* into the package as `extraResources` — `build:win` even passes `--config.npmRebuild=false`. Nothing in a normal build compiles them. ## Development principles @@ -86,7 +86,13 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). - The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. -- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced: `findstr /M /C:"fragmented-mp4" wgc-capture.exe` — `findstr` handles binaries and ships with Windows, whereas Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Always search a control string the old binary also has (`encoder-selection`) so a broken search cannot masquerade as a stale binary. If it is stale and you have no toolchain, test the CI-built artifact instead; a dev build cannot answer the question. +- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced — from the repo root: + + ``` + findstr /M /C:"fragmented-mp4" electron\native\bin\win32-x64\wgc-capture.exe + ``` + + `findstr` handles binaries and ships with Windows, whereas Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Always search a control string the old binary also has (`encoder-selection`) so a broken search cannot masquerade as a stale binary. If it is stale, rebuild it with `npm run build:native:win` (or `:mac` / `:linux`) — that is the only thing that compiles a helper. Without the toolchain, test the CI-built artifact instead; a dev build cannot answer the question. **Granting access** @@ -99,7 +105,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. -- **Only a real OS mouse move reaches the HUD.** On Windows that `forward` option is a global `WH_MOUSE_LL` hook, and driving it is what lifts the input-transparency over a control. CDP-injected input never does: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD testids and stays green for exactly that reason; it proves renderer wiring, not reachability. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. +- **Only a real OS mouse move reaches the HUD.** On Windows that `forward` option is a global `WH_MOUSE_LL` hook, and driving it is what lifts the input-transparency over a control. CDP-injected input never does: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). **The tray icon** (bottom-right notification area) From dbd0a2954a6daab1604e38dea1d3b76d236135f9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:24:35 +0200 Subject: [PATCH 07/26] docs(agents): the HUD click-through rule is macOS too, not Windows only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bullet opened with "On Windows", which reads as a scope and is one. `forward` is `@platform darwin,win32` in Electron's typings, and the renderer asks for click-through on both — `!enabled && !isLinuxHud`. Linux is the exception, and the only platform where a blind click on the HUD lands; LaunchWindow.tsx already said so thirty lines from where I wrote the opposite. That mattered: computer-use drives the macOS build too, and an agent reading "On Windows" concludes the caveat is somebody else's problem, then spends an hour on an injected click that fires the DOM handler and proves nothing. The mechanisms do differ — WH_MOUSE_LL on Windows, Electron's own forwarding on macOS — so the sentence now separates the implementation from the consequence, which is shared. Also notes that a macOS spec written like the Windows one would prove no more than it does, since there is no macOS e2e spec yet to say it in. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 12165d63..286e2855 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. -- **Only a real OS mouse move reaches the HUD.** On Windows that `forward` option is a global `WH_MOUSE_LL` hook, and driving it is what lifts the input-transparency over a control. CDP-injected input never does: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. +- **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** `forward` is `@platform darwin,win32` in Electron's own typings, and the renderer asks for click-through on both; **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. The implementations differ — Windows installs a global `WH_MOUSE_LL` hook, macOS forwards through its own event path — but the consequence is identical: moving the real cursor onto a control is what lifts the input-transparency. CDP-injected input never does that, on any platform: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). **The tray icon** (bottom-right notification area) From 7c4ce519d16733f43dff510120ba8832977ba443 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:27:38 +0200 Subject: [PATCH 08/26] docs(agents): point at the testing docs, which only linked one way manual-e2e-checklist.md sends the reader to AGENTS.md for the computer-use mechanics. AGENTS.md sent nobody back: its whole "Desktop E2E testing with computer-use" section, and the testing section above it, named no file under technical-documentation/testing/ at all. An agent starting from AGENTS.md -- which its own first line calls the canonical guide -- could read every mechanic for driving the app and never learn that a 410-line capture-to-export checklist exists, with per-platform sections and a results log meant to be appended to. The repo already solved this shape for releases: the Release flow section carries "Full operational guide ... read it before touching a release". Same treatment here, for writing-tests.md and the checklist, plus native-cursor-diagnostics.md for cursor work. Pointers only, no content moved -- the checklist stays the place that says what to run, this stays the place that says how. --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 286e2855..00932d9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,11 +76,14 @@ every edit is the main way an agent turns a 5-minute task into a 30-minute one, - E2E tests are in `tests/e2e/` (Playwright). Some specs are platform-specific (e.g. `windows-native-checklist.spec.ts`). - Add a test for every new behavior in the same package as the code under test. - All tests must pass before opening a PR. CI runs `npm run test` on every PR. +- **Which kind of test to write, and where: [`technical-documentation/testing/writing-tests.md`](technical-documentation/testing/writing-tests.md).** ## Desktop E2E testing with computer-use Unit/browser tests can't exercise real capture (native screen recording, a physical webcam, the tray). To verify a recording/editor feature end to end, drive the actual Electron app with the **computer-use** MCP (screenshot + click/type on the desktop). This is the required "manual smoke test on real Windows/macOS" for native changes. +This section is the *mechanics*. **What to actually run is [`technical-documentation/testing/manual-e2e-checklist.md`](technical-documentation/testing/manual-e2e-checklist.md)** — the capture-to-export pass, per-platform sections, and a results log to append to. Run it before promoting a release candidate and after any change to native capture, preview or export. For cursor work specifically, [`native-cursor-diagnostics.md`](technical-documentation/testing/native-cursor-diagnostics.md) gets you sidecars and reports without a full record-edit-export cycle. The checklist links back here for the mechanics below; the pairing only works if you know both halves exist. + **Launch the app** - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). From 3ba1305ae8d50d5f3f9e0bcbf26fbd9d1942656a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:29:10 +0200 Subject: [PATCH 09/26] docs(testing): log the 1.9.5-rc.1 Windows pass in the results table The table has had one row since July and asks for the run to be recorded. This pass was run and not recorded, which is the same failure as not running it: the next person cannot tell what was covered. Records what the shipped artifact actually did (fragmented MP4 confirmed, 48 fragments over 47.6s), the defect found and where it was fixed, and the finding that matters most for anyone reaching for this checklist next -- a dev build cannot answer a native question, because the prebuilt worktree helper predated the change under test and ran the old path without a word. --- technical-documentation/testing/manual-e2e-checklist.md | 1 + 1 file changed, 1 insertion(+) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index f1375ff7..d3a8fc99 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -406,5 +406,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | Date | Build / tag | Platform | Pass/fail | Notes | |------|-------------|----------|-----------|-------| | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | +| 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | | | | | | | | | | | | From f26fc4577cde5b9c555bee686a4fcb7ef5f9bf6e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:33:48 +0200 Subject: [PATCH 10/26] docs(testing): the manual tester is an agent, so say what not to drive it with I had left this fact out of the checklist on the reasoning that a manual tester uses a real cursor by definition. That is only true of a human. "Manual" here means an agent holding the mouse, and an agent has a choice a human does not: it can drive the same real app through CDP. That choice is the failure. Injected input arrives below the OS hit-test, so on Windows and macOS -- where the HUD is input-transparent until a real cursor move lifts it -- a Playwright click fires the DOM handler and returns green while the path a user takes was never exercised. Injection is also the faster-looking option, which is what makes it worth an explicit prohibition rather than an implication. Step 1 named the tool and contrasted it with a browser shim; the shim was never the temptation. Two prerequisites promoted next to it, both of which silently void a run rather than failing it: the prebuilt helpers are frozen and a stale one exercises the old path, and the access resolver cannot see a dev build until it is running, while granting the installed name instead reports success and leaves the window masked. --- technical-documentation/testing/manual-e2e-checklist.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index d3a8fc99..678b831f 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -6,10 +6,12 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing ## How to run this -1. Drive the real Electron app with computer-use, not a browser shim. Start a dev build with `npm run dev`, or launch the packaged build under test. +1. Drive the real Electron app with computer-use — real OS mouse and keyboard events. Start a dev build with `npm run dev`, or launch the packaged build under test. + + "Manual" here usually means an agent holding the mouse, so the tempting shortcut is not a browser shim: it is driving the real app through CDP instead. **Do not.** Playwright's `.click()`, `javascript_tool`-dispatched pointer events and anything else synthesised into the renderer arrive *below* the OS hit-test. On Windows and macOS the HUD is input-transparent until a real cursor move lifts it, so an injected click fires the DOM handler and comes back green while the path a user actually takes was never exercised at all. Injection is faster, and it is the one thing that can make this entire checklist mean nothing. 2. The app is single-instance per `userData` path. If a leftover Electron/OpenScreen process still holds the lock, stop that process before relaunching; a second launch can exit successfully without opening a window. The lock is held by the OS and is released when the process dies, so there is nothing to delete on disk. -3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. -4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build. +3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. +4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. 5. Read [AGENTS.md](../../AGENTS.md) for the computer-use mechanics, screenshot permissions, tray interaction, and cleanup procedure. Read one check, perform it, observe the result, then continue; close each modal or popover with `Esc` before the next check. 6. The recording HUD is protected from capture by default and is invisible in screenshots. For this session only, launch with `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; this is the environment variable checked before `setContentProtection(true)`. Unset it before making any recording whose HUD must not appear in the video. 7. A preview screenshot is downscaled. Settle every pixel-level question by exporting a frame and measuring the exported frame, not by judging fine edges, corners, shadows, or alignment from the preview screenshot. From 2ec6473137208656ab01de8d3833860647825a43 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:39:15 +0200 Subject: [PATCH 11/26] docs(testing): give the editor sections their own reason not to inject The prohibition I just added was argued entirely from the HUD being input-transparent. That is true, and it is also HUD-only: the HUD and the countdown overlay are the only click-through windows, the editor is `transparent: false` and never calls setIgnoreMouseEvents, and an injected click there really does reach the handler a user would. Which means an agent that reads the reason, clears the HUD sections and then thinks about the ~350 editor checks can conclude, correctly from what was written, that injection is fine for the rest. That guts the document. The editor's reason is different and is in this file's own first line: it covers what unit, browser and Playwright tests cannot reach. Driving it the way those tests already drive it re-runs coverage that exists and writes "passed" beside the parts nothing checked. --- technical-documentation/testing/manual-e2e-checklist.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 678b831f..40511ce9 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -8,7 +8,9 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing 1. Drive the real Electron app with computer-use — real OS mouse and keyboard events. Start a dev build with `npm run dev`, or launch the packaged build under test. - "Manual" here usually means an agent holding the mouse, so the tempting shortcut is not a browser shim: it is driving the real app through CDP instead. **Do not.** Playwright's `.click()`, `javascript_tool`-dispatched pointer events and anything else synthesised into the renderer arrive *below* the OS hit-test. On Windows and macOS the HUD is input-transparent until a real cursor move lifts it, so an injected click fires the DOM handler and comes back green while the path a user actually takes was never exercised at all. Injection is faster, and it is the one thing that can make this entire checklist mean nothing. + "Manual" here usually means an agent holding the mouse, so the tempting shortcut is not a browser shim: it is driving the real app through CDP instead. **Do not.** Playwright's `.click()`, `javascript_tool`-dispatched pointer events and anything else synthesised into the renderer arrive *below* the OS hit-test. On Windows and macOS the HUD is input-transparent until a real cursor move lifts it, so an injected click fires the DOM handler and comes back green while the path a user actually takes was never exercised at all. + + That trap is specific to the HUD and the countdown overlay — they are the only click-through windows; the editor is an ordinary one, and an injected click there does reach the handler a user would reach. The reason not to inject in the editor either is the first line of this file: this checklist covers what unit, browser and **Playwright** tests cannot. Drive it the way those tests already drive it and you have re-run the coverage you had, then written "passed" beside the parts nothing checked. 2. The app is single-instance per `userData` path. If a leftover Electron/OpenScreen process still holds the lock, stop that process before relaunching; a second launch can exit successfully without opening a window. The lock is held by the OS and is released when the process dies, so there is nothing to delete on disk. 3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. 4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. From 6496934c0d0545338f01fd3c9a88d024e2bd588f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:46:08 +0200 Subject: [PATCH 12/26] docs(agents): orient an agent toward computer-use before it needs to know MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ordering and framing problems, all of which only bite someone reading this front to back and acting as they go. The content-protection flag was documented at line 109 and the launch step is at line 89. Environment variables are set at launch. By the time you reach the explanation you have already started the app without it, screenshotted, found no HUD, and started looking for a bug. It is a module-scope constant read once as the main process loads (`electron/windows.ts:20`), so there is no recovery short of relaunching — which is exactly why it belongs in the launch step, with the log line that confirms it took. The section opened by framing computer-use as the answer to real capture — screen recording, webcam, tray. That is what forces it, not what it covers: the checklist it points at runs the editor, timeline, regions, transcript, export, settings and persistence the same way. An agent reading the old sentence would use computer-use for the capture checks and reach for something faster afterwards. And "Testing instructions" listed Vitest and Playwright and stopped, with no path to the computer-use section below it. Whoever reads only that section concludes Playwright is where e2e ends. It now says what Playwright structurally cannot reach, and links onward. --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 00932d9e..e0e10eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,19 +74,21 @@ every edit is the main way an agent turns a 5-minute task into a 30-minute one, every Windows and macOS machine — `electron/recording/webm-seek-index.test.ts` is the worked example. - E2E tests are in `tests/e2e/` (Playwright). Some specs are platform-specific (e.g. `windows-native-checklist.spec.ts`). +- **Playwright is not the end of the e2e story.** It drives the app through CDP, which cannot reach real capture, a real webcam, the tray, or the click-through HUD. Everything those miss is covered by a manual pass driven with computer-use — see [Desktop E2E testing with computer-use](#desktop-e2e-testing-with-computer-use) below, which is required for native changes and before promoting a release candidate. - Add a test for every new behavior in the same package as the code under test. - All tests must pass before opening a PR. CI runs `npm run test` on every PR. - **Which kind of test to write, and where: [`technical-documentation/testing/writing-tests.md`](technical-documentation/testing/writing-tests.md).** ## Desktop E2E testing with computer-use -Unit/browser tests can't exercise real capture (native screen recording, a physical webcam, the tray). To verify a recording/editor feature end to end, drive the actual Electron app with the **computer-use** MCP (screenshot + click/type on the desktop). This is the required "manual smoke test on real Windows/macOS" for native changes. +**Computer-use is how the manual end-to-end pass is driven — all of it, not only the native parts.** Real capture is what forces it (native screen recording, a physical webcam, the tray: no unit or browser test reaches those), but once the app is up you drive everything the same way — editor, timeline, regions, transcript, export, settings, persistence. Screenshot and click/type on the desktop, through the **computer-use** MCP, against the actual Electron app. This is the required "manual smoke test on real Windows/macOS" for native changes, and the only mode in which the checklist below means anything. This section is the *mechanics*. **What to actually run is [`technical-documentation/testing/manual-e2e-checklist.md`](technical-documentation/testing/manual-e2e-checklist.md)** — the capture-to-export pass, per-platform sections, and a results log to append to. Run it before promoting a release candidate and after any change to native capture, preview or export. For cursor work specifically, [`native-cursor-diagnostics.md`](technical-documentation/testing/native-cursor-diagnostics.md) gets you sidecars and reports without a full record-edit-export cycle. The checklist links back here for the mechanics below; the pairing only works if you know both halves exist. **Launch the app** - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). +- **Set `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1` in the environment you launch from, or the HUD is invisible in every screenshot you take.** It is a module-scope constant (`electron/windows.ts:20`), read once as the main process loads, so it cannot be turned on afterwards — you relaunch or you work blind. The main process prints `[content-protection] OFF for the HUD window` when it took effect; if that line is missing, stop and relaunch rather than hunting a HUD you will never see. What it does and when to unset it: the HUD notes below. - The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. - **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced — from the repo root: From b1b81de582e5b801a58b1b3d3fdd33b7f86239b9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:52:24 +0200 Subject: [PATCH 13/26] docs(agents): show the control search, not just require it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prose demanded a control string and the example showed only the positive search, which makes the control read as optional advice. It is the load-bearing half: without it a broken search is indistinguishable from a stale binary, and that is not hypothetical — `strings … | grep` in Git Bash produced five confident false negatives earlier in this PR, including against the helper that does contain the change. Both commands now appear, with the repository-root path, and the fence is tagged. Outcomes measured against the two helpers the section is about rather than reasoned about: stale gives no match then HIT, current gives HIT twice. --- AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e0e10eb2..e966eeeb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,11 +93,14 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. - **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced — from the repo root: - ``` + ```powershell + # the string the change introduced — absent from a stale helper findstr /M /C:"fragmented-mp4" electron\native\bin\win32-x64\wgc-capture.exe + # the control — present in every helper, stale or not + findstr /M /C:"encoder-selection" electron\native\bin\win32-x64\wgc-capture.exe ``` - `findstr` handles binaries and ships with Windows, whereas Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Always search a control string the old binary also has (`encoder-selection`) so a broken search cannot masquerade as a stale binary. If it is stale, rebuild it with `npm run build:native:win` (or `:mac` / `:linux`) — that is the only thing that compiles a helper. Without the toolchain, test the CI-built artifact instead; a dev build cannot answer the question. + Run **both**. Only the second tells "the binary is stale" apart from "my search is broken", and that distinction is not hypothetical: `findstr` handles binaries and ships with Windows, but Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Measured against the two helpers this section is about — stale: no match, then HIT; current: HIT, HIT. A control that does not hit means you learned nothing about the binary. If it is stale, rebuild it with `npm run build:native:win` (or `:mac` / `:linux`) — that is the only thing that compiles a helper. Without the toolchain, test the CI-built artifact instead; a dev build cannot answer the question. **Granting access** From dfff6e2b5ec644834f76d5d5d7c1ece5a904e36a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 11:10:47 +0200 Subject: [PATCH 14/26] docs(testing): log the rc.2 regression pass, and what nearly faked a bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checklist run this records covers the 65 commits since v1.9.2 rather than the rc.2 delta, which is what a release candidate actually needs. Four recordings; no defect found. The numbers that matter are in the row. The AGENTS.md addition is the one thing this pass got wrong about itself. The staleness warning I wrote yesterday said to date "the binary" — so I refreshed the capture helper and nothing else, and an export then died on `open_input: -22 (Invalid argument)` out of `compositor.exportMulti`. It reads exactly like a product bug, and I nearly filed it as one. The file was fine: `ffmpeg` opened it from the command line without complaint. The compositor addon was four days older than the av* DLLs it was built against. A full hash diff of the directory found sixteen files differing and two missing outright. So the unit is the directory, not the binary. Copy all of it and diff by hash, or a mismatched set will hand you a failure that looks like the thing you came to test. --- AGENTS.md | 1 + technical-documentation/testing/manual-e2e-checklist.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e966eeeb..cb065a87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta ``` Run **both**. Only the second tells "the binary is stale" apart from "my search is broken", and that distinction is not hypothetical: `findstr` handles binaries and ships with Windows, but Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Measured against the two helpers this section is about — stale: no match, then HIT; current: HIT, HIT. A control that does not hit means you learned nothing about the binary. If it is stale, rebuild it with `npm run build:native:win` (or `:mac` / `:linux`) — that is the only thing that compiles a helper. Without the toolchain, test the CI-built artifact instead; a dev build cannot answer the question. +- **And it is the whole directory, not the one binary you came for.** `electron/native/bin//` also holds the compositor addon, the cursor sampler, the ffmpeg DLLs it dlopens, and the STT binaries — each frozen independently at whenever someone last ran a build. Refreshing only the helper leaves a mismatched set, and a mismatched set fails like a product bug: an export died on `open_input: -22 (Invalid argument)` from `compositor.exportMulti` purely because the addon was four days older than the av\* DLLs it was built against, while `ffmpeg` on the command line opened the very same file without complaint. If you are borrowing binaries from an installed build, copy the **entire** directory and diff it by hash afterwards — the last check turned up sixteen differing files and two missing outright. **Granting access** diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 40511ce9..f9e3940d 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -411,5 +411,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a |------|-------------|----------|-----------|-------| | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | +| 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling (this machine is 100% — those bugs are structurally invisible here), webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | | | | | | | | | | | | | From d8b49e3e3c22939e275a8033bb1469fc1a2e8a1f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 11:54:39 +0200 Subject: [PATCH 15/26] docs(testing): "the machine is at 100%" is not a reason to skip DPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row justified skipping DPI coverage with "this machine is 100% — those bugs are structurally invisible here". The display scale is a setting. Changing it takes about two minutes and has been the documented procedure since #346, so the honest sentence was "not re-run in this pass", not "cannot be tested here". Left as not-covered, because it was already validated when 60bb6d7c and 71cc88d6 landed, but the reason now says that instead of dressing a choice up as a constraint — which is exactly how a gap outlives the release it was skipped for. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index f9e3940d..7f5ea08d 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -411,6 +411,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a |------|-------------|----------|-----------|-------| | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | -| 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling (this machine is 100% — those bugs are structurally invisible here), webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | +| 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | | | | | | | | | | | | | From e3c332cdaa499ee926debc8a98c00a96677f5ea6 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 12:06:56 +0200 Subject: [PATCH 16/26] docs(testing): batch the computer-use grants so the operator can leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full capture-to-export pass is dozens of computer-use actions and, once the grants are in place, not one of them prompts again. Verified across the 2026-08-14 run: four dialogs, all at unpredictable moments, then forty-odd uninterrupted actions. So what pins a human to the keyboard is not the grant model, it is that the requests arrive scattered through the run. One batched call at the start and the operator answers once and walks away; discovering a fourth app you need an hour in and they cannot. Names the two easy-to-forget ones: the desktop shell, because the tray is the only reliable route back to the HUD and the save dialogs live there too, and the OS settings app, because changing display scaling is how DPI checks get run at all. Also records why batching is the whole mitigation rather than a preference — there is no config to pre-approve any of it (claude-code#46907, closed stale), and bypassPermissions does not cover it (#43172). --- technical-documentation/testing/manual-e2e-checklist.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 7f5ea08d..57eee2d9 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -14,6 +14,13 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing 2. The app is single-instance per `userData` path. If a leftover Electron/OpenScreen process still holds the lock, stop that process before relaunching; a second launch can exit successfully without opening a window. The lock is held by the OS and is released when the process dies, so there is nothing to delete on disk. 3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. 4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. + + **Ask for everything in ONE call, here, before anything else.** `request_access` takes a list, and once a grant is in place the rest of the pass runs without a single further prompt — a full capture-to-export run is dozens of clicks and none of them ask again. So the only thing keeping a human at the keyboard is *how many* dialogs you raise and *when*. Raise one, at the start, and the operator can walk away for the rest of the run; discover a fourth app you need an hour in and they cannot. Beyond the app under test, ask for: + + - the desktop shell (`Explorateur de fichiers` / Finder) — the tray icon and the native save dialogs live there, and the tray is the only reliable way back to the HUD; + - the OS settings app (`systemsettings.exe` / System Settings) — needed to change display scaling, which is how DPI checks are run (see AGENTS.md; "the machine is at 100%" is not a reason to skip them). + + There is no way to pre-approve any of this in config: the request has to be answered live. That is upstream ([claude-code#46907](https://github.com/anthropics/claude-code/issues/46907), closed stale), and `bypassPermissions` does not cover it either ([#43172](https://github.com/anthropics/claude-code/issues/43172)). Batching is the whole mitigation. 5. Read [AGENTS.md](../../AGENTS.md) for the computer-use mechanics, screenshot permissions, tray interaction, and cleanup procedure. Read one check, perform it, observe the result, then continue; close each modal or popover with `Esc` before the next check. 6. The recording HUD is protected from capture by default and is invisible in screenshots. For this session only, launch with `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; this is the environment variable checked before `setContentProtection(true)`. Unset it before making any recording whose HUD must not appear in the video. 7. A preview screenshot is downscaled. Settle every pixel-level question by exporting a frame and measuring the exported frame, not by judging fine edges, corners, shadows, or alignment from the preview screenshot. From 322fe947013574c83af06353912736a881906c75 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 12:57:29 +0200 Subject: [PATCH 17/26] docs(testing): fix the grant ordering, and name apps the way the resolver does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, one valid and one that would have broken the recipe. Valid: "here, before anything else" contradicted the launch-first rule stated three lines above it. Now "after the launches above, before the first check", with the reason attached so nobody moves it back. Not valid: the suggestion to use `explorer.exe` instead of the localized label. Tested it — `explorer.exe` returns notInstalled and suggests "Windows Software Development Kit", while `Explorateur de fichiers` resolves to c:\windows\explorer.exe. The resolver matches Start-menu display names, not executables, so that change would have short-circuited the whole batch: exactly the failure this step warns about. The concern underneath it was real though — a localized label is machine-specific and this doc is not. So the step now says the names are display names in the system's language, gives both spellings for the shell, and says to ask rather than guess. --- technical-documentation/testing/manual-e2e-checklist.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 57eee2d9..e8b3020e 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -15,10 +15,12 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing 3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. 4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. - **Ask for everything in ONE call, here, before anything else.** `request_access` takes a list, and once a grant is in place the rest of the pass runs without a single further prompt — a full capture-to-export run is dozens of clicks and none of them ask again. So the only thing keeping a human at the keyboard is *how many* dialogs you raise and *when*. Raise one, at the start, and the operator can walk away for the rest of the run; discover a fourth app you need an hour in and they cannot. Beyond the app under test, ask for: + **Ask for everything in ONE call — after the launches above, before the first check.** `request_access` takes a list, and once a grant is in place the rest of the pass runs without a single further prompt: a full capture-to-export run is dozens of clicks and none of them ask again. So the only thing keeping a human at the keyboard is *how many* dialogs you raise and *when*. Raise one, before the first check, and the operator can walk away for the rest of the run; discover a fourth app you need an hour in and they cannot. That is also why this cannot move earlier — the resolver needs the app running, and one unresolvable name voids the batch. Beyond the app under test, ask for: - - the desktop shell (`Explorateur de fichiers` / Finder) — the tray icon and the native save dialogs live there, and the tray is the only reliable way back to the HUD; - - the OS settings app (`systemsettings.exe` / System Settings) — needed to change display scaling, which is how DPI checks are run (see AGENTS.md; "the machine is at 100%" is not a reason to skip them). + - the desktop shell — the tray icon and the native save dialogs live there, and the tray is the only reliable way back to the HUD; + - the OS settings app — needed to change display scaling, which is how DPI checks are run (see AGENTS.md; "the machine is at 100%" is not a reason to skip them). + + **Name them the way the Start menu does, in the system's own language.** The resolver matches installed-app display names, not executables: on a French Windows the shell is `Explorateur de fichiers` and `explorer.exe` fails outright — `notInstalled`, with a nonsense suggestion attached — which then voids every other name in the same call. On an English install it is `File Explorer`. When unsure, ask rather than guess; the tool lists the installed names it knows. There is no way to pre-approve any of this in config: the request has to be answered live. That is upstream ([claude-code#46907](https://github.com/anthropics/claude-code/issues/46907), closed stale), and `bypassPermissions` does not cover it either ([#43172](https://github.com/anthropics/claude-code/issues/43172)). Batching is the whole mitigation. 5. Read [AGENTS.md](../../AGENTS.md) for the computer-use mechanics, screenshot permissions, tray interaction, and cleanup procedure. Read one check, perform it, observe the result, then continue; close each modal or popover with `Esc` before the next check. From ba1d746f38981583ad1bf139f9aab39eb9c80ca5 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 13:00:39 +0200 Subject: [PATCH 18/26] docs(testing): log the macOS rc.1 pass, and why a clean stop proves nothing The macOS half of a6795d23 had never been tested. It is active -- but the check the plan prescribed cannot see it. AVAssetWriter collapses its fragments back into a normal movie in finishWriting(), so a cleanly stopped macOS file is `ftyp mdat moov` with zero moof and no mfra: byte-for-byte the shape the plan calls the headline failure, and the same shape a pre-a6795d23 recording has. Only a take whose writer died shows mvex and ~1 moof per second. On macOS the kill test is the assertion; the clean-stop box walk is a coin flip. It also found a blocker on the way. Every app-driven recording truncates -- media stops at 4.0s, 36.0s, 15.0s while the HUD counts to 02:02, 01:30, 01:04 -- and the app then discards a take it could have kept: writer-failed (AVFoundation -11800 / -16341), no sidecars, no editor, ~530 MB of decodable video dropped across three takes. That is the #363 gap firing with nothing killed at all. The cause is narrowed by building the helper twice from the rc.1 source, one line apart. With system audio, movieFragmentInterval present fails 2/2 inside two seconds; removed, it stops cleanly 3/3 at ~40s. The row records the one thing that does not fit -- video-only, the local build outlived the shipped binary 2/2 against 0/5 -- because a report that hides its loose end invites the next person to re-run the easy half and call it settled. --- technical-documentation/testing/manual-e2e-checklist.md | 1 + 1 file changed, 1 insertion(+) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index e8b3020e..c59b030f 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,5 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor, no error dialog. 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From a3dd4a12619d58f77a268462e7980a05f3e61d1a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 13:37:16 +0200 Subject: [PATCH 19/26] docs(testing): fold in the by-hand macOS repro, and correct the 'no error dialog' claim Six takes made by hand on the same machine separate the trigger that my automated runs could not: system audio on, 3/3 die at ~1.0s and mint no project; audio off, 3/3 survive to 3.3s, 7.4s and 25.0s and each mint one. That matches the movieFragmentInterval A/B exactly. It also corrects the row. I wrote that stop produces no error dialog. It does -- a toast carrying the raw AVFoundation string. My screenshots simply landed after it auto-dismissed. The defect is unchanged; the claim about what the user sees was wrong, and a row that misdescribes the symptom sends the next person looking for a silent failure that is not silent. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index c59b030f..5612a9f5 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,6 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor, no error dialog. 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** Audio is not required for the failure (my own app runs had it disabled and still died at 4.0/36.0/15.0 s) but it makes it near-immediate, matching the A/B below. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From a37dca9482d6e949dda168278f2ba26f66a9ee77 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 13:53:37 +0200 Subject: [PATCH 20/26] =?UTF-8?q?docs(testing):=20kill=20the=20last=20conf?= =?UTF-8?q?ound=20=E2=80=94=20audio=20off,=20no=20screenshots,=20same=20de?= =?UTF-8?q?ath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row could say audio was not required for the failure, but only by pointing at runs that took screenshots mid-capture. That layer hides non-allowlisted windows at the window-server level, which is exactly the kind of thing that makes ScreenCaptureKit hiccup, so the claim rested on the one variable a reader would rightly challenge. Controlled run: audio off, not one screenshot taken while capturing. Same death -- 8.008s of video, flat for the next 76s with the helper still alive, zero sidecars, same -11800/-16341. That also reconciles the by-hand takes with mine, which looked contradictory: the writer dies after a variable delay, so a take stopped before it dies is genuinely clean. 3.3s and 7.4s survive, 8.0s does not. The row now says the thing that matters to anyone reaching for a workaround -- turning audio off buys time, it does not buy safety. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 5612a9f5..d6300c87 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,6 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** Audio is not required for the failure (my own app runs had it disabled and still died at 4.0/36.0/15.0 s) but it makes it near-immediate, matching the A/B below. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From 9535bc17e8621e75dc0759f7ac58cf2c94e7e813 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 14:21:00 +0200 Subject: [PATCH 21/26] docs(testing): point the macOS blocker at its fix The Results log is the durable artifact, and a row that records a blocker with no pointer to its resolution invites the next person to re-derive it. #375 root-causes this one to a version 0 trun carrying a negative composition offset, and the row now says so -- along with the part that still needs doing, which is re-running this section against a CI build that carries the fix. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index d6300c87..144aacf2 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,6 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** (Root-caused and fixed in #375 — the fragments carried a negative composition offset in a version 0 `trun`, where the field is unsigned, because frame reordering was left on; `AVVideoAllowFrameReorderingKey: false` clears it and restores the crash-resilience the container change was for. Re-run this section against a CI build before rc.2 ships.) 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From ee2a1ee49b6dd0f01f5e361bdcadb7344f905ada Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 15:33:44 +0200 Subject: [PATCH 22/26] docs(testing): downgrade four claims the evidence does not carry Review pushed on four sentences, and rebuilding the broken arm while answering it turned one of them from overstated into wrong. "Not load-related" was drawn from two standalone reproductions at a lower resolution. Those show the failure is not confined to the app's 4K60 path, which is not the same thing: append rate demonstrably changes how reliably it bites, reliably at ~57 fps and intermittently at 30. "A/B isolates it" was a sample presented as a law. A later rebuild of the with-the-line arm survived 22.2s at settings that had killed it twice at 1-2s, so the counts narrow the with-audio path and no more. The case rests on the bytes, not the tally, and the row now says so. The same variable also dissolves the video-only local-versus-shipped gap this row called unexplained: 56.6 fps shipped against 29 fps locally, not the released artifact. "Duration exact" was followed in the same clause by the 7 ms it differed by. "Root-caused and fixed in #375" claimed for this run a validation it never did. The run reproduced the failure; the fix is verified at helper level in #375 and in the packaged app nowhere yet. Also attributes the mvex/moof observations to the samples they came from, including the one kill that carries mvex with zero moof because capture had already stalled twelve seconds before the kill landed. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 144aacf2..a5720970 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,6 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** (Root-caused and fixed in #375 — the fragments carried a negative composition offset in a version 0 `trun`, where the field is unsigned, because frame reordering was left on; `AVVideoAllowFrameReorderingKey: false` clears it and restores the crash-resilience the container change was for. Re-run this section against a CI build before rc.2 ships.) 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: the takes whose writer died mid-fragment retain `mvex` + ~1 `moof` per second of media (shipped-build writer-failure samples: 35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s; plus 18 on a surviving-helper kill). The one kill on the shipped build is the exception that proves the scope — capture had already stalled ~12 s before the kill, so it carries `mvex` but **0 `moof`** and only 1.0 s. No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** (Root cause and fix reported in #375 — the fragments carry a negative composition offset in a version 0 `trun`, where ISO/IEC 14496-12 8.8.8.2 defines the field as unsigned, because frame reordering was left on; `AVVideoAllowFrameReorderingKey: false` clears it and restores the crash-resilience the fragmenting was for. Verified at helper level there; **this rc.1 run only reproduced the failure and validated nothing about the fix**. Re-run this section against a CI build carrying #375 before rc.2 ships.) 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not confined to the app's 4K60 path — but do not read that as load-independent: append rate demonstrably modulates how reliably it bites (#375 measures it reliable at ~57 fps and intermittent at 30 fps). **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **Helper A/B narrows the with-audio path to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Read those counts as a sample, not a law**: a later rebuild of the with-the-line arm survived 22.2 s at the same settings, so the failure is probabilistic and rate-dependent, and the byte-level evidence in #375 is what actually carries the case. The video-only local-vs-shipped gap (local survived 45 s, shipped failed 5/5) is explained by the same variable rather than by the released artifact — the shipped runs encoded at 56.6 fps against 29 fps locally. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration matches to within 7 ms — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured, under one frame at 60 fps. **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From 155ba4c0fa21679d49a6f8a697744d0730025502 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 14:19:52 +0200 Subject: [PATCH 23/26] fix(recording): stop macOS fragments carrying an offset the box cannot hold a6795d23 gave macOS the same crash-resilience Windows got, in one line: movieFragmentInterval. On macOS that line destroyed every recording it touched. Capture stopped after a few seconds while the HUD counted on, and stop answered AVFoundationErrorDomain -11800 / -16341, so the take was discarded: no sidecars, no editor. Six takes on the shipped rc.1 lost ~530 MB of perfectly decodable video between them. The container was never the problem, and neither were the timestamps -- every sample file has strictly monotonic DTS. What is wrong is in the fragment bytes: each `trun` goes out version 0 carrying composition offsets like 0xFFFFFFF6, which is -10 reinterpreted, because ISO/IEC 14496-12 8.8.8.2 defines that field as unsigned in version 0 and signed only in version 1. Offsets are negative only because the encoder reorders frames, and it reorders because AVVideoAllowFrameReorderingKey is never set, so it runs High profile with has_b_frames=2. MediaToolbox raises -16341 from exactly one site -- inside the function that writes moof/mfhd/traf/trun -- which is why the failure needs movieFragmentInterval to exist at all and always lands on a fragment boundary: the two audio failures hit at 1.0s and 2.0s against a 1s interval. Turning reordering off makes every offset zero and PTS == DTS, and the fragment becomes representable. A screen recorder pays nothing for it -- B-frames buy compression on lookahead-friendly content and cost encode latency, the wrong trade for real-time capture. Measured on macOS 26.5 / M1, 1080p30 with system audio, the configuration that kills the current build in 1-2s: clean stop at 43.66s, has_b_frames 2 -> 0, 0 of 819 packets with pts != dts. SIGKILL at 25s leaves 27 moof, decodes clean (ffmpeg -v error -f null - exit 0) and recovers 28.01s with both tracks. So the recording survives AND the crash-resilience the commit existed for now actually works on macOS, which it never did. The second change is why this cost a whole recording to learn one bit. A failed AVAssetWriter keeps accepting appends and keeps answering false; the helper discarded that Bool after the first frame and read writer.status only in finishWriter(). That is the entire reason the HUD counted to 02:02 over a writer that died at 00:04. The Windows helper checks every WriteSample HRESULT and escalates; this reports once, at the append that failed, carrying the live writer.error. It does not abort the capture -- handlers.ts tears its error listener down once recording-started arrives, so acting on this mid-recording is a TypeScript change and belongs in its own commit. --- .../ScreenCaptureRecorder.swift | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 42e764e3..eaca432e 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -141,6 +141,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var audioMixer: AudioTrackMixer? private var didStartWriting = false private var didEmitRecordingStarted = false + private var didReportWriterFailure = false private var isStopping = false private var isPaused = false private var pauseStartedAt: CMTime? @@ -309,7 +310,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } if videoInput.isReadyForMoreMediaData { - if videoInput.append(sampleBuffer), !didEmitRecordingStarted { + let appended = videoInput.append(sampleBuffer) + if appended, !didEmitRecordingStarted { didEmitRecordingStarted = true emit([ "event": "recording-started", @@ -318,10 +320,31 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { "height": outputHeight, "captureBounds": captureBoundsPayload(), ]) + } else if !appended { + reportWriterFailure("video append") } } } + /// A failed AVAssetWriter keeps accepting appends and keeps answering false, so + /// a recorder that discards that Bool records nothing while the HUD counts on. + /// That is how a two-minute take was already lost by its fourth second and only + /// said so at finishWriting(). The Windows helper checks every WriteSample + /// HRESULT and escalates; this is the macOS half of the same contract -- report + /// once, at the append that actually failed, carrying the live writer.error. + private func reportWriterFailure(_ stage: String) { + guard !didReportWriterFailure, let writer else { + return + } + didReportWriterFailure = true + emitError( + code: "writer-failed", + message: "\(stage): " + + (writer.error.map { "\($0)" } + ?? "AVAssetWriter status \(writer.status.rawValue)"), + ) + } + private func ensureRequestedPermissions() throws { if !CGPreflightScreenCaptureAccess() { let granted = CGRequestScreenCaptureAccess() @@ -456,6 +479,25 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000, AVVideoExpectedSourceFrameRateKey: request.video.fps, + // Without this the encoder defaults to B-frames, and a reordered + // stream needs a composition offset per sample. AVAssetWriter emits + // those in a version 0 `trun`, where ISO/IEC 14496-12 8.8.8.2 defines + // the field as UNSIGNED -- so a negative offset goes out as + // 0xFFFFFFF6 and the fragment writer refuses the fragment it is + // about to emit. That refusal is -11800 / -16341, raised from the + // single site in MediaToolbox that writes moof/traf/trun, which is + // why it appears if and only if movieFragmentInterval is set and + // lands exactly on a fragment boundary. + // + // Turning reordering off makes every offset zero and PTS == DTS, so + // the fragment stays representable. A screen recorder gives up + // nothing for it: B-frames buy compression on lookahead-friendly + // content and cost encode latency, which is the wrong trade for + // real-time capture. Measured on macOS 26.5 / M1, 1080p30 with + // system audio: with reordering the writer dies after 1-2s, without + // it a 43.6s take stops clean and a SIGKILL at 25s still leaves 27 + // readable `moof` fragments. + AVVideoAllowFrameReorderingKey: false, ], ] let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) From 327e74201b554744273198798a0fbd0666db5d0f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 15:32:21 +0200 Subject: [PATCH 24/26] fix(recording): separate the two writer-failure events, and quote the rate Review caught that reportWriterFailure and finishWriter both emitted `writer-failed`, and proposed routing finalization through the one-time reporter. That would break stopping. handlers.ts settles the stop promise on exactly one of `recording-stopped` or `writer-failed`, so suppressing the terminal event whenever an append already fired turns every writer failure into the "Saving..." hang instead of an error -- the exact symptom this branch exists to remove. The two sites answer different questions, so they now carry different codes: `writer-failed-during-capture` says when the writer died, `writer-failed` says whether stopping worked. Verified by putting the bug back and watching a failing run emit exactly one of each. Rebuilding that broken variant also corrected the evidence. It survived 22.2s at 30 fps, where the same configuration had failed twice at 1-2s, so the failure is probabilistic and my "2/2 versus 3/3" was a sample, not a law. It is rate-dependent: at ~57 fps, the rate the app drives and the rate at which the shipped binary failed 6/6, reordering on dies at 13.0s and reordering off stops clean at 31.6s. The comment now quotes the frame rate beside every number, because a reproduction that is only sometimes reproducible is exactly the kind a future reader will try once, fail to trigger, and conclude was never real. The case for the fix does not rest on those counts. It rests on the bytes: the composition offsets are unrepresentable in a version 0 trun in every fragmented file, whether or not that particular run happened to die. --- .../ScreenCaptureRecorder.swift | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index eaca432e..c5e19105 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -332,13 +332,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { /// said so at finishWriting(). The Windows helper checks every WriteSample /// HRESULT and escalates; this is the macOS half of the same contract -- report /// once, at the append that actually failed, carrying the live writer.error. + /// + /// Deliberately not the code finishWriter() emits, and the difference is load + /// bearing. That one is the terminal result of stopping, and the Electron side + /// settles its stop on exactly one of `recording-stopped` or `writer-failed`. + /// Give both sites the same code behind this one-shot guard and a writer that + /// died mid-capture emits nothing at all at stop, so the stop promise never + /// settles and every failure becomes the "Saving..." hang instead of an error. + /// This event answers "when did the writer die"; that one answers "did stopping + /// work". Two questions, two codes. private func reportWriterFailure(_ stage: String) { guard !didReportWriterFailure, let writer else { return } didReportWriterFailure = true emitError( - code: "writer-failed", + code: "writer-failed-during-capture", message: "\(stage): " + (writer.error.map { "\($0)" } ?? "AVAssetWriter status \(writer.status.rawValue)"), @@ -493,10 +502,18 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { // the fragment stays representable. A screen recorder gives up // nothing for it: B-frames buy compression on lookahead-friendly // content and cost encode latency, which is the wrong trade for - // real-time capture. Measured on macOS 26.5 / M1, 1080p30 with - // system audio: with reordering the writer dies after 1-2s, without - // it a 43.6s take stops clean and a SIGKILL at 25s still leaves 27 - // readable `moof` fragments. + // real-time capture. + // + // Measured on macOS 26.5 / M1, 1080p with system audio. How reliably + // the bug bites scales with append rate, so quote the rate with the + // result: at ~57 fps, the rate the app actually drives, reordering + // on dies at 13.0s while reordering off stops clean at 31.6s; at + // 30 fps it is intermittent, dying at 1.0s and 2.0s but once + // surviving 22.2s. That intermittency is why the byte-level evidence + // leads here and the run counts only corroborate: the offsets are + // out of spec in every fragmented file whether or not that + // particular run happened to die. Reordering off is 3/3 clean across + // both rates, and a SIGKILL at 25s still leaves 27 readable `moof`. AVVideoAllowFrameReorderingKey: false, ], ] From fcd96d6ffc27c1bf6968175c774e5e8e4eaaf854 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:33:52 +0000 Subject: [PATCH 25/26] chore(release): bump to 1.9.5-rc.2 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98c0ff6c..62327ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.9.5-rc.1", + "version": "1.9.5-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.9.5-rc.1", + "version": "1.9.5-rc.2", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index c7869e5d..6d7b2776 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.5-rc.1", + "version": "1.9.5-rc.2", "type": "module", "packageManager": "npm@10.9.4", "engines": { From afbfb7bbc3c3108a74faca8ffd99030946ddc1f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:30:57 +0000 Subject: [PATCH 26/26] chore(release): bump to 1.9.5 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62327ee6..03324d6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.9.5-rc.2", + "version": "1.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.9.5-rc.2", + "version": "1.9.5", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index 6d7b2776..76f8c962 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.5-rc.2", + "version": "1.9.5", "type": "module", "packageManager": "npm@10.9.4", "engines": {