diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index eff44f1..e05c299 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -256,14 +256,15 @@ const stream = out.streams?.[0]; console.log("ffprobe:", JSON.stringify({ codec: stream?.codec_name, size: `${stream?.width}x${stream?.height}`, duration: out.format?.duration })); // the cursor-following crop produces a square video around the - // cursor, capped at 512px (256 floor = the crop actually happened) + // cursor, capped at 768px (256 floor = the crop actually happened); + // at the e2e viewport (1280x720) the crop is 720px 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 <= 768 && width >= 256; } catch (error) { if (String(error).includes("ENOENT")) console.log("ffprobe: not installed, skipping"); diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 53b6d56..861d40c 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -245,9 +245,11 @@ async function uploadTrackGif(buffer: RecorderBuffer, startedAt: number): Promise { if (buffer.video.length) { const viewport = (buffer.environment?.viewport as { w?: number; h?: number } | undefined) ?? null; + // the WebM crop is generous (768 CSS px); the GIF fallback keeps 512 so + // its file size stays sane const crop = viewport?.w && viewport?.h - ? { viewport: { w: viewport.w, h: viewport.h }, startedAt, track: buildMouseTrack(buffer)?.points ?? [], size: 512 } + ? { viewport: { w: viewport.w, h: viewport.h }, startedAt, track: buildMouseTrack(buffer)?.points ?? [], size: 768 } : undefined; try { const video = await encodeVideoOffscreen(buffer.video, crop); @@ -258,12 +260,9 @@ } try { // video encoding failed but the screencast frames are here — cursor GIF - const blob = await buildCursorGif(buffer.video, crop ?? { - size: 512, - viewport: { w: 1280, h: 720 }, - startedAt, - track: [], - }); + // (always a 512px square, regardless of the video crop size) + const gifCrop = crop ? { ...crop, size: 512 } : { size: 512, viewport: { w: 1280, h: 720 }, startedAt, track: [] }; + const blob = await buildCursorGif(buffer.video, gifCrop); if (blob) return await uploadRecordingMedia(blob, "recording.gif"); debugLog("cursor gif failed: no frames encoded"); } catch (error) { @@ -482,14 +481,15 @@ /** tabs with a recording screencast attached */ const screencastTabs = new Set(); -/** ~15 fps of 512px squares; bounded memory, decimated when overflowing */ +/** ~15 fps of cursor-crop squares; bounded memory, decimated when overflowing */ const VIDEO_MIN_INTERVAL_MS = 66; -const MAX_VIDEO_FRAMES = 600; +const MAX_VIDEO_FRAMES = 400; /** - * screencast JPEGs are downscaled by CDP; 1280 keeps the 512px cursor crop - * at native resolution for common viewports (DPR 1) and sharp under downscale + * screencast JPEGs are downscaled by CDP; 2560 keeps the 768px cursor crop at + * native resolution up to a 2560px-wide viewport (DPR 1) and sharp under + * downscale beyond it */ -const VIDEO_MAX_DIMENSION = 1280; +const VIDEO_MAX_DIMENSION = 2560; /** * Starts a CDP screencast on the tab so the recording gets continuous frames diff --git a/packages/extension/src/background/video.ts b/packages/extension/src/background/video.ts index a3066ce..254dafa 100644 --- a/packages/extension/src/background/video.ts +++ b/packages/extension/src/background/video.ts @@ -56,7 +56,14 @@ failure.error = error instanceof Error ? error : new Error(String(error)); }, }); - encoder.configure({ codec: "vp8", width, height, bitrate: 1_200_000 }); + // ~0.18 bits per pixel per frame keeps VP8 readable at 15fps (capped so + // huge squares don't explode the upload) + encoder.configure({ + codec: "vp8", + width, + height, + bitrate: Math.min(4_000_000, Math.max(1_000_000, Math.round(width * height * 15 * 0.18))), + }); const canvas = new OffscreenCanvas(width, height); const context = canvas.getContext("2d"); diff --git a/packages/web/src/i18n/en.json b/packages/web/src/i18n/en.json index 652f497..7f0b600 100644 --- a/packages/web/src/i18n/en.json +++ b/packages/web/src/i18n/en.json @@ -84,6 +84,7 @@ "elementContext": "Element context", "environment": "Environment", "screenshot": "Screenshot", + "screenRecording": "Screen recording", "editAnnotations": "Edit annotations", "savingAnnotations": "Saving…", "openFullscreen": "Open fullscreen", diff --git a/packages/web/src/i18n/ru.json b/packages/web/src/i18n/ru.json index c251aa9..861536e 100644 --- a/packages/web/src/i18n/ru.json +++ b/packages/web/src/i18n/ru.json @@ -84,6 +84,7 @@ "elementContext": "Контекст элемента", "environment": "Окружение", "screenshot": "Скриншот", + "screenRecording": "Запись экрана", "editAnnotations": "Редактировать аннотации", "savingAnnotations": "Сохранение…", "openFullscreen": "Открыть на весь экран", diff --git a/packages/web/src/pages/ReportPage.vue b/packages/web/src/pages/ReportPage.vue index 2dfe91c..78ee8e7 100644 --- a/packages/web/src/pages/ReportPage.vue +++ b/packages/web/src/pages/ReportPage.vue @@ -128,7 +128,9 @@ const deleteOpen = ref(false); const screenshot = computed(() => report.value?.attachments.find((a) => a.kind === "screenshot" || a.kind === "annotation") ?? null); -const otherAttachments = computed(() => report.value?.attachments.filter((a) => a !== screenshot.value) ?? []); +/** the screen recording travels with the report — shown right in the details */ +const recordingVideo = computed(() => report.value?.attachments.find((a) => a.mime.startsWith("video/")) ?? null); +const otherAttachments = computed(() => report.value?.attachments.filter((a) => a !== screenshot.value && a !== recordingVideo.value) ?? []); /** The share token doubles as authorization for the file download URL — no cookies needed. */ function fileUrl(fileId: string) { @@ -336,6 +338,11 @@ /> + +

{{ t("report.screenRecording") }}

+
+

{{ t("report.elementContext") }}

@@ -413,7 +420,7 @@