diff --git a/packages/core/src/colorGrading.ts b/packages/core/src/colorGrading.ts index d71ce1776..d5411e20a 100644 --- a/packages/core/src/colorGrading.ts +++ b/packages/core/src/colorGrading.ts @@ -1,5 +1,17 @@ export const HF_COLOR_GRADING_ATTR = "data-color-grading"; +// Runtime <-> studio contract attributes. The runtime grading engine writes +// them; studio editing/soft-reload code reads them. Single owner — never +// re-declare these literals elsewhere. +/** Set on a graded source while its pixels render on the grading canvas. */ +export const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden"; +/** + * The element's AUTHORED inline opacity, stamped at document parse time before + * any animation engine mutates it ("" = authored none; attribute absent = + * never captured). See installAuthoredOpacityCapture in the runtime. + */ +export const COLOR_GRADING_AUTHORED_OPACITY_ATTR = "data-hf-authored-opacity"; + export const HF_COLOR_GRADING_CANVAS_ID_PREFIX = "__hf_color_grading_"; export const HF_COLOR_GRADING_COLOR_SPACE = "rec709"; diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index e3a3ce49d..cf3e0e048 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -1,7 +1,11 @@ // fallow-ignore-file code-duplication import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading"; -import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading"; +import { + createColorGradingRuntime, + installAuthoredOpacityCapture, + type RuntimeColorGradingApi, +} from "./colorGrading"; let lastUniform1f: ReturnType | null = null; let lastUniform3f: ReturnType | null = null; @@ -192,6 +196,60 @@ describe("createColorGradingRuntime", () => { runtime?.redraw(); } + it("restores the authored inline opacity captured before animation transients", () => { + const video = makeDrawableVideo(); + // Parse-time capture stamped the authored value; by hide time GSAP has + // already left a from()-tween transient (0) in the inline style. + video.setAttribute("data-hf-authored-opacity", "0.75"); + video.style.opacity = "0"; + startRuntimeWithVideo(video); + + expect(video.style.getPropertyPriority("opacity")).toBe("important"); + + runtime?.destroy(); + runtime = null; + + // Restore must use the authored 0.75, not the GSAP transient 0. + expect(video.style.getPropertyValue("opacity")).toBe("0.75"); + expect(video.style.getPropertyPriority("opacity")).toBe(""); + }); + + it("restores no inline opacity when the authored capture recorded none", () => { + const video = makeDrawableVideo(); + video.setAttribute("data-hf-authored-opacity", ""); + video.style.opacity = "0"; + startRuntimeWithVideo(video); + + runtime?.destroy(); + runtime = null; + + expect(video.style.getPropertyValue("opacity")).toBe(""); + }); + + it("re-syncs the graded canvas when the source's inline transform changes", async () => { + const { video } = startRuntimeWithVideo(); + const drawsBefore = texImage2DCalls.length; + + // Simulate a studio drag draft: only the inline transform moves. + video.style.transform = "translate(120px, 60px)"; + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + + expect(texImage2DCalls.length).toBeGreaterThan(drawsBefore); + }); + + it("does not redraw-loop on its own hide writes (opacity/visibility only)", async () => { + const { video } = startRuntimeWithVideo(); + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + const drawsBefore = texImage2DCalls.length; + + // drawEntry's own source-hide writes touch opacity — geometry unchanged. + video.style.opacity = "0.5"; + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + + expect(texImage2DCalls.length).toBe(drawsBefore); + }); + it("re-hides source media after timeline visibility sync", () => { const { video, canvas } = startRuntimeWithVideo(); @@ -555,3 +613,34 @@ describe("createColorGradingRuntime", () => { expect(video.style.getPropertyValue("opacity")).toBe("0"); }); }); + +describe("installAuthoredOpacityCapture", () => { + it("stamps graded elements at insertion and never overwrites the stamp", async () => { + installAuthoredOpacityCapture(); + const el = document.createElement("img"); + el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } })); + el.style.opacity = "0.98"; + document.body.appendChild(el); + await Promise.resolve(); + expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.98"); + + // A re-insert after an animation engine mutated the element keeps the + // original capture (has-attribute guard). + el.style.opacity = "0"; + el.remove(); + document.body.appendChild(el); + await Promise.resolve(); + expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.98"); + el.remove(); + }); + + it("stamps an empty value for graded elements without an authored inline opacity", async () => { + installAuthoredOpacityCapture(); + const el = document.createElement("img"); + el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } })); + document.body.appendChild(el); + await Promise.resolve(); + expect(el.getAttribute("data-hf-authored-opacity")).toBe(""); + el.remove(); + }); +}); diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 5b32c6c65..75e85ebd8 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -6,6 +6,8 @@ import { normalizeHfColorGradingWithVariables, type HfColorGradingTarget, type NormalizedHfColorGrading, + COLOR_GRADING_SOURCE_HIDDEN_ATTR, + COLOR_GRADING_AUTHORED_OPACITY_ATTR, } from "../colorGrading"; import { DEFAULT_MAX_CUBE_LUT_SIZE, @@ -196,8 +198,51 @@ type LutCacheEntry = const LUT_CACHE = new Map(); const COLOR_GRADING_CANVAS_ATTR = "data-hf-color-grading-canvas"; -const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden"; const COLOR_GRADING_CANVAS_CLASS = "__hf_color_grading_canvas__"; + +/** + * Capture each color-graded element's AUTHORED inline opacity before any + * animation engine can mutate it. + * + * The grading engine hides its source elements with `opacity: 0 !important` + * and mirrors their pixels onto a canvas — so at runtime, a graded element's + * inline/computed opacity no longer represents authored state. Everything that + * later re-reads element state (GSAP from()-tween re-initialization after an + * invalidate or a studio soft reload, restoring the source when grading is + * removed, lint/selection tooling) needs the authored value, and by then it is + * unrecoverable from the DOM. Stamp it onto the element as + * `data-hf-authored-opacity` (empty string = no authored inline opacity). + * + * Must be installed at runtime-bundle evaluation, while the document is still + * parsing: the runtime `