Skip to content

fix(core): show a paused video frame with the same color as the playing video on Mac - #7584

Merged
miguel-heygen merged 4 commits into
mainfrom
fix/preview-color-one-path
Nov 12, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
fix/preview-color-one-path

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

What people saw

On a Mac, a video in the preview looks lighter and less rich while it plays than while it is paused. The exported file looks like the playing preview.

Cause

A paused frame is decoded and drawn on a canvas that stands in for the video (pausedVideoFrames.ts); a playing video is shown by the browser's own video layer. On macOS, Chromium's video layer shows 8-bit SDR video with Apple's video gamma (about 1.961), while a canvas shows the same decoded pixels as sRGB. Same file, same frame, two conversions.

Measured on one Mac (M4 Pro, macOS 26.5, built-in display), same synthetic bars clip, every state captured from the screen the same way. Green channel of the mid-gray patch, and the light skin patch:

What mid-gray light skin
Studio paused, before this change 127 213, 172, 112
Studio playing 139 218, 181, 122
QuickTime playing and paused 139 218, 181, 122
Chrome, plain <video>, playing and paused 139 218, 181, 122
The exported MP4 in QuickTime, playing and paused 139 218, 181, 122
Studio paused, with this change 138 217, 180, 123

So the video layer's look is the one the person gets everywhere else for the same file, and the canvas is the odd one out. A 16-step gray ramp, video layer against canvas, follows sRGB(canvas ^ 1.961) within 1 at every step (the BT.709 curve fits only the mid-tones). On Linux Chromium the two paths agree, so nothing changes there.

Fix

One module, nativeVideoLook.ts, owns the conversion. refresh() in pausedVideoFrames.ts is the one place that styles the visible stand-in, so both ways a paused frame is shown (decoded frames and the held native frame) get it. The correction is an SVG table filter on the stand-in canvas, placed ahead of any filter the author put on the video.

It is display-only. The canvas pixels are untouched, so drawImage and WebGL readers keep the same values a playing video gives them.

It applies to Chromium on macOS only (Safari, Windows and Linux are left alone) and only to a source the studio's scan positively reports as 8-bit. Facts come from the media proxy's own lookup (now in mediaSourceLookup.ts, shared with mediaProxy.ts: origin-aware, exact, then longest suffix, then case and NFC normalized, ambiguous normalized matches refused). Missing facts, an unmatched or remote source and an unknown depth are left as decoded. Both ways a native frame is held while seeking get the same helper: the provider hold in pausedVideoFrames.ts and the native-only hold in previewSeek.ts.

Tried and not taken:

  • Show the real video element when paused at rest and keep the decoded canvas only while scrubbing. Measured on the same Mac, the paused element shows the exact frame: a barcode clip (frame number in the picture) gave 0 wrong frames in 60 seeks (with and without the runtime's 1e-5 s lead), 40 single-frame steps, and a pause after play, on a 1280x720 H.264 clip and a 3840x2160 10-bit HEVC clip. But it does not remove the constant: the canvas still shows during every scrub and, without the correction, the picture would flip from 125 to 137 when a scrub ends. It also waits on the element's seek, which on the 4K clip took a median of 86 ms and up to 418 ms (14 and 147 ms on the 720p clip), which is why the decoded canvas exists. Kept the filter; revealing the element at rest can come later on top of it.
  • Draw playback through the canvas: playing is already the right look.
  • A <video> fed by a MediaStreamTrackGenerator: exact, but needs the decoder to hand over VideoFrames and gives up the rotation, flip and alpha handling the canvas does.
  • Baking the table into the canvas pixels: changes what WebGL and drawImage readers see and costs a read-back per frame.
  • A WebGL display canvas: exact on the ramp, but one more context per video.

Checked

  • fails without the fix: packages/core/src/runtime/pausedVideoFrames.test.ts › "shows a paused frame with the look its video layer has, and the authored filter after it"; packages/core/src/runtime/previewSeek.frames.test.ts › "gives the native-only held picture the look of its video layer" (each run against main's source file and failing there). The cases in nativeVideoLook.test.ts need the new module, so they cannot pass on main.
  • Unit tests: the measured ramp, the browser gate, the filter order, missing facts, an unmatched or remote source, a normalized 10-bit path, an ambiguous normalized match, both hold paths. The new paused-frame test fails on main and passes here. Full src/runtime suite: 3057 passed. tsc, oxlint and oxfmt clean.
  • Mac screen test, Studio from this tree in Chrome on the Mac, seek, play, pause, compare screen pixels of the mid-gray and skin patches. Main: paused 125, 127, 127 against playing 137, 139, 138. This branch: 135, 138, 138 against 137, 139, 138, and skin within 1.
  • Smoothness, same Mac and display (120 Hz), page frame gaps while the film plays for 3 s and while scrubbing for 2 s: main p50 8.3 ms, p95 9.9 to 10.2 ms, max 10.4 ms, no gap over 25 ms. This branch, three runs: p50 8.3 ms, p95 9.9 to 10.3 ms, max 10.4 ms, no gap over 25 ms.

Not covered

  • HDR sources (10-bit): paused is darker and richer than playing there too, but the correction was only measured for 8-bit SDR, so they keep today's look. That includes an 8-bit lighter copy made from an HDR source: it is classified by the original's 10-bit facts and keeps the old mismatch.
  • Sources the scan has no facts for (auto-proxy off, remote files, failed probes) also keep the old mismatch until their facts are known.
  • Saturated primaries: the filter path puts a pure red 7 steps off in the minor channels (235, 58, 38 against 232, 51, 35). Mid-tones and skin are within 2.
  • Safari and Windows were not measured.
  • 1.961 is the Mac video pipeline's constant, not a property of one display. The same 16-step ramp, video layer against canvas, was captured on the built-in Liquid Retina XDR display and on an external 4K UHD display of the same Mac and the values are identical at every step (video layer 0, 15, 37, 58, 76, 95, 113, 130, 147, 162, 179, 195, 210, 225, 239, 255). A second Mac or macOS version is unproven.

Before and after: paused and playing, main above and this change below

…ng video on Mac

On macOS Chromium the video layer shows 8-bit SDR video with Apple's video gamma while a canvas shows the decoded pixels as sRGB, so a paused frame looked lighter-to-darker than the playing video. One module now owns the conversion and the paused stand-in canvas is displayed through it.
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 92 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at 49de6ab88dff5cd5a863651b4e821812a235c96d. The delta from tested 59cf74af only removes one comment; executable code and dependencies are unchanged.

Important — the SDR gate also applies the correction to unclassified sources, including HDR (nativeVideoLook.ts:27–41, pausedVideoFrames.ts:160). Missing facts, an unmatched pathname, and a missing bit depth all mean “not high bit depth”, so refresh() applies the SDR table. Missing facts are reachable: the preview injection skips scanning with auto-proxy disabled, remote videos are not scanned, and failed probes are omitted. This contradicts the stated HDR exclusion and introduces a new display transform on sources the measurement did not cover.

The new suffix lookup also bypasses the established media lookup's case/NFC normalization and origin rules (mediaProxy.ts:325–402). With facts for /Original.WEBM at 10 bits and a video loading /original.webm, my presentation probe observes url("#__hf_native_video_look") instead of leaving it alone. The existing media-proxy tests already pin normalized identity matching. A missing-facts presentation probe independently observes the same incorrect opt-in.

Fix: resolve source facts through a shared origin-aware, exact/longest-suffix/normalized lookup, and distinguish unknown depth from positively known supported bit depth. Skip this correction when classification is unknown; add tests for missing facts, an unmatched/remote source, and normalized 10-bit paths. Preserve the existing exact-match precedence and ambiguous-normalized-match behavior when sharing that lookup. Passing reliable decoded-source facts is another way to make the opt-in explicit.

Follow-up, not a new regression: previewSeek.ts:17–32 still has its own native-only canvas hold and never calls the correction. It is reachable when no frame provider is installed (init.ts:5060–5062). A native-only seek probe finds both held canvases unfiltered, while the provider-native-hold control receives the correction. Use the same display-style helper at both hold paths so fallback seeks do not keep the old dark flash. Converted 8-bit HDR proxies also retain the old mismatch because they are classified by their original 10-bit facts.

The integration otherwise preserves the existing seek/decode boundary: decoded frames and provider-native holds both reach refresh(), the correction precedes authored filters, and clear/recovery removes the filtered canvas without modifying the native video. Canvas2D/WebGL/WebGPU consumers keep reading the raw surface rather than the CSS-filtered display. The diff does not change decoder, cache, source timing or worker transport code. The SVG table is defined once per document; per-scrub styling does not perform pixel readback. This is not an independent Mac GPU-performance or transition-flicker measurement.

A Linux Chromium browser probe with the Mac UA forced confirms the display-only mechanism: a gray canvas screens at 138 while both drawImage and a WebGL texture read remain 127. Adding authored brightness(0.5) screens at 69, confirming the intended order. This tests the filter plumbing, not native Mac video gamma.

Validation: full workspace build succeeds; 192 existing focused tests pass across native look, paused frames/callbacks, preview seeks, buffering, media proxy and texture compatibility. Four temporary presentation probes yield three failures (unknown depth, normalized HDR identity, native-only coverage) and one pass (provider-native hold and cleanup); all temporary test edits were restored. Miguel's screen and smoothness measurements remain author evidence from one Mac/display, with saturated-color error and unmeasured platforms still as documented.

Need: match the paused stand-in to the native video display · Size: focused · Reuse: share the established media-source identity lookup · Simpler: positively opt in known supported sources · Structure: fine (checked presentation, native fallback, source-facts producer and graphics consumers; no broad split)
Risk: medium — a display correction can misclassify HDR sources; the inspected export/graphics consumers retain their pixel inputs.

Verdict: REQUEST CHANGES
Reasoning: The measured SDR correction is narrowly placed, but its source-depth gate fails open and does not preserve the existing source-identity contract.

— Rames

…as 8-bit, on both hold paths

Source facts now come from the shared media lookup (origin-aware, exact then longest suffix then normalized), unknown depth is left alone, and the native-only seek hold gets the same display helper.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks, all three taken at 49bd58ef.

  1. Source facts. The lookup the media proxy uses (origin-aware, exact, then longest suffix, then case and NFC normalized, ambiguous normalized matches refused) moved unchanged into mediaSourceLookup.ts; mediaProxy.ts and the correction both call it. The suffix match I added is gone. It lives in its own module because mediaProxy.ts cannot be imported from pausedVideoFrames.ts without pulling the runtime into the package's tsc program.
  2. Unknown is not 8-bit. The correction applies only when the facts positively say 8-bit. New tests: no facts, facts for another file, a remote source whose path matches a scanned file, a missing bitDepth, a normalized 10-bit path (/Original.WEBM at 10 bits against /original.webm), an ambiguous normalized pair, and a route-prefixed path that does match. Converted 8-bit lighter copies of HDR sources stay out, since they are classified by the original's 10-bit facts; the body says so.
  3. Both hold paths. previewSeek.ts's native-only hold now calls the same helper as the provider hold in pausedVideoFrames.ts. The new test builds the hold with no frame provider and fails without the change.

Re-measured on the Mac with the facts injected by the preview server (8-bit): paused 135, 138, 138 against playing 137, 139, 138 on the mid-gray, skin within 1, no page frame gap over 25 ms (max 10.4 ms) while playing and scrubbing. src/runtime: 3057 tests pass, tsc, oxlint and oxfmt clean.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 0 nits · head 49bd58ef

  • blocker (lane rule): the body has no fails without the fix: line naming a test. "The new paused-frame test fails on main" doesn't name one. Add a line like fails without the fix: packages/core/src/runtime/pausedVideoFrames.test.ts › "shows a paused frame with the look its video layer has, and the authored filter after it". The native-only hold test in previewSeek.frames.test.ts can go there too.

I found no code blocker at this head. @jrusso1020's points from 49de6ab8 are all fixed here: the look now applies only to sources known to be 8-bit, the facts lookup is shared with the media proxy (mediaSourceLookup.ts), and previewSeek.ts holdVideoFrame gets the same helper. Each fix has a test.

Scope

Read the full diff at 49bd58ef (9 files). refresh() copies the computed style again on every call, which resets filter first, so when a source stops qualifying the look is dropped and doesn't stick. The look comes ahead of the authored filter and is display-only (CSS on the stand-in canvas only). The isWebKit + Chrom(e|ium)/ gate leaves Safari, Windows and Linux out. CI at this head is green (Test, Lint, Format, Windows, Producer integration, Studio edit accuracy). I didn't run the Mac gamma measurement myself; that evidence comes from the author.

— Genesis

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE · 0 blocker · 0 important · 0 nits · 1 resolved, 0 open, 0 new · 49bd58ef → 49bd58ef

✅ The body now has a fails without the fix: line naming both tests. The code is unchanged.

— Genesis

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed at 49bd58ef9560b380fd6c3e2cbee7950f315252b5, against my request for changes at 49de6ab8.

The earlier blockers are resolved: unknown, unmatched and remote sources no longer opt into correction, and normalized 10-bit paths retain their uncorrected display. The shared mediaSourceLookup preserves all six extracted function bodies after removing comments, including exact-match precedence and ambiguous-normalized-match refusal. The native-only seek hold now uses the same helper as provider-held and decoded frames.

The native-only delta adds styling immediately after copying the video's computed styles. It does not alter seek destinations, deadlines, decoder suspension, publication, cancellation or recovery ownership. The correction still precedes authored filters, affects the display surface only, and is removed with its existing canvas. The range-diff retains the earlier commits and adds one focused follow-up. No new code blocker found.

Validation through pytest-gate.sh: full workspace build succeeds; 199 committed tests and five temporary probes pass across the look, paused frames/callbacks, native/decoded seek publication, buffering, media proxy and graphics compatibility. These include the three formerly failing cases plus provider-hold cleanup and eligibility refresh. Reintroducing unknown-depth eligibility makes seven tests fail, including the original missing-facts probe. Temporary probes and the mutation were restored. The native-only probe supplies known 8-bit facts to match the new opt-in contract. The Mac screen/performance results remain the author's measurements; I did not independently repeat them. Unknown sources, HDR-derived proxies and the documented saturated-color approximation remain explicit limitations.

Need: yes, consistent paused-frame presentation · Size: focused follow-up · Reuse: one established source lookup and one display helper · Simpler: explicit known-source opt-in · Structure: fine (checked lookup extraction and both native hold paths; no further split)
Risk: low — source eligibility is now conservative; seek/decode control flow is unchanged.

Verdict: APPROVE
Reasoning: The prior source-classification defects and native-only coverage gap are fixed without changing seek or playback ownership.

— Rames

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE · 0 resolved, 0 open, 0 new · 49bd58ef → 27dfdea6 · the only change is two comments reworded in mediaSourceLookup.ts and nativeVideoLook.ts; no code changed, and CI is green at this head.

— Genesis

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 303a4c4 Nov 12, 2026
98 checks passed
@miguel-heygen
miguel-heygen deleted the fix/preview-color-one-path branch November 12, 2026 06:02
@miguel-heygen miguel-heygen mentioned this pull request Nov 12, 2026
4 of 6 tasks
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.

2 participants