Skip to content

perf(recorder): wrap MJPEG frames in MKV with timestamps#41191

Open
Skn0tt wants to merge 2 commits into
microsoft:mainfrom
Skn0tt:skn0tt/ffmpeg-mkv-frame-ingest
Open

perf(recorder): wrap MJPEG frames in MKV with timestamps#41191
Skn0tt wants to merge 2 commits into
microsoft:mainfrom
Skn0tt:skn0tt/ffmpeg-mkv-frame-ingest

Conversation

@Skn0tt

@Skn0tt Skn0tt commented Jun 8, 2026

Copy link
Copy Markdown
Member

The video recorder piped MJPEG to ffmpeg via -f image2pipe and, to fake a constant frame rate, repeated the previous JPEG on the Node side ~25×/sec — which ffmpeg then had to decode and discard.

This PR instead wraps each MJPEG frame in a minimal streaming Matroska (MKV) container (new ebml.ts writer) with an explicit timestamp and lets ffmpeg handle constant-frame-rate duplication itself.

Result: −43% ffmpeg CPU on static / low-animation pages — the common e2e case. (Peak ffmpeg RSS also drops ~33% there.) The gain shrinks as pages get busier, since then most frames are genuinely distinct and have to be encoded either way.

Full measurements

Measured the ffmpeg process before/after across three page types: static (mostly still, the common e2e case), moderate (some animation), and noise (worst case: full-viewport random noise every frame).

ffmpeg CPU time:

page type before after delta
static 0.66s 0.38s −43%
moderate 0.98s 0.97s ~parity
noise 5.82s 5.38s −8%

ffmpeg peak RSS:

page type before after delta
static 70 MB 47 MB −33%
moderate 69 MB 45 MB −35%
noise 70 MB 58 MB −17%

The Node.js side is roughly neutral: slightly lower CPU on static pages (~27ms → ~24ms), within noise on busy pages. The JPEG buffer is written by reference — writeClusterHeader() returns only the ~15-25 byte cluster/block wrapper and never copies the frame — so we avoid the buffer-copy overhead a mux would otherwise add.

Notes:

  • ebml.ts emits only the subset of Matroska needed for a single live MJPEG track.
  • -avioflags direct is intentionally dropped: the Matroska demuxer seeks while parsing the header, and direct mode routes those seeks to the non-seekable pipe:0 instead of AVIO's buffer, which breaks header parsing.

Previously the recorder piped MJPEG via image2pipe and repeated the last
frame ~25x/sec on the Node side to fake a constant frame rate. Instead,
wrap each MJPEG frame in a minimal streaming Matroska container with an
explicit timestamp and let ffmpeg handle CFR frame duplication. This cuts
the ffmpeg process CPU usage significantly on the common e2e case.
@Skn0tt

Skn0tt commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Let's not merge this before the version cut, it's risky and i'd like to soak it on CI for a while.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@Skn0tt Skn0tt added the CQ1 label Jun 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests others"

3 flaky ⚠️ [chromium-library] › library/screencast.spec.ts:219 › start dispose stops recording `@frozen-time-library-chromium-linux`
⚠️ [chromium-library] › library/video.spec.ts:717 › screencast › should work with video+trace `@frozen-time-library-chromium-linux`
⚠️ [chromium-page] › page/page-request-continue.spec.ts:756 › propagate headers cross origin redirect after interception `@frozen-time-library-chromium-linux`

19950 passed, 672 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 2"

3 fatal errors, not part of any test
27 failed
❌ [chromium-library] › library/screencast.spec.ts:219 › start dispose stops recording @chrome-ubuntu-22.04
❌ [chromium-library] › library/screencast.spec.ts:219 › start dispose stops recording @chrome-beta-ubuntu-22.04
❌ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:111 › should restore state from userDataDir @firefox-macos-15-large
❌ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:138 › should create userDataDir if it does not exist @firefox-macos-15-large
❌ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:145 › should goto about:blank on relaunched persistent context @firefox-macos-15-large
❌ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:162 › should have default URL when launching browser @firefox-macos-15-large
❌ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:176 › should have passed URL when launching with ignoreDefaultArgs: true @firefox-macos-15-large
❌ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:205 › should fire close event for a persistent context @firefox-macos-15-large
❌ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:225 › should respect selectors @firefox-macos-15-large
❌ [firefox-library] › library/heap.spec.ts:203 › cycle handles @firefox-macos-15-large
❌ [firefox-library] › library/inspector/cli-codegen-1.spec.ts:757 › cli codegen › should await popup @firefox-macos-15-large
❌ [firefox-library] › library/inspector/cli-codegen-1.spec.ts:817 › cli codegen › should attribute navigation to click @firefox-macos-15-large
❌ [firefox-library] › library/inspector/cli-codegen-2.spec.ts:526 › cli codegen › should generate getByTestId for any of the configured testIdAttributes @firefox-macos-15-large
❌ [firefox-library] › library/inspector/cli-codegen-2.spec.ts:543 › cli codegen › should auto-generate toBeVisible @firefox-macos-15-large
❌ [firefox-library] › library/inspector/cli-codegen-3.spec.ts:224 › cli codegen › should generate frame locators (4) @firefox-macos-15-large
❌ [firefox-library] › library/inspector/cli-codegen-csharp.spec.ts:208 › should print context options method override in nunit if options were passed @firefox-macos-15-large
❌ [firefox-library] › library/inspector/cli-codegen-csharp.spec.ts:202 › should not print context options method override in mstest if no options were passed @firefox-macos-15-large
❌ [firefox-library] › library/inspector/cli-codegen-csharp.spec.ts:208 › should print context options method override in mstest if options were passed @firefox-macos-15-large
❌ [firefox-library] › library/proxy.spec.ts:165 › should reconnect with credentials after CONNECT 407 closes the socket @firefox-macos-15-large
❌ [firefox-library] › library/proxy.spec.ts:203 › should work with authenticate followed by redirect @firefox-macos-15-large
❌ [android-page] › page/page-autowaiting-basic.spec.ts:94 › should work with noWaitAfter: true
❌ [android-page] › page/page-autowaiting-basic.spec.ts:100 › should work with dblclick without noWaitAfter when navigation is stalled
❌ [android-page] › page/page-dialog.spec.ts:67 › should be able to close context with open alert
❌ [android-page] › page/page-goto.spec.ts:371 › should fail when exceeding maximum navigation timeout
❌ [android-page] › page/page-goto.spec.ts:439 › should prioritize default navigation timeout over default timeout
❌ [android-page] › page/page-request-continue.spec.ts:125 › should not allow changing protocol when overriding url
❌ [android-page] › page/page-set-content.spec.ts:153 › should handle timeout properly 2

45 flaky ⚠️ [chromium-library] › library/screencast.spec.ts:219 › start dispose stops recording `@tracing-chromium`
⚠️ [chromium-library] › library/trace-viewer.spec.ts:1615 › should highlight locator in iframe while typing `@msedge-dev-windows-latest`
⚠️ [chromium-library] › library/video.spec.ts:456 › screencast › should be 800x600 with null viewport `@msedge-dev-windows-latest`
⚠️ [chromium-library] › library/video.spec.ts:456 › screencast › should be 800x600 with null viewport `@chrome-ubuntu-22.04`
⚠️ [chromium-library] › library/screencast.spec.ts:219 › start dispose stops recording `@msedge-windows-latest`
⚠️ [chromium-library] › library/video.spec.ts:456 › screencast › should be 800x600 with null viewport `@msedge-windows-latest`
⚠️ [chromium-library] › library/browsertype-connect.spec.ts:189 › launchServer › should ignore page.pause when headed `@chromium-macos-15-large`
⚠️ [chromium-library] › library/heap.spec.ts:203 › cycle handles `@chromium-macos-15-large`
⚠️ [chromium-library] › library/screenshot.spec.ts:215 › page screenshot › should not hang when event loop is blocked `@chromium-macos-14-xlarge`
⚠️ [chromium-library] › library/beforeunload.spec.ts:130 › should support dismissing the dialog multiple times `@chromium-macos-15-xlarge`
⚠️ [chromium-library] › library/chromium/oopif.spec.ts:152 › should take screenshot `@chromium-macos-15-xlarge`
⚠️ [chromium-library] › library/screencast.spec.ts:219 › start dispose stops recording `@chrome-windows-latest`
⚠️ [chromium-library] › library/video.spec.ts:456 › screencast › should be 800x600 with null viewport `@chrome-windows-latest`
⚠️ [chromium-library] › library/inspector/cli-codegen-test.spec.ts:108 › should generate routeFromHAR with --save-har and --save-har-glob `@chrome-beta-ubuntu-22.04`
⚠️ [chromium-library] › library/video.spec.ts:456 › screencast › should be 800x600 with null viewport `@chrome-beta-ubuntu-22.04`
⚠️ [chromium-library] › library/browsertype-connect.spec.ts:686 › launchServer › should filter launch options `@chrome-macos-latest`
⚠️ [chromium-library] › library/browsertype-connect.spec.ts:1117 › launchServer only › should be able to reconnect to a browser 12 times without warnings `@chrome-macos-latest`
⚠️ [chromium-library] › library/video.spec.ts:456 › screencast › should be 800x600 with null viewport `@chrome-macos-latest`
⚠️ [firefox-library] › library/beforeunload.spec.ts:20 › should close browser with beforeunload page `@firefox-macos-15-large`
⚠️ [firefox-library] › library/browsercontext-base-url.spec.ts:37 › should construct a new URL when a baseURL in browserType.launchPersistentContext is passed to page.goto `@firefox-macos-15-large`
⚠️ [firefox-library] › library/browsertype-connect.spec.ts:499 › launchServer › should not throw on close after disconnect `@firefox-macos-15-large`
⚠️ [firefox-library] › library/browsertype-connect.spec.ts:510 › launchServer › should saveAs videos from remote browser `@firefox-macos-15-large`
⚠️ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:198 › should handle exception `@firefox-macos-15-large`
⚠️ [firefox-library] › library/defaultbrowsercontext-2.spec.ts:242 › should connect to a browser with the default page `@firefox-macos-15-large`
⚠️ [firefox-library] › library/har.spec.ts:109 › should populate entry startedDateTime from the browser `@firefox-macos-15-large`
⚠️ [firefox-library] › library/har.spec.ts:700 › should contain http2 for http2 requests `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-3.spec.ts:308 › cli codegen › should generate frame locators with special characters in name attribute `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-csharp.spec.ts:43 › should print the correct context options for custom settings `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-csharp.spec.ts:231 › should work with --save-har and --save-har-glob in nunit `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-java.spec.ts:24 › should print the correct imports and context options `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-javascript.spec.ts:36 › should print the correct context options for custom settings `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-javascript.spec.ts:84 › should save the codegen output to a file if specified `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-pytest.spec.ts:49 › should save the codegen output to a file if specified `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-python-async.spec.ts:80 › should save the codegen output to a file if specified `@firefox-macos-15-large`
⚠️ [firefox-library] › library/inspector/cli-codegen-python.spec.ts:76 › should save the codegen output to a file if specified `@firefox-macos-15-large`
⚠️ [firefox-library] › library/logger.spec.ts:34 › should log context-level `@firefox-macos-15-large`
⚠️ [firefox-library] › library/proxy.spec.ts:144 › should authenticate `@firefox-macos-15-large`
⚠️ [firefox-library] › library/proxy.spec.ts:235 › should exclude patterns `@firefox-macos-15-large`
⚠️ [firefox-library] › library/tracing.spec.ts:210 › should respect tracesDir and name `@firefox-macos-15-large`
⚠️ [webkit-library] › library/defaultbrowsercontext-2.spec.ts:96 › should accept userDataDir `@tracing-webkit`
⚠️ [webkit-library] › library/video.spec.ts:476 › screencast › should capture static page in persistent context @smoke `@tracing-webkit`
⚠️ [webkit-library] › library/har.spec.ts:460 › should return receive time `@webkit-windows-latest`
⚠️ [webkit-library] › library/inspector/cli-codegen-csharp.spec.ts:100 › should print the correct context options when using a device and additional options `@webkit-windows-latest`
⚠️ [webkit-page] › page/page-request-continue.spec.ts:194 › post data › should compute content-length from post data `@webkit-macos-15-large`
⚠️ [android-page] › page/page-route.spec.ts:317 › should not throw if request was cancelled by the page

93391 passed, 4206 skipped


Merge workflow run.

@Skn0tt Skn0tt removed the CQ1 label Jun 12, 2026
When several screencast frames map to the same constant-frame-rate output
slot (e.g. fast bursts before dispose), we kept the first frame and only
updated _lastFrame for later ones without ever piping them. The final real
frame was then emitted once at timestamp+addTime, an isolated trailing
cluster that ffmpeg's "-r 25" CFR conversion drops - leaving the whole video
showing only the first (stale) frame.

Coalesce to the most recent frame per slot instead, emitting it once the
slot is complete, and at stop emit the last real frame at its own slot plus
the >=1s hold. Preserves the per-slot dedup while making the last encoded
frame reflect the latest pixels.
@Skn0tt Skn0tt requested a review from yury-s June 12, 2026 14:49
@Skn0tt

Skn0tt commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

@yury-s please take a look at the approach, I wonder if you think it's worth the risk.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

7289 passed, 1122 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

2 flaky ⚠️ [chromium-library] › library/chromium/chromium.spec.ts:177 › serviceWorker(), and fromServiceWorker() work `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-page] › page/workers.spec.ts:190 › should attribute network activity for worker inside iframe to the iframe `@chromium-ubuntu-22.04-arm-node20`

39547 passed, 743 skipped


Merge workflow run.

Sign up for free to 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.

1 participant