diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index a8c6499..7a0f162 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -65,7 +65,14 @@ headers: { "Content-Type": "application/json", Authorization: `Bearer ${login.token}` }, body: JSON.stringify({ name: "Extension E2E" }), }); -console.log("seeded user+project:", project.id); +// a second project: the tester juggles several at once, so the popup must +// be able to switch between them +const project2 = await api("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${login.token}` }, + body: JSON.stringify({ name: "Extension E2E Second" }), +}); +console.log("seeded user+projects:", project.id, project2.id); // --- launch with the extension --- const executablePath = join(homedir(), ".cache/ms-playwright/chromium-1234/chrome-linux64/chrome"); @@ -184,7 +191,14 @@ await page.mouse.click(buttonBox.x + box.width / 2, buttonBox.y + buttonBox.height / 2); await page.waitForTimeout(600); // let the input debounce flush await worker.evaluate(() => self.__lttToggleRecorder()); - await page.waitForTimeout(4000); // upload step screenshots + submit + // stopping no longer submits right away: the recording goes "pending" and + // the composer overlay opens in the tab (title input focused + selected) + await page.waitForTimeout(2000); + await page.keyboard.type("E2E recorded bug"); // replaces the selected prefill + await page.keyboard.press("Tab"); // title → description textarea + await page.keyboard.type("Recorded via e2e composer."); + await page.keyboard.press("Control+Enter"); // composer save shortcut + await page.waitForTimeout(8000); // media upload (awaited by finalize) + submit const itemsAfter = (await api(`/api/projects/${project.id}/reports`, { headers: { Authorization: `Bearer ${login.token}` }, @@ -229,7 +243,16 @@ ); const stream = out.streams?.[0]; console.log("ffprobe:", JSON.stringify({ codec: stream?.codec_name, size: `${stream?.width}x${stream?.height}`, duration: out.format?.duration })); - videoOk = stream?.codec_name === "vp8" && Number(out.format?.duration) > 0; + // the cursor-following crop produces a square video around the + // cursor, capped at 512px (256 floor = the crop actually happened) + const width = stream?.width ?? 0; + const height = stream?.height ?? 0; + videoOk = + stream?.codec_name === "vp8" && + Number(out.format?.duration) > 0 && + width === height && + width <= 512 && + width >= 256; } catch (error) { if (String(error).includes("ENOENT")) console.log("ffprobe: not installed, skipping"); else { @@ -279,12 +302,58 @@ const trackPoints = recordingSummary.mouse_track?.points?.length ?? 0; const mouseTrackOk = trackPoints >= 2; console.log("mouse track:", JSON.stringify({ points: trackPoints })); + // the composer's title/description must reach the server verbatim + const composerOk = + recordingSummary.title === "E2E recorded bug" && (recordingSummary.description ?? "") === "Recorded via e2e composer."; + console.log("composer:", JSON.stringify({ title: recordingSummary.title, description: recordingSummary.description })); + + // --- discard flow: a short second recording dropped in the composer --- + // keyboard path through the closed shadow root: title input is focused on + // mount, so Tab×2 reaches the two-step Discard button, Enter×2 confirms + const beforeDiscard = all.length; + await page.bringToFront(); + await worker.evaluate(() => self.__lttToggleRecorder()); + await page.waitForTimeout(800); + const headingBox = await page.locator("h1").boundingBox(); + await page.mouse.click(headingBox.x + 30, headingBox.y + 10); // one event so the buffer is non-empty + await page.waitForTimeout(400); + await worker.evaluate(() => self.__lttToggleRecorder()); + await page.waitForTimeout(2000); // composer mounts, title focused + await page.keyboard.press("Tab"); // title → description + await page.keyboard.press("Tab"); // description → Discard button + await page.keyboard.press("Enter"); // arm the two-step discard + await page.waitForTimeout(250); + await page.keyboard.press("Enter"); // confirm + await page.waitForTimeout(1500); + const afterDiscard = (await api(`/api/projects/${project.id}/reports`, { + headers: { Authorization: `Bearer ${login.token}` }, + })); + const discardOk = (afterDiscard.items ?? afterDiscard).length === beforeDiscard; + console.log("discard:", JSON.stringify({ before: beforeDiscard, after: (afterDiscard.items ?? afterDiscard).length, discardOk })); // --- popup: mode buttons + latest report card --- const popup = await context.newPage(); await popup.goto(`chrome-extension://${extensionId}/src/popup/popup.html`); await popup.waitForSelector("text=Element note", { timeout: 10000 }); const hasRecord = await popup.locator("text=Record steps").count(); + + // --- popup: project switcher — the tester juggles several projects --- + await popup.selectOption(".popup-project-select", project2.id); + await popup.waitForTimeout(1000); + const savedSecond = await popup.evaluate(() => chrome.runtime.sendMessage({ type: "settings_get" })); + const emptyShown = await popup.locator("text=No reports yet in this project").count(); + await popup.selectOption(".popup-project-select", project.id); + await popup.waitForTimeout(1000); + const savedFirst = await popup.evaluate(() => chrome.runtime.sendMessage({ type: "settings_get" })); + const switcherOk = + savedSecond?.data?.defaultProjectId === project2.id && + emptyShown > 0 && + savedFirst?.data?.defaultProjectId === project.id; + console.log( + "project switcher:", + JSON.stringify({ second: savedSecond?.data?.defaultProjectId, back: savedFirst?.data?.defaultProjectId, emptyShown, switcherOk }) + ); + const latestTitle = await popup.locator(".popup-report-title").textContent(); const popupOk = hasRecord > 0 && Boolean(latestTitle && latestTitle.length > 0); @@ -396,7 +465,7 @@ console.log("popup:", JSON.stringify({ hasRecord: hasRecord > 0, latestOk: popupOk, deleteOk })); const ok = - noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk && jsHoverOk && videoOk && consoleOk; + noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk && jsHoverOk && videoOk && consoleOk && composerOk && discardOk && switcherOk; console.log("background log:", JSON.stringify(await worker.evaluate(() => self.__lttDebug()), null, 1)); console.log(noteOk ? "note flow OK" : "note flow MISMATCH"); console.log(recorderOk ? "recorder flow OK" : "recorder flow MISMATCH"); @@ -405,6 +474,9 @@ console.log(mouseTrackOk ? "mouse track OK" : "mouse track MISMATCH"); console.log(videoOk ? "screen video OK" : "screen video MISMATCH"); console.log(consoleOk ? "console dump OK" : "console dump MISMATCH"); + console.log(composerOk ? "recording composer OK" : "recording composer MISMATCH"); + console.log(discardOk ? "recording discard OK" : "recording discard MISMATCH"); + console.log(switcherOk ? "project switcher OK" : "project switcher MISMATCH"); console.log(clipboardOk ? "clipboard link OK" : "clipboard link MISMATCH"); console.log(popupOk ? "popup OK" : "popup MISMATCH"); console.log(relayOk && replayResultOk ? "panel relay OK" : "panel relay MISMATCH"); diff --git a/packages/extension/src/background/gif.ts b/packages/extension/src/background/gif.ts index 0ae0b65..8d6fb94 100644 --- a/packages/extension/src/background/gif.ts +++ b/packages/extension/src/background/gif.ts @@ -72,6 +72,46 @@ maxFrames?: number; } +/** Options shared by every cursor-following encoder (GIF and WebM). */ +export interface CursorCropOptions { + /** CSS viewport the cursor track was recorded in */ + viewport: { w: number; h: number }; + /** recording start (Date.now()) — frame times convert to track offsets */ + startedAt: number; + /** track points: {t (ms from start), x, y} in CSS px */ + track: { t: number; x: number; y: number }[]; + /** output square size in CSS px */ + size: number; +} + +/** + * Source rectangle for a cursor-following `size`×`size` CSS-px square crop of + * a screencast frame, centered on the cursor position (interpolated from the + * mouse track at the frame's time). The square is clamped to stay inside the + * viewport; when the viewport is smaller than the square the crop centers on + * the smaller canvas. Shared by the GIF and WebM encoders. + */ +export function cursorCropRect( + frame: VideoFrame, + options: { viewport: { w: number; h: number }; startedAt: number; track: { t: number; x: number; y: number }[]; size: number } +): { sx: number; sy: number; s: number } { + // device px per CSS px for this frame (fallback: frame pixels are CSS px) + const scale = options.viewport.w > 0 ? frame.w / options.viewport.w : 1; + if (!Number.isFinite(scale) || scale <= 0) throw new Error("bad frame scale"); + + const cursor = cursorAt(options.track, frame.at - options.startedAt, options.viewport); + // clamp the square so it stays inside the viewport + const crop = Math.min(options.size, options.viewport.w, options.viewport.h); + const half = crop / 2; + const cx = Math.min(Math.max(cursor.x, half), Math.max(options.viewport.w - half, half)); + const cy = Math.min(Math.max(cursor.y, half), Math.max(options.viewport.h - half, half)); + + const sx = Math.min(Math.max((cx - half) * scale, 0), Math.max(frame.w - crop * scale, 0)); + const sy = Math.min(Math.max((cy - half) * scale, 0), Math.max(frame.h - crop * scale, 0)); + const s = Math.min(crop * scale, frame.w - sx, frame.h - sy); + return { sx, sy, s }; +} + /** * Builds a square GIF that follows the cursor: each screencast frame is * cropped to a `size`×`size` CSS-px square centered on the cursor position @@ -99,21 +139,7 @@ const image = await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" })); try { - // device px per CSS px for this frame (fallback: frame pixels are CSS px) - const scale = options.viewport.w > 0 ? frame.w / options.viewport.w : 1; - if (!Number.isFinite(scale) || scale <= 0) throw new Error("bad frame scale"); - - const cursor = cursorAt(options.track, frame.at - options.startedAt, options.viewport); - // clamp the square so it stays inside the viewport - const crop = Math.min(size, options.viewport.w, options.viewport.h); - const half = crop / 2; - const cx = Math.min(Math.max(cursor.x, half), Math.max(options.viewport.w - half, half)); - const cy = Math.min(Math.max(cursor.y, half), Math.max(options.viewport.h - half, half)); - - const sx = Math.min(Math.max((cx - half) * scale, 0), Math.max(frame.w - crop * scale, 0)); - const sy = Math.min(Math.max((cy - half) * scale, 0), Math.max(frame.h - crop * scale, 0)); - const s = Math.min(crop * scale, frame.w - sx, frame.h - sy); - + const { sx, sy, s } = cursorCropRect(frame, options); context.fillStyle = "#10121c"; context.fillRect(0, 0, size, size); context.drawImage(image, sx, sy, s, s, 0, 0, size, size); @@ -128,7 +154,7 @@ } /** Last known cursor position at track offset t (ms); falls back progressively. */ -function cursorAt( +export function cursorAt( track: { t: number; x: number; y: number }[], t: number, viewport: { w: number; h: number } diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index e2ace8e..be14248 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -39,6 +39,14 @@ videoActive: boolean; lastVideoAt: number; consoleCount: number; + /** stopped but not yet saved/discarded — the composer is open */ + pending: boolean; + /** media file id once the parallel WebM/GIF upload finishes */ + preparedAttachmentId: string | null; + /** in-flight media upload, awaited at submit time if not settled yet */ + mediaPromise: Promise | null; + /** auto-save timer for a pending recording the user never answered */ + pendingTimer: ReturnType | null; } const recorders = new Map(); @@ -60,6 +68,10 @@ videoActive: false, lastVideoAt: 0, consoleCount: 0, + pending: false, + preparedAttachmentId: null, + mediaPromise: null, + pendingTimer: null, }; recorders.set(tabId, buffer); } @@ -105,10 +117,111 @@ } } +function recorderStateOf(buffer: RecorderBuffer): RecorderState { + return { + recording: buffer.recording, + stepCount: buffer.events.length, + startedAt: buffer.startedAt, + pending: buffer.pending, + }; +} + +function clearRecorderBuffer(buffer: RecorderBuffer): void { + if (buffer.pendingTimer) clearTimeout(buffer.pendingTimer); + buffer.recording = false; + buffer.pending = false; + buffer.startedAt = null; + buffer.events = []; + buffer.screenshots = new Map(); + buffer.moveTrack = []; + buffer.video = []; + buffer.preparedAttachmentId = null; + buffer.mediaPromise = null; + buffer.pendingTimer = null; +} + +/** pending recordings the user never answered are auto-saved (data safety) */ +const PENDING_TIMEOUT_MS = 10 * 60 * 1000; + +function autoTitle(buffer: RecorderBuffer): string { + return `Recording ${new Date().toLocaleString()} — ${buffer.pageTitle ?? "page"}`; +} + +/** + * Submits the pending recording (composer Save, or the auto-save timeout). + * The media upload usually finished while the composer was open; if it is + * still in flight it is awaited, and a failed upload still submits the steps. + */ +async function finalizePendingRecording( + buffer: RecorderBuffer, + tabId: number, + title?: string, + description?: string +): Promise<{ report_token: string } | null> { + const hadEvents = buffer.events.length > 0; + if (!hadEvents) { + clearRecorderBuffer(buffer); + return null; + } + let attachmentId = buffer.preparedAttachmentId; + if (!attachmentId && buffer.mediaPromise) { + attachmentId = await buffer.mediaPromise.catch(() => null); + } + const settings = await getSettings(); + const steps = buffer.events.map((event) => ({ + type: event.type, + offset_ms: buffer.startedAt ? Math.max(event.at - buffer.startedAt, 0) : 0, + data: event.data, + attachment_id: null, + })); + let result: { report_token: string } | null = null; + try { + result = await submitRecordingReport({ + payload: { + type: "recording", + title: title?.trim() || autoTitle(buffer), + description: description?.trim() || null, + page_url: buffer.pageUrl, + page_title: buffer.pageTitle, + environment: buffer.environment ?? {}, + steps, + attachment_ids: attachmentId ? [attachmentId] : [], + mouse_track: buildMouseTrack(buffer), + }, + settings, + }); + debugLog(`recording submitted: ${JSON.stringify(result)}`); + } catch (error) { + debugLog(`recording submit failed: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } finally { + clearRecorderBuffer(buffer); + void notifyPendingCleared(tabId); + } + return result; +} + +function discardPendingRecording(buffer: RecorderBuffer, tabId: number): void { + debugLog("recording discarded"); + clearRecorderBuffer(buffer); + void notifyPendingCleared(tabId); +} + +/** tells the page's overlay the pending composer can go away */ +async function notifyPendingCleared(tabId: number): Promise { + await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false, pending: false }).catch(() => {}); +} + async function recorderStart(tabId: number): Promise { const buffer = recorder(tabId); + // a pending recording that was never answered is auto-saved first — + // one buffer per tab, and its data must not be lost + if (buffer.pending) void finalizePendingRecording(buffer, tabId).catch(() => {}); const tab = await browser.tabs.get(tabId); buffer.recording = true; + buffer.pending = false; + if (buffer.pendingTimer) clearTimeout(buffer.pendingTimer); + buffer.pendingTimer = null; buffer.startedAt = Date.now(); buffer.events = []; buffer.screenshots = new Map(); @@ -117,6 +230,8 @@ buffer.videoActive = false; buffer.lastVideoAt = 0; buffer.consoleCount = 0; + buffer.preparedAttachmentId = null; + buffer.mediaPromise = null; buffer.pageUrl = tab.url ?? null; buffer.pageTitle = tab.title ?? null; buffer.lastUrl = tab.url ?? null; @@ -126,14 +241,6 @@ return recorderStateOf(buffer); } -function recorderStateOf(buffer: RecorderBuffer): RecorderState { - return { - recording: buffer.recording, - stepCount: buffer.events.length, - startedAt: buffer.startedAt, - }; -} - /** Builds the persisted mouse track: offsets from recording start, capped. */ function buildMouseTrack(buffer: RecorderBuffer): { viewport: { w: number; h: number }; points: { t: number; x: number; y: number }[] } | null { if (!buffer.moveTrack.length || !buffer.startedAt) return null; @@ -149,6 +256,13 @@ return { viewport: { w: viewport.w, h: viewport.h }, points }; } +/** + * Stops the capture side of the recording and parks it as *pending*: the + * buffer stays alive while the composer (title/description + Save/Discard) + * is open, and the media upload runs in parallel so it is usually done by + * the time the user hits Save. The user's answer lands via recorder_submit / + * recorder_discard; an unanswered pending recording auto-saves on timeout. + */ async function recorderStop(tabId: number): Promise { const buffer = recorder(tabId); ensureRecordingFreshness(buffer); @@ -156,6 +270,7 @@ // the screencast must stop before anything else can race its frames void stopScreenVideo(tabId); buffer.videoActive = false; + buffer.recording = false; // collect the tail of the cursor track before anything else can race it const flushed = (await browser.tabs.sendMessage(tabId, { type: "recorder_flush_moves" }).catch(() => null)) as @@ -163,54 +278,49 @@ | null; if (flushed?.points?.length) buffer.moveTrack.push(...flushed.points); - const settings = await getSettings(); - const gifFileId = await uploadTrackGif(buffer); - const steps = buffer.events.map((event) => ({ - type: event.type, - offset_ms: buffer.startedAt ? Math.max(event.at - buffer.startedAt, 0) : 0, - data: event.data, - attachment_id: null, - })); + // media encoding + upload runs while the composer is open (not awaited) + const startedAt = buffer.startedAt ?? Date.now(); + const mediaPromise = uploadTrackGif(buffer, startedAt) + .then((id) => { + buffer.preparedAttachmentId = id; + return id; + }) + .catch((error) => { + debugLog(`recording media failed: ${error instanceof Error ? error.message : String(error)}`); + return null; + }); + buffer.mediaPromise = mediaPromise; if (buffer.events.length > 0) { - await submitRecordingReport({ - payload: { - type: "recording", - title: `Recording ${new Date().toLocaleString()} — ${buffer.pageTitle ?? "page"}`, - description: null, - page_url: buffer.pageUrl, - page_title: buffer.pageTitle, - environment: buffer.environment ?? {}, - steps, - attachment_ids: gifFileId ? [gifFileId] : [], - mouse_track: buildMouseTrack(buffer), - }, - settings, - }).catch(async (error) => { - // surface submit failure to the content script - buffer.recording = false; - throw error; - }); + buffer.pending = true; + if (buffer.pendingTimer) clearTimeout(buffer.pendingTimer); + buffer.pendingTimer = setTimeout(() => { + // the composer was never answered — auto-save so nothing is lost + void finalizePendingRecording(buffer, tabId).catch(() => {}); + }, PENDING_TIMEOUT_MS); + debugLog(`recording pending: ${buffer.events.length} steps`); + } else { + // nothing recorded — clear silently + clearRecorderBuffer(buffer); } - - buffer.recording = false; - buffer.startedAt = null; - buffer.events = []; - buffer.screenshots = new Map(); - buffer.moveTrack = []; - buffer.video = []; return recorderStateOf(buffer); } /** - * Uploads the recorded screen as a silent WebM video; when video encoding - * isn't possible (Firefox, WebCodecs missing) falls back to the cursor GIF - * and then to per-event screenshots. Returns the uploaded file id. + * Uploads the recorded screen as a silent WebM video (cursor-following crop + * when the viewport is known); when video encoding isn't possible (Firefox, + * WebCodecs missing) falls back to the cursor GIF and then to per-event + * screenshots. Returns the uploaded file id. */ -async function uploadTrackGif(buffer: RecorderBuffer): Promise { +async function uploadTrackGif(buffer: RecorderBuffer, startedAt: number): Promise { if (buffer.video.length) { + const viewport = (buffer.environment?.viewport as { w?: number; h?: number } | undefined) ?? null; + const crop = + viewport?.w && viewport?.h + ? { viewport: { w: viewport.w, h: viewport.h }, startedAt, track: buildMouseTrack(buffer)?.points ?? [], size: 512 } + : undefined; try { - const video = await encodeVideoOffscreen(buffer.video, buffer.startedAt ?? Date.now()); + const video = await encodeVideoOffscreen(buffer.video, crop); if (video) return await uploadRecordingMedia(video, "recording.webm"); debugLog("screen video failed: offscreen API unavailable"); } catch (error) { @@ -218,13 +328,11 @@ } try { // video encoding failed but the screencast frames are here — cursor GIF - const viewport = (buffer.environment?.viewport as { w?: number; h?: number } | undefined) ?? null; - const track = buildMouseTrack(buffer)?.points ?? []; - const blob = await buildCursorGif(buffer.video, { + const blob = await buildCursorGif(buffer.video, crop ?? { size: 512, - viewport: viewport?.w && viewport?.h ? { w: viewport.w, h: viewport.h } : { w: 1280, h: 720 }, - startedAt: buffer.startedAt ?? Date.now(), - track, + viewport: { w: 1280, h: 720 }, + startedAt, + track: [], }); if (blob) return await uploadRecordingMedia(blob, "recording.gif"); debugLog("cursor gif failed: no frames encoded"); @@ -305,7 +413,7 @@ * WebM back. Runs over a dedicated runtime port: the service worker's own * async onMessage listener would win the response race for plain messages. */ -async function encodeVideoOffscreen(frames: VideoFrame[], startedAt: number): Promise { +async function encodeVideoOffscreen(frames: VideoFrame[], crop?: import("./gif").CursorCropOptions): Promise { const api = await ensureOffscreenDocument(); if (!api) return null; try { @@ -345,7 +453,7 @@ } } if (batch.length) port.postMessage({ type: "encode_video_frames", frames: batch }); - port.postMessage({ type: "encode_video_run" }); + port.postMessage({ type: "encode_video_run", crop: crop ?? null }); }); } finally { await api.closeDocument().catch(() => {}); @@ -382,7 +490,7 @@ async function startRecorder(tabId: number): Promise { await ensureContentScript(tabId); const state = await recorderStart(tabId); - await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true }).catch(() => {}); + await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true, pending: false }).catch(() => {}); return state; } @@ -390,7 +498,7 @@ const buffer = recorder(tabId); if (buffer.recording) { const state = await recorderStop(tabId); - await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false }).catch(() => {}); + await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false, pending: state.pending }).catch(() => {}); return state; } return startRecorder(tabId); @@ -448,8 +556,11 @@ /** ~15 fps of 512px squares; bounded memory, decimated when overflowing */ const VIDEO_MIN_INTERVAL_MS = 66; const MAX_VIDEO_FRAMES = 600; -/** screencast JPEGs are downscaled by CDP; the crop needs some headroom */ -const VIDEO_MAX_DIMENSION = 800; +/** + * screencast JPEGs are downscaled by CDP; 1280 keeps the 512px cursor crop + * at native resolution for common viewports (DPR 1) and sharp under downscale + */ +const VIDEO_MAX_DIMENSION = 1280; /** * Starts a CDP screencast on the tab so the recording gets continuous frames @@ -743,8 +854,8 @@ if (tab?.id == null) return; const buffer = recorder(tab.id); if (buffer.recording) { - await recorderStop(tab.id).catch(console.error); - await browser.tabs.sendMessage(tab.id, { type: "recorder_state", recording: false }).catch(() => {}); + const state = await recorderStop(tab.id).catch(console.error); + await browser.tabs.sendMessage(tab.id, { type: "recorder_state", recording: false, pending: state?.pending }).catch(() => {}); } else { await startRecorder(tab.id).catch(console.error); } @@ -767,10 +878,14 @@ if (!buffer.videoActive) void captureForEvent(tabId, eventIndex); } // a full navigation destroyed the page's capture listeners — reinstall them - // (and the recorder bar) once the new document is ready + // (and the recorder bar) once the new document is ready; a pending composer + // must survive navigation too, so the new document re-opens it if (changeInfo.status === "complete") { await ensureContentScript(tabId); - await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true }).catch(() => {}); + await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true, pending: false }).catch(() => {}); + if (buffer.pending) { + await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false, pending: true }).catch(() => {}); + } } }); @@ -952,6 +1067,30 @@ } } + case "recorder_submit": { + // the recording composer's Save — finalize the pending buffer + const buffer = tabId != null ? recorders.get(tabId) : undefined; + if (!buffer?.pending) return { ok: false, error: "No pending recording" }; + try { + const result = await finalizePendingRecording( + buffer, + tabId!, + typeof msg.title === "string" ? msg.title : undefined, + typeof msg.description === "string" ? msg.description : undefined + ); + return { ok: true, data: result ?? { report_token: null } }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + case "recorder_discard": { + const buffer = tabId != null ? recorders.get(tabId) : undefined; + if (!buffer?.pending) return { ok: false, error: "No pending recording" }; + discardPendingRecording(buffer, tabId!); + return { ok: true }; + } + case "recorder_state_request": { const buffer = tabId != null ? recorders.get(tabId) : undefined; return { ok: true, data: buffer ? recorderStateOf(buffer) : { recording: false, stepCount: 0, startedAt: null } }; diff --git a/packages/extension/src/background/video.ts b/packages/extension/src/background/video.ts index 555319b..327803f 100644 --- a/packages/extension/src/background/video.ts +++ b/packages/extension/src/background/video.ts @@ -1,11 +1,14 @@ /** * Encodes the recorded screencast frames into a silent WebM video. WebCodecs * VideoEncoder is not exposed to service workers, so this runs in the - * offscreen document (hosted by src/offscreen/main.ts). Returns null when - * WebCodecs is unavailable (Firefox) so the caller can fall back to the GIF - * path. + * offscreen document (hosted by src/offscreen/main.ts). With cursor crop + * options the output is a square that follows the cursor (like the GIF + * path); without them the whole frame is downscaled to 800px wide. Returns + * null when WebCodecs is unavailable (Firefox) so the caller can fall back + * to the GIF path. */ import { Muxer, ArrayBufferTarget } from "webm-muxer"; +import { cursorCropRect, type CursorCropOptions } from "./gif"; import type { VideoFrame } from "./gif"; /** VP8 needs even dimensions. */ @@ -13,14 +16,25 @@ return Math.max(2, Math.floor(value / 2) * 2); } -export async function buildVideoWebm(frames: import("./gif").VideoFrame[]): Promise { +export async function buildVideoWebm(frames: VideoFrame[], crop?: CursorCropOptions): Promise { if (!frames.length || typeof VideoEncoder === "undefined") return null; - // fixed output size: first frame's dimensions capped at 800px wide - const first = frames[0]; - const scale = Math.min(1, 800 / first.w); - const width = even(first.w * scale); - const height = even(first.h * scale); + // 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 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 muxer = new Muxer({ target: new ArrayBufferTarget(), @@ -54,7 +68,15 @@ const image = await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" })); try { - context.drawImage(image, 0, 0, width, height); + if (crop && crop.viewport.w > 0 && crop.viewport.h > 0) { + const { sx, sy, s } = cursorCropRect(frame, crop); + // letterbox fill guards against rounding gaps on the edge px + context.fillStyle = "#10121c"; + context.fillRect(0, 0, width, height); + context.drawImage(image, sx, sy, s, s, 0, 0, width, height); + } else { + context.drawImage(image, 0, 0, width, height); + } } finally { image.close(); } diff --git a/packages/extension/src/content/index.ts b/packages/extension/src/content/index.ts index f3d748f..b31564d 100644 --- a/packages/extension/src/content/index.ts +++ b/packages/extension/src/content/index.ts @@ -314,7 +314,8 @@ case "recorder_state": { const recording = Boolean((message as { recording?: unknown }).recording); - getOverlay().setRecorderState(recording); + const pending = Boolean((message as { pending?: unknown }).pending); + getOverlay().setRecorderState(recording, pending); if (recording) { moveSamples = []; lastMove = null; diff --git a/packages/extension/src/content/overlay/App.vue b/packages/extension/src/content/overlay/App.vue index 0d21059..e4fb6e4 100644 --- a/packages/extension/src/content/overlay/App.vue +++ b/packages/extension/src/content/overlay/App.vue @@ -7,6 +7,7 @@ import RegionSelector from "./RegionSelector.vue"; import NoteComposer from "./NoteComposer.vue"; import RecorderBar from "./RecorderBar.vue"; +import RecordingComposer from "./RecordingComposer.vue"; const props = defineProps<{ state: OverlayState }>(); @@ -32,6 +33,16 @@ await copyReportLink(reportToken); } +/** recording composer: Save → share link like a note, Discard → just close */ +async function onRecordingSaved(reportToken: string) { + props.state.pending = false; + if (reportToken) await copyReportLink(reportToken); +} + +function onRecordingDiscarded() { + props.state.pending = false; +} + /** * Share links are copied automatically so they can go straight into the * bug tracker. The page is usually focused (the user just clicked around), @@ -122,6 +133,12 @@ @note="state.mode = 'picker'" @stop="stopRecorder" /> +
Link copied to clipboard diff --git a/packages/extension/src/content/overlay/RecordingComposer.vue b/packages/extension/src/content/overlay/RecordingComposer.vue new file mode 100644 index 0000000..4a7d8b1 --- /dev/null +++ b/packages/extension/src/content/overlay/RecordingComposer.vue @@ -0,0 +1,195 @@ + + +