@@ -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;
108109const MAX_VISIBLE_TEXT_LENGTH = 20_000 ;
109110const MAX_INTERACTIVE_ELEMENTS = 200 ;
110111const 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 ;
113116const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480 ;
114117const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320 ;
115118const 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 - z 0 - 9 ] / g, "" ) ;
197+ return extension || "video" ;
198+ } ;
199+
191200const artifactSiteSlug = ( rawUrl : string ) : string => {
192201 try {
193202 const url = new URL ( rawUrl ) ;
@@ -379,7 +388,7 @@ interface ManagedListeners {
379388type FrameCaptureConsumer = "picture-in-picture" | "recording" ;
380389
381390interface 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+
39123997export 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 ,
0 commit comments