Repository navigation
fix(core): show a paused video frame with the same color as the playing video on Mac - #7584
Conversation
…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.
jrusso1020
left a comment
There was a problem hiding this comment.
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.
|
Thanks, all three taken at
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. |
There was a problem hiding this comment.
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 likefails 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 inpreviewSeek.frames.test.tscan 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
jrusso1020
left a comment
There was a problem hiding this comment.
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
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:
<video>, playing and pausedSo 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()inpausedVideoFrames.tsis 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
drawImageand 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 withmediaProxy.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 inpausedVideoFrames.tsand the native-only hold inpreviewSeek.ts.Tried and not taken:
<video>fed by aMediaStreamTrackGenerator: exact, but needs the decoder to hand overVideoFrames and gives up the rotation, flip and alpha handling the canvas does.drawImagereaders see and costs a read-back per frame.Checked
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 innativeVideoLook.test.tsneed the new module, so they cannot pass on main.src/runtimesuite: 3057 passed.tsc,oxlintandoxfmtclean.Not covered