Skip to content

fix(core): color grading applies to canvas compositions - #5860

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/core-canvas-color-grading
Oct 19, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/core-canvas-color-grading

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

What

A <canvas> with data-color-grading is now graded in preview and in hyperframes render, the same way an <img> or <video> is, and the grade follows every frame the canvas draws. Before this, the attribute on a canvas did nothing at runtime, and lint reported it as an error.

Why

Hand-drawn and generative compositions paint into a canvas. Grading only worked on <img> and <video>, so those compositions could not be graded at all.

Related work

No open or merged PR covers canvas grading.

How

  • One list of grading sources. COLOR_GRADING_SOURCE_TAGS (video, img, canvas) lives in the grading contract (@hyperframes/parsers/color-grading-contract). Every place that picks a grading source reads it: the runtime refresh selector and isColorGradingMediaElement, the color_grading_non_media lint rule, the editing affordance that shows the grading section, and hyperframes media-treatment.
  • Size and readiness. readSourceSize is now the single owner for all three kinds. A canvas is measured by its drawing buffer (width/height), not its CSS box, and is drawable once both are above zero.
  • Per-frame updates. A canvas has no media events and no requestVideoFrameCallback. Every seek already ends in colorGrading.redraw(), which re-uploads every graded source. In preview this runs straight after the timeline and hf-seek listeners; in render it runs through waitForActiveLuts after the page's seek handlers settle. So a canvas drawn in hf-seek or a GSAP onUpdate is graded at the current frame. During playback the transport tick calls redrawAnimated(), which skipped any entry without animated grading properties; it now always redraws canvas sources (redrawsOnTransportTick).
  • Grain seed. A canvas has no currentSrc, so its seed comes from its id or position.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

Unit tests (vitest, one file per run, each run 3 times in a row):

File Result Without the fix
core/src/runtime/colorGrading.test.ts 66 passed new a graded canvas tests fail: expected false to be true (not graded) and expected { state: 'missing' } to match { state: 'pending' }
core/src/editing/affordances.test.ts 31 passed canvas: colorGrading fails
lint/src/rules/media.test.ts 117 passed accepts color grading on a canvas fails (color_grading_non_media reported)
cli/src/commands/media-treatment.test.ts 36 passed Color grading requires an <img> or <video>; selector matched <canvas>
parsers/src/colorGradingContract.test.ts, .docs.test.ts 8 + 1 passed

Mutation checks on the runtime: removing the canvas case from the transport tick fails the tick test (expected +0 to be 1); measuring a canvas as 0x0 fails both canvas tests. Typecheck of core, lint, parsers and cli is green, as are the oxlint, oxfmt and fallow pre-commit hooks.

End to end. The fixture is a 640x360, 3 s composition with a 2D canvas painted on hf-seek: a grey patch, a patch that is red before 1.5 s and blue after, and a white bar that moves with time. Its grade is {"adjust":{"exposure":0.8,"temperature":0.6},"effects":{"bloom":0.5}}. I rendered it as a 10 fps PNG sequence with this branch's CLI and with the published 0.8.172 CLI. Between 0.8.172 and this branch's base, the runtime grading code, the lint rule and the affordances are unchanged. Mean RGB of each patch:

Frame Build Grey patch Time-coloured patch Moving bar Background
t=0.5 s 0.8.172 128,128,128 200,40,40 240,240,240 40,40,40
t=0.5 s branch 255,255,255 255,70,57 255,255,255 82,70,57
t=2.5 s 0.8.172 128,128,128 40,40,200 240,240,240 40,40,40
t=2.5 s branch 255,255,255 82,70,255 255,255,255 82,70,57

At t=2.5 s the branch shows the blue patch and the bar at its late position, graded. The bar's early position (x=150) reads as graded background, so the render is not reusing an old frame.

Preview. In the branch's preview server, __player.seek(2.5) gave a pixel of 82,70,255 on the grading canvas. After play() for 2 s (t=2.00), the bar was graded white at its t=2.00 position and its old spot was background, so the playback tick updates the grade.

WebGL source. The same check with a WebGL canvas cleared on hf-seek, once with preserveDrawingBuffer: false and once with true: both renders were graded and current (t=0: 235,223,77; t=0.75 s: 101,223,255).

Before / After

Left: published 0.8.172 (canvas ignored). Right: this branch. Frames from the fixture render at t=0.5 s and t=2.5 s.

Before and after at 0.5 s

Before and after at 2.5 s

Known limits

  • WebGL canvases without preserveDrawingBuffer. The WebGL spec lets such a canvas clear its buffer once it is presented. The macOS render read the current frame either way. A redraw that runs outside the frame the source painted in (for example a window resize in preview, before the next seek) could upload a cleared buffer. I did not reproduce this; preserveDrawingBuffer: true avoids it.
  • Canvases that paint asynchronously after hf-seek (through waitUntil). In preview they are re-graded on the next redraw, not when their promise settles. Render waits for those handlers before grading.
  • Studio now shows the grading section for a canvas, through the shared affordance. I did not walk it in Studio.
  • Render was run on macOS only, not on headless Linux.

data-color-grading on a <canvas> was ignored by the runtime and refused by
lint, the Studio panel and the CLI writer. One source-tag list in the grading
contract now feeds all of them. A canvas is sized from its drawing buffer and
re-uploaded on every seek and transport tick.
@mintlify

mintlify Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Oct 18, 2026, 10:15 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@somanshreddy somanshreddy 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.

Clean at f8bdb30c: no blocker.

  • All four selection sites (the runtime selector, lint, the editing affordance and media-treatment) now read the one COLOR_GRADING_SOURCE_TAGS list. readSourceSize uses a canvas's drawing buffer (width/height), not its CSS box, and a 0×0 canvas is treated as not drawable.
  • redrawsOnTransportTick keeps the old video and animated-property behaviour unchanged and adds only "a canvas always redraws".
  • The runtime's own output canvas can't be picked up by refresh(): it never carries data-color-grading, and the selector requires that attribute.
  • A tainted canvas (one that drew a cross-origin image without CORS) fails open. texImage2D throws inside renderGradedFrame, under drawEntry's try, and hideSourceElement runs only after a successful draw, so the source stays visible and ungraded.

Non-blocking:

  1. isColorGradingMediaElement now accepts any canvas, including the runtime's own [data-hf-color-grading-canvas] output. Through the live API's resolveTarget, a target with no hfId/id, such as {selector: "canvas", selectorIndex: 1}, can land on the output canvas that was inserted after a graded canvas, and grade the grading canvas. So can the id __hf_color_grading_<id>. Excluding COLOR_GRADING_CANVAS_ATTR in that guard closes it.
  2. A tainted canvas also hits swallow("runtime.colorGrading.drawEntry") on every transport tick, because canvases always redraw. It may be worth latching drawError so it stops retrying.
  3. Comments is red, though not required: the comment ratchet flags +2 lines in runtime/colorGrading.ts and +1 in colorGradingContract.ts.

— Somu

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 190 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 (0)

Unstable (2)

  • move-hold-px-r0-nested-z100: tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true
  • crop-hold-px-r0-nested-z100: tracking 0.04, pressJump 0, drop 0.08, reload 0.08, render 0.03, renderKey -, undo true, teleport true / tracking 0.04, pressJump 0, drop 0.08, reload 0.08, render 0.03, renderKey -, undo true, teleport true / tracking 0.04, pressJump 0, drop 0.08, reload 0.08, render 0.03, renderKey -, undo true, teleport true

@somanshreddy somanshreddy 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.

Re-approve at 7c54a9ca, the delta from my reviewed head f8bdb30c. It's one commit that deletes 3 comment lines, two in runtime/colorGrading.ts and one in colorGradingContract.ts, with no code change. My review at f8bdb30c stands, including its non-blocking notes 1 and 2. Note 3 (the comment ratchet) is resolved by this commit.

— Somu

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 18, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 18, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 5658f70 Oct 19, 2026
83 checks passed
@miguel-heygen
miguel-heygen deleted the fix/core-canvas-color-grading branch October 19, 2026 00:57
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