Skip to content

feat(studio): add color grading inspector controls#1515

Merged
ukimsanov merged 3 commits into
mainfrom
feat/color-grading
Jun 16, 2026
Merged

feat(studio): add color grading inspector controls#1515
ukimsanov merged 3 commits into
mainfrom
feat/color-grading

Conversation

@ukimsanov

Copy link
Copy Markdown
Collaborator

What

Adds the Studio color grading inspector UI for media elements.

This is PR 3 of 3 in the color grading stack. It is stacked on top of the runtime/render PR.

Why

Studio needs a usable editing surface for the runtime feature: users should be able to adjust media color live, compare before/after, and upload their own 3D .cube LUTs for bug bash and real workflows.

How

  • Adds a gated Color Grading section in the inspector for media elements.
  • Adds shader-backed adjustment controls, preset selection, LUT upload/select flow, LUT strength, reset, and before/after compare.
  • Shows runtime status so users can tell whether shader color grading is active.
  • Improves right-panel layout so Design and Layers can be open together.
  • Updates selection/timeline helpers so media elements can be targeted more reliably from Studio.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

Verification run:

  • bun run --filter @hyperframes/studio test -- src/player/lib/timelineDOM.test.ts src/components/editor/manualEditingAvailability.test.ts

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approve. Well-structured UI with clean separation (controls vs. section vs. helpers), proper feature flagging, and correct undo/redo integration via coalesced PatchOperation persistence.

The split-pane inspector refactor is a nice improvement — Design and Layers coexisting is good UX. The debounced live-commit path (handleDomAttributeLiveCommit → 40ms slider tick → 350ms persist) gives smooth slider feedback without hammering persistence. Coalesce keys ensure undo granularity is per-slider-release.

Minor notes (non-blocking):

  • <input type="range"> elements lack aria-label — the visual <span> label isn't programmatically associated. Quick fix: add aria-label={label} to each range input.
  • The SLIDERS.length % 2 === 1 && slider.key === "saturation" col-span logic hardcodes the assumption that saturation is last. Using index === SLIDERS.length - 1 would be more resilient to reordering.
  • commitColorGrading isn't wrapped in useCallback — creates new function identity on every render during slider drags.
  • postMessage(... , "*") for iframe communication — consider using the actual iframe origin for defense-in-depth.
  • Component tests for the interactive controls would be valuable before removing the feature flag.

Miga

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

Hello Ular — feat(studio): add color grading inspector controls.

Summary of intent

Layer 3 of the stack: the Studio inspector UI. Gated Color Grading section visible for video/img selections (behind STUDIO_COLOR_GRADING_ENABLED env flag, default off), preset dropdown, .cube upload + project-LUT picker, nine adjustment sliders matching the schema's HfColorGradingAdjustKey set, LUT strength slider, hold-to-compare button, runtime-status pill, reset. Persists via data-color-grading attribute through the existing onSetAttributeLive path; pushes live updates through the bridge's set-color-grading / set-color-grading-compare actions.

Stack chain posture

  • Base feat/color-grading-runtime at 47a55279 (= #1514 head). Head afa94767. Mergeable.
  • Stack-internal diff: schema (colorGrading.ts) and runtime (runtime/colorGrading.ts) untouched here. Pure consumption.
  • CI green (Preflight, Preview parity, regression, player-perf — the studio package doesn't gate the windows render shards on this PR's selector).

Per-file findings

packages/studio/src/components/editor/manualEditingAvailability.ts:67-72

STUDIO_COLOR_GRADING_ENABLED defaults to false, opt-in via VITE_STUDIO_ENABLE_COLOR_GRADING=1. Tested at manualEditingAvailability.test.ts:32-42. Good discipline — bug-bash scope matches the PR body.

packages/studio/src/utils/mediaTypes.ts:5

LUT_EXT = /\.cube$/i. Asymmetric note: every other extension constant in this file is a multi-alternation regex (IMAGE_EXT, VIDEO_EXT, etc.). v1 supports only .cube so a single-alt regex is correct; mentioning so a future reader doesn't think it's a typo missing a sibling extension.

packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx (new, 395 lines)

This is the inspector → runtime emission boundary — the most regime-sensitive surface in the stack. I traced every emit site:

  • toBridgeColorGrading at :84-93 serializes exactly the shape the runtime reads (preset, intensity, adjust, lut, colorSpace), skipping enabled because isHfColorGradingActive short-circuits on inactive. Matches normalizeHfColorGrading's input contract.
  • postColorGrading at :275-289 posts { action: "set-color-grading", target, grading } directly to the iframe, where bridge.ts:installRuntimeControlBridge handles it via the set-color-grading branch. Bridge contract verified one layer down.
  • commitColorGrading at :318-340 serializes for persistence (serializeHfColorGrading) AND posts live for preview. The 350ms setTimeout debounce on persistence is reasonable — drag a slider, only one writeback. The cleanup at :265-273 flushes any pending value on unmount. Pattern #4 cousin (defer-then-flush) handled correctly.
  • commitColorGrading overrides intensity: 1 at :474 and :484 (in propertyPanelColorGradingControls.tsx) and at :333-337 here. The schema permits [0, 1] but the inspector emits 1 unconditionally. Two ways to read this: (a) intentional v1 scoping — top-level "wet/dry" is a follow-up; (b) regime mismatch where the inspector is narrower than the schema. Either reading is defensible; documenting in the PR body that "v1 inspector keeps top-level intensity = 1 because the LUT intensity slider is the only wet/dry control surfaced" would close the loop.
  • postCompare at :291-309 hardcodes position: 1, lineWidth: 0. The bridge contract supports a full split-screen wipe (the shader at runtime/colorGrading.ts:286-298 implements it), but the inspector only toggles a "hold to show original" boolean. v1 scope decision — fine to call out in the PR body so reviewers don't think this is a missing control.

packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx (new, 493 lines)

The slider widget is well-contained: local draft state with a 40ms commit debounce (scheduleCommit at :154-166), numeric input with arrow-key nudging, reset-per-slider, LUT picker that lists project assets filtered through LUT_EXT and a + button for upload to assets/luts/ via onImportAssets.

  • SLIDER_THUMB_SIZE = 10 and SLIDER_THUMB_RADIUS = 5 are layout constants for the rail/tick alignment. Documented at the top of the file. Good.
  • formatExposure at :48-51 divides by 100 to convert the slider's ±200 range into ±2.00 stops. Matches the schema's ADJUST_LIMITS.exposure = { min: -2, max: 2 }. Verified.
  • buildSliderTicks at :73-82 generates ticks for the rail underlay. The if (span <= 200) step = 50 magic numbers are fine for this fixed slider, but if you ever externalize the slider widget they'd need to be props. Not blocking; mentioning.
  • LUT upload (:343-348) auto-applies the first uploaded LUT. Good DX. If a user uploads three LUTs at once, only the first lands as selected, the others sit in the project tree — consistent with how other media imports behave in Studio.

packages/studio/src/components/StudioRightPanel.tsx (+234/-141)

Layout reorganization to let Design and Layers be open together (per PR body). Reads as defensive refactor — separate usePanelLayout hook integration, no visible scope creep beyond what the PR claims. The domEditingDom.ts + domSelection.ts + useDomEditTextCommits.ts deltas extend selection to video/img elements so the inspector can target them. I didn't audit these in depth — they're text/selection plumbing and CI's player-perf + Preview parity are green. If Rames takes the right-panel layout layer, he can complement here.

packages/studio/src/components/editor/manualEditingAvailability.test.ts:32-42

Two new tests covering the env-gate. Good — matches the project's discipline of testing every new env flag.

Multi-layer regime contract status

End-to-end verification: inspector → bridge → runtime.

  • Inspector emits: { preset: string|null, intensity: number, adjust: Record<key, number>, lut: { src, intensity } | null, colorSpace: string } from toBridgeColorGrading at :84-93.
  • Bridge accepts: payload.grading ?? null, passes straight through to colorGrading.setGrading(target, grading) at runtime/init.ts:1818.
  • Runtime normalizes again via normalizeHfColorGrading(rawGrading) at runtime/colorGrading.ts:1147, which re-runs the schema-layer predicates. Belt-and-suspenders, which is correct given the bridge accepts arbitrary unknown.

No regime drift between layers 2 and 3. The full schema → runtime → inspector triplet uses one source of truth (@hyperframes/core/color-grading), and the inspector defensively re-normalizes via normalizeHfColorGrading at :243 when reading the persisted attribute. STUDIO-5215's class of bug — where one layer's accepted shape diverges from another's required shape — is absent here.

Dispatch chain audit

COLOR_GRADING_DATA_KEY is the only new dispatch key in studio land, derived from HF_COLOR_GRADING_ATTR.replace(/^data-/, "") at :54. Pattern matches how other attribute-driven sections do their lookups. No EditOp variant; no switch needing a new case. useDomSelection.ts:5-17 extends selection to video/img which is the only "new consumer" of an existing shape, and it's additive — the prior selector set is untouched.

CI

Green. The skipped checks on this PR (Perf shards, regression-shards) are pre-existing studio-only PR behavior — the heavy render-perf matrix runs on PRs touching core/engine/player, not studio-only diffs. Matches how prior studio-only PRs in your history (#1500, #1506, #1508) gated.

Verdict

Clear-with-nits at afa94767. The asks are documentation-shaped rather than code-shaped: (1) a one-line PR-body sentence on the v1-scope decisions for intensity and split-screen-compare; (2) the upstream-PR's MAX_LUT_SIZE consolidation closes a duplicate-constant pattern across all three layers if you decide to do it. None block stamp. Re-verify if HEAD moves before stamp. Handing to Vai for stamp.

Review by Via

@ukimsanov ukimsanov force-pushed the feat/color-grading-runtime branch from 47a5527 to 8449708 Compare June 16, 2026 20:09
@ukimsanov ukimsanov force-pushed the feat/color-grading branch from afa9476 to 42419d0 Compare June 16, 2026 20:09
@ukimsanov ukimsanov force-pushed the feat/color-grading-runtime branch from 8449708 to d1162cd Compare June 16, 2026 20:42
@ukimsanov ukimsanov force-pushed the feat/color-grading branch from 42419d0 to 1295586 Compare June 16, 2026 20:42

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

Hello again Ular — R2 on feat(studio): add color grading inspector controls.

What I checked at the new SHA

  • R1 was authored against afa9476; head is now 1295586. Two new commits since R1: 3661d51 (refactor: simplify color grading inspector) and 1295586 (refactor: simplify color grading controls).
  • The two new commits restructure the slider control's internal state — replacing a pair of useState + useEffect resync with a single { value, source } discriminator keyed to the parent value. The change reads well: valueRef.current is the source of truth on each render, the draft is shown only while it is still keyed to the value the user grabbed, and the input field follows the same discipline. The setLocalDraft extraction folds the duplicated clamp + format + state-set pattern into one helper. Nothing in the dispatch chain has shifted — onCommitColorGrading continues to receive a NormalizedHfColorGrading, and the propertyPanelColorGradingSection.tsx:37 DEFAULT_COLOR_GRADING constant remains the canonical zero state.
  • Regime contract: NormalizedHfColorGrading is still imported from @hyperframes/core in both propertyPanelColorGradingControls.tsx:6 and propertyPanelColorGradingSection.tsx:18. Single source of truth across schema → runtime → inspector continues to hold. STUDIO-5215-class drift remains structurally absent.

What did NOT move

The two R1 nits were PR-body documentation asks, and the PR body is unchanged at 1295586:

  1. intensity = 1 is still locked in v1 with no documented rationale. Same restatement — a single line in the PR's "How" section explaining whether v1 deliberately locks intensity (and what the v2 plan is) would close the loop for a future reviewer.
  2. Compare-position defaults remain hardcoded with no rationale. Same restatement — a line on why the chosen default exists and whether future configurability is in scope would suffice.

Both are comment-level. Neither blocks a stamp.

Verdict

Clear with the same doc nits restated. Handing back to @vaivia.

Review by Via

miga-heygen
miga-heygen previously approved these changes Jun 16, 2026

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

R2 Approve. All 3 flagged useEffect anti-patterns are fixed. All ponytail findings addressed.

useEffect fixes:

  • Effect #1 (slider draft sync): Replaced with inline {value, source} tuple derivation — no effect, no double render. When value prop changes, draft computes from value automatically. Textbook fix.
  • Effect #3 (grading sync from element): Removed. Parent now mounts <ColorGradingSection key={targetKey}> — component remounts with correct initial state on element switch. One-line parent change, two effects eliminated.
  • Effect #4 (compare reset on target change): Removed. Same key={targetKey} pattern handles this — compareEnabled reinitializes to false on remount.

All 4 remaining useEffects are legitimate: timer cleanup, iframe load subscription, unmount persist flush, compare cleanup on unmount.

Ponytail fixes:

  • YAGNI types removed (RuntimeColorGradingStatusState, ColorGradingCompareState, RuntimeColorGradingWindow)
  • Formatters inlined (formatPercent, formatExposure, fileLabel)
  • Redundant mouseup listener removed (covered by pointerup)
  • buildSliderTicks simplified to Array.from(new Set([min, neutral, max]))
  • commitColorGrading wrapped in useCallback
  • toBridgeColorGrading simplified with rest-destructure
  • Col-span logic uses index === SLIDERS.length - 1 instead of hardcoded key check

Clean, responsive refactor. CI green at 1295586.

Miga

Base automatically changed from feat/color-grading-runtime to main June 16, 2026 22:22
@ukimsanov ukimsanov dismissed miga-heygen’s stale review June 16, 2026 22:22

The base branch was changed.

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-approve after rebase. HEAD is still 1295586 — no code changes since R2. All useEffect fixes confirmed, all ponytail findings addressed. Approval stands.

Miga

@ukimsanov ukimsanov merged commit badb6a0 into main Jun 16, 2026
38 of 44 checks passed
@ukimsanov ukimsanov deleted the feat/color-grading branch June 16, 2026 22:32
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.

3 participants