Allow to override ffmpeg path and options - #41007

Closed
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality
Closed

Allow to override ffmpeg path and options#41007
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality

Conversation

@azmeuk

@azmeukÉloi Rivard (azmeuk) commented May 26, 2026

Copy link
Copy Markdown

Oops. I misclicked, I did not want to open the PR here yet, just on my own fork to trigger the CI. I'll update the description soon with all the details.

Sorry for that noise ☝️

I am a contributor of sphinxcontrib-screenshot, which is an extension that takes screenshots and screencasts for integration in Python sphinx documentations. I use it to automatically generate videos and demonstrate reactive behaviors in other apps documentations.

The current static quality settings generate some glitches in the videos, and too much blur. The end quality is not fitting professional documentation, so I opened this PR to add quality customization parameters that are passed to ffmpeg. I evoked this in #17217

This PR adds an optional field to recordVideo to control ffmpeg encoding quality:

recordVideo: {dir: 'videos/',size: {width: 640,height: 480},quality: {mode: 'crf',value: 30},// or { mode: 'bitrate', value: 1_000_000 }}
  • mode: 'crf' — constant rate factor (constant visual quality, variable file size). value is an integer between 0 (lossless) and 63 (worst).
  • mode: 'bitrate' — target bitrate (variable visual quality, predictable file size). value is in bits per second.

When quality is omitted, the ffmpeg command line stays identical to today. The tests just check that the command run. I am not really sure how to check deterministically which quality parameters were passed from the output video.

Let me know if the quality tweaking seems right but the implementation feel wrong, and I will update the PR.

Regards

related to to #22257

@azmeuk
Éloi Rivard (azmeuk) marked this pull request as draft May 26, 2026 19:54
@azmeuk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@azmeuk
Éloi Rivard (azmeuk)force-pushed the 17217-screencast-quality branch 2 times, most recently from d3cf0da to 7cad89aCompareMay 26, 2026 20:39
@azmeuk
Éloi Rivard (azmeuk) marked this pull request as ready for review May 26, 2026 20:45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather make it lower level and allow passing ffmpeg properties that would override our defaults.

@azmeuk

Copy link
Copy Markdown
Author

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

@Skn0tt

Simon Knott (Skn0tt) commented May 27, 2026

Copy link
Copy Markdown
Contributor

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

@pavelfeldman

Copy link
Copy Markdown
Member

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

That works for me.

@pavelfeldman

Copy link
Copy Markdown
Member

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

Browsers arsgs are generally used for additional args, which is not override-friendly. I'd use object notation as proposed - ignore-default-args is overall horrible.

@azmeukÉloi Rivard (azmeuk) changed the title Add quality option to recordVideoAllow to override ffmpeg path and optionsMay 28, 2026
@azmeuk

Éloi Rivard (azmeuk) commented May 28, 2026

Copy link
Copy Markdown
Author

Updated. So there are two options:

  • ffmpegPath which can be a path to a custom ffmpeg, for support for codecs additional than vp8
  • ffmpegOptions which is the form { 'c:v': 'libvpx-vp9', crf: 30, 'b:v': null } and can override the default options. null value can delete a default value

@pavelfeldman

Copy link
Copy Markdown
Member

I love the custom `ffmpeg path option. We discussed it at the API review meeting and are thinking that we want to give you full control over the options (both input and output, even though we produce the input), for simplicity. So we are now thinking that { ffmpegExecutable: string, ffmpegOptions: string, fps: number, outputExtension: string } should be sufficient for you to produce any content desired. Sorry for going back and forth on this one. Wdyt?

@azmeuk

Copy link
Copy Markdown
Author

Sounds good, thanks for the feedback. Give me a few days to work this out.

@azmeuk

Copy link
Copy Markdown
Author

Done, and tested in the dowstream project: tushuhei/sphinxcontrib-screenshot#118

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7207 passed, 1113 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

5 flaky⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-page] › page/page-emulate-media.spec.ts:144 › should keep reduced motion and color emulation after reload `@firefox-ubuntu-22.04-node20`
⚠️ [webkit-page] › page/page-set-input-files.spec.ts:38 › should upload a folder `@webkit-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:812 › should update state on subsequent run `@windows-latest-node20`

44002 passed, 870 skipped


Merge workflow run.

@Skn0tt

Simon Knott (Skn0tt) commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

We gave this another review as part of our release preparation, and decided against shipping this. The proposed public API around the ffmpeg input is very restrictive and makes it hard for Playwright to change its implementation, and we'd rather not lock this in.

The Screencast API we shipped in Playwright 1.59 allows implementing this in userland, and after consideration this is what we recommend for your usecase. Essentially you can take our videoRecorder.ts, change it to your liking, and hook page.screencast.start({ onFrame }) into FfmpegVideoRecorder#_writeFrame. Here's a draft of how that could look:

userland ffmpeg recording
importjpegjsfrom'jpeg-js';importchild_processfrom'child_process';constfps=25;functionmonotonicTime(): number{returnMath.floor(performance.now()*1000)/1000;}classFfmpegVideoRecorder{private_size: {width: number,height: number};private_process: child_process.ChildProcess;private_lastWritePromise: Promise<void>=Promise.resolve();private_firstFrameTimestamp: number=0;private_lastFrame: {timestamp: number,frameNumber: number,buffer: Buffer}|null=null;private_lastWriteNodeTime: number=0;private_frameQueue: Buffer[]=[];private_isStopped=false;constructor(ffmpegPath: string,size: {width: number,height: number},outputFile: string){if(!outputFile.endsWith('.webm'))thrownewError('File must have .webm extension');this._size=size;constw=this._size.width;consth=this._size.height;constargs=`-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${outputFile}`.split(' ');this._process=child_process.spawn(ffmpegPath,args,{stdio: 'pipe'});this._process.stdin!.on('finish',()=>{console.log('ffmpeg finished input.');});this._process.stdin!.on('error',()=>{console.log('ffmpeg error.');});}writeFrame(frame: Buffer,timestamp: number){this._writeFrame(frame,timestamp);}private_writeFrame(frame: Buffer,timestamp: number){if(this._isStopped)return;if(!this._firstFrameTimestamp)this._firstFrameTimestamp=timestamp;constframeNumber=Math.floor((timestamp-this._firstFrameTimestamp)*fps);if(this._lastFrame){constrepeatCount=frameNumber-this._lastFrame.frameNumber;for(leti=0;i<repeatCount;++i)this._frameQueue.push(this._lastFrame.buffer);this._lastWritePromise=this._lastWritePromise.then(()=>this._sendFrames());}this._lastFrame={buffer: frame, timestamp, frameNumber };this._lastWriteNodeTime=monotonicTime();}privateasync_sendFrames(){while(this._frameQueue.length)awaitthis._sendFrame(this._frameQueue.shift()!);}privateasync_sendFrame(frame: Buffer){returnnewPromise(f=>this._process!.stdin!.write(frame,f)).then(error=>{if(error)console.error('ffmpeg failed to write frame',error);});}async_stop(){// Only report the error on stop. This allows to make the constructor synchronous.if(this._isStopped)return;if(!this._lastFrame){// ffmpeg only creates a file upon some non-empty inputthis._writeFrame(createWhiteImage(this._size.width,this._size.height),monotonicTime());}// Pad with at least 1s of the last frame in the end for convenience.// This also ensures non-empty videos with 1 frame.constaddTime=Math.max((monotonicTime()-this._lastWriteNodeTime)/1000,1);this._writeFrame(Buffer.from([]),this._lastFrame!.timestamp+addTime);this._isStopped=true;try{awaitthis._lastWritePromise;this._process?.kill('SIGINT');}catch(e){console.error(e);}}}functioncreateWhiteImage(width: number,height: number): Buffer{constdata=Buffer.alloc(width*height*4,255);returnjpegjs.encode({ data, width, height },80).data;}constrecorder=newFfmpegVideoRecorder(outputFile,{width: 800,height: 600},'output.webm');page.screencast.start({onFrame: frame=>{// playwright we should probably expose frameSwapWillTime publically here.recorder.writeFrame(frame.data,Date.now()/1000);}});

This can probably be simplified some more. As noted at the bottom, we should probably expose frameswap time in our public API to make this solid, i'll see if we can get this released in 1.60.

Thanks for taking the time to work on this Pull request, and sorry that we have to reject it after going through multiple rounds!

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@azmeuk@Skn0tt@pavelfeldman
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Allow to override ffmpeg path and options - #41007

Closed
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality
Closed

Allow to override ffmpeg path and options#41007
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality

Conversation

@azmeuk

@azmeukÉloi Rivard (azmeuk) commented May 26, 2026

Copy link
Copy Markdown

Oops. I misclicked, I did not want to open the PR here yet, just on my own fork to trigger the CI. I'll update the description soon with all the details.

Sorry for that noise ☝️

I am a contributor of sphinxcontrib-screenshot, which is an extension that takes screenshots and screencasts for integration in Python sphinx documentations. I use it to automatically generate videos and demonstrate reactive behaviors in other apps documentations.

The current static quality settings generate some glitches in the videos, and too much blur. The end quality is not fitting professional documentation, so I opened this PR to add quality customization parameters that are passed to ffmpeg. I evoked this in #17217

This PR adds an optional field to recordVideo to control ffmpeg encoding quality:

recordVideo: {dir: 'videos/',size: {width: 640,height: 480},quality: {mode: 'crf',value: 30},// or { mode: 'bitrate', value: 1_000_000 }}
  • mode: 'crf' — constant rate factor (constant visual quality, variable file size). value is an integer between 0 (lossless) and 63 (worst).
  • mode: 'bitrate' — target bitrate (variable visual quality, predictable file size). value is in bits per second.

When quality is omitted, the ffmpeg command line stays identical to today. The tests just check that the command run. I am not really sure how to check deterministically which quality parameters were passed from the output video.

Let me know if the quality tweaking seems right but the implementation feel wrong, and I will update the PR.

Regards

related to to #22257

@azmeuk
Éloi Rivard (azmeuk) marked this pull request as draft May 26, 2026 19:54
@azmeuk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@azmeuk
Éloi Rivard (azmeuk)force-pushed the 17217-screencast-quality branch 2 times, most recently from d3cf0da to 7cad89aCompareMay 26, 2026 20:39
@azmeuk
Éloi Rivard (azmeuk) marked this pull request as ready for review May 26, 2026 20:45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather make it lower level and allow passing ffmpeg properties that would override our defaults.

@azmeuk

Copy link
Copy Markdown
Author

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

@Skn0tt

Simon Knott (Skn0tt) commented May 27, 2026

Copy link
Copy Markdown
Contributor

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

@pavelfeldman

Copy link
Copy Markdown
Member

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

That works for me.

@pavelfeldman

Copy link
Copy Markdown
Member

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

Browsers arsgs are generally used for additional args, which is not override-friendly. I'd use object notation as proposed - ignore-default-args is overall horrible.

@azmeukÉloi Rivard (azmeuk) changed the title Add quality option to recordVideoAllow to override ffmpeg path and optionsMay 28, 2026
@azmeuk

Éloi Rivard (azmeuk) commented May 28, 2026

Copy link
Copy Markdown
Author

Updated. So there are two options:

  • ffmpegPath which can be a path to a custom ffmpeg, for support for codecs additional than vp8
  • ffmpegOptions which is the form { 'c:v': 'libvpx-vp9', crf: 30, 'b:v': null } and can override the default options. null value can delete a default value

@pavelfeldman

Copy link
Copy Markdown
Member

I love the custom `ffmpeg path option. We discussed it at the API review meeting and are thinking that we want to give you full control over the options (both input and output, even though we produce the input), for simplicity. So we are now thinking that { ffmpegExecutable: string, ffmpegOptions: string, fps: number, outputExtension: string } should be sufficient for you to produce any content desired. Sorry for going back and forth on this one. Wdyt?

@azmeuk

Copy link
Copy Markdown
Author

Sounds good, thanks for the feedback. Give me a few days to work this out.

@azmeuk

Copy link
Copy Markdown
Author

Done, and tested in the dowstream project: tushuhei/sphinxcontrib-screenshot#118

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7207 passed, 1113 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

5 flaky⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-page] › page/page-emulate-media.spec.ts:144 › should keep reduced motion and color emulation after reload `@firefox-ubuntu-22.04-node20`
⚠️ [webkit-page] › page/page-set-input-files.spec.ts:38 › should upload a folder `@webkit-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:812 › should update state on subsequent run `@windows-latest-node20`

44002 passed, 870 skipped


Merge workflow run.

@Skn0tt

Simon Knott (Skn0tt) commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

We gave this another review as part of our release preparation, and decided against shipping this. The proposed public API around the ffmpeg input is very restrictive and makes it hard for Playwright to change its implementation, and we'd rather not lock this in.

The Screencast API we shipped in Playwright 1.59 allows implementing this in userland, and after consideration this is what we recommend for your usecase. Essentially you can take our videoRecorder.ts, change it to your liking, and hook page.screencast.start({ onFrame }) into FfmpegVideoRecorder#_writeFrame. Here's a draft of how that could look:

userland ffmpeg recording
importjpegjsfrom'jpeg-js';importchild_processfrom'child_process';constfps=25;functionmonotonicTime(): number{returnMath.floor(performance.now()*1000)/1000;}classFfmpegVideoRecorder{private_size: {width: number,height: number};private_process: child_process.ChildProcess;private_lastWritePromise: Promise<void>=Promise.resolve();private_firstFrameTimestamp: number=0;private_lastFrame: {timestamp: number,frameNumber: number,buffer: Buffer}|null=null;private_lastWriteNodeTime: number=0;private_frameQueue: Buffer[]=[];private_isStopped=false;constructor(ffmpegPath: string,size: {width: number,height: number},outputFile: string){if(!outputFile.endsWith('.webm'))thrownewError('File must have .webm extension');this._size=size;constw=this._size.width;consth=this._size.height;constargs=`-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${outputFile}`.split(' ');this._process=child_process.spawn(ffmpegPath,args,{stdio: 'pipe'});this._process.stdin!.on('finish',()=>{console.log('ffmpeg finished input.');});this._process.stdin!.on('error',()=>{console.log('ffmpeg error.');});}writeFrame(frame: Buffer,timestamp: number){this._writeFrame(frame,timestamp);}private_writeFrame(frame: Buffer,timestamp: number){if(this._isStopped)return;if(!this._firstFrameTimestamp)this._firstFrameTimestamp=timestamp;constframeNumber=Math.floor((timestamp-this._firstFrameTimestamp)*fps);if(this._lastFrame){constrepeatCount=frameNumber-this._lastFrame.frameNumber;for(leti=0;i<repeatCount;++i)this._frameQueue.push(this._lastFrame.buffer);this._lastWritePromise=this._lastWritePromise.then(()=>this._sendFrames());}this._lastFrame={buffer: frame, timestamp, frameNumber };this._lastWriteNodeTime=monotonicTime();}privateasync_sendFrames(){while(this._frameQueue.length)awaitthis._sendFrame(this._frameQueue.shift()!);}privateasync_sendFrame(frame: Buffer){returnnewPromise(f=>this._process!.stdin!.write(frame,f)).then(error=>{if(error)console.error('ffmpeg failed to write frame',error);});}async_stop(){// Only report the error on stop. This allows to make the constructor synchronous.if(this._isStopped)return;if(!this._lastFrame){// ffmpeg only creates a file upon some non-empty inputthis._writeFrame(createWhiteImage(this._size.width,this._size.height),monotonicTime());}// Pad with at least 1s of the last frame in the end for convenience.// This also ensures non-empty videos with 1 frame.constaddTime=Math.max((monotonicTime()-this._lastWriteNodeTime)/1000,1);this._writeFrame(Buffer.from([]),this._lastFrame!.timestamp+addTime);this._isStopped=true;try{awaitthis._lastWritePromise;this._process?.kill('SIGINT');}catch(e){console.error(e);}}}functioncreateWhiteImage(width: number,height: number): Buffer{constdata=Buffer.alloc(width*height*4,255);returnjpegjs.encode({ data, width, height },80).data;}constrecorder=newFfmpegVideoRecorder(outputFile,{width: 800,height: 600},'output.webm');page.screencast.start({onFrame: frame=>{// playwright we should probably expose frameSwapWillTime publically here.recorder.writeFrame(frame.data,Date.now()/1000);}});

This can probably be simplified some more. As noted at the bottom, we should probably expose frameswap time in our public API to make this solid, i'll see if we can get this released in 1.60.

Thanks for taking the time to work on this Pull request, and sorry that we have to reject it after going through multiple rounds!

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@azmeuk@Skn0tt@pavelfeldman
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Allow to override ffmpeg path and options - #41007

Closed
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality
Closed

Allow to override ffmpeg path and options#41007
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality

Conversation

@azmeuk

@azmeukÉloi Rivard (azmeuk) commented May 26, 2026

Copy link
Copy Markdown

Oops. I misclicked, I did not want to open the PR here yet, just on my own fork to trigger the CI. I'll update the description soon with all the details.

Sorry for that noise ☝️

I am a contributor of sphinxcontrib-screenshot, which is an extension that takes screenshots and screencasts for integration in Python sphinx documentations. I use it to automatically generate videos and demonstrate reactive behaviors in other apps documentations.

The current static quality settings generate some glitches in the videos, and too much blur. The end quality is not fitting professional documentation, so I opened this PR to add quality customization parameters that are passed to ffmpeg. I evoked this in #17217

This PR adds an optional field to recordVideo to control ffmpeg encoding quality:

recordVideo: {dir: 'videos/',size: {width: 640,height: 480},quality: {mode: 'crf',value: 30},// or { mode: 'bitrate', value: 1_000_000 }}
  • mode: 'crf' — constant rate factor (constant visual quality, variable file size). value is an integer between 0 (lossless) and 63 (worst).
  • mode: 'bitrate' — target bitrate (variable visual quality, predictable file size). value is in bits per second.

When quality is omitted, the ffmpeg command line stays identical to today. The tests just check that the command run. I am not really sure how to check deterministically which quality parameters were passed from the output video.

Let me know if the quality tweaking seems right but the implementation feel wrong, and I will update the PR.

Regards

related to to #22257

@azmeuk
Éloi Rivard (azmeuk) marked this pull request as draft May 26, 2026 19:54
@azmeuk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@azmeuk
Éloi Rivard (azmeuk)force-pushed the 17217-screencast-quality branch 2 times, most recently from d3cf0da to 7cad89aCompareMay 26, 2026 20:39
@azmeuk
Éloi Rivard (azmeuk) marked this pull request as ready for review May 26, 2026 20:45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather make it lower level and allow passing ffmpeg properties that would override our defaults.

@azmeuk

Copy link
Copy Markdown
Author

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

@Skn0tt

Simon Knott (Skn0tt) commented May 27, 2026

Copy link
Copy Markdown
Contributor

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

@pavelfeldman

Copy link
Copy Markdown
Member

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

That works for me.

@pavelfeldman

Copy link
Copy Markdown
Member

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

Browsers arsgs are generally used for additional args, which is not override-friendly. I'd use object notation as proposed - ignore-default-args is overall horrible.

@azmeukÉloi Rivard (azmeuk) changed the title Add quality option to recordVideoAllow to override ffmpeg path and optionsMay 28, 2026
@azmeuk

Éloi Rivard (azmeuk) commented May 28, 2026

Copy link
Copy Markdown
Author

Updated. So there are two options:

  • ffmpegPath which can be a path to a custom ffmpeg, for support for codecs additional than vp8
  • ffmpegOptions which is the form { 'c:v': 'libvpx-vp9', crf: 30, 'b:v': null } and can override the default options. null value can delete a default value

@pavelfeldman

Copy link
Copy Markdown
Member

I love the custom `ffmpeg path option. We discussed it at the API review meeting and are thinking that we want to give you full control over the options (both input and output, even though we produce the input), for simplicity. So we are now thinking that { ffmpegExecutable: string, ffmpegOptions: string, fps: number, outputExtension: string } should be sufficient for you to produce any content desired. Sorry for going back and forth on this one. Wdyt?

@azmeuk

Copy link
Copy Markdown
Author

Sounds good, thanks for the feedback. Give me a few days to work this out.

@azmeuk

Copy link
Copy Markdown
Author

Done, and tested in the dowstream project: tushuhei/sphinxcontrib-screenshot#118

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7207 passed, 1113 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

5 flaky⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-page] › page/page-emulate-media.spec.ts:144 › should keep reduced motion and color emulation after reload `@firefox-ubuntu-22.04-node20`
⚠️ [webkit-page] › page/page-set-input-files.spec.ts:38 › should upload a folder `@webkit-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:812 › should update state on subsequent run `@windows-latest-node20`

44002 passed, 870 skipped


Merge workflow run.

@Skn0tt

Simon Knott (Skn0tt) commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

We gave this another review as part of our release preparation, and decided against shipping this. The proposed public API around the ffmpeg input is very restrictive and makes it hard for Playwright to change its implementation, and we'd rather not lock this in.

The Screencast API we shipped in Playwright 1.59 allows implementing this in userland, and after consideration this is what we recommend for your usecase. Essentially you can take our videoRecorder.ts, change it to your liking, and hook page.screencast.start({ onFrame }) into FfmpegVideoRecorder#_writeFrame. Here's a draft of how that could look:

userland ffmpeg recording
importjpegjsfrom'jpeg-js';importchild_processfrom'child_process';constfps=25;functionmonotonicTime(): number{returnMath.floor(performance.now()*1000)/1000;}classFfmpegVideoRecorder{private_size: {width: number,height: number};private_process: child_process.ChildProcess;private_lastWritePromise: Promise<void>=Promise.resolve();private_firstFrameTimestamp: number=0;private_lastFrame: {timestamp: number,frameNumber: number,buffer: Buffer}|null=null;private_lastWriteNodeTime: number=0;private_frameQueue: Buffer[]=[];private_isStopped=false;constructor(ffmpegPath: string,size: {width: number,height: number},outputFile: string){if(!outputFile.endsWith('.webm'))thrownewError('File must have .webm extension');this._size=size;constw=this._size.width;consth=this._size.height;constargs=`-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${outputFile}`.split(' ');this._process=child_process.spawn(ffmpegPath,args,{stdio: 'pipe'});this._process.stdin!.on('finish',()=>{console.log('ffmpeg finished input.');});this._process.stdin!.on('error',()=>{console.log('ffmpeg error.');});}writeFrame(frame: Buffer,timestamp: number){this._writeFrame(frame,timestamp);}private_writeFrame(frame: Buffer,timestamp: number){if(this._isStopped)return;if(!this._firstFrameTimestamp)this._firstFrameTimestamp=timestamp;constframeNumber=Math.floor((timestamp-this._firstFrameTimestamp)*fps);if(this._lastFrame){constrepeatCount=frameNumber-this._lastFrame.frameNumber;for(leti=0;i<repeatCount;++i)this._frameQueue.push(this._lastFrame.buffer);this._lastWritePromise=this._lastWritePromise.then(()=>this._sendFrames());}this._lastFrame={buffer: frame, timestamp, frameNumber };this._lastWriteNodeTime=monotonicTime();}privateasync_sendFrames(){while(this._frameQueue.length)awaitthis._sendFrame(this._frameQueue.shift()!);}privateasync_sendFrame(frame: Buffer){returnnewPromise(f=>this._process!.stdin!.write(frame,f)).then(error=>{if(error)console.error('ffmpeg failed to write frame',error);});}async_stop(){// Only report the error on stop. This allows to make the constructor synchronous.if(this._isStopped)return;if(!this._lastFrame){// ffmpeg only creates a file upon some non-empty inputthis._writeFrame(createWhiteImage(this._size.width,this._size.height),monotonicTime());}// Pad with at least 1s of the last frame in the end for convenience.// This also ensures non-empty videos with 1 frame.constaddTime=Math.max((monotonicTime()-this._lastWriteNodeTime)/1000,1);this._writeFrame(Buffer.from([]),this._lastFrame!.timestamp+addTime);this._isStopped=true;try{awaitthis._lastWritePromise;this._process?.kill('SIGINT');}catch(e){console.error(e);}}}functioncreateWhiteImage(width: number,height: number): Buffer{constdata=Buffer.alloc(width*height*4,255);returnjpegjs.encode({ data, width, height },80).data;}constrecorder=newFfmpegVideoRecorder(outputFile,{width: 800,height: 600},'output.webm');page.screencast.start({onFrame: frame=>{// playwright we should probably expose frameSwapWillTime publically here.recorder.writeFrame(frame.data,Date.now()/1000);}});

This can probably be simplified some more. As noted at the bottom, we should probably expose frameswap time in our public API to make this solid, i'll see if we can get this released in 1.60.

Thanks for taking the time to work on this Pull request, and sorry that we have to reject it after going through multiple rounds!

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@azmeuk@Skn0tt@pavelfeldman
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Allow to override ffmpeg path and options - #41007

Closed
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality
Closed

Allow to override ffmpeg path and options#41007
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality

Conversation

@azmeuk

@azmeukÉloi Rivard (azmeuk) commented May 26, 2026

Copy link
Copy Markdown

Oops. I misclicked, I did not want to open the PR here yet, just on my own fork to trigger the CI. I'll update the description soon with all the details.

Sorry for that noise ☝️

I am a contributor of sphinxcontrib-screenshot, which is an extension that takes screenshots and screencasts for integration in Python sphinx documentations. I use it to automatically generate videos and demonstrate reactive behaviors in other apps documentations.

The current static quality settings generate some glitches in the videos, and too much blur. The end quality is not fitting professional documentation, so I opened this PR to add quality customization parameters that are passed to ffmpeg. I evoked this in #17217

This PR adds an optional field to recordVideo to control ffmpeg encoding quality:

recordVideo: {dir: 'videos/',size: {width: 640,height: 480},quality: {mode: 'crf',value: 30},// or { mode: 'bitrate', value: 1_000_000 }}
  • mode: 'crf' — constant rate factor (constant visual quality, variable file size). value is an integer between 0 (lossless) and 63 (worst).
  • mode: 'bitrate' — target bitrate (variable visual quality, predictable file size). value is in bits per second.

When quality is omitted, the ffmpeg command line stays identical to today. The tests just check that the command run. I am not really sure how to check deterministically which quality parameters were passed from the output video.

Let me know if the quality tweaking seems right but the implementation feel wrong, and I will update the PR.

Regards

related to to #22257

@azmeuk
Éloi Rivard (azmeuk) marked this pull request as draft May 26, 2026 19:54
@azmeuk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@azmeuk
Éloi Rivard (azmeuk)force-pushed the 17217-screencast-quality branch 2 times, most recently from d3cf0da to 7cad89aCompareMay 26, 2026 20:39
@azmeuk
Éloi Rivard (azmeuk) marked this pull request as ready for review May 26, 2026 20:45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather make it lower level and allow passing ffmpeg properties that would override our defaults.

@azmeuk

Copy link
Copy Markdown
Author

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

@Skn0tt

Simon Knott (Skn0tt) commented May 27, 2026

Copy link
Copy Markdown
Contributor

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

@pavelfeldman

Copy link
Copy Markdown
Member

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

That works for me.

@pavelfeldman

Copy link
Copy Markdown
Member

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

Browsers arsgs are generally used for additional args, which is not override-friendly. I'd use object notation as proposed - ignore-default-args is overall horrible.

@azmeukÉloi Rivard (azmeuk) changed the title Add quality option to recordVideoAllow to override ffmpeg path and optionsMay 28, 2026
@azmeuk

Éloi Rivard (azmeuk) commented May 28, 2026

Copy link
Copy Markdown
Author

Updated. So there are two options:

  • ffmpegPath which can be a path to a custom ffmpeg, for support for codecs additional than vp8
  • ffmpegOptions which is the form { 'c:v': 'libvpx-vp9', crf: 30, 'b:v': null } and can override the default options. null value can delete a default value

@pavelfeldman

Copy link
Copy Markdown
Member

I love the custom `ffmpeg path option. We discussed it at the API review meeting and are thinking that we want to give you full control over the options (both input and output, even though we produce the input), for simplicity. So we are now thinking that { ffmpegExecutable: string, ffmpegOptions: string, fps: number, outputExtension: string } should be sufficient for you to produce any content desired. Sorry for going back and forth on this one. Wdyt?

@azmeuk

Copy link
Copy Markdown
Author

Sounds good, thanks for the feedback. Give me a few days to work this out.

@azmeuk

Copy link
Copy Markdown
Author

Done, and tested in the dowstream project: tushuhei/sphinxcontrib-screenshot#118

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7207 passed, 1113 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

5 flaky⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-page] › page/page-emulate-media.spec.ts:144 › should keep reduced motion and color emulation after reload `@firefox-ubuntu-22.04-node20`
⚠️ [webkit-page] › page/page-set-input-files.spec.ts:38 › should upload a folder `@webkit-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:812 › should update state on subsequent run `@windows-latest-node20`

44002 passed, 870 skipped


Merge workflow run.

@Skn0tt

Simon Knott (Skn0tt) commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

We gave this another review as part of our release preparation, and decided against shipping this. The proposed public API around the ffmpeg input is very restrictive and makes it hard for Playwright to change its implementation, and we'd rather not lock this in.

The Screencast API we shipped in Playwright 1.59 allows implementing this in userland, and after consideration this is what we recommend for your usecase. Essentially you can take our videoRecorder.ts, change it to your liking, and hook page.screencast.start({ onFrame }) into FfmpegVideoRecorder#_writeFrame. Here's a draft of how that could look:

userland ffmpeg recording
importjpegjsfrom'jpeg-js';importchild_processfrom'child_process';constfps=25;functionmonotonicTime(): number{returnMath.floor(performance.now()*1000)/1000;}classFfmpegVideoRecorder{private_size: {width: number,height: number};private_process: child_process.ChildProcess;private_lastWritePromise: Promise<void>=Promise.resolve();private_firstFrameTimestamp: number=0;private_lastFrame: {timestamp: number,frameNumber: number,buffer: Buffer}|null=null;private_lastWriteNodeTime: number=0;private_frameQueue: Buffer[]=[];private_isStopped=false;constructor(ffmpegPath: string,size: {width: number,height: number},outputFile: string){if(!outputFile.endsWith('.webm'))thrownewError('File must have .webm extension');this._size=size;constw=this._size.width;consth=this._size.height;constargs=`-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${outputFile}`.split(' ');this._process=child_process.spawn(ffmpegPath,args,{stdio: 'pipe'});this._process.stdin!.on('finish',()=>{console.log('ffmpeg finished input.');});this._process.stdin!.on('error',()=>{console.log('ffmpeg error.');});}writeFrame(frame: Buffer,timestamp: number){this._writeFrame(frame,timestamp);}private_writeFrame(frame: Buffer,timestamp: number){if(this._isStopped)return;if(!this._firstFrameTimestamp)this._firstFrameTimestamp=timestamp;constframeNumber=Math.floor((timestamp-this._firstFrameTimestamp)*fps);if(this._lastFrame){constrepeatCount=frameNumber-this._lastFrame.frameNumber;for(leti=0;i<repeatCount;++i)this._frameQueue.push(this._lastFrame.buffer);this._lastWritePromise=this._lastWritePromise.then(()=>this._sendFrames());}this._lastFrame={buffer: frame, timestamp, frameNumber };this._lastWriteNodeTime=monotonicTime();}privateasync_sendFrames(){while(this._frameQueue.length)awaitthis._sendFrame(this._frameQueue.shift()!);}privateasync_sendFrame(frame: Buffer){returnnewPromise(f=>this._process!.stdin!.write(frame,f)).then(error=>{if(error)console.error('ffmpeg failed to write frame',error);});}async_stop(){// Only report the error on stop. This allows to make the constructor synchronous.if(this._isStopped)return;if(!this._lastFrame){// ffmpeg only creates a file upon some non-empty inputthis._writeFrame(createWhiteImage(this._size.width,this._size.height),monotonicTime());}// Pad with at least 1s of the last frame in the end for convenience.// This also ensures non-empty videos with 1 frame.constaddTime=Math.max((monotonicTime()-this._lastWriteNodeTime)/1000,1);this._writeFrame(Buffer.from([]),this._lastFrame!.timestamp+addTime);this._isStopped=true;try{awaitthis._lastWritePromise;this._process?.kill('SIGINT');}catch(e){console.error(e);}}}functioncreateWhiteImage(width: number,height: number): Buffer{constdata=Buffer.alloc(width*height*4,255);returnjpegjs.encode({ data, width, height },80).data;}constrecorder=newFfmpegVideoRecorder(outputFile,{width: 800,height: 600},'output.webm');page.screencast.start({onFrame: frame=>{// playwright we should probably expose frameSwapWillTime publically here.recorder.writeFrame(frame.data,Date.now()/1000);}});

This can probably be simplified some more. As noted at the bottom, we should probably expose frameswap time in our public API to make this solid, i'll see if we can get this released in 1.60.

Thanks for taking the time to work on this Pull request, and sorry that we have to reject it after going through multiple rounds!

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@azmeuk@Skn0tt@pavelfeldman
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Allow to override ffmpeg path and options - #41007

Closed
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality
Closed

Allow to override ffmpeg path and options#41007
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality

Conversation

@azmeuk

@azmeukÉloi Rivard (azmeuk) commented May 26, 2026

Copy link
Copy Markdown

Oops. I misclicked, I did not want to open the PR here yet, just on my own fork to trigger the CI. I'll update the description soon with all the details.

Sorry for that noise ☝️

I am a contributor of sphinxcontrib-screenshot, which is an extension that takes screenshots and screencasts for integration in Python sphinx documentations. I use it to automatically generate videos and demonstrate reactive behaviors in other apps documentations.

The current static quality settings generate some glitches in the videos, and too much blur. The end quality is not fitting professional documentation, so I opened this PR to add quality customization parameters that are passed to ffmpeg. I evoked this in #17217

This PR adds an optional field to recordVideo to control ffmpeg encoding quality:

recordVideo: {dir: 'videos/',size: {width: 640,height: 480},quality: {mode: 'crf',value: 30},// or { mode: 'bitrate', value: 1_000_000 }}
  • mode: 'crf' — constant rate factor (constant visual quality, variable file size). value is an integer between 0 (lossless) and 63 (worst).
  • mode: 'bitrate' — target bitrate (variable visual quality, predictable file size). value is in bits per second.

When quality is omitted, the ffmpeg command line stays identical to today. The tests just check that the command run. I am not really sure how to check deterministically which quality parameters were passed from the output video.

Let me know if the quality tweaking seems right but the implementation feel wrong, and I will update the PR.

Regards

related to to #22257

@azmeuk
Éloi Rivard (azmeuk) marked this pull request as draft May 26, 2026 19:54
@azmeuk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@azmeuk
Éloi Rivard (azmeuk)force-pushed the 17217-screencast-quality branch 2 times, most recently from d3cf0da to 7cad89aCompareMay 26, 2026 20:39
@azmeuk
Éloi Rivard (azmeuk) marked this pull request as ready for review May 26, 2026 20:45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather make it lower level and allow passing ffmpeg properties that would override our defaults.

@azmeuk

Copy link
Copy Markdown
Author

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

@Skn0tt

Simon Knott (Skn0tt) commented May 27, 2026

Copy link
Copy Markdown
Contributor

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

@pavelfeldman

Copy link
Copy Markdown
Member

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

That works for me.

@pavelfeldman

Copy link
Copy Markdown
Member

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

Browsers arsgs are generally used for additional args, which is not override-friendly. I'd use object notation as proposed - ignore-default-args is overall horrible.

@azmeukÉloi Rivard (azmeuk) changed the title Add quality option to recordVideoAllow to override ffmpeg path and optionsMay 28, 2026
@azmeuk

Éloi Rivard (azmeuk) commented May 28, 2026

Copy link
Copy Markdown
Author

Updated. So there are two options:

  • ffmpegPath which can be a path to a custom ffmpeg, for support for codecs additional than vp8
  • ffmpegOptions which is the form { 'c:v': 'libvpx-vp9', crf: 30, 'b:v': null } and can override the default options. null value can delete a default value

@pavelfeldman

Copy link
Copy Markdown
Member

I love the custom `ffmpeg path option. We discussed it at the API review meeting and are thinking that we want to give you full control over the options (both input and output, even though we produce the input), for simplicity. So we are now thinking that { ffmpegExecutable: string, ffmpegOptions: string, fps: number, outputExtension: string } should be sufficient for you to produce any content desired. Sorry for going back and forth on this one. Wdyt?

@azmeuk

Copy link
Copy Markdown
Author

Sounds good, thanks for the feedback. Give me a few days to work this out.

@azmeuk

Copy link
Copy Markdown
Author

Done, and tested in the dowstream project: tushuhei/sphinxcontrib-screenshot#118

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7207 passed, 1113 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

5 flaky⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-page] › page/page-emulate-media.spec.ts:144 › should keep reduced motion and color emulation after reload `@firefox-ubuntu-22.04-node20`
⚠️ [webkit-page] › page/page-set-input-files.spec.ts:38 › should upload a folder `@webkit-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:812 › should update state on subsequent run `@windows-latest-node20`

44002 passed, 870 skipped


Merge workflow run.

@Skn0tt

Simon Knott (Skn0tt) commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

We gave this another review as part of our release preparation, and decided against shipping this. The proposed public API around the ffmpeg input is very restrictive and makes it hard for Playwright to change its implementation, and we'd rather not lock this in.

The Screencast API we shipped in Playwright 1.59 allows implementing this in userland, and after consideration this is what we recommend for your usecase. Essentially you can take our videoRecorder.ts, change it to your liking, and hook page.screencast.start({ onFrame }) into FfmpegVideoRecorder#_writeFrame. Here's a draft of how that could look:

userland ffmpeg recording
importjpegjsfrom'jpeg-js';importchild_processfrom'child_process';constfps=25;functionmonotonicTime(): number{returnMath.floor(performance.now()*1000)/1000;}classFfmpegVideoRecorder{private_size: {width: number,height: number};private_process: child_process.ChildProcess;private_lastWritePromise: Promise<void>=Promise.resolve();private_firstFrameTimestamp: number=0;private_lastFrame: {timestamp: number,frameNumber: number,buffer: Buffer}|null=null;private_lastWriteNodeTime: number=0;private_frameQueue: Buffer[]=[];private_isStopped=false;constructor(ffmpegPath: string,size: {width: number,height: number},outputFile: string){if(!outputFile.endsWith('.webm'))thrownewError('File must have .webm extension');this._size=size;constw=this._size.width;consth=this._size.height;constargs=`-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${outputFile}`.split(' ');this._process=child_process.spawn(ffmpegPath,args,{stdio: 'pipe'});this._process.stdin!.on('finish',()=>{console.log('ffmpeg finished input.');});this._process.stdin!.on('error',()=>{console.log('ffmpeg error.');});}writeFrame(frame: Buffer,timestamp: number){this._writeFrame(frame,timestamp);}private_writeFrame(frame: Buffer,timestamp: number){if(this._isStopped)return;if(!this._firstFrameTimestamp)this._firstFrameTimestamp=timestamp;constframeNumber=Math.floor((timestamp-this._firstFrameTimestamp)*fps);if(this._lastFrame){constrepeatCount=frameNumber-this._lastFrame.frameNumber;for(leti=0;i<repeatCount;++i)this._frameQueue.push(this._lastFrame.buffer);this._lastWritePromise=this._lastWritePromise.then(()=>this._sendFrames());}this._lastFrame={buffer: frame, timestamp, frameNumber };this._lastWriteNodeTime=monotonicTime();}privateasync_sendFrames(){while(this._frameQueue.length)awaitthis._sendFrame(this._frameQueue.shift()!);}privateasync_sendFrame(frame: Buffer){returnnewPromise(f=>this._process!.stdin!.write(frame,f)).then(error=>{if(error)console.error('ffmpeg failed to write frame',error);});}async_stop(){// Only report the error on stop. This allows to make the constructor synchronous.if(this._isStopped)return;if(!this._lastFrame){// ffmpeg only creates a file upon some non-empty inputthis._writeFrame(createWhiteImage(this._size.width,this._size.height),monotonicTime());}// Pad with at least 1s of the last frame in the end for convenience.// This also ensures non-empty videos with 1 frame.constaddTime=Math.max((monotonicTime()-this._lastWriteNodeTime)/1000,1);this._writeFrame(Buffer.from([]),this._lastFrame!.timestamp+addTime);this._isStopped=true;try{awaitthis._lastWritePromise;this._process?.kill('SIGINT');}catch(e){console.error(e);}}}functioncreateWhiteImage(width: number,height: number): Buffer{constdata=Buffer.alloc(width*height*4,255);returnjpegjs.encode({ data, width, height },80).data;}constrecorder=newFfmpegVideoRecorder(outputFile,{width: 800,height: 600},'output.webm');page.screencast.start({onFrame: frame=>{// playwright we should probably expose frameSwapWillTime publically here.recorder.writeFrame(frame.data,Date.now()/1000);}});

This can probably be simplified some more. As noted at the bottom, we should probably expose frameswap time in our public API to make this solid, i'll see if we can get this released in 1.60.

Thanks for taking the time to work on this Pull request, and sorry that we have to reject it after going through multiple rounds!

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@azmeuk@Skn0tt@pavelfeldman
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Allow to override ffmpeg path and options - #41007

Closed
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality
Closed

Allow to override ffmpeg path and options#41007
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality

Conversation

@azmeuk

@azmeukÉloi Rivard (azmeuk) commented May 26, 2026

Copy link
Copy Markdown

Oops. I misclicked, I did not want to open the PR here yet, just on my own fork to trigger the CI. I'll update the description soon with all the details.

Sorry for that noise ☝️

I am a contributor of sphinxcontrib-screenshot, which is an extension that takes screenshots and screencasts for integration in Python sphinx documentations. I use it to automatically generate videos and demonstrate reactive behaviors in other apps documentations.

The current static quality settings generate some glitches in the videos, and too much blur. The end quality is not fitting professional documentation, so I opened this PR to add quality customization parameters that are passed to ffmpeg. I evoked this in #17217

This PR adds an optional field to recordVideo to control ffmpeg encoding quality:

recordVideo: {dir: 'videos/',size: {width: 640,height: 480},quality: {mode: 'crf',value: 30},// or { mode: 'bitrate', value: 1_000_000 }}
  • mode: 'crf' — constant rate factor (constant visual quality, variable file size). value is an integer between 0 (lossless) and 63 (worst).
  • mode: 'bitrate' — target bitrate (variable visual quality, predictable file size). value is in bits per second.

When quality is omitted, the ffmpeg command line stays identical to today. The tests just check that the command run. I am not really sure how to check deterministically which quality parameters were passed from the output video.

Let me know if the quality tweaking seems right but the implementation feel wrong, and I will update the PR.

Regards

related to to #22257

@azmeuk
Éloi Rivard (azmeuk) marked this pull request as draft May 26, 2026 19:54
@azmeuk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@azmeuk
Éloi Rivard (azmeuk)force-pushed the 17217-screencast-quality branch 2 times, most recently from d3cf0da to 7cad89aCompareMay 26, 2026 20:39
@azmeuk
Éloi Rivard (azmeuk) marked this pull request as ready for review May 26, 2026 20:45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather make it lower level and allow passing ffmpeg properties that would override our defaults.

@azmeuk

Copy link
Copy Markdown
Author

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

@Skn0tt

Simon Knott (Skn0tt) commented May 27, 2026

Copy link
Copy Markdown
Contributor

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

@pavelfeldman

Copy link
Copy Markdown
Member

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

That works for me.

@pavelfeldman

Copy link
Copy Markdown
Member

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

Browsers arsgs are generally used for additional args, which is not override-friendly. I'd use object notation as proposed - ignore-default-args is overall horrible.

@azmeukÉloi Rivard (azmeuk) changed the title Add quality option to recordVideoAllow to override ffmpeg path and optionsMay 28, 2026
@azmeuk

Éloi Rivard (azmeuk) commented May 28, 2026

Copy link
Copy Markdown
Author

Updated. So there are two options:

  • ffmpegPath which can be a path to a custom ffmpeg, for support for codecs additional than vp8
  • ffmpegOptions which is the form { 'c:v': 'libvpx-vp9', crf: 30, 'b:v': null } and can override the default options. null value can delete a default value

@pavelfeldman

Copy link
Copy Markdown
Member

I love the custom `ffmpeg path option. We discussed it at the API review meeting and are thinking that we want to give you full control over the options (both input and output, even though we produce the input), for simplicity. So we are now thinking that { ffmpegExecutable: string, ffmpegOptions: string, fps: number, outputExtension: string } should be sufficient for you to produce any content desired. Sorry for going back and forth on this one. Wdyt?

@azmeuk

Copy link
Copy Markdown
Author

Sounds good, thanks for the feedback. Give me a few days to work this out.

@azmeuk

Copy link
Copy Markdown
Author

Done, and tested in the dowstream project: tushuhei/sphinxcontrib-screenshot#118

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7207 passed, 1113 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

5 flaky⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-page] › page/page-emulate-media.spec.ts:144 › should keep reduced motion and color emulation after reload `@firefox-ubuntu-22.04-node20`
⚠️ [webkit-page] › page/page-set-input-files.spec.ts:38 › should upload a folder `@webkit-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:812 › should update state on subsequent run `@windows-latest-node20`

44002 passed, 870 skipped


Merge workflow run.

@Skn0tt

Simon Knott (Skn0tt) commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

We gave this another review as part of our release preparation, and decided against shipping this. The proposed public API around the ffmpeg input is very restrictive and makes it hard for Playwright to change its implementation, and we'd rather not lock this in.

The Screencast API we shipped in Playwright 1.59 allows implementing this in userland, and after consideration this is what we recommend for your usecase. Essentially you can take our videoRecorder.ts, change it to your liking, and hook page.screencast.start({ onFrame }) into FfmpegVideoRecorder#_writeFrame. Here's a draft of how that could look:

userland ffmpeg recording
importjpegjsfrom'jpeg-js';importchild_processfrom'child_process';constfps=25;functionmonotonicTime(): number{returnMath.floor(performance.now()*1000)/1000;}classFfmpegVideoRecorder{private_size: {width: number,height: number};private_process: child_process.ChildProcess;private_lastWritePromise: Promise<void>=Promise.resolve();private_firstFrameTimestamp: number=0;private_lastFrame: {timestamp: number,frameNumber: number,buffer: Buffer}|null=null;private_lastWriteNodeTime: number=0;private_frameQueue: Buffer[]=[];private_isStopped=false;constructor(ffmpegPath: string,size: {width: number,height: number},outputFile: string){if(!outputFile.endsWith('.webm'))thrownewError('File must have .webm extension');this._size=size;constw=this._size.width;consth=this._size.height;constargs=`-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${outputFile}`.split(' ');this._process=child_process.spawn(ffmpegPath,args,{stdio: 'pipe'});this._process.stdin!.on('finish',()=>{console.log('ffmpeg finished input.');});this._process.stdin!.on('error',()=>{console.log('ffmpeg error.');});}writeFrame(frame: Buffer,timestamp: number){this._writeFrame(frame,timestamp);}private_writeFrame(frame: Buffer,timestamp: number){if(this._isStopped)return;if(!this._firstFrameTimestamp)this._firstFrameTimestamp=timestamp;constframeNumber=Math.floor((timestamp-this._firstFrameTimestamp)*fps);if(this._lastFrame){constrepeatCount=frameNumber-this._lastFrame.frameNumber;for(leti=0;i<repeatCount;++i)this._frameQueue.push(this._lastFrame.buffer);this._lastWritePromise=this._lastWritePromise.then(()=>this._sendFrames());}this._lastFrame={buffer: frame, timestamp, frameNumber };this._lastWriteNodeTime=monotonicTime();}privateasync_sendFrames(){while(this._frameQueue.length)awaitthis._sendFrame(this._frameQueue.shift()!);}privateasync_sendFrame(frame: Buffer){returnnewPromise(f=>this._process!.stdin!.write(frame,f)).then(error=>{if(error)console.error('ffmpeg failed to write frame',error);});}async_stop(){// Only report the error on stop. This allows to make the constructor synchronous.if(this._isStopped)return;if(!this._lastFrame){// ffmpeg only creates a file upon some non-empty inputthis._writeFrame(createWhiteImage(this._size.width,this._size.height),monotonicTime());}// Pad with at least 1s of the last frame in the end for convenience.// This also ensures non-empty videos with 1 frame.constaddTime=Math.max((monotonicTime()-this._lastWriteNodeTime)/1000,1);this._writeFrame(Buffer.from([]),this._lastFrame!.timestamp+addTime);this._isStopped=true;try{awaitthis._lastWritePromise;this._process?.kill('SIGINT');}catch(e){console.error(e);}}}functioncreateWhiteImage(width: number,height: number): Buffer{constdata=Buffer.alloc(width*height*4,255);returnjpegjs.encode({ data, width, height },80).data;}constrecorder=newFfmpegVideoRecorder(outputFile,{width: 800,height: 600},'output.webm');page.screencast.start({onFrame: frame=>{// playwright we should probably expose frameSwapWillTime publically here.recorder.writeFrame(frame.data,Date.now()/1000);}});

This can probably be simplified some more. As noted at the bottom, we should probably expose frameswap time in our public API to make this solid, i'll see if we can get this released in 1.60.

Thanks for taking the time to work on this Pull request, and sorry that we have to reject it after going through multiple rounds!

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@azmeuk@Skn0tt@pavelfeldman
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Allow to override ffmpeg path and options - #41007

Closed
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality
Closed

Allow to override ffmpeg path and options#41007
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality

Conversation

@azmeuk

@azmeukÉloi Rivard (azmeuk) commented May 26, 2026

Copy link
Copy Markdown

Oops. I misclicked, I did not want to open the PR here yet, just on my own fork to trigger the CI. I'll update the description soon with all the details.

Sorry for that noise ☝️

I am a contributor of sphinxcontrib-screenshot, which is an extension that takes screenshots and screencasts for integration in Python sphinx documentations. I use it to automatically generate videos and demonstrate reactive behaviors in other apps documentations.

The current static quality settings generate some glitches in the videos, and too much blur. The end quality is not fitting professional documentation, so I opened this PR to add quality customization parameters that are passed to ffmpeg. I evoked this in #17217

This PR adds an optional field to recordVideo to control ffmpeg encoding quality:

recordVideo: {dir: 'videos/',size: {width: 640,height: 480},quality: {mode: 'crf',value: 30},// or { mode: 'bitrate', value: 1_000_000 }}
  • mode: 'crf' — constant rate factor (constant visual quality, variable file size). value is an integer between 0 (lossless) and 63 (worst).
  • mode: 'bitrate' — target bitrate (variable visual quality, predictable file size). value is in bits per second.

When quality is omitted, the ffmpeg command line stays identical to today. The tests just check that the command run. I am not really sure how to check deterministically which quality parameters were passed from the output video.

Let me know if the quality tweaking seems right but the implementation feel wrong, and I will update the PR.

Regards

related to to #22257

@azmeuk
Éloi Rivard (azmeuk) marked this pull request as draft May 26, 2026 19:54
@azmeuk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@azmeuk
Éloi Rivard (azmeuk)force-pushed the 17217-screencast-quality branch 2 times, most recently from d3cf0da to 7cad89aCompareMay 26, 2026 20:39
@azmeuk
Éloi Rivard (azmeuk) marked this pull request as ready for review May 26, 2026 20:45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather make it lower level and allow passing ffmpeg properties that would override our defaults.

@azmeuk

Copy link
Copy Markdown
Author

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

@Skn0tt

Simon Knott (Skn0tt) commented May 27, 2026

Copy link
Copy Markdown
Contributor

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

@pavelfeldman

Copy link
Copy Markdown
Member

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

That works for me.

@pavelfeldman

Copy link
Copy Markdown
Member

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

Browsers arsgs are generally used for additional args, which is not override-friendly. I'd use object notation as proposed - ignore-default-args is overall horrible.

@azmeukÉloi Rivard (azmeuk) changed the title Add quality option to recordVideoAllow to override ffmpeg path and optionsMay 28, 2026
@azmeuk

Éloi Rivard (azmeuk) commented May 28, 2026

Copy link
Copy Markdown
Author

Updated. So there are two options:

  • ffmpegPath which can be a path to a custom ffmpeg, for support for codecs additional than vp8
  • ffmpegOptions which is the form { 'c:v': 'libvpx-vp9', crf: 30, 'b:v': null } and can override the default options. null value can delete a default value

@pavelfeldman

Copy link
Copy Markdown
Member

I love the custom `ffmpeg path option. We discussed it at the API review meeting and are thinking that we want to give you full control over the options (both input and output, even though we produce the input), for simplicity. So we are now thinking that { ffmpegExecutable: string, ffmpegOptions: string, fps: number, outputExtension: string } should be sufficient for you to produce any content desired. Sorry for going back and forth on this one. Wdyt?

@azmeuk

Copy link
Copy Markdown
Author

Sounds good, thanks for the feedback. Give me a few days to work this out.

@azmeuk

Copy link
Copy Markdown
Author

Done, and tested in the dowstream project: tushuhei/sphinxcontrib-screenshot#118

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7207 passed, 1113 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

5 flaky⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-page] › page/page-emulate-media.spec.ts:144 › should keep reduced motion and color emulation after reload `@firefox-ubuntu-22.04-node20`
⚠️ [webkit-page] › page/page-set-input-files.spec.ts:38 › should upload a folder `@webkit-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:812 › should update state on subsequent run `@windows-latest-node20`

44002 passed, 870 skipped


Merge workflow run.

@Skn0tt

Simon Knott (Skn0tt) commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

We gave this another review as part of our release preparation, and decided against shipping this. The proposed public API around the ffmpeg input is very restrictive and makes it hard for Playwright to change its implementation, and we'd rather not lock this in.

The Screencast API we shipped in Playwright 1.59 allows implementing this in userland, and after consideration this is what we recommend for your usecase. Essentially you can take our videoRecorder.ts, change it to your liking, and hook page.screencast.start({ onFrame }) into FfmpegVideoRecorder#_writeFrame. Here's a draft of how that could look:

userland ffmpeg recording
importjpegjsfrom'jpeg-js';importchild_processfrom'child_process';constfps=25;functionmonotonicTime(): number{returnMath.floor(performance.now()*1000)/1000;}classFfmpegVideoRecorder{private_size: {width: number,height: number};private_process: child_process.ChildProcess;private_lastWritePromise: Promise<void>=Promise.resolve();private_firstFrameTimestamp: number=0;private_lastFrame: {timestamp: number,frameNumber: number,buffer: Buffer}|null=null;private_lastWriteNodeTime: number=0;private_frameQueue: Buffer[]=[];private_isStopped=false;constructor(ffmpegPath: string,size: {width: number,height: number},outputFile: string){if(!outputFile.endsWith('.webm'))thrownewError('File must have .webm extension');this._size=size;constw=this._size.width;consth=this._size.height;constargs=`-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${outputFile}`.split(' ');this._process=child_process.spawn(ffmpegPath,args,{stdio: 'pipe'});this._process.stdin!.on('finish',()=>{console.log('ffmpeg finished input.');});this._process.stdin!.on('error',()=>{console.log('ffmpeg error.');});}writeFrame(frame: Buffer,timestamp: number){this._writeFrame(frame,timestamp);}private_writeFrame(frame: Buffer,timestamp: number){if(this._isStopped)return;if(!this._firstFrameTimestamp)this._firstFrameTimestamp=timestamp;constframeNumber=Math.floor((timestamp-this._firstFrameTimestamp)*fps);if(this._lastFrame){constrepeatCount=frameNumber-this._lastFrame.frameNumber;for(leti=0;i<repeatCount;++i)this._frameQueue.push(this._lastFrame.buffer);this._lastWritePromise=this._lastWritePromise.then(()=>this._sendFrames());}this._lastFrame={buffer: frame, timestamp, frameNumber };this._lastWriteNodeTime=monotonicTime();}privateasync_sendFrames(){while(this._frameQueue.length)awaitthis._sendFrame(this._frameQueue.shift()!);}privateasync_sendFrame(frame: Buffer){returnnewPromise(f=>this._process!.stdin!.write(frame,f)).then(error=>{if(error)console.error('ffmpeg failed to write frame',error);});}async_stop(){// Only report the error on stop. This allows to make the constructor synchronous.if(this._isStopped)return;if(!this._lastFrame){// ffmpeg only creates a file upon some non-empty inputthis._writeFrame(createWhiteImage(this._size.width,this._size.height),monotonicTime());}// Pad with at least 1s of the last frame in the end for convenience.// This also ensures non-empty videos with 1 frame.constaddTime=Math.max((monotonicTime()-this._lastWriteNodeTime)/1000,1);this._writeFrame(Buffer.from([]),this._lastFrame!.timestamp+addTime);this._isStopped=true;try{awaitthis._lastWritePromise;this._process?.kill('SIGINT');}catch(e){console.error(e);}}}functioncreateWhiteImage(width: number,height: number): Buffer{constdata=Buffer.alloc(width*height*4,255);returnjpegjs.encode({ data, width, height },80).data;}constrecorder=newFfmpegVideoRecorder(outputFile,{width: 800,height: 600},'output.webm');page.screencast.start({onFrame: frame=>{// playwright we should probably expose frameSwapWillTime publically here.recorder.writeFrame(frame.data,Date.now()/1000);}});

This can probably be simplified some more. As noted at the bottom, we should probably expose frameswap time in our public API to make this solid, i'll see if we can get this released in 1.60.

Thanks for taking the time to work on this Pull request, and sorry that we have to reject it after going through multiple rounds!

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@azmeuk@Skn0tt@pavelfeldman
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Allow to override ffmpeg path and options - #41007

Closed
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality
Closed

Allow to override ffmpeg path and options#41007
Éloi Rivard (azmeuk) wants to merge 1 commit into
microsoft:mainfrom
azmeuk:17217-screencast-quality

Conversation

@azmeuk

@azmeukÉloi Rivard (azmeuk) commented May 26, 2026

Copy link
Copy Markdown

Oops. I misclicked, I did not want to open the PR here yet, just on my own fork to trigger the CI. I'll update the description soon with all the details.

Sorry for that noise ☝️

I am a contributor of sphinxcontrib-screenshot, which is an extension that takes screenshots and screencasts for integration in Python sphinx documentations. I use it to automatically generate videos and demonstrate reactive behaviors in other apps documentations.

The current static quality settings generate some glitches in the videos, and too much blur. The end quality is not fitting professional documentation, so I opened this PR to add quality customization parameters that are passed to ffmpeg. I evoked this in #17217

This PR adds an optional field to recordVideo to control ffmpeg encoding quality:

recordVideo: {dir: 'videos/',size: {width: 640,height: 480},quality: {mode: 'crf',value: 30},// or { mode: 'bitrate', value: 1_000_000 }}
  • mode: 'crf' — constant rate factor (constant visual quality, variable file size). value is an integer between 0 (lossless) and 63 (worst).
  • mode: 'bitrate' — target bitrate (variable visual quality, predictable file size). value is in bits per second.

When quality is omitted, the ffmpeg command line stays identical to today. The tests just check that the command run. I am not really sure how to check deterministically which quality parameters were passed from the output video.

Let me know if the quality tweaking seems right but the implementation feel wrong, and I will update the PR.

Regards

related to to #22257

@azmeuk
Éloi Rivard (azmeuk) marked this pull request as draft May 26, 2026 19:54
@azmeuk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@azmeuk
Éloi Rivard (azmeuk)force-pushed the 17217-screencast-quality branch 2 times, most recently from d3cf0da to 7cad89aCompareMay 26, 2026 20:39
@azmeuk
Éloi Rivard (azmeuk) marked this pull request as ready for review May 26, 2026 20:45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather make it lower level and allow passing ffmpeg properties that would override our defaults.

@azmeuk

Copy link
Copy Markdown
Author

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

@Skn0tt

Simon Knott (Skn0tt) commented May 27, 2026

Copy link
Copy Markdown
Contributor

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

@pavelfeldman

Copy link
Copy Markdown
Member

Thanks for the feedback. To make sure I'm building what you have in mind: would recordVideo: { ffmpegOptions: { crf: 30, 'b:v': '500k', codec: 'libvpx-vp9' } } work - a dict of ffmpeg option name -> value that overrides the built-in defaults?

That works for me.

@pavelfeldman

Copy link
Copy Markdown
Member

I'd try to mirror what we have for browser launching: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args

Browsers arsgs are generally used for additional args, which is not override-friendly. I'd use object notation as proposed - ignore-default-args is overall horrible.

@azmeukÉloi Rivard (azmeuk) changed the title Add quality option to recordVideoAllow to override ffmpeg path and optionsMay 28, 2026
@azmeuk

Éloi Rivard (azmeuk) commented May 28, 2026

Copy link
Copy Markdown
Author

Updated. So there are two options:

  • ffmpegPath which can be a path to a custom ffmpeg, for support for codecs additional than vp8
  • ffmpegOptions which is the form { 'c:v': 'libvpx-vp9', crf: 30, 'b:v': null } and can override the default options. null value can delete a default value

@pavelfeldman

Copy link
Copy Markdown
Member

I love the custom `ffmpeg path option. We discussed it at the API review meeting and are thinking that we want to give you full control over the options (both input and output, even though we produce the input), for simplicity. So we are now thinking that { ffmpegExecutable: string, ffmpegOptions: string, fps: number, outputExtension: string } should be sufficient for you to produce any content desired. Sorry for going back and forth on this one. Wdyt?

@azmeuk

Copy link
Copy Markdown
Author

Sounds good, thanks for the feedback. Give me a few days to work this out.

@azmeuk

Copy link
Copy Markdown
Author

Done, and tested in the dowstream project: tushuhei/sphinxcontrib-screenshot#118

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7207 passed, 1113 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

5 flaky⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/video.spec.ts:658 › screencast › should capture full viewport `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-page] › page/page-emulate-media.spec.ts:144 › should keep reduced motion and color emulation after reload `@firefox-ubuntu-22.04-node20`
⚠️ [webkit-page] › page/page-set-input-files.spec.ts:38 › should upload a folder `@webkit-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:812 › should update state on subsequent run `@windows-latest-node20`

44002 passed, 870 skipped


Merge workflow run.

@Skn0tt

Simon Knott (Skn0tt) commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

We gave this another review as part of our release preparation, and decided against shipping this. The proposed public API around the ffmpeg input is very restrictive and makes it hard for Playwright to change its implementation, and we'd rather not lock this in.

The Screencast API we shipped in Playwright 1.59 allows implementing this in userland, and after consideration this is what we recommend for your usecase. Essentially you can take our videoRecorder.ts, change it to your liking, and hook page.screencast.start({ onFrame }) into FfmpegVideoRecorder#_writeFrame. Here's a draft of how that could look:

userland ffmpeg recording
importjpegjsfrom'jpeg-js';importchild_processfrom'child_process';constfps=25;functionmonotonicTime(): number{returnMath.floor(performance.now()*1000)/1000;}classFfmpegVideoRecorder{private_size: {width: number,height: number};private_process: child_process.ChildProcess;private_lastWritePromise: Promise<void>=Promise.resolve();private_firstFrameTimestamp: number=0;private_lastFrame: {timestamp: number,frameNumber: number,buffer: Buffer}|null=null;private_lastWriteNodeTime: number=0;private_frameQueue: Buffer[]=[];private_isStopped=false;constructor(ffmpegPath: string,size: {width: number,height: number},outputFile: string){if(!outputFile.endsWith('.webm'))thrownewError('File must have .webm extension');this._size=size;constw=this._size.width;consth=this._size.height;constargs=`-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${outputFile}`.split(' ');this._process=child_process.spawn(ffmpegPath,args,{stdio: 'pipe'});this._process.stdin!.on('finish',()=>{console.log('ffmpeg finished input.');});this._process.stdin!.on('error',()=>{console.log('ffmpeg error.');});}writeFrame(frame: Buffer,timestamp: number){this._writeFrame(frame,timestamp);}private_writeFrame(frame: Buffer,timestamp: number){if(this._isStopped)return;if(!this._firstFrameTimestamp)this._firstFrameTimestamp=timestamp;constframeNumber=Math.floor((timestamp-this._firstFrameTimestamp)*fps);if(this._lastFrame){constrepeatCount=frameNumber-this._lastFrame.frameNumber;for(leti=0;i<repeatCount;++i)this._frameQueue.push(this._lastFrame.buffer);this._lastWritePromise=this._lastWritePromise.then(()=>this._sendFrames());}this._lastFrame={buffer: frame, timestamp, frameNumber };this._lastWriteNodeTime=monotonicTime();}privateasync_sendFrames(){while(this._frameQueue.length)awaitthis._sendFrame(this._frameQueue.shift()!);}privateasync_sendFrame(frame: Buffer){returnnewPromise(f=>this._process!.stdin!.write(frame,f)).then(error=>{if(error)console.error('ffmpeg failed to write frame',error);});}async_stop(){// Only report the error on stop. This allows to make the constructor synchronous.if(this._isStopped)return;if(!this._lastFrame){// ffmpeg only creates a file upon some non-empty inputthis._writeFrame(createWhiteImage(this._size.width,this._size.height),monotonicTime());}// Pad with at least 1s of the last frame in the end for convenience.// This also ensures non-empty videos with 1 frame.constaddTime=Math.max((monotonicTime()-this._lastWriteNodeTime)/1000,1);this._writeFrame(Buffer.from([]),this._lastFrame!.timestamp+addTime);this._isStopped=true;try{awaitthis._lastWritePromise;this._process?.kill('SIGINT');}catch(e){console.error(e);}}}functioncreateWhiteImage(width: number,height: number): Buffer{constdata=Buffer.alloc(width*height*4,255);returnjpegjs.encode({ data, width, height },80).data;}constrecorder=newFfmpegVideoRecorder(outputFile,{width: 800,height: 600},'output.webm');page.screencast.start({onFrame: frame=>{// playwright we should probably expose frameSwapWillTime publically here.recorder.writeFrame(frame.data,Date.now()/1000);}});

This can probably be simplified some more. As noted at the bottom, we should probably expose frameswap time in our public API to make this solid, i'll see if we can get this released in 1.60.

Thanks for taking the time to work on this Pull request, and sorry that we have to reject it after going through multiple rounds!

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@azmeuk@Skn0tt@pavelfeldman