Skip to content

Commit ca91aec

Browse files
maria-rcksrynfar
authored andcommitted
fix(preview): improve browser recording quality (#8839)
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 3958111057c10c10350dd9c20ec2a2df00f504be)
1 parent 9ade80d commit ca91aec

15 files changed

Lines changed: 861 additions & 793 deletions

apps/desktop/src/ipc/methods/preview.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
DesktopPreviewConfigInputSchema,
1212
DesktopPreviewNavigateInputSchema,
1313
DesktopPreviewRecordingArtifactSchema,
14+
DesktopPreviewRecordingSourceSchema,
1415
DesktopPreviewRecordingSaveInputSchema,
1516
DesktopPreviewRegisterWebviewInputSchema,
1617
DesktopPreviewScreenshotArtifactSchema,
@@ -173,11 +174,15 @@ export const cancelPickElement = tabMethod(
173174
"desktop.ipc.preview.cancelPickElement",
174175
(manager, tabId) => manager.cancelPickElement(tabId),
175176
);
176-
export const startRecording = tabMethod(
177-
IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
178-
"desktop.ipc.preview.startRecording",
179-
(manager, tabId) => manager.startRecording(tabId),
180-
);
177+
export const startRecording = DesktopIpc.makeIpcMethod({
178+
channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
179+
payload: DesktopPreviewTabInputSchema,
180+
result: DesktopPreviewRecordingSourceSchema,
181+
handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) {
182+
const manager = yield* PreviewManager.PreviewManager;
183+
return yield* manager.startRecording(tabId);
184+
}),
185+
});
181186
export const stopRecording = tabMethod(
182187
IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL,
183188
"desktop.ipc.preview.stopRecording",

apps/desktop/src/preview/Manager.test.ts

Lines changed: 180 additions & 319 deletions
Large diffs are not rendered by default.

apps/desktop/src/preview/Manager.ts

Lines changed: 150 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
PreviewAnnotationSubmissionResult,
1717
DesktopPreviewRecordingArtifact,
1818
DesktopPreviewRecordingFrame,
19+
DesktopPreviewRecordingSource,
1920
DesktopPreviewScreenshotArtifact,
2021
DesktopPreviewTabDefaults,
2122
PreviewAutomationClickInput,
@@ -108,8 +109,10 @@ const MAX_EVALUATION_BYTES = 64_000;
108109
const MAX_VISIBLE_TEXT_LENGTH = 20_000;
109110
const MAX_INTERACTIVE_ELEMENTS = 200;
110111
const MAX_SCREENSHOT_WIDTH = 1280;
111-
const RECORDING_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
112-
const RECORDING_JPEG_QUALITY = 80;
112+
const RECORDING_SOURCE_SIZE_EXPRESSION =
113+
"({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })";
114+
const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
115+
const PICTURE_IN_PICTURE_JPEG_QUALITY = 80;
113116
const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;
114117
const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
115118
const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
@@ -188,6 +191,12 @@ export const fitPictureInPictureContentSize = (
188191
return [Math.round(width), Math.round(height)];
189192
};
190193

194+
export const recordingFileExtension = (mimeType: string): string => {
195+
const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? "";
196+
const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, "");
197+
return extension || "video";
198+
};
199+
191200
const artifactSiteSlug = (rawUrl: string): string => {
192201
try {
193202
const url = new URL(rawUrl);
@@ -379,7 +388,7 @@ interface ManagedListeners {
379388
type FrameCaptureConsumer = "picture-in-picture" | "recording";
380389

381390
interface FrameCaptureSession {
382-
readonly scope: Scope.Closeable;
391+
readonly scope: Scope.Closeable | null;
383392
readonly consumers: ReadonlySet<FrameCaptureConsumer>;
384393
readonly lastPictureInPictureFrame: Buffer | null;
385394
}
@@ -675,10 +684,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
675684
consumers.delete(consumer);
676685
if (consumers.size > 0) {
677686
return [
678-
undefined,
687+
consumer === "picture-in-picture" ? current.scope : undefined,
679688
replaceMap(sessions, (copy) => {
680689
copy.set(tabId, {
681690
...current,
691+
scope: consumer === "picture-in-picture" ? null : current.scope,
682692
consumers,
683693
lastPictureInPictureFrame:
684694
consumer === "picture-in-picture" ? null : current.lastPictureInPictureFrame,
@@ -2580,7 +2590,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
25802590
tabId: string,
25812591
) {
25822592
const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
2583-
if (!captureSession) return;
2593+
if (!captureSession?.consumers.has("picture-in-picture") || captureSession.scope === null)
2594+
return;
25842595
const wc = yield* requireWebContents(tabId);
25852596
const image = yield* attemptPromise(
25862597
{
@@ -2626,15 +2637,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26262637
tabId,
26272638
webContentsId: wc.id,
26282639
},
2629-
() => image.toJPEG(RECORDING_JPEG_QUALITY),
2640+
() => image.toJPEG(PICTURE_IN_PICTURE_JPEG_QUALITY),
26302641
);
26312642
const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
26322643
if (frameSession?.scope !== captureSession.scope) return;
2633-
const recording = frameSession.consumers.has("recording");
26342644
const pictureInPicture =
26352645
frameSession.consumers.has("picture-in-picture") &&
26362646
frameSession.lastPictureInPictureFrame?.equals(encoded) !== true;
2637-
if (!recording && !pictureInPicture) return;
2647+
if (!pictureInPicture) return;
26382648
const receivedAt = yield* currentIso;
26392649
const frame: DesktopPreviewRecordingFrame = {
26402650
tabId,
@@ -2644,16 +2654,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26442654
receivedAt,
26452655
};
26462656
const deliveries: Array<Effect.Effect<void>> = [];
2647-
if (recording) {
2648-
const listeners = yield* Ref.get(recordingFrameListenersRef);
2649-
deliveries.push(
2650-
Effect.forEach(
2651-
listeners,
2652-
(listener) => deliverEvent("recording-frame", frame.tabId, () => listener(frame)),
2653-
{ discard: true },
2654-
),
2655-
);
2656-
}
26572657
if (pictureInPicture) {
26582658
const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(
26592659
tabId,
@@ -2731,12 +2731,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
27312731
tabId: string,
27322732
consumer: FrameCaptureConsumer,
27332733
) {
2734-
// Validate the tab synchronously, but treat capturePage failures as
2735-
// transient. Chromium can return UnknownVizError while a hidden guest is
2736-
// warming its first compositor frame; the scheduled loop should keep the
2737-
// consumer alive and recover instead of tearing recording/PiP back down.
2734+
// Recording keeps only the activity lease. Picture-in-picture owns the
2735+
// capturePage loop and tolerates transient compositor warmup failures.
27382736
yield* requireWebContents(tabId);
2739-
const captureNextFrame = Effect.sleep(RECORDING_FRAME_INTERVAL_MS).pipe(
2737+
const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe(
27402738
Effect.andThen(capturePreviewFrame(tabId)),
27412739
Effect.catch((error) =>
27422740
Effect.logWarning("Background preview frame capture failed.", {
@@ -2745,48 +2743,60 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
27452743
}),
27462744
),
27472745
);
2748-
const created = yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => {
2749-
return Effect.gen(function* () {
2750-
if (!frameCaptureWindowOpen) {
2751-
return yield* new PreviewMainWindowClosedError({ tabId });
2752-
}
2753-
const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2754-
if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) {
2755-
return yield* new PreviewTabNotFoundError({ tabId });
2756-
}
2757-
const current = sessions.get(tabId);
2758-
if (current) {
2759-
if (current.consumers.has(consumer)) {
2760-
return [false, sessions] as const;
2746+
const captureInitialFrame = yield* SynchronizedRef.modifyEffect(
2747+
frameCaptureSessionsRef,
2748+
(sessions) => {
2749+
return Effect.gen(function* () {
2750+
if (!frameCaptureWindowOpen) {
2751+
return yield* new PreviewMainWindowClosedError({ tabId });
2752+
}
2753+
const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2754+
if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) {
2755+
return yield* new PreviewTabNotFoundError({ tabId });
2756+
}
2757+
const current = sessions.get(tabId);
2758+
if (current) {
2759+
if (current.consumers.has(consumer)) {
2760+
return [false, sessions] as const;
2761+
}
2762+
let scope = current.scope;
2763+
if (consumer === "picture-in-picture" && scope === null) {
2764+
scope = yield* Scope.fork(parentScope, "sequential");
2765+
yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
2766+
}
2767+
return [
2768+
consumer === "picture-in-picture",
2769+
replaceMap(sessions, (copy) => {
2770+
copy.set(tabId, {
2771+
...current,
2772+
scope,
2773+
consumers: new Set([...current.consumers, consumer]),
2774+
});
2775+
}),
2776+
] as const;
2777+
}
2778+
if (sessions.size === 0) {
2779+
yield* setFrameCaptureBackgroundThrottling(false);
2780+
}
2781+
const scope =
2782+
consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null;
2783+
if (scope !== null) {
2784+
yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
27612785
}
27622786
return [
2763-
false,
2787+
consumer === "picture-in-picture",
27642788
replaceMap(sessions, (copy) => {
27652789
copy.set(tabId, {
2766-
...current,
2767-
consumers: new Set([...current.consumers, consumer]),
2790+
scope,
2791+
consumers: new Set([consumer]),
2792+
lastPictureInPictureFrame: null,
27682793
});
27692794
}),
27702795
] as const;
2771-
}
2772-
if (sessions.size === 0) {
2773-
yield* setFrameCaptureBackgroundThrottling(false);
2774-
}
2775-
const scope = yield* Scope.fork(parentScope, "sequential");
2776-
yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
2777-
return [
2778-
true,
2779-
replaceMap(sessions, (copy) => {
2780-
copy.set(tabId, {
2781-
scope,
2782-
consumers: new Set([consumer]),
2783-
lastPictureInPictureFrame: null,
2784-
});
2785-
}),
2786-
] as const;
2787-
});
2788-
}).pipe(Effect.uninterruptible);
2789-
if (!created) return;
2796+
});
2797+
},
2798+
).pipe(Effect.uninterruptible);
2799+
if (!captureInitialFrame) return;
27902800
yield* capturePreviewFrame(tabId).pipe(
27912801
Effect.catch((error) =>
27922802
Effect.logWarning("Initial background preview frame was not ready; capture will retry.", {
@@ -3095,11 +3105,77 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
30953105
});
30963106

30973107
const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
3098-
yield* startFrameCapture(tabId, "recording");
3108+
if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) {
3109+
return yield* new PreviewTabNotFoundError({ tabId });
3110+
}
3111+
return yield* withTabLifecycleLock(
3112+
tabId,
3113+
Effect.gen(function* () {
3114+
yield* startFrameCapture(tabId, "recording");
3115+
const wc = yield* requireWebContents(tabId);
3116+
const requestWebContents = wc.hostWebContents;
3117+
if (requestWebContents === null) {
3118+
return yield* new PreviewMainWindowClosedError({ tabId });
3119+
}
3120+
const measuredSize = yield* attemptPromise(
3121+
{
3122+
operation: "recording.measureSource",
3123+
tabId,
3124+
webContentsId: wc.id,
3125+
},
3126+
() => wc.executeJavaScript(RECORDING_SOURCE_SIZE_EXPRESSION, true),
3127+
);
3128+
if (
3129+
typeof measuredSize !== "object" ||
3130+
measuredSize === null ||
3131+
!("width" in measuredSize) ||
3132+
!("height" in measuredSize) ||
3133+
typeof measuredSize.width !== "number" ||
3134+
typeof measuredSize.height !== "number" ||
3135+
!Number.isInteger(measuredSize.width) ||
3136+
!Number.isInteger(measuredSize.height) ||
3137+
measuredSize.width <= 0 ||
3138+
measuredSize.height <= 0
3139+
) {
3140+
return yield* new PreviewRecordingSourceSizeUnavailableError({
3141+
tabId,
3142+
webContentsId: wc.id,
3143+
});
3144+
}
3145+
yield* attemptPromise(
3146+
{
3147+
operation: "recording.warmSource",
3148+
tabId,
3149+
webContentsId: wc.id,
3150+
},
3151+
() => wc.capturePage().then(() => undefined),
3152+
).pipe(Effect.retry({ times: 1 }), Effect.ignore);
3153+
const currentWebContents = yield* requireWebContents(tabId);
3154+
if (currentWebContents !== wc || wc.isDestroyed()) {
3155+
return yield* new PreviewWebContentsNotFoundError({
3156+
tabId,
3157+
webContentsId: wc.id,
3158+
});
3159+
}
3160+
const sourceId = yield* attempt(
3161+
{
3162+
operation: "recording.getMediaSourceId",
3163+
tabId,
3164+
webContentsId: wc.id,
3165+
},
3166+
() => wc.getMediaSourceId(requestWebContents),
3167+
);
3168+
return {
3169+
sourceId,
3170+
width: measuredSize.width,
3171+
height: measuredSize.height,
3172+
} satisfies DesktopPreviewRecordingSource;
3173+
}).pipe(Effect.onError(() => stopFrameCapture(tabId, "recording").pipe(Effect.ignore))),
3174+
);
30993175
});
31003176

31013177
const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) {
3102-
yield* stopFrameCapture(tabId, "recording");
3178+
yield* withTabLifecycleLock(tabId, stopFrameCapture(tabId, "recording"));
31033179
});
31043180

31053181
const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* (
@@ -3109,7 +3185,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
31093185
) {
31103186
const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]);
31113187
const id = `browser-recording-${millis.toString(36)}`;
3112-
const extension = mimeType.includes("mp4") ? "mp4" : "webm";
3188+
const extension = recordingFileExtension(mimeType);
31133189
const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);
31143190
yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
31153191
Effect.mapError(
@@ -3909,6 +3985,15 @@ export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass<Previe
39093985
}
39103986
}
39113987

3988+
export class PreviewRecordingSourceSizeUnavailableError extends Schema.TaggedErrorClass<PreviewRecordingSourceSizeUnavailableError>()(
3989+
"PreviewRecordingSourceSizeUnavailableError",
3990+
{ tabId: Schema.String, webContentsId: Schema.Number },
3991+
) {
3992+
override get message(): string {
3993+
return `Preview media source dimensions are unavailable for tab ${this.tabId}`;
3994+
}
3995+
}
3996+
39123997
export class PreviewOperationError extends Schema.TaggedErrorClass<PreviewOperationError>()(
39133998
"PreviewOperationError",
39143999
{
@@ -4116,6 +4201,7 @@ export const PreviewManagerError = Schema.Union([
41164201
PreviewWebContentsNotFoundError,
41174202
PreviewWebviewNotInitializedError,
41184203
PreviewMainWindowClosedError,
4204+
PreviewRecordingSourceSizeUnavailableError,
41194205
PreviewOperationError,
41204206
PreviewArtifactPathOutsideDirectoryError,
41214207
PreviewArtifactImageLoadError,
@@ -4193,7 +4279,9 @@ export class PreviewManager extends Context.Service<
41934279
readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>;
41944280
readonly openPictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
41954281
readonly closePictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
4196-
readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
4282+
readonly startRecording: (
4283+
tabId: string,
4284+
) => Effect.Effect<DesktopPreviewRecordingSource, PreviewManagerError>;
41974285
readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
41984286
readonly saveRecording: (
41994287
tabId: string,

apps/desktop/src/settings/DesktopClientSettings.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const clientSettings: ClientSettings = {
1717
browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
1818
browserDefaultZoomFactor: 1.25,
1919
browserDefaultAppearance: "dark",
20+
browserRecordingFrameRate: 60,
2021
browserAutoShowFloatingPreview: false,
2122
confirmQuit: true,
2223
confirmThreadArchive: true,

0 commit comments

Comments
 (0)