diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index f10c009c6..6bfe88fb4 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -85,19 +85,26 @@ distributed by their own registries, not redistributed inside our binaries. - The MediaPipe **JavaScript** solution and its two ~5.6 MB WASM builds are no longer bundled: inference moved into the native compositor, and nothing loaded them. -## Microsoft OpenMP runtime — `vcomp140.dll` (Windows only) +## Microsoft Visual C++ runtime — `vcomp140.dll`, `msvcp140*.dll`, `vcruntime140*.dll` (Windows only) -- **Component**: `resources/electron/native/bin/win32-x64/vcomp140.dll`. +- **Components**: under `resources/electron/native/bin/win32-x64/` — + `vcomp140.dll`, `msvcp140.dll`, `msvcp140_1.dll`, `vcruntime140.dll`, + `vcruntime140_1.dll`. - **License**: redistributable under the Microsoft Visual C++ Redistributable - terms accompanying Visual Studio; the copy shipped is taken from the - `VC\Redist\MSVC\\x64\Microsoft.VC.OpenMP\` directory of the - Visual Studio installation that builds the release, never from `System32`. -- **Why it ships**: the ggml backends above are compiled with OpenMP and import - it. It is **not** part of Windows, so without it `whisper-stt-server` dies in - the loader before `main()` on any machine that has no Visual C++ - Redistributable, and transcription and captions fail with no usable error. - Staged by `scripts/stage-vcomp-runtime.mjs`; `scripts/before-pack.cjs` refuses - to package if it is missing while anything still imports it. + terms accompanying Visual Studio; the copies shipped are taken from the + `VC\Redist\MSVC\\x64\Microsoft.VC.OpenMP\` and + `…\Microsoft.VC.CRT\` directories of the Visual Studio installation that + builds the release, never from `System32`. +- **Why they ship**: two prebuilt binaries in the payload import them, and + neither is ours to recompile against the static CRT. The ggml backends above + are compiled with OpenMP and import `vcomp140.dll`; the vendored ONNX Runtime + imports the CRT proper. None of these are **part of Windows**, so without them + `whisper-stt-server` dies in the loader before `main()` on any machine that has + no Visual C++ Redistributable — transcription and captions fail with no usable + error — and `onnxruntime.dll` fails to load, leaving the camera background + silently inert. Staged by `scripts/stage-vcomp-runtime.mjs`; + `scripts/before-pack.cjs` refuses to package if any is missing while something + still imports it. ## PipeWire — headers (Linux only) diff --git a/scripts/stage-vcomp-runtime.mjs b/scripts/stage-vcomp-runtime.mjs index 06b35d6cf..d987946ea 100644 --- a/scripts/stage-vcomp-runtime.mjs +++ b/scripts/stage-vcomp-runtime.mjs @@ -1,4 +1,6 @@ -// Stages vcomp140.dll beside the whisper/ggml payload it is loaded by. +// Stages the Visual C++ runtime DLLs that the prebuilt payload imports, beside it. +// +// Two independent binaries need this, for the same reason and with the same fix. // // ggml-base.dll and ggml-cpu.dll are compiled with OpenMP, so they import // vcomp140.dll — Microsoft's OpenMP runtime, which ships with the Visual C++ @@ -8,16 +10,26 @@ // and transcription fail with the unactionable timeout described in // scripts/before-pack.cjs. // +// onnxruntime.dll — vendored by scripts/fetch-onnxruntime.mjs for the camera +// background segmentation — imports the CRT proper: msvcp140, msvcp140_1, +// vcruntime140, vcruntime140_1. It arrives as an upstream release binary, so +// `-C target-feature=+crt-static` is not available the way it is for our own Rust +// addon; the only remedy left is the one before-pack names, which is this file. +// Without it `checkWinNoRedistDependency` refuses to pack at all, and the Windows +// installer cannot be built — while `WIN_REQUIRED` in the same hook refuses to pack +// *without* onnxruntime.dll, so dropping it is not an escape either. +// // This is the same class of failure that Store certification rejected 1.9.1 for, // and it survived that fix because the guard only looked for msvcp/vcruntime/concrt // prefixes — `vcomp` matches none of them. The guard now covers the whole family // and, more usefully, only objects when the DLL is not shipped alongside. // -// Shipping the DLL rather than rebuilding whisper without OpenMP is deliberate: +// Shipping the DLLs rather than rebuilding without them is deliberate. For whisper // it leaves the computation byte-for-byte identical, where -DGGML_OPENMP=OFF would // swap OpenMP's scheduler for ggml's own and change transcription throughput by an -// amount nobody has measured. 200 KB against that unknown is a cheap trade. If the -// dependency ever becomes inconvenient, measure first, then switch. +// amount nobody has measured. For ONNX Runtime there is nothing to rebuild. ~1 MB +// against that is a cheap trade. If the dependency ever becomes inconvenient, +// measure first, then switch. import fs from "node:fs"; import path from "node:path"; @@ -27,10 +39,19 @@ import { findVcVarsAll } from "./msvcEnv.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); const DEST_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64"); -const DLL = "vcomp140.dll"; +// Lower-case, because that is how they are compared against `readdirSync` names. +// vcomp140 lives in Microsoft.VC.OpenMP, the other four in Microsoft.VC.CRT — +// sibling directories under the same Redist tree, so one walk finds them all. +const DLLS = [ + "vcomp140.dll", + "msvcp140.dll", + "msvcp140_1.dll", + "vcruntime140.dll", + "vcruntime140_1.dll", +]; if (process.platform !== "win32") { - console.log("Skipping OpenMP runtime staging: Windows-only."); + console.log("Skipping Visual C++ runtime staging: Windows-only."); process.exit(0); } @@ -65,8 +86,11 @@ function searchRoots() { ]; } +/** Candidate paths per DLL name, from ONE walk — the trees are large enough that + * walking them once per name would be the slowest part of the build. */ function findRedistCopies() { - const found = []; + const wanted = new Set(DLLS); + const found = new Map(DLLS.map((name) => [name, []])); const walk = (dir, depth) => { if (depth > 8) return; let entries; @@ -77,16 +101,13 @@ function findRedistCopies() { } for (const entry of entries) { const full = path.join(dir, entry.name); + const lower = entry.name.toLowerCase(); if (entry.isDirectory()) { walk(full, depth + 1); - } else if ( - entry.name.toLowerCase() === DLL && - /\\Redist\\/i.test(full) && - /\\x64\\/i.test(full) - ) { + } else if (wanted.has(lower) && /\\Redist\\/i.test(full) && /\\x64\\/i.test(full)) { // `onecore\x64` is a trimmed variant for Windows Core headless SKUs; the // desktop app wants the ordinary one. - if (!/\\onecore\\/i.test(full)) found.push(full); + if (!/\\onecore\\/i.test(full)) found.get(lower).push(full); } } }; @@ -94,35 +115,42 @@ function findRedistCopies() { return found; } -const candidates = findRedistCopies(); -if (candidates.length === 0) { - throw new Error( - `Could not find a redistributable ${DLL} under any Visual Studio installation.\n\n` + - "It lives in VC\\Redist\\MSVC\\\\x64\\Microsoft.VC.OpenMP\\.\n" + - "Install the Visual Studio C++ workload, which is required to build the native\n" + - "helpers anyway. Without this file the shipped whisper/ggml libraries cannot load\n" + - "on a machine that has no Visual C++ Redistributable, and transcription fails there\n" + - "with no usable error.", - ); -} - // Newest by file version, so a machine carrying several toolsets stages the latest. const versionOf = (file) => { const match = file.match(/MSVC\\(\d+(?:\.\d+)*)\\/i); return match ? match[1].split(".").map(Number) : [0]; }; -candidates.sort((a, b) => { +const newestFirst = (a, b) => { const [x, y] = [versionOf(a), versionOf(b)]; for (let i = 0; i < Math.max(x.length, y.length); i++) { if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0); } return 0; -}); +}; -const source = candidates[0]; -fs.mkdirSync(DEST_DIR, { recursive: true }); -const dest = path.join(DEST_DIR, DLL); -fs.copyFileSync(source, dest); +const copies = findRedistCopies(); + +// Report every missing name at once. Staging four of five and failing on the fifth +// would send someone back through the same install-and-retry loop per DLL. +const missing = DLLS.filter((name) => copies.get(name).length === 0); +if (missing.length > 0) { + throw new Error( + `Could not find a redistributable ${missing.join(", ")} under any Visual Studio installation.\n\n` + + "They live in VC\\Redist\\MSVC\\\\x64\\ — vcomp140.dll under\n" + + "Microsoft.VC.OpenMP, the rest under Microsoft.VC.CRT.\n" + + "Install the Visual Studio C++ workload, which is required to build the native\n" + + "helpers anyway. Without these files the shipped whisper/ggml libraries and the\n" + + "ONNX Runtime cannot load on a machine that has no Visual C++ Redistributable:\n" + + "transcription fails there with no usable error, and the camera background is\n" + + "silently inert. before-pack refuses to package either way.", + ); +} -console.log(`Staged ${DLL} from ${source}`); -console.log(` -> ${path.relative(ROOT, dest)}`); +fs.mkdirSync(DEST_DIR, { recursive: true }); +for (const name of DLLS) { + const source = copies.get(name).sort(newestFirst)[0]; + const dest = path.join(DEST_DIR, name); + fs.copyFileSync(source, dest); + console.log(`Staged ${name} from ${source}`); + console.log(` -> ${path.relative(ROOT, dest)}`); +} diff --git a/technical-documentation/engineering/build-and-packaging.md b/technical-documentation/engineering/build-and-packaging.md index bbb1d4084..65a437712 100644 --- a/technical-documentation/engineering/build-and-packaging.md +++ b/technical-documentation/engineering/build-and-packaging.md @@ -104,6 +104,12 @@ Two lessons, and the second is the useful one: The remedy here is to ship it: `scripts/stage-vcomp-runtime.mjs` copies `vcomp140.dll` out of the Visual Studio redistributable directory into the payload, and `win.extraResources` carries it like everything else in that folder. Shipping rather than rebuilding whisper with `-DGGML_OPENMP=OFF` is deliberate — the DLL leaves the computation identical, where dropping OpenMP swaps its scheduler for ggml's own and changes transcription throughput by an amount nobody has measured. 200 KB against that unknown is a cheap trade; measure before revisiting it. +#### The CRT proper, via ONNX Runtime + +The camera-background feature vendors `onnxruntime.dll` (`scripts/fetch-onnxruntime.mjs`), an upstream release binary that imports `msvcp140`, `msvcp140_1`, `vcruntime140` and `vcruntime140_1`. It is not ours to rebuild, so `-C target-feature=+crt-static` — the answer for our own Rust addon — does not apply, and the guard's other exit does not either: `WIN_REQUIRED` in the same hook refuses to package *without* `onnxruntime.dll`, because a build missing it degrades cleanly and silently into a camera-background control that does nothing. Requiring the DLL and refusing its imports left the Windows installer unbuildable from the moment the feature landed, and nothing noticed, because `build.yml` only runs on dispatch or a tag and no Windows build had been dispatched since. + +So the same remedy covers both: `stage-vcomp-runtime.mjs` stages the CRT set alongside `vcomp140.dll`, all five from the redistributable directory of the building toolchain, and the guard stops objecting because the imports now ship. **The check that this is complete is the build itself** — `before-pack` reads the real import table of the real payload and refuses on anything still unshipped, which is a stronger statement than any unit test of the staging script could make. + **Local testing cannot confirm this class of fix.** This machine has the redistributable and always will, so a successful run here proves the build is not broken — it says nothing about the clean-machine behaviour. The import table is the only evidence for that half, which is why the guard reads it rather than running anything. Everything the payload needs from outside itself is now either shipped beside it or present on every Windows edition — with one exception worth knowing: `wgc-capture.exe` imports `mf.dll`, `mfplat.dll` and `mfreadwrite.dll`, and **Media Foundation is absent from Windows N editions** unless the user installs the Media Feature Pack. Recording would fail there with the same `0xC0000135` as above. Untested and unhandled; N editions are sold in Europe. diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 02a3b542c..6bb70ee30 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -4,7 +4,9 @@ This checklist covers the real desktop capture-to-export path: the parts that un **"Manual" is about the input, not the operator.** These checks need real OS mouse and keyboard events, not a human hand — so an agent with the computer-use MCP runs them, on demand, and a request for one section after a targeted change is as much a run as the whole file before a promote. Availability check and the rule on partial runs: [AGENTS.md](../../AGENTS.md#desktop-e2e-testing-with-computer-use). -Sections marked **v1.8.0** cover what this release changed: chat-driven editing through the agent tool set, clip-anchored modifiers, local transcription, the macOS Metal compositor, and the new effect controls. Run the whole file for a release candidate; the v1.8.0 sections are the ones with no prior release to fall back on. +Sections marked **v1.8.0** cover what that release changed: chat-driven editing through the agent tool set, clip-anchored modifiers, local transcription, the macOS Metal compositor, and the new effect controls. + +Sections marked **post-1.10.0** cover what has landed on `main` since the v1.10.0 tag: the AI camera background, the caption anchor model, pixel-resolution crop, editor window bounds, update settings, and the Windows recording encoder and AAC changes. Run the whole file for a release candidate; the marked sections are the ones with no prior release to fall back on. ## How to run this @@ -15,6 +17,8 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing 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. + + **A junctioned `node_modules` is only as current as the checkout it points at.** Diff the two `package.json` dependency sets before trusting it: a dependency added after that checkout's HEAD is simply absent, and the failure does not name it — `vite` logs one `Rollup failed to resolve import` line among the build noise, the main process starts anyway, and the IPC handlers in the module that failed to bundle are never registered. Symptom seen: `No handler registered for 'get-app-info'` and no visible window, from a missing `electron-updater`. Installing the one missing package into the shared tree reconciles that tree against the *older* lockfile, so re-check the packages you need afterwards rather than assuming the install was additive. 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 — 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: @@ -30,6 +34,8 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing 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. 8. Keep the first real recording or imported project available for the editor sections. Log crashes, hangs, data loss, security issues, and reproducible visual failures as soon as they occur. 9. Several v1.8.0 sections need a configured AI provider (chat editing, caption translation) or a built native compositor addon (preview, export). A dev build from a worktree needs the compositor addon installed for its platform, not only the capture binaries. When a prerequisite is missing, record the section as skipped with the reason; do not mark it passed. + + The **AI camera background** needs two more things that nothing else does: the ONNX Runtime shared library staged beside the addon (`npm run fetch:onnxruntime`, which a plain `npm run dev` does not run) and a recording that actually has a webcam track. Without the library the control is *correctly* absent — so an absent control is only a defect once you have confirmed the library is there. Check both directions before writing a verdict. 10. Prefer a project with at least two clips from the same asset for the modifier sections. A single-clip project cannot exercise anchoring, reorder, or cross-boundary splitting at all, which is where the v1.8.0 timeline model changed. ## Launch and HUD @@ -153,6 +159,18 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing - [ ] Select an annotation and delete it from its inspector; confirm it disappears from the preview and lane. - [ ] Use undo and redo after adding, editing, and deleting at least one region and confirm each operation restores the prior state. +### Modifiers under a trim — post-1.10.0 + +A trim is skipped during playback but the playhead can still be parked on it. Both halves of this are preview-only — the render still cuts. + +- [ ] Park the playhead inside a trim and confirm the preview shows **the frame that is actually at that time**, not the first frame of the next kept segment. +- [ ] Draw a zoom that lies **entirely** inside a trim, park the playhead on it, and confirm the zoom fires in the preview. +- [ ] Repeat with an annotation and with a Full Camera segment entirely under a trim. +- [ ] Confirm a region that merely **overlaps** a trim without being contained by it behaves as an ordinary region — this is not the same case, and testing it instead is the easy way to record a false failure. Check the saved spans rather than the pills, which look identical at normal zoom. +- [ ] Confirm a zoom under a trim plays dry: full strength on its own span, with no ease-in reaching the kept frames beside the cut. +- [ ] Confirm speed regions under a trim are **not** emitted — a still frame has no rate to show. +- [ ] Export the range and confirm the trimmed span is absent from the output: the modifiers showing in the preview must not resurrect the cut frames. + ## Modifiers are anchored to clips — v1.8.0 Zoom, speed, annotation, and full-camera regions are stored against a clip in that clip's own source time, not at an absolute ruler position. See [timeline-model.md](../architecture/timeline-model.md). These checks exist because the failure mode is silent: the pill stays where it was drawn while the effect fires somewhere else. @@ -211,6 +229,21 @@ Zoom, speed, annotation, and full-camera regions are stored against a clip in th - [ ] Play across a zoom region with captions on and confirm the captions stay in the frame instead of scaling and drifting with the zoom. - [ ] Export that range and confirm the exported frames show the same caption placement as the preview. +### Caption placement — post-1.10.0 + +Captions are placed by an **anchor and a margin**, not by an invisible band: `anchorV` (top/bottom) with `insetY`, and `anchorH` (left/center/right) with `insetX`. The margin is reserved on the anchored side and applies to the plate, not the text, so it is measurable in an exported frame. Projects from before this change migrate their `insetX`. + +- [ ] Open the Captions facet, enable captions, and seek to a moment with speech; confirm a caption renders with its plate. +- [ ] Confirm the Position row offers **Bottom / Top** and a separate **Left / Center / Right** row, and that the hint text names which edge stays put. +- [ ] Choose Top and confirm the caption moves to the top of the frame, the hint changes to say long captions grow downward, and the slider relabels to "Distance from top". +- [ ] Choose Left and confirm the caption band moves to the left edge with its margin, and that the label reads "Distance from left". +- [ ] Drag the distance slider to each extreme and confirm the caption reaches the true frame edge rather than stopping short at an invisible band boundary. +- [ ] Drag the slider away from a preset's value and confirm the preset button stops being highlighted; click the preset again and confirm the slider snaps back. +- [ ] **Export a frame and measure the plate's edge against the inset**: with `insetX: 10` on a 1920-wide output, the plate's left edge is at x=192. Measure the exported frame, not the preview screenshot. +- [ ] Confirm the plate's margin is reserved on the *anchored* side — a right-anchored caption keeps its margin on the right as the text grows. +- [ ] Open a project saved before this change and confirm its captions land where they did, with `insetX` migrated rather than reset. +- [ ] Play across a zoom with captions on and confirm the captions do not scale or drift with the zoom, in the preview and in the export. + ## AI chat and providers — requires a configured provider - [ ] Open the chat panel with the top-bar control identified by its `aria-label` and confirm the chat surface appears. @@ -304,6 +337,19 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a - [ ] Compare that exported result with the preview for timing, skipped intervals, audio, webcam, captions, and effects. - [ ] For every pixel-level comparison, export a frame and measure it with an image tool rather than relying on a preview screenshot. +### Export progress and speed-region audio — post-1.10.0 + +The percentage is computed in the renderer against a predicted frame total, and speed regions change that total. The audio stretch now runs through libavfilter `atempo` rather than WSOLA; the chain is capped at eight stages and its priming loss is compensated, so the stretched span must land on its target without a silence hole. + +- [ ] Export a project containing a speed region and confirm the progress bar **reaches 100%** rather than stalling at a fraction (a 1.25× region used to peg it at exactly 80%). +- [ ] Confirm the bar keeps moving during the audio phase instead of freezing once frame rendering ends. +- [ ] Confirm the reported frame total accounts for the speed region — a region at N× emits its span's frames divided by N. +- [ ] Probe the finished file and confirm the **audio and video durations agree to within one frame**; a stretch that fell short would show as an audio track measurably shorter than the video. Record the export's frame rate alongside the two durations — "within one frame" is a claim about `1/fps`, and without the rate written down it cannot be checked (at 30 fps the tolerance is 33 ms; at 60 fps it is half that, and the same numbers would fail). +- [ ] **Record something that is deliberately noisy across each speed boundary** — count out loud, or play a continuous tone — before running `silencedetect` over the exported audio. A screen recording with only ambient mic is mostly silence, so the detector reports silence at the boundary whether or not `atempo` left a hole, and the check passes or fails for reasons that have nothing to do with the stretch. +- [ ] With that audio, confirm no silence block coincides with a speed region's boundary. If one does, run the same `silencedetect` over the **source** and map the boundary back through the trims before recording a failure: silence that was already there is not a defect the stretch introduced. +- [ ] Confirm the exported duration matches the source minus the trimmed spans, adjusted for each speed region. Compute it in **frames**, not seconds, and state the arithmetic: source frames − trimmed frames − (speed span × (1 − 1/N)) at the export's rate. A discrepancy of a handful of frames is not "close enough" — it is either explained or it is an open question, and the results log has to say which. +- [ ] Cancel an export mid-render and confirm no audio decode threads outlive the attempt. + ## Settings, shortcuts, themes, i18n - [ ] Change one shortcut, save it, use the new key in the editor, and confirm it triggers the configured action. @@ -336,6 +382,13 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a - [ ] Repeat that in Media mode, in Rec mode, and in Edit mode with the chat panel collapsed — the three states in which the dialog had no owner before, and the reason the row must not be Edit-only. - [ ] Connect or disconnect a provider from the menu's dialog while the chat panel is open behind it, close the dialog, and confirm the composer and the model pill follow without reopening the panel. - [ ] Open the wordmark menu, then press Escape, click elsewhere in the top bar, and click the wordmark again — confirm each closes it and that the window does not start dragging instead of registering the click. +- [ ] **post-1.10.0** — In a **packaged** build on a channel that owns its updates, right-click the tray icon and confirm an **Update Settings** submenu offers "Notify when an update is available", "Download updates automatically", and "Download and install updates automatically". +- [ ] **post-1.10.0** — Pick a mode, restart, and confirm it is still selected; `update-settings.json` in `userData` carries it. +- [ ] **post-1.10.0** — Confirm the submenu is absent in a dev build and in a build on a channel that does not own its updates (`app.isPackaged && ownsItsUpdates`), rather than present and inert. +- [ ] **post-1.10.0** — Confirm a background check that finds nothing shows no dialog at all — the background path never reports "you are current". +- [ ] **post-1.10.0** — Confirm a failed or unavailable download stops short of the restart prompt rather than offering to restart into an installer that was never fetched. +- [ ] **post-1.10.0** — Confirm no mode installs on quit: closing the HUD must not fire the installer. +- [ ] **post-1.10.0** — Reach **Save Diagnostics** from the tray context menu while idle, and from the Help menu on Windows and Linux (Alt) or the app menu on macOS; confirm each writes a bundle. It is deliberately not in the wordmark menu. - [ ] With the wordmark menu open, walk it with the Down and Up arrows and confirm focus wraps at both ends. - [ ] Switch the app language and confirm the wordmark menu's four labels follow — the first two matching the dialogs they open, the last two the wording the macOS app menu and the tray use. - [ ] Open About and confirm it names the running version, the Electron/Chromium/Node versions, and the install channel. @@ -372,6 +425,44 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a - [ ] Open the crop dialog, change the ratio with aspect lock on and off, apply, and confirm the preview reframes. - [ ] Confirm a cropped project exports with the cropped framing rather than the original. +### AI camera background — post-1.10.0 + +The mask comes from the native compositor (ONNX Runtime + the vendored selfie-segmentation model), not from the renderer. Three things have to line up — the addon, the ONNX Runtime shared library beside it in `electron/native/bin//`, and the model under `public/mediapipe/` — and `probeSegmentation` answers with `ready` only when all three do. Check both directions: a control that silently does nothing is the defect this replaced. + +- [ ] With a webcam recording loaded, open the Layout facet and confirm a **Camera Background** row offers Original, Cutout, Blur and Custom. +- [ ] Choose Cutout and confirm the camera's background disappears in the preview, leaving the subject over the project background. +- [ ] Choose Blur and confirm the background blurs while the subject stays sharp, and that a blur-intensity slider appears. +- [ ] Choose Custom and confirm an image/color/gradient chooser appears and the selected wallpaper replaces the camera's background. +- [ ] Confirm the first frames after switching modes may render unsegmented — the worker starts lazily. Scrub or let the preview advance before judging; a still, paused preview is not evidence the effect is inert. +- [ ] Export a range with a camera background set and confirm the exported frames carry the same mask as the preview, not the untouched camera. +- [ ] **Remove the ONNX Runtime library from `electron/native/bin//`, restart, and confirm the whole Camera Background row is absent** rather than present and inert. The persisted mode stays in the document and the camera renders unsegmented; that is correct. +- [ ] Put the library back, restart, and confirm the row returns without any other change. +- [ ] On an Intel Mac, confirm the row is absent: upstream publishes no ONNX Runtime for osx-x64, so the probe can never answer `ready` there. + +## Editor shell and dialogs — post-1.10.0 + +- [ ] Double-click a clip and confirm the Edit Clip preview box carries the **source's own aspect ratio**, not a fixed 16:9 box — a portrait source must fill it rather than letterbox. +- [ ] Focus a crop W or H field and press the down arrow once; confirm the value moves by **one source pixel**, not by one percent (on a 1920-wide source, 100 → 99.9479). +- [ ] Type a partial value into a crop field and confirm it stays editable mid-entry instead of being rounded or reset under the caret. +- [ ] Change the ratio with aspect lock on and off, apply, and confirm the preview reframes and the clip's stale crop metadata does not survive. +- [ ] Hold Ctrl and scroll **over the ruler, over the hint labels, and over the navigator bar** — not only over the lanes — and confirm the timeline zooms in each case. +- [ ] Hold Shift and scroll over those same three places and confirm the visible range pans. +- [ ] Open the clip picker, click outside it, and confirm it closes. +- [ ] Confirm the timeline clip's delete icon carries the same dark chip treatment as its filename label rather than sitting bare on the waveform. +- [ ] Switch to the light theme and confirm the gradient picker and the dialogs follow it instead of staying dark. +- [ ] Confirm the top bar carries no second settings button beside the wordmark menu. +- [ ] Start a transcription and confirm the spinner says **initializing** before it says **transcribing**, on every spinner that shows one. +- [ ] Regenerate a transcript and confirm the busy label stays visible for the duration and is scoped to the timeline rather than leaking to unrelated surfaces. +- [ ] Open the media asset card's **Regenerate as** picker and confirm it lists every whisper language (101 entries including Auto), sorted by localized name — not a hand-picked handful. +- [ ] Choose a language, regenerate, and confirm the new transcript replaces the old one. + +### Editor window bounds — post-1.10.0 + +- [ ] Move and resize the editor window, close it, reopen it, and confirm it returns at the same position and size. +- [ ] Maximize the editor, close and reopen, and confirm it returns maximized. +- [ ] Un-maximize, close, and confirm `editor-window.json` in `userData` records `maximized: false` with the restored bounds. +- [ ] **Hand-edit that file to a zero width and height and a non-boolean `maximized`, then relaunch**; confirm the editor opens at its default size rather than restoring an unusable window. + ## Persistence (save, reopen, reload) - [ ] Make a project change and confirm the top bar shows an unsaved indicator. @@ -403,6 +494,11 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a - [ ] Switch the recording HUD between displays and confirm it remains positioned on the intended display. - [ ] Switch the desktop to an odd-pixel window size and confirm the recorded frame dimensions remain valid. - [ ] Open Settings diagnostics when available and confirm a diagnostic bundle can be written. +- [ ] **post-1.10.0** — Record with no encoder override and confirm the helper's `encoder-selection` log line reports `videoEncoderRuntime: "hardware"`. The plain sink-writer path never asked for hardware transforms before, so every ordinary recording ran the software encoder; on a slow machine that is what blew the stop-shutdown budget. +- [ ] **post-1.10.0** — Confirm forcing the software encoder still reports `"software"`, so the default is a default and not a hard-wire. +- [ ] **post-1.10.0** — Record with microphone and system audio and confirm the resulting MP4 carries a valid AAC track at a legal rate (48 kHz). +- [ ] **post-1.10.0** — On a device whose native rate AAC cannot take (96 kHz), confirm the recording still succeeds with the rate snapped to 48 kHz rather than failing at `SetInputMediaType`. The helper's own `audio_sample_utils_test` covers the accept/reject probes at build time; this check is the end-to-end half. +- [ ] **post-1.10.0** — Confirm a long recording's audio stays in sync, so the downsample remainder is carried across packets rather than drifting. ### macOS @@ -452,5 +548,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 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"). | | 2026-08-22 | installed `v1.10.0-rc.3` — CI-built NSIS artifact from build run 32582966489 (`openscreen-windows`), App menu → About reports `1.10.0-rc.3`, native payload complete and uniformly stamped (19 files in `resources/electron/native/bin/win32-x64`, all `17:59:10`, so helper + compositor addon + av\* DLLs are one matched CI set) | Windows 11 26200, 1920×1080 @ 100% | **Pass — 2 minor defects** | **Pause works, and the measurement that says so is the wall clock.** `createdAt` 20:25:52.208 against a file finalised at 20:30:56.754 is 304.55 s elapsed for a **286.333 s** file — **18.21 s shorter, exactly the paused interval**, so capture was genuinely suspended. The HUD timer froze at `03:58` across two reads 7 s apart with the indicator amber, and resume was clean (`04:01` → `04:08` over 7 s, no time lost). An earlier draft of this row called this a blocking defect, on the strength of comparing the file duration against a timer read *before* the stop click; with tool round-trips of ~20 s that comparison is worthless, and the packet count offered as corroboration proves nothing either — a file is continuous 60 fps whether or not capture was ever suspended. Written down because the wrong version of this measurement is easy to repeat: compare against wall-clock elapsed, never against the last timer you happened to screenshot. **Capture is otherwise sound, on two takes.** 15.8 s: fragmented (`ftyp uuid pdin moov` then 16 `moof`/`mdat`, `mvex` present), `mfra` on the clean stop, 1920×1080 @ 60/1, 948 packets = 15.8 × 60, `ffmpeg -v error -f null -` exit 0, both sidecars written. 286.3 s: 287 `moof`, `mfra` present, 17,180 packets, decodes clean, `.cursor.json` 1.3 MB. No pacing drift and no dropped frames over 4 min 46. **Export passes and honours its settings**: 720p/30 requested from a 1080p60 source gave 1280×720, `avg_frame_rate` 85900/2863 = 30.004, 8590 packets matching the frame count the progress UI itself reported, duration 286.333 s identical to source, decodes clean, 124.5 MB, written to the path chosen in the native save dialog and reported back as "Saved to …". Composition verified by extracting a frame and reading it at full resolution (not from a preview screenshot): gradient background, content inset as a rounded card with a drop shadow, content aspect ≈1.76 against the 16:9 target, synthetic cursor drawn. Note the exporter adds a silent **AAC 48 kHz stereo** track even though no audio source was enabled. **Retracted: "the HUD language menu ignores `Escape`".** It does not — the maintainer confirms the key works by hand. **Claude Desktop swallows `Escape` before it reaches the app under test**, so a synthesised press proves nothing about the app, and `GetForegroundWindow()` returning the HUD does not rescue the inference: the key never left the driver. The companion observation (an outside click on the HUD's own drag handle did not dismiss the menu) is withdrawn with it, since the HUD's own chrome is not "outside" the popover in any meaningful sense. What *is* established is that the blur path shipped in this RC works: `54e12706 fix(hud): dismiss the HUD popovers when the window loses focus` dismissed the menu on a click to the desktop. **Rule for anyone driving keyboard checks from computer-use: `Escape` is unusable as evidence, and any negative keyboard result needs a by-hand confirmation before it goes in this table.** **Behaviour vs doc**: the record button is not disabled without a source — it opens the source selector. No recording starts, so the check's intent holds, but AGENTS.md still describes a disabled button with a "Please select a source to record" tooltip, and that is why no tooltip appears. **Passed**: single launch window, no startup crash; HUD visible under `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; tray layout toggles horizontal↔vertical both ways; HUD drag follows the pointer without drift and stays at the drop point; language menu opens with its locale list; minimize hides the HUD without quitting (6 processes still alive); relaunching routes through the single-instance lock, restores the window and mints no duplicate; source selector opens, selecting a card enables Share, and the HUD label becomes the picked source (`Tout l'écran`); record → stop opens the editor with the asset, a timeline clip and a rendered preview; About reports the RC version. **Local transcription works, on GPU** — an earlier draft of this row reported it broken, which was wrong. Relaunching with stdout/stderr captured and importing a 15 s asset that carries an audio track settles it: `[whisper-stt] boot: model=…\whisper-ggml\ggml-small-q8_0.bin host=127.0.0.1 port=64720 threads=16`, `ggml_vulkan: 0 = NVIDIA GeForce RTX 4070 Ti`, `model loaded; backend=whispercpp-vulkan`, then `[stt] done on whispercpp-vulkan: 1 chunk(s), 15.0s audio in 0.1s (0.01 rtf, 106.8x real-time)`. The pane switched to "1 caption lines, derived live from the transcript". **The real (minor) defect is the error message**: on an asset with *no audio track* the captions pane says **"Failed to fetch"**, which reads as a network failure and sent this run hunting a broken STT server that was never involved — the pipeline simply has no audio to extract. It should say so. **Second minor find, from the same stderr**: `listProjects` cannot read three saved projects — one `ZodError` (`transcript.segments[0].endSec must be greater than or equal to startSec`, repeated across `segments`, `words` and `transcripts[0]`) and two `SyntaxError: Unexpected non-whitespace character after JSON`, i.e. truncated or double-written project files. They are skipped silently in the UI. **Caption anchoring — the rc.2→rc.3 delta — is present but its rendering was not measured.** The Position section carries exactly the model those commits describe: `Bottom`/`Top`, the note "Long captions grow upward — the bottom edge stays put", `Distance from bottom` defaulting to **1.5 %**, and Left/Center/Right. What could not be checked is where a caption actually lands, because the only transcript obtainable here came from a 300 Hz sine and yielded one line that never surfaced at any scrubbed position. **Closed out of band: the maintainer ran the caption sections by hand on a real spoken-audio recording and reports them correct**, which is the coverage this automated run could not supply and the last gap standing between this RC and a promote. Also confirmed from stderr: `[content-protection] OFF for the HUD window (OPENSCREEN_DISABLE_CONTENT_PROTECTION=1)`, so the flag does log its effect, and with the flag unset the HUD is correctly invisible to screenshots. **The consequence matters more than the cause: the eight caption anchoring/margin/inset cherry-picks that are the entire delta from rc.2 to rc.3 are NOT covered by this run.** **Not run**: restart and cancel actions; audio capture of any kind; webcam PiP; GIF; DPI scaling; HUD/notes exclusion from captured video with content protection ON (the whole session ran with it off, and the exported frame confirms the HUD *is* captured when it is off); regions, modifiers, timeline navigation, clip operations, persistence; macOS and Linux. **Environment limits that shaped this run, worth knowing before the next one.** `parsecd.exe` runs **elevated** and holds an invisible always-foreground window (`ParsecMinFrameRate16`); the moment OpenScreen loses focus every computer-use click is refused, and because the process is elevated UIPI makes granting Parsec useless — **tray-icon refocus could therefore not be tested at all**. Relaunching the app (single-instance raises it) is the way back. Dragging the HUD only works while every intermediate pointer position stays inside the HUD's own 904×698 mostly-transparent window; as soon as one lands on the desktop, the tier-"click" shell gate refuses the drag mid-gesture and leaves the button down — release it explicitly. Finally, the Microsoft Store package (`EtienneLescot.OpenScreen`, 1.9.6) **shadows the NSIS install in `request_access`**: every grant resolved to the Store bundle and the RC window stayed masked in screenshots while reporting success, until the Store package was removed. Screenshots do **not** interrupt a recording — that hypothesis was raised and disproved by running a 90 s capture with none taken and then taking one mid-capture with the helper surviving. | | 2026-08-23 | installed `v1.10.0-rc.3` (Developer ID, unmodified) run with `OPENSCREEN_SCK_CAPTURE_EXE` pointed at a helper built from this branch | macOS 26.6.2 (25G83), M1, 1728×1117 @ 2× | Pass — fixes a blocker | **Window capture section only.** Before: selecting any window in the source picker kills the helper the instant `start()` builds its filter — `Assertion failed: (did_initialize), function CGS_REQUIRE_INIT, file CGInitialization.c, line 44`, SIGABRT, `-[SCContentFilter initWithDesktopIndependentWindow:]` → `SLSGetDisplaysWithRect`. 6/6 attempts on the shipped rc.3, no file, no error surfaced in the UI (the HUD returns to idle as if nothing happened). Display capture is unaffected and always worked, which is why this went unnoticed: the two paths diverge at `makeCaptureTarget`, and only the window branch resolves a rect through SkyLight. After: record → 25.2s → stop → **editor opened on the take**, `recording-1787475175449.mp4` 12,559,123 bytes / 25.18s / 2674×1684, the MP4 and both sidecars written (`.cursor.json`, `.session.json`), one project minted, zero crash reports. Helper-level A/B on an identical request JSON isolates the change: shipped signed helper → assertion, no file; this branch's helper → `recording-started`/`recording-stopped`, 4.49s / 1336×840 decodable MP4. NOT covered: webcam PiP, microphone, system audio (all off for these runs), export, GIF, AI/transcript sections, Windows, Linux. Not covered by unit tests either — `Package.swift` scopes the Swift test target to what runs without a screen, a display server or a TCC grant, and this crash needs all three. | +| 2026-09-03 | dev build, worktree `github-issue-385-38d731` @ `437e4bd2` (main). Natives rebuilt from this tree: `wgc-capture.exe` and `compositor_view.node` both dated 2026-09-03, verified by string probe (`[segmentation]` HIT / control `OPENSCREEN_EXPORT_ENCODER` HIT; helper control `encoder-selection` HIT). ONNX Runtime 1.27.1 staged. | Windows 11 26200, 1920×1080 @ 100% | Partial — no defect, one validation unresolved | Post-1.10.0 slice. **Passed:** hardware H.264 is the Windows default (`videoEncoderRuntime: "hardware"` on the plain path); recording AAC valid at 48 kHz; camera background Original/Cutout/Blur/Custom all render in preview **and** in the exported frames; the control is correctly hidden when `onnxruntime.dll` is removed and returns when it is restored; caption anchor presets Top/Left move the band and relabel their sliders, and the exported plate's left edge measured x=192/1920 = 10.00% against `insetX: 10`; crop field down-arrow steps one source pixel (100 → 99.9479) and the preview box carries the source aspect; a zoom lying entirely inside a trim fires in the preview with the playhead parked on it (zoom 19.956–22.927 inside trim 19.931–23.596, checked in the saved project, not by eye); export progress reached 100% with a 1.5× speed region present and reported a real frame total; exported audio and video durations agree within one frame at the export's **30 fps** — 41.200 s audio vs 41.167 s video, a 33 ms gap that is exactly 1/30; `editor-window.json` persists bounds and a hand-planted zero-size/non-boolean state is rejected on relaunch; Regenerate-as lists all 101 whisper language entries; regions and settings survive a restart. **Skipped:** Ctrl/Shift+scroll zoom and pan — the computer-use `scroll` action does not carry a modifier, so the fix that moved the wheel listener to the whole pane could not be exercised either over the ruler or over the lanes; tray context menu (Save Diagnostics, Update Settings) — the desktop shell is granted at tier `click`, which blocks right-click; Update Settings in general — dev build, `app.isPackaged` is false so the submenu is correctly absent; the illegal-AAC-rate snap end-to-end — no 96 kHz device on this machine, covered instead by the helper's own `audio_sample_utils_test` MF probes, which passed at build time. **Note, not a defect:** Escape does not reach the app through this driver (it failed to close the Edit Clip dialog too), so any Escape-based check here is untestable rather than failing — same conclusion as the rc.3 retraction. **Unresolved — do not read this row as clearing it:** the exported duration ran 0.48 s (≈13 frames at 30 fps) under the trim/speed arithmetic — 1236 frames against a predicted 1249, from source 46.300 s minus a 3.665 s trim minus 0.990 s saved by a 2.971 s span at 1.5×. Ruled out: the source being shorter than its container claims (`-count_frames` gives exactly 2778 = 46.300 s × 60 fps). Not reproduced under controlled conditions, and not attributable to any post-1.10.0 change — the trim/speed length arithmetic predates them — so it is logged as an open question rather than a defect against this slice. The silence check in the same run is also weaker than it looks: that recording was ambient-mic screen capture, i.e. mostly silence, so it could not have distinguished an `atempo` hole from the source's own quiet. Both are why the two checks above now demand controlled audio and frame-level arithmetic. | | | | | | | | | | | | |