Skip to content

fix(export): project native cursor onto cropped rect, not mask rect#78

Merged
EtienneLescot merged 4 commits into
mainfrom
opencode/swift-mountain
Jul 7, 2026
Merged

fix(export): project native cursor onto cropped rect, not mask rect#78
EtienneLescot merged 4 commits into
mainfrom
opencode/swift-mountain

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

After cropping the recorded video, the exported cursor drifted from where it should sit. The export pipeline was projecting the recorded cursor sample onto the screen mask rect (compositeLayout.screenRect) instead of the rectangle the cropped video is actually painted on. In cover layouts where the cropped video letterboxes inside screenRect (coverOffset != 0), the cursor's offset was proportional to the letterbox margin — visible immediately after applying a crop.

This adds a croppedRect field to FrameRenderer's LayoutCache (computed from the existing coverOffsetX/Y and croppedDisplayWidth/Height in updateLayout) and passes it to projectNativeCursorToLocal so the cursor lands on the same pixels the cropped video is drawn on. maskRect is still used for cursorClipToBounds viewport clipping — that behavior is unchanged.

Adds a projectNativeCursorToLocal regression test suite covering identity, cropped-region mapping, the cover-letterbox drift case, out-of-region culling, and a degenerate crop.

Follow-up: Windows cursor-position DPI normalization

While validating this fix end-to-end on Windows, a pre-existing latent bug in the native cursor pipeline surfaced (it had been there since 1.5.0 but was invisible before the native helpers were built — browser-capture fallback handled DPI internally). The Windows cursor-sampler (Win32 GetCursorInfo) reports raw x/y in physical screen pixels, but the Electron session was normalizing against bounds from Electron's screen API, which are in logical pixels (DIP). On a 100% DPI display these coincide; on any high-DPI display the normalized cursor position is wrong by the scale factor, and the error compounds through projectNativeCursorToLocal in the export, producing a visible cursor offset proportional to the DPI ratio.

Fix in windowsNativeRecordingSession.ts: convert the logical bounds to physical screen coordinates via screen.dipToScreenRect(null, bounds), which correctly handles the virtual-screen origin across multi-monitor and mixed-DPI setups. A naive bounds.x * scaleFactor (the first attempt) was wrong for non-primary displays — a secondary 200% DPI monitor to the right of a 100% primary has DIP bounds {x:1920, y:0, w:1920, h:1080} but physical bounds {x:1920, y:0, w:3840, h:2160}, so multiplying the origin by 2 would have pushed it to x=3840. payload.bounds (from the sampler's GetWindowRect, used for window captures) is already physical and is left as-is.

The macOS path (macNativeCursorRecordingSession.ts) is unaffected: screen.getCursorScreenPoint() and screen.getDisplayNearestPoint().bounds both return Cocoa points, the macOS helper doesn't report raw bounds in its sample events, and the [0,1] normalization is scale-invariant between points and the video's pixels.

Related issue

Fixes #64

Type of change

  • Bug fix

Release impact

  • Patch

Desktop impact

  • Windows (native cursor position DPI normalization)
  • Not platform-specific (cursor projection onto cropped rect)

Screenshots / video

N/A — export rendering geometry fix.

Testing

  • npx tsc --noEmit clean.
  • npx vitest run --pool=vmThreads src/lib/cursor src/lib/exporter/frameRenderer.test.ts → 40/40 passing (12 pre-existing + 4 new projectNativeCursorToLocal cases + the rest of the cursor/export suites).
  • Biome check clean on the changed files.
  • Manual smoke test on real Windows: recorded a short clip with the native helpers enabled, verified the cursor lands on the correct pixel (e.g. on a 1920×1080 physical / 1536×864 logical @ 125% DPI display, the diagnostic log shows normalizedX/Y matching rawX/Y ÷ 1920/1080 instead of ÷ 1536/864).

Manual smoke test on real macOS is still recommended for native-capture changes per the AGENTS.md guidance, though the DPI fix is Windows-specific and the cursor-projection fix is platform-agnostic. The macOS cursor path was reviewed for the same DPI issue and is unaffected (see PR description).

Summary by CodeRabbit

  • Bug Fixes
    • Improved native cursor positioning over cropped and cover-letterboxed video by projecting pointer samples using the actual on-stage cropped region geometry.
    • Fixed Windows cursor sample normalization to correctly use physical-screen coordinates across mixed-DPI and virtual desktop layouts.
  • Tests
    • Expanded unit tests to validate native cursor point projection for non-cropped, cropped, and cover-letterboxed scenarios, including cases where the crop region is outside or degenerate.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR updates native cursor coordinate normalization in the recording bridge and renderer, adds croppedRect to layout state, routes projection through that rect, and expands tests for cropped and degenerate cursor mapping.

Changes

Native Cursor Crop Projection Fix

Layer / File(s) Summary
Recording sample normalization
electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts
normalizeSample now converts bounds to physical coordinates when needed before computing normalized cursor positions.
LayoutCache croppedRect computation and cursor projection
src/lib/exporter/frameRenderer.ts
LayoutCache gains croppedRect, updateLayout computes it from the composite screen rect and crop offsets, and drawNativeCursor passes croppedRect into projectNativeCursorToLocal.
Projection unit tests
src/lib/cursor/nativeCursor.test.ts
Adds projectNativeCursorToLocal tests for no-crop mapping, cropped mapping, cover-letterboxed projection, and null results for out-of-bounds or degenerate crop regions.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant updateLayout
  participant LayoutCache
  participant drawNativeCursor
  participant projectNativeCursorToLocal
  updateLayout->>LayoutCache: store croppedRect
  drawNativeCursor->>LayoutCache: read croppedRect
  drawNativeCursor->>projectNativeCursorToLocal: project sample with croppedRect
  projectNativeCursorToLocal-->>drawNativeCursor: return local cursor point or null
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately reflects the main change: projecting native cursor onto the cropped rect instead of the mask rect.
Description check ✅ Passed The description follows the template well, covering summary, related issue, change type, release impact, desktop impact, screenshots, and testing.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch opencode/swift-mountain

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
scripts/start-ct2-server.cmd (1)

1-6: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Hardcoded personal paths; same issue as ct2-build.cmd / configure-ct2-build.ps1.

All three paths (model dir, server exe, log file) are pinned to one developer's worktree and will not resolve elsewhere.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/start-ct2-server.cmd` around lines 1 - 6, The start-ct2-server.cmd
script is using hardcoded developer-specific paths for the model directory,
server executable, and log file. Update the batch file to resolve these
locations dynamically or via configurable variables, following the same approach
used in ct2-build.cmd and configure-ct2-build.ps1, and make sure the script
references OPENSCREEN_CT2_MODEL_DIR and the ctranslate2-server path without
embedding a single worktree path.
scripts/stt-wrapper.bat (1)

1-22: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Convert to CRLF line endings.

Static analysis flags LF-only line endings, which can cause GOTO/label parsing failures in some Windows batch parsers. Low-cost fix given the file is small.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/stt-wrapper.bat` around lines 1 - 22, The batch wrapper script uses
LF-only line endings, which can break label and GOTO parsing in Windows batch
execution. Convert the entire stt-wrapper.bat file to CRLF while keeping the
existing logic in the parse and run sections unchanged, so the wrapper remains
compatible with Windows batch parsers.

Source: Linters/SAST tools

🟠 Major comments (24)
docs/architecture/ai-edition-roadmap.md-153-155 (1)

153-155: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Swap keytar for the locked safeStorage choice.

P2.2 still instructs storing secrets in keytar, but the locked decisions explicitly chose Electron safeStorage instead. Leaving both in the roadmap will send implementation toward the wrong secret store.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture/ai-edition-roadmap.md` around lines 153 - 155, Update the
P2.2 OAuth device-flow / PAT auth item to match the locked secret-storage
decision by replacing the `keytar` reference with Electron `safeStorage`. Keep
the rest of the flow anchored around `llm-call.ts` and `ProviderSettings.tsx`,
but revise the storage step so the device-flow access token is saved and
retrieved using `safeStorage` rather than `keytar`, and make sure the roadmap
text consistently points implementers to that choice.
docs/architecture/ai-edition-roadmap.md-36-60 (1)

36-60: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Resolve the persisted file-extension mismatch.
docs/architecture/ai-edition-roadmap.md:36-40,57 defines AxcutDocument v3 as the canonical model and says new projects are written to userData/projects/<id>.axcut, but locked decision 7 says to keep .openscreen. Clarify which extension is the on-disk contract and keep the migration note separate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture/ai-edition-roadmap.md` around lines 36 - 60, The roadmap
currently contradicts itself about the persisted project extension: the
AxcutDocument persistence note says projects are saved as .axcut, while the
locked decisions for the ai-edition contract say to keep .openscreen. Update the
canonical storage description near AxcutDocument and the project persistence
wording to use the same on-disk extension as the locked decision, and keep the
v2-to-v3 migration note separate so the document model and file-format contract
are not mixed.
docs/architecture/ai-edition-collision-analysis.md-11-18 (1)

11-18: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update schema documentation to reflect current v4 baseline.

The schema has already advanced to v4 (see src/lib/ai-edition/schema/index.ts:15), and v3→v4 upgrades are handled transparently inside documentSchema (lines 464–480). The doc's v2/v3 and SCHEMA_VERSION = 3 baseline is stale. Refresh lines 14–18 to accurately reflect that the current contract is v4, clarify that v3 documents auto-upgrade, and align the migration reference with the actual migrateProjectDataToAxcutDocument and schema-upgrade behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture/ai-edition-collision-analysis.md` around lines 11 - 18, The
architecture doc is stale: it still describes a v2/v3 baseline and a shared
SCHEMA_VERSION = 3, but the actual contract is now v4. Update the “Both projects
have version” section to reflect the current v4 schema in
src/lib/ai-edition/schema/index.ts, note that v3 documents are transparently
upgraded by documentSchema, and align the migration description with
migrateProjectDataToAxcutDocument and the schema-upgrade flow rather than the
old v2/v3 wording.
electron/media/mediaLinksRegistry.ts-244-253 (1)

244-253: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fire-and-forget backfill has no rejection handler.

void updateRegistry(...) discards the promise without a .catch(). If the write fails (disk full, permission error, etc.) this becomes an unhandled promise rejection in the Electron main process, unlike the equivalent backfill call in handlers.ts (resolveMediaLinksForVideo) which does attach .catch(...). Add the same handling here for consistency and to avoid crashing/warning the main process on a best-effort operation.

🔧 Proposed fix
 	if (match.lastKnownPath !== videoPath) {
-		void updateRegistry(baseDir, (file) => ({
+		updateRegistry(baseDir, (file) => ({
 			version: 1,
 			entries: file.entries.map((e) =>
 				fingerprintsMatch(e.fingerprint, fingerprint) ? { ...e, lastKnownPath: videoPath } : e,
 			),
-		}));
+		})).catch((error) => console.warn("[media-links] lastKnownPath refresh failed:", error));
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/media/mediaLinksRegistry.ts` around lines 244 - 253, The
fire-and-forget registry backfill in mediaLinksRegistry’s path-refresh block
drops the promise from updateRegistry without handling failures. Update that
updateRegistry call to attach a .catch() rejection handler, matching the pattern
used by resolveMediaLinksForVideo in handlers.ts, so best-effort writes don’t
trigger unhandled rejections in the Electron main process.
electron/media/mediaLinksRegistry.ts-32-36 (1)

32-36: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Registry fingerprints should be hashed, not stored raw electron/media/mediaLinksRegistry.ts:32-36,71-94,148-154

MediaFingerprint stores two 64KB samples as base64, so each entry adds roughly 175KB before the other fields. With no pruning here, the registry grows without bound, and every lookup parses — and every update reserializes — the whole file. Store digests for the samples instead of the raw base64 bytes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/media/mediaLinksRegistry.ts` around lines 32 - 36, The
MediaFingerprint data in mediaLinksRegistry.ts is storing large raw base64
samples, which makes the registry file grow and forces expensive full-file
parse/serialize on every lookup and update. Update MediaFingerprint and the
registry read/write path in the MediaLinksRegistry methods that manage
fingerprints to store hashed digests for the head/tail samples instead of the
raw base64 content. Make sure the lookup/comparison logic still uses the same
identifying fields but compares hashes, and keep the existing sizeBytes-based
matching behavior intact.
scripts/stt-dev-server.mjs-18-23 (1)

18-23: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Default port fallback never triggers — silently produces NaN.

process.argv[process.argv.indexOf("--port") + 1] evaluates to process.argv[0] (the node executable path) when --port is absent, since indexOf returns -1 and -1 + 1 = 0. That value is always a truthy string, so the ?? "20199" fallback never runs, and parseInt(nodePath, 10) yields NaN, breaking server.listen(NaN, ...) for anyone running the script without --port.

🐛 Proposed fix
-const PORT = parseInt(
-	process.argv.find((a) => a.startsWith("--port="))?.split("=")[1] ??
-		process.argv[process.argv.indexOf("--port") + 1] ??
-		"20199",
-	10,
-);
+const portFlagIndex = process.argv.indexOf("--port");
+const portArg =
+	process.argv.find((a) => a.startsWith("--port="))?.split("=")[1] ??
+	(portFlagIndex !== -1 ? process.argv[portFlagIndex + 1] : undefined) ??
+	"20199";
+const PORT = parseInt(portArg, 10);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/stt-dev-server.mjs` around lines 18 - 23, The PORT parsing logic in
stt-dev-server.mjs is incorrectly falling through to process.argv[0] when
"--port" is missing, so the default never applies and parseInt can return NaN.
Update the argument lookup around the PORT constant to safely detect whether
"--port" exists before reading the next element, and only then fall back to
"20199" when neither "--port=" nor a valid "--port" value is present. Keep the
fix localized to the PORT initialization so server.listen receives a real
numeric port.
scripts/e2e-stt-smoke.mjs-27-36 (1)

27-36: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Binary path hardcodes win32-x64 regardless of process.platform.

The extension is chosen based on process.platform === "win32", implying intent to run cross-platform, but the directory component is hardcoded to "win32-x64". On macOS/Linux this will look for .../win32-x64/ctranslate2-server-ctranslate2-cpu, which won't exist (per the build script's <os>-<arch> layout), causing spawn to fail with ENOENT.

🐛 Proposed fix
+const PLATFORM_DIR =
+	process.platform === "win32"
+		? "win32-x64"
+		: process.platform === "darwin"
+			? `darwin-${process.arch}`
+			: `${process.platform}-${process.arch}`;
 const CT2_BIN = join(
 	ROOT,
 	"electron",
 	"native",
 	"bin",
-	"win32-x64",
+	PLATFORM_DIR,
 	process.platform === "win32"
 		? "ctranslate2-server-ctranslate2-cpu.exe"
 		: "ctranslate2-server-ctranslate2-cpu",
 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/e2e-stt-smoke.mjs` around lines 27 - 36, The CT2_BIN path is
hardcoded to the win32-x64 directory, which breaks cross-platform smoke runs.
Update the CT2_BIN construction in the e2e-stt-smoke script to derive the
platform/arch directory from process.platform and process.arch, matching the
build layout used by the native binaries, while keeping the existing executable
name selection logic. This will ensure the spawn target resolves correctly on
macOS and Linux instead of pointing at a non-existent win32-x64 path.
scripts/e2e-pipeline-smoke.mjs-1-31 (1)

1-31: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Switch this smoke test to ctranslate2-server
This still shells out to electron/native/bin/win32-x64/whisper-server-whisper-cpu.exe and a ggml-small-q5_1.bin model, but the build workflow now stages only ctranslate2-server-* binaries. As written, this will fail on fresh builds; if it’s meant to cover the same path as scripts/e2e-stt-smoke.mjs, fold it into that flow instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/e2e-pipeline-smoke.mjs` around lines 1 - 31, The smoke test still
targets the old whisper-server binary path, but the build now stages only
ctranslate2-server artifacts, so this script will fail on fresh builds. Update
the e2e-pipeline-smoke.mjs flow to use the same ctranslate2-server setup as
scripts/e2e-stt-smoke.mjs, including swapping the WHISPER_BIN/old model
assumptions for the staged ctranslate2-server binary and matching invocation
path. Keep the test aligned with the IPC-covered pipeline modules so it
exercises the same runtime path without depending on
electron/native/bin/win32-x64/whisper-server-whisper-cpu.exe.
scripts/configure-ct2-build.ps1-1-11 (1)

1-11: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Script only works on one developer's machine.

$root and the CMAKE_PREFIX_PATH value are hardcoded to G:\repos\openscreen\.claude\worktrees\stt-migration. This will fail for anyone else, or on CI, since it doesn't derive the repo root dynamically (e.g., via $PSScriptRoot).

♻️ Proposed fix
-$root = 'G:\repos\openscreen\.claude\worktrees\stt-migration'
+$root = Resolve-Path (Join-Path $PSScriptRoot '..')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/configure-ct2-build.ps1` around lines 1 - 11, The
configure-ct2-build.ps1 script is hardcoded to one local checkout, so it should
derive paths dynamically instead of using a fixed
G:\repos\openscreen\.claude\worktrees\stt-migration value. Update the $root and
related path construction to use the script location (for example via
$PSScriptRoot) and build $buildDir, $srcDir, and the CMAKE_PREFIX_PATH from that
computed repo root so the script works on any machine and in CI.
electron/preload.ts-180-182 (1)

180-182: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Type findRecordingCamera's return instead of leaking Promise<any>.

ipcRenderer.invoke types as Promise<any> by default. Every other new bridge method in this diff (stt.transcribe) explicitly types its invoke result; this one doesn't, introducing an implicit any into the exposed electronAPI surface. As per coding guidelines, "new code should not introduce any types."

♻️ Proposed typed return
+type FindRecordingCameraResult = {
+	success: boolean;
+	webcamVideoPath?: string;
+	offsetMs?: number;
+	error?: string;
+};
+
 findRecordingCamera: (videoPath: string) => {
-	return ipcRenderer.invoke("find-recording-camera", videoPath);
+	return ipcRenderer.invoke("find-recording-camera", videoPath) as Promise<FindRecordingCameraResult>;
 },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/preload.ts` around lines 180 - 182, The electronAPI bridge method
findRecordingCamera is leaking an implicit Promise<any> from ipcRenderer.invoke.
Update the findRecordingCamera function in preload.ts to explicitly type the
invoke result, following the pattern used by stt.transcribe, and make sure the
exposed return type is a concrete Promise<T> instead of any so the electronAPI
surface stays fully typed.

Source: Coding guidelines

electron/native/ctranslate2-server/src/mel.cpp-83-96 (1)

83-96: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Out-of-bounds read in reflect_pad for short inputs.

The front-padding loop reads x[i] up to x[pad] without checking x.size() > pad, and the tail loop clamps idx to x.size() (one past the last valid index) instead of x.size()-1. If mono_16k.size() <= pad (audio shorter than n_fft/2 samples), this is a heap buffer over-read/UB that can crash the native server on a short or near-empty WAV input.

🛡️ Proposed bounds-safe fix
 std::vector<float> reflect_pad(const std::vector<float>& x, int pad) {
-  if (pad <= 0) return x;
+  if (pad <= 0 || x.empty()) return x;
   std::vector<float> out;
   out.reserve(x.size() + size_t(2 * pad));
   for (int i = pad; i > 0; --i) {
-    out.push_back(x[size_t(i)]);
+    size_t idx = (size_t(i) < x.size()) ? size_t(i) : (x.size() - 1);
+    out.push_back(x[idx]);
   }
   out.insert(out.end(), x.begin(), x.end());
   for (size_t i = 1; i <= size_t(pad); ++i) {
-    size_t idx = (i > x.size()) ? x.size() : (x.size() - i);
+    size_t idx = (i >= x.size()) ? (x.size() - 1) : (x.size() - i);
     out.push_back(x[idx]);
   }
   return out;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/ctranslate2-server/src/mel.cpp` around lines 83 - 96, The
reflect_pad helper is reading past the end of the input for short vectors, so
update the padding logic in reflect_pad to handle inputs smaller than pad
safely. Clamp the front-padding and tail-padding indices to valid positions
before indexing x, and make sure the right-side reflection never uses x.size()
as an element access target; use the last valid sample instead. Verify the
caller path in mel.cpp that feeds mono_16k into reflect_pad still works when the
audio is shorter than the padding size.
electron/main-process-errors.ts-6-6 (1)

6-6: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Limit the swallow to the IPC reply case

electron/main-process-errors.ts:6-32 swallows ECONNRESET and ERR_STREAM_DESTROYED process-wide. Those codes can also surface from the STT HTTP/streaming paths, so this can hide real download/transcription failures. Narrow the guard to the specific IPC case, or drop the extra codes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/main-process-errors.ts` at line 6, The global swallow list in
main-process error handling is too broad and can hide real STT
download/transcription failures. Update the logic around SWALLOWED_ERROR_CODES
and the main-process error guard in electron/main-process-errors.ts so that
ECONNRESET and ERR_STREAM_DESTROYED are only ignored for the IPC reply path, or
remove those extra codes if that scope can’t be distinguished. Keep the
suppression narrowly tied to the IPC reply case and leave other errors to
surface normally.
electron-builder.json5-34-42 (1)

34-42: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add the Linux extraResources mapping electron-builder.json5:82-92 already packages electron/native/bin/<platform>-<arch>/ for macOS and Windows, but Linux has no matching electron/native/bin/linux-*/* entry. Packaged Linux builds will miss ctranslate2-server, so STT won’t work there.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron-builder.json5` around lines 34 - 42, The Linux packaging config is
missing the `extraResources` mapping for `electron/native/bin/linux-<arch>/`, so
packaged Linux builds do not include `ctranslate2-server`. Update the Linux
section in `electron-builder.json5` to mirror the macOS and Windows resource
mappings and ensure the existing native binary path pattern is included for the
matching Linux architecture.
electron/native/ctranslate2-server/src/main.cpp-503-503 (1)

503-503: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Signed integer overflow in payload limit.

2 * 1024 * 1024 * 1024 is evaluated in int; the result (2147483648) exceeds INT_MAX, which is undefined behavior. The wrapped value then converts to size_t, so the effective limit is unpredictable rather than 2 GiB.

🐛 Proposed fix
-  svr.set_payload_max_length(2 * 1024 * 1024 * 1024);
+  svr.set_payload_max_length(static_cast<size_t>(2) * 1024 * 1024 * 1024);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/ctranslate2-server/src/main.cpp` at line 503, The payload
limit in set_payload_max_length is computed with an int expression that
overflows before conversion, so update the CTranslate2 server setup to use an
explicitly unsigned/64-bit constant for the 2 GiB limit. Locate the payload
limit assignment in main.cpp near svr.set_payload_max_length and change the
literal arithmetic so the value is evaluated safely and passed as the intended
size_t amount.

Source: Linters/SAST tools

electron/native/ctranslate2-server/src/main.cpp-484-497 (1)

484-497: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

load_tokenizer can throw uncaught, terminating the process.

load_tokenizer (Line 485) throws std::runtime_error when tokenizer.json is missing or malformed, but the call is outside any try block. The uncaught exception triggers std::terminate rather than the clean return 3 path used for model-load failure. Wrap it like the model load above.

🛡️ Proposed fix
-  // Load the tokenizer
-  openscreen::ct2::WhisperTokenizer tok = load_tokenizer(cfg.model_dir);
-  log("tokenizer sanity: id(<|en|>)=" + std::to_string(tok.id_for("<|en|>")));
+  // Load the tokenizer
+  std::optional<openscreen::ct2::WhisperTokenizer> tok_opt;
+  try {
+    tok_opt = load_tokenizer(cfg.model_dir);
+  } catch (const std::exception& e) {
+    std::cerr << "FATAL: tokenizer load failed: " << e.what() << std::endl;
+    return 3;
+  }
+  openscreen::ct2::WhisperTokenizer tok = std::move(*tok_opt);
+  log("tokenizer sanity: id(<|en|>)=" + std::to_string(tok.id_for("<|en|>")));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/ctranslate2-server/src/main.cpp` around lines 484 - 497, The
tokenizer load path in main currently calls load_tokenizer(cfg.model_dir)
without any exception handling, so a missing or malformed tokenizer.json can
terminate the process instead of using the existing fatal exit flow. Wrap the
tokenizer initialization and the subsequent tok.id_for sanity check in the same
kind of try/catch used for the model load, and on failure log a fatal message
with the exception details and return 3 so main handles tokenizer errors
consistently.

Source: Linters/SAST tools

electron/stt/gpuDetector.ts-92-101 (1)

92-101: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid require in this ESM module. package.json sets "type": "module", so require("node:fs") can throw here; the catch turns that into false, which makes CUDA detection silently fall back to CPU even when the binary exists. Use a top-level import { existsSync } from "node:fs" here too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/stt/gpuDetector.ts` around lines 92 - 101, The binaryAvailable
helper in gpuDetector is using require("node:fs") inside an ESM module, which
can fail and incorrectly mark backend binaries as unavailable. Update
binaryAvailable to use the existing ESM import style by bringing in existsSync
at the top of the module and calling it directly inside the candidateBinaryPaths
check, so CUDA detection works reliably instead of silently falling back to CPU.
electron/stt/index.test.ts-8-30 (1)

8-30: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Move fakeCt2Server into vi.hoisted()

vi.mock() is hoisted ahead of top-level const initialization, so this factory can hit the TDZ and fail on import. Hoist the shared fake instead.

Suggested fix
-const fakeCt2Server = {
+const fakeCt2Server = vi.hoisted(() => ({
 	start: vi.fn(),
 	status: {
 		backend: "ctranslate2-cpu" as const,
 		port: 9000,
@@
 	},
 	transcribe: vi.fn(),
 	stop: vi.fn(),
-};
+}));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/stt/index.test.ts` around lines 8 - 30, The shared fake used by the
vi.mock factory is initialized too late, so the hoisted mock can access it
during import and hit the TDZ. Move fakeCt2Server into a vi.hoisted()
declaration in electron/stt/index.test.ts, then keep the
CTranslate2ServerManager mock class wiring to that hoisted object’s
start/status/transcribe/stop members so the factory can safely reference it.
electron/ai-edition/document-service.ts-144-163 (1)

144-163: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Non-atomic project writes risk corrupting .axcut files on crash/interruption.

writeProject calls fs.writeFile directly against the final path. If the process crashes or is killed mid-write (e.g. during a large document save), the .axcut file is left truncated/corrupted, and getProject/listProjects would then either throw or silently skip the project (Line 107-111). There's also no per-project write serialization, so two concurrent saveProject calls for the same project could interleave. The media-links registry elsewhere in this PR stack uses atomic persistence with write serialization for exactly this reason — consider applying the same pattern here (write to a temp file, then fs.rename into place, optionally behind a per-project write queue/lock).

🛡️ Suggested fix (atomic write)
 	private async writeProject(doc: AxcutDocument): Promise<void> {
 		await this.ensureProjectsDir();
 		const filePath = this.fileFor(doc.project.id);
-		await fs.writeFile(filePath, JSON.stringify(doc, null, 2), "utf8");
+		const tmpPath = `${filePath}.tmp-${Date.now()}`;
+		await fs.writeFile(tmpPath, JSON.stringify(doc, null, 2), "utf8");
+		await fs.rename(tmpPath, filePath);
 	}

Also applies to: 232-237

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ai-edition/document-service.ts` around lines 144 - 163, The project
persistence path in writeProject is currently writing directly to the final
.axcut file, which can leave corrupted files on interruption and allow
concurrent saveProject calls to interleave. Update writeProject to persist via a
temp file followed by an atomic rename, and add per-project write
serialization/locking so only one save runs at a time for a given projectId.
Keep the change localized around writeProject and the saveProject flow that
calls it, and apply the same atomic pattern anywhere else the project file is
written.
electron/ai-edition/deep-agent/agent-provider-capabilities.ts-203-212 (1)

203-212: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Missing openai/ prefix strip breaks OpenRouter reasoning detection for OpenAI-slug models.

isOpenRouterReasoningModel calls isOpenAIReasoningModel(model) directly without stripping an openai/ prefix first. isOpenAIReasoningModel's regex (Line 187) is anchored with ^, so a slug like "openai/gpt-5" never matches (^gpt-5 fails since the string starts with "openai/").

The sibling implementation in electron/ai-edition/agent-provider-capabilities.ts (Line 261) explicitly does isOpenAIReasoningModel(normalized.replace(/^openai\//, "")), and its test suite (agent-provider-capabilities.test.ts Line 47) asserts getReasoningCapability("openrouter", "openai/gpt-5").supported === true. This deep-agent copy would silently disable reasoning wiring for the same model on the LangChain path.

🐛 Suggested fix
 function isOpenRouterReasoningModel(model: string): boolean {
-	if (isOpenAIReasoningModel(model)) return true;
+	if (isOpenAIReasoningModel(model.replace(/^openai\//, ""))) return true;
 	if (
 		model.startsWith("anthropic/") &&
 		isAnthropicReasoningModel(model.slice("anthropic/".length))
 	) {
 		return true;
 	}
 	return /deepseek-r1/i.test(model) || /qwen.*thinking/i.test(model) || /grok-4/i.test(model);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ai-edition/deep-agent/agent-provider-capabilities.ts` around lines
203 - 212, `isOpenRouterReasoningModel` is missing the `openai/` slug
normalization before delegating to `isOpenAIReasoningModel`, so OpenRouter
models like `openai/gpt-5` are not detected as reasoning models. Update
`isOpenRouterReasoningModel` to strip the `openai/` prefix before calling
`isOpenAIReasoningModel`, mirroring the normalization used in the sibling
`agent-provider-capabilities` implementation. Keep the existing Anthropic and
regex fallback checks intact, and ensure the function still returns true for
OpenRouter reasoning slugs with vendor prefixes.
electron/ai-edition/deep-agent/agent-provider-capabilities.ts-56-107 (1)

56-107: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle MiniMax in the deep-agent reasoning branch

provider === "anthropic" excludes minimax and minimax-token-plan, so this falls through to supported: false for MiniMax even though the registry marks both providers as reasoning-capable. The startsWith("MiniMax-M3") check is also dead code because normalizeModelName() lowercases the model first. Add an explicit MiniMax branch here, matching the direct MiniMax handling in electron/ai-edition/agent-provider-capabilities.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ai-edition/deep-agent/agent-provider-capabilities.ts` around lines
56 - 107, The reasoning capability check in getReasoningCapability currently
misses MiniMax because it only routes through the anthropic provider branch and
also uses a dead case-sensitive MiniMax-M3 prefix check after
normalizeModelName() lowercases the model. Add an explicit
MiniMax/minimax-token-plan branch in this function, matching the direct MiniMax
handling used in agent-provider-capabilities.ts, so the MiniMax providers return
supported reasoning instead of falling through to false.
electron/ai-edition/agent-provider-capabilities.ts-118-129 (1)

118-129: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Route MiniMax through its own reasoning payload (electron/ai-edition/agent-provider-capabilities.ts:118-129, :150-157).

The minimax branch still reuses openai-responses, which produces { reasoning: { effort } } and sets useResponsesApi; the Anthropic transport ignores that flag and just merges extraBody, so MiniMax never gets the flat reasoning_effort field this branch is supposed to send. Add a MiniMax-specific strategy/body shape instead of sharing the OpenAI Responses path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ai-edition/agent-provider-capabilities.ts` around lines 118 - 129,
The MiniMax handling in agent-provider-capabilities should not reuse the
openai-responses strategy because that produces a reasoning object and
useResponsesApi, while the Anthropic transport only forwards extraBody and never
emits the flat reasoning_effort field. Update the minimax/minimax-token-plan
branch and the related provider-body mapping logic so MiniMax uses its own
strategy/body shape with reasoning_effort at the top level, using the existing
provider capability symbols to keep the routing separate from the OpenAI
Responses path.
electron/ai-edition/chat-service.ts-301-301 (1)

301-301: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

allowAgentEdits setting is computed but never enforced.

editsAllowed is derived from config.allowAgentEdits (Line 301) but the only use is void editsAllowed; inside agentSink.toolStart (Line 331), which does nothing at runtime. There is no code path that prevents the deep agent from calling mutating tools (addSkip, setSkipRange, setClipRange, replaceTimeline) when the user has disabled agent edits — the setting is effectively a no-op today.

🐛 Suggested direction

Thread editsAllowed through to invokeOpenScreenAgent/buildTools and omit the mutating tools from the tool list (or make executeAgentTool reject mutations) when editsAllowed is false, rather than only silencing the unused-variable warning.

Also applies to: 327-332

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ai-edition/chat-service.ts` at line 301, The allowAgentEdits setting
is computed in chat-service.ts but never actually enforced, so the deep agent
can still invoke mutating tools even when edits are disabled. Thread the
editsAllowed flag from the chat-service flow into invokeOpenScreenAgent and
buildTools, and use it to omit the mutating tools (addSkip, setSkipRange,
setClipRange, replaceTimeline) from the tool list when false, or have
executeAgentTool reject those mutations at runtime. Remove the no-op void
editsAllowed usage and make the enforcement happen at the tool
construction/execution boundary.
electron/ai-edition/deep-agent/service.ts-96-181 (1)

96-181: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Duplicated if/else pattern across all four mutating tools.

addSkip, setSkipRange, setClipRange, and replaceTimeline each repeat the identical if (execution.document) {...} else {...} structure that only differs by the tool name — the sink.toolEnd(...) call is literally the same in both branches. Worth extracting into a small helper to reduce duplication.

♻️ Suggested helper
+function mutatingToolHandler(holder: DocumentHolder, sink: OpenScreenAgentSink, name: string) {
+	return async (args: unknown) => {
+		sink.toolStart(name, args);
+		const execution = executeAgentTool(holder.current, name, JSON.stringify(args));
+		if (execution.document) holder.current = execution.document;
+		sink.toolEnd(name, execution.ok, execution.summary);
+		return execution.resultJson;
+	};
+}

Then each tool becomes e.g. tool(mutatingToolHandler(holder, sink, "addSkip"), { name: "addSkip", description: ..., schema: addSkipArgs }).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ai-edition/deep-agent/service.ts` around lines 96 - 181, The
mutating tool handlers for addSkip, setSkipRange, setClipRange, and
replaceTimeline repeat the same execution/document update logic with an
unnecessary if/else around sink.toolEnd. Extract the shared behavior into a
small helper like mutatingToolHandler that takes holder, sink, and the tool
name, calls executeAgentTool, updates holder.current when execution.document
exists, and always emits the same sink.toolEnd result, then wire each tool
definition to use that helper.
electron/ai-edition/deep-agent/service.ts-92-183 (1)

92-183: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Skip the generic tool lifecycle emit for the mutating tools.
addSkip, setSkipRange, setClipRange, and replaceTimeline already call sink.toolStart/sink.toolEnd inside their handlers, so the on_tool_start/on_tool_end stream handler emits a second start/end pair for the same call. That produces duplicate lifecycle events in the UI, and the second toolEnd drops the summary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ai-edition/deep-agent/service.ts` around lines 92 - 183, The
mutating tool handlers in buildTools already emit their own lifecycle events, so
the generic stream handler is causing duplicate start/end notifications for
addSkip, setSkipRange, setClipRange, and replaceTimeline. Update the tool wiring
so these tools bypass the shared on_tool_start/on_tool_end emission path, or
otherwise ensure sink.toolStart and sink.toolEnd are only called once per
invocation; keep the existing per-tool calls in buildTools as the source of
truth so the summary is preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a71f57-555c-446b-bfd2-757fd12c3af8

📥 Commits

Reviewing files that changed from the base of the PR and between 654375b and a15f1f5.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (253)
  • .github/workflows/build-ctranslate2-server.yml
  • .github/workflows/build.yml
  • .gitignore
  • .opencode/prompts/playwright-test-generator.md
  • .opencode/prompts/playwright-test-healer.md
  • .opencode/prompts/playwright-test-planner.md
  • biome.json
  • ct2-build.cmd
  • design/DESIGN.md
  • design/openscreen-editor-v2.html
  • design/openscreen-editor.html
  • design/openscreen-widget.html
  • docs/architecture/ai-edition-collision-analysis.md
  • docs/architecture/ai-edition-data-flow.md
  • docs/architecture/ai-edition-roadmap.md
  • docs/architecture/axcut-inventory.md
  • docs/architecture/openscreen-inventory.md
  • docs/axcut-ux-ui-spec.md
  • docs/cursor-feature-inventory.md
  • docs/engineering/stt-ctranslate2-implementation-status.md
  • docs/engineering/stt-ctranslate2-migration.md
  • docs/engineering/transcription-engine-migration.md
  • docs/openscreen-ux-ui-spec.md
  • docs/provider-parity-plan.md
  • electron-builder.json5
  • electron/ai-edition/agent-provider-capabilities.test.ts
  • electron/ai-edition/agent-provider-capabilities.ts
  • electron/ai-edition/agent-tools.test.ts
  • electron/ai-edition/agent-tools.ts
  • electron/ai-edition/chat-compaction.test.ts
  • electron/ai-edition/chat-compaction.ts
  • electron/ai-edition/chat-service.test.ts
  • electron/ai-edition/chat-service.toolloop.test.ts
  • electron/ai-edition/chat-service.ts
  • electron/ai-edition/codex-session.test.ts
  • electron/ai-edition/codex-session.ts
  • electron/ai-edition/deep-agent/agent-provider-capabilities.ts
  • electron/ai-edition/deep-agent/chat-model.ts
  • electron/ai-edition/deep-agent/service.ts
  • electron/ai-edition/document-service.test.ts
  • electron/ai-edition/document-service.ts
  • electron/ai-edition/llm-call.ts
  • electron/ai-edition/llm-config-store.ts
  • electron/ai-edition/llm-provider-auth.test.ts
  • electron/ai-edition/llm-provider-auth.ts
  • electron/ai-edition/provider-registry.ts
  • electron/electron-env.d.ts
  • electron/ipc/handlers.ts
  • electron/ipc/nativeBridge.ts
  • electron/main-process-errors.test.ts
  • electron/main-process-errors.ts
  • electron/main.ts
  • electron/media/mediaLinksRegistry.test.ts
  • electron/media/mediaLinksRegistry.ts
  • electron/native-bridge/services/aiEditionService.ts
  • electron/native/ctranslate2-server/CMakeLists.txt
  • electron/native/ctranslate2-server/README.md
  • electron/native/ctranslate2-server/include/mel.h
  • electron/native/ctranslate2-server/include/tokenizer.h
  • electron/native/ctranslate2-server/include/wav.h
  • electron/native/ctranslate2-server/src/main.cpp
  • electron/native/ctranslate2-server/src/mel.cpp
  • electron/native/ctranslate2-server/third_party/kissfft/LICENSE
  • electron/native/ctranslate2-server/third_party/kissfft/_kiss_fft_guts.h
  • electron/native/ctranslate2-server/third_party/kissfft/kiss_fft.c
  • electron/native/ctranslate2-server/third_party/kissfft/kiss_fft.h
  • electron/native/ctranslate2-server/third_party/kissfft/kiss_fft_log.h
  • electron/preload.ts
  • electron/stt/ctranslate2Server.test.ts
  • electron/stt/ctranslate2Server.ts
  • electron/stt/gpuDetector.test.ts
  • electron/stt/gpuDetector.ts
  • electron/stt/index.test.ts
  • electron/stt/index.ts
  • electron/stt/modelManager.test.ts
  • electron/stt/modelManager.ts
  • electron/stt/transcriptionContract.ts
  • electron/stt/wav.ts
  • package.json
  • scripts/before-pack.cjs
  • scripts/build-ctranslate2-server.sh
  • scripts/configure-ct2-build.ps1
  • scripts/e2e-pipeline-smoke.mjs
  • scripts/e2e-stt-smoke.mjs
  • scripts/fetch-caption-model.mjs
  • scripts/start-ct2-server.cmd
  • scripts/stt-dev-server.mjs
  • scripts/stt-wrapper.bat
  • specs/README.md
  • specs/computer-use/00-launch-and-hud.md
  • specs/computer-use/01-source-selection-and-record.md
  • specs/computer-use/02-editor-foundation.md
  • specs/computer-use/03-transport-and-preview.md
  • specs/computer-use/04-timeline-pan-zoom-scrub.md
  • specs/computer-use/05-clip-operations.md
  • specs/computer-use/06-skip-regions.md
  • specs/computer-use/07-zoom-regions.md
  • specs/computer-use/08-speed-regions.md
  • specs/computer-use/09-annotation-regions.md
  • specs/computer-use/10-properties-right-panel.md
  • specs/computer-use/11-transcript-editor.md
  • specs/computer-use/12-chat-panel.md
  • specs/computer-use/13-provider-settings.md
  • specs/computer-use/14-sessions-and-history.md
  • specs/computer-use/15-export-dialog.md
  • specs/computer-use/16-modal-shortcuts-i18n.md
  • specs/computer-use/17-themes-and-settings.md
  • specs/computer-use/18-final-qa-checklist.md
  • specs/computer-use/INDEX.md
  • specs/f2.6-region-snap-guide-and-tooltip.md
  • specs/f2.7-multi-select-regions.md
  • specs/p1.7-chat-tool-call-summaries.md
  • specs/p3.1-asset-file-size.md
  • specs/p3.3-right-panes-help.md
  • specs/p3.7-ruler-hover-scrub.md
  • specs/t19-zoom-timeline-and-preview.md
  • src/App.tsx
  • src/components/ai-edition/AiEditionShell.tsx
  • src/components/ai-edition/AnnotationLayer.tsx
  • src/components/ai-edition/AnnotationOverlay.tsx
  • src/components/ai-edition/ArrowSvgs.tsx
  • src/components/ai-edition/Bottombar.tsx
  • src/components/ai-edition/CursorPreviewLayer.module.css
  • src/components/ai-edition/CursorPreviewLayer.test.tsx
  • src/components/ai-edition/CursorPreviewLayer.tsx
  • src/components/ai-edition/EditorEmptyState.test.tsx
  • src/components/ai-edition/EditorEmptyState.tsx
  • src/components/ai-edition/ExportDialog.tsx
  • src/components/ai-edition/LeftPanel.tsx
  • src/components/ai-edition/Modals.tsx
  • src/components/ai-edition/NewEditorShell.module.css
  • src/components/ai-edition/NewEditorShell.tsx
  • src/components/ai-edition/Preview.tsx
  • src/components/ai-edition/PreviewCanvas.tsx
  • src/components/ai-edition/ProviderSettings.tsx
  • src/components/ai-edition/RegionTimeline.tsx
  • src/components/ai-edition/RightPanelStack.tsx
  • src/components/ai-edition/RightPanes.tsx
  • src/components/ai-edition/TimelinePane.module.css
  • src/components/ai-edition/TimelinePane.tsx
  • src/components/ai-edition/Titlebar.tsx
  • src/components/ai-edition/TransportBar.tsx
  • src/components/ai-edition/VirtualPreview.module.css
  • src/components/ai-edition/VirtualPreview.tsx
  • src/components/ai-edition/WebcamOverlay.test.tsx
  • src/components/ai-edition/WebcamOverlay.tsx
  • src/components/ai-edition/ZoomFocusOverlay.module.css
  • src/components/ai-edition/ZoomFocusOverlay.tsx
  • src/components/ai-edition/chatBudget.ts
  • src/components/ai-edition/preview-compositor/PreviewCompositor.module.css
  • src/components/ai-edition/preview-compositor/PreviewCompositor.tsx
  • src/components/launch/LaunchWindow.tsx
  • src/components/video-editor/AddCustomFontDialog.tsx
  • src/components/video-editor/AnnotationSettingsPanel.tsx
  • src/components/video-editor/BlurSettingsPanel.tsx
  • src/components/video-editor/CropControl.tsx
  • src/components/video-editor/EditorEmptyState.tsx
  • src/components/video-editor/ExportDialog.tsx
  • src/components/video-editor/FormatSelector.tsx
  • src/components/video-editor/GifOptionsPanel.tsx
  • src/components/video-editor/KeyboardShortcutsHelp.tsx
  • src/components/video-editor/PlaybackControls.tsx
  • src/components/video-editor/SettingsPanel.tsx
  • src/components/video-editor/ShortcutsConfigDialog.tsx
  • src/components/video-editor/TutorialHelp.tsx
  • src/components/video-editor/UnsavedChangesDialog.tsx
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/VideoPlayback.tsx
  • src/components/video-editor/featureFlags.ts
  • src/components/video-editor/index.ts
  • src/components/video-editor/timeline/BackgroundWaveform.tsx
  • src/components/video-editor/timeline/Item.module.css
  • src/components/video-editor/timeline/Item.tsx
  • src/components/video-editor/timeline/ItemGlass.module.css
  • src/components/video-editor/timeline/KeyframeMarkers.tsx
  • src/components/video-editor/timeline/Row.tsx
  • src/components/video-editor/timeline/Subrow.tsx
  • src/components/video-editor/timeline/TimelineEditor.tsx
  • src/components/video-editor/timeline/TimelineWrapper.tsx
  • src/components/video-editor/timeline/zoomSuggestionUtils.ts
  • src/components/video-editor/videoPlayback/index.ts
  • src/components/video-editor/videoPlayback/layoutUtils.ts
  • src/components/video-editor/videoPlayback/overlayUtils.ts
  • src/components/video-editor/videoPlayback/videoEventHandlers.ts
  • src/components/video-editor/videoPlayback/zoomRegionUtils.ts
  • src/hooks/rendererConsoleForwarder.ts
  • src/hooks/useTheme.ts
  • src/lib/ai-edition/annotations/blurEffects.ts
  • src/lib/ai-edition/annotations/constants.ts
  • src/lib/ai-edition/annotations/textAnimation.ts
  • src/lib/ai-edition/document/ids.ts
  • src/lib/ai-edition/document/migrate.test.ts
  • src/lib/ai-edition/document/migrate.ts
  • src/lib/ai-edition/document/operations.test.ts
  • src/lib/ai-edition/document/operations.ts
  • src/lib/ai-edition/document/timeline.test.ts
  • src/lib/ai-edition/document/timeline.ts
  • src/lib/ai-edition/document/transcribe.test.ts
  • src/lib/ai-edition/document/transcribe.ts
  • src/lib/ai-edition/exporter/documentExporter.ts
  • src/lib/ai-edition/schema/index.test.ts
  • src/lib/ai-edition/schema/index.ts
  • src/lib/ai-edition/store/editorSettings.test.ts
  • src/lib/ai-edition/store/editorSettings.ts
  • src/lib/ai-edition/store/projectStore.test.ts
  • src/lib/ai-edition/store/projectStore.ts
  • src/lib/ai-edition/store/regionClipboard.ts
  • src/lib/ai-edition/store/undo.ts
  • src/lib/ai-edition/store/useEditorSettings.ts
  • src/lib/ai-edition/store/useOptimisticTimelineOps.ts
  • src/lib/ai-edition/store/useTimeline.test.ts
  • src/lib/ai-edition/store/useTimeline.ts
  • src/lib/ai-edition/store/zoomSuggestions.test.ts
  • src/lib/ai-edition/store/zoomSuggestions.ts
  • src/lib/ai-edition/timeline/aggregated-transcript.test.ts
  • src/lib/ai-edition/timeline/aggregated-transcript.ts
  • src/lib/ai-edition/timeline/camera.test.ts
  • src/lib/ai-edition/timeline/camera.ts
  • src/lib/ai-edition/timeline/duration.test.ts
  • src/lib/ai-edition/timeline/duration.ts
  • src/lib/ai-edition/timeline/format.ts
  • src/lib/ai-edition/timeline/playback-clock.test.ts
  • src/lib/ai-edition/timeline/playback-clock.ts
  • src/lib/ai-edition/timeline/pointer-drag.test.tsx
  • src/lib/ai-edition/timeline/pointer-drag.ts
  • src/lib/ai-edition/timeline/speed.ts
  • src/lib/ai-edition/timeline/virtual-preview.test.ts
  • src/lib/ai-edition/timeline/virtual-preview.ts
  • src/lib/ai-edition/timeline/zoom-preview.ts
  • src/lib/captioning/index.ts
  • src/lib/captioning/leadingSilence.ts
  • src/lib/captioning/transcribe.test.ts
  • src/lib/captioning/transcribe.ts
  • src/lib/captioning/transcribe.worker.ts
  • src/lib/captioning/transcribeCore.ts
  • src/lib/compositeLayout.test.ts
  • src/lib/compositeLayout.ts
  • src/lib/cursor/nativeCursor.test.ts
  • src/lib/cursor/pixiCursorRenderer.ts
  • src/lib/cursor/uploadedCursorAssets.ts
  • src/lib/exporter/frameRenderer.ts
  • src/lib/exporter/muxer.ts
  • src/lib/vite-stubs/empty-node-module.ts
  • src/lib/vite-stubs/onnxruntime-node-stub.ts
  • src/main.tsx
  • src/native/browserShim.ts
  • src/native/client.ts
  • src/native/contracts.ts
  • src/styles/design-tokens.css
  • tests/e2e/diagnostic.spec.ts
  • tests/e2e/roadmap-coverage.spec.ts
  • tests/e2e/seed.spec.ts
  • vite.config.ts
💤 Files with no reviewable changes (2)
  • scripts/fetch-caption-model.mjs
  • scripts/before-pack.cjs

Comment thread .github/workflows/build-ctranslate2-server.yml Outdated
Comment thread .github/workflows/build-ctranslate2-server.yml Outdated
Comment thread electron/native/ctranslate2-server/CMakeLists.txt Outdated
Comment thread scripts/build-ctranslate2-server.sh Outdated
After cropping, the exported cursor drifted because the recorded sample
was projected onto the screen mask rect instead of the rectangle the
cropped video is actually painted on. In cover layouts where the
cropped video letterboxes inside screenRect (coverOffset != 0), the
cursor offset was proportional to the letterbox margin.

Track the painted croppedRect in FrameRenderer layoutCache and pass it
to projectNativeCursorToLocal so the cursor overlays the cropped video
pixels exactly. Adds regression tests for projectNativeCursorToLocal.

Fixes #64
@EtienneLescot EtienneLescot force-pushed the opencode/swift-mountain branch from a15f1f5 to 99d6107 Compare July 7, 2026 09:52
@EtienneLescot

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@EtienneLescot EtienneLescot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix — the cursor projection is now correct for cover-layout exports, and the test coverage (identity, crop-region mapping, cover drift, culling, degenerate crop) is solid.

A few follow-ups worth considering before merge:

  1. Likely same bug in the preview path. VideoPlayback.tsx:1583,1593 calls projectNativeCursorToLocal with baseMaskRef.current, which is set from result.maskRect in layoutUtils.ts:149 (= compositeLayout.screenRect, the full screen rect). In fit-to-width/height layouts where the cropped video letterboxes inside the screen, the preview cursor will land at the wrong offset — same drift this PR fixes for export. Not a blocker for this fix(export): PR, but worth a follow-up issue.

  2. Test name mixes two opposite concepts — inline comment on the test.

  3. sizeNorm comment is now inaccurate — inline comment on the sizeNorm block. After this fix, layoutCache.maskRect is croppedRect, so the two paths no longer use the same width source in letterbox layouts.

Comment thread src/lib/cursor/nativeCursor.test.ts Outdated
Comment thread src/lib/exporter/frameRenderer.ts
- Rename cover-letterboxed test to cover-overflowing (fixture is cover, not letterbox)
- Update sizeNorm comment to acknowledge the export/preview asymmetry introduced
  by the croppedRect field: export now uses croppedRect.width, preview still
  uses screenRect.width. They agree in cover mode but differ in fit-to-height
  letterbox layouts.

@EtienneLescot EtienneLescot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed the review fixes in fe4562e:

  • Renamed the \cover-letterboxed\ test to \cover-overflowing\ (the fixture is a cover case, not letterbox).
  • Updated the \sizeNorm\ comment to acknowledge the export/preview asymmetry introduced by the \croppedRect\ field — export now uses \croppedRect.width, preview still uses \screenRect.width. The follow-up preview-path fix is intentionally deferred to a separate PR to keep this one scoped to the export renderer geometry.

All 40 unit tests in \src/lib/cursor\ + \src/lib/exporter/frameRenderer.test.ts\ pass; Biome clean on both touched files.

The Windows cursor-sampler (Win32 GetCursorInfo) reports raw x/y in
physical screen pixels. For display captures, normalizeSample was
dividing by bounds from Electron's \screen\ API, which are in logical
pixels (DIP). On a 100% DPI display these coincide and the bug is
invisible, but on any high-DPI display (125%, 150%, 200%) the normalized
cursor position is wrong by the scale factor. The error then compounds
through projectNativeCursorToLocal in the export, producing a visible
cursor offset proportional to the DPI ratio.

Fix by converting the logical bounds to physical via the display's
scaleFactor before normalizing. payload.bounds (from the sampler's
GetWindowRect, used for window captures) is already physical and is
left as-is.

Pre-existing since 1.5.0; never caught because CI is Linux-only and
manual Windows smoke tests ran on 100% DPI displays.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts`:
- Around line 235-242: The cursor normalization logic in
windowsNativeRecordingSession is using a single scaleFactor to convert
Display.bounds, which can misplace the origin on secondary or mixed-DPI
monitors. Update the coordinate conversion in the recording session code to use
Electron’s screen helpers, such as screen.dipToScreenRect(null, bounds) or
dipToScreenPoint, before computing normalizedX and normalizedY. Keep the
existing bounds/normalization flow, but replace the manual bounds.x/bounds.y
scaling in the relevant cursor recording path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 68960589-8868-4fb8-bf71-dfad37dddfb5

📥 Commits

Reviewing files that changed from the base of the PR and between fe4562e and e6a57cd.

📒 Files selected for processing (1)
  • electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts

Comment thread electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts Outdated
The previous fix multiplied bounds.x/y/width/height by a single
scaleFactor to convert from DIPs to physical pixels. That works for the
primary display (origin at 0,0 in the virtual screen) but misplaces the
origin on non-primary or mixed-DPI displays: a secondary 200% DPI
monitor to the right of a 100% primary has DIP bounds {x:1920, y:0,
w:1920, h:1080} but physical bounds {x:1920, y:0, w:3840, h:2160} —
multiplying the origin by 2 would push it to 3840.

Use Electron's \screen.dipToScreenRect(null, bounds)\ instead, which
picks the correct display from the rect's center and handles the
virtual-screen origin correctly across multi-monitor and mixed-DPI
setups. payload.bounds (from the sampler's GetWindowRect) is already
physical and is left as-is.
@EtienneLescot EtienneLescot merged commit b67811f into main Jul 7, 2026
13 of 15 checks passed
@EtienneLescot EtienneLescot deleted the opencode/swift-mountain branch July 7, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Mouse drifting after cropping the video

1 participant