diff --git a/packages/extension/src/background/gif.ts b/packages/extension/src/background/gif.ts index 8d6fb94..123e2a0 100644 --- a/packages/extension/src/background/gif.ts +++ b/packages/extension/src/background/gif.ts @@ -139,7 +139,9 @@ const image = await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" })); try { - const { sx, sy, s } = cursorCropRect(frame, options); + // crop in the JPEG's true pixel space — the screencast metadata's device + // size can be larger than the delivered (downscaled) frame + const { sx, sy, s } = cursorCropRect({ ...frame, w: image.width, h: image.height }, options); context.fillStyle = "#10121c"; context.fillRect(0, 0, size, size); context.drawImage(image, sx, sy, s, s, 0, 0, size, size); diff --git a/packages/extension/src/background/video.ts b/packages/extension/src/background/video.ts index 327803f..a3066ce 100644 --- a/packages/extension/src/background/video.ts +++ b/packages/extension/src/background/video.ts @@ -19,21 +19,27 @@ export async function buildVideoWebm(frames: VideoFrame[], crop?: CursorCropOptions): Promise { if (!frames.length || typeof VideoEncoder === "undefined") return null; + // the screencast metadata's device size can exceed the JPEG Chrome actually + // delivers (frames are scaled to fit maxWidth/maxHeight), so decode the + // first frame to learn the true pixel space the crop math must work in + const firstImage = await createImageBitmap( + new Blob([frames[0].jpeg.buffer as ArrayBuffer], { type: "image/jpeg" }) + ); + // fixed output size for the whole file; the cursor crop outputs the source // square at (nearly) native resolution — capped at the requested square size let width: number; let height: number; if (crop && crop.viewport.w > 0 && crop.viewport.h > 0) { - const scale = frames[0].w / crop.viewport.w; + const scale = firstImage.width / crop.viewport.w; const cropDevice = Math.min(crop.size, crop.viewport.w, crop.viewport.h) * scale; width = even(Math.min(cropDevice, crop.size)); height = width; } else { // fallback: full frame capped at 800px wide - const first = frames[0]; - const scale = Math.min(1, 800 / first.w); - width = even(first.w * scale); - height = even(first.h * scale); + const scale = Math.min(1, 800 / firstImage.width); + width = even(firstImage.width * scale); + height = even(firstImage.height * scale); } const muxer = new Muxer({ @@ -66,10 +72,13 @@ const timestampUs = Math.max(frame.at - baseAt, 0) * 1000; const durationUs = nextAt != null ? Math.max(nextAt - frame.at, 1) * 1000 : 33_000; - const image = await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" })); + // the first frame is already decoded (its size sized the output) + const image = position === 0 ? firstImage : await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" })); try { if (crop && crop.viewport.w > 0 && crop.viewport.h > 0) { - const { sx, sy, s } = cursorCropRect(frame, crop); + // crop in the JPEG's true pixel space — the metadata's device size + // can be larger than the delivered (downscaled) frame + const { sx, sy, s } = cursorCropRect({ ...frame, w: image.width, h: image.height }, crop); // letterbox fill guards against rounding gaps on the edge px context.fillStyle = "#10121c"; context.fillRect(0, 0, width, height);