Merge pull request #2755 from heygen-com/feat/media-treatment-studio

feat(studio): add media treatment inspector
This commit is contained in:
Ular Kimsanov
2026-07-24 21:20:01 -07:00
committed by GitHub
23 changed files with 2612 additions and 429 deletions
+4 -4
View File
@@ -12,10 +12,7 @@ import { useFileManager } from "./hooks/useFileManager";
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import {
persistTimelineMoveEditsAtomically,
type TimelineMoveOperation,
} from "./hooks/timelineMoveAdapter";
import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter";
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
@@ -65,6 +62,7 @@ import {
} from "./utils/studioUrlState";
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
@@ -204,6 +202,7 @@ export function StudioApp() {
setActiveBlockParams,
handleAddBlock,
handleTimelineBlockDrop,
handleAddMediaOverlay,
handlePreviewBlockDrop,
} = useBlockHandlers({
projectId,
@@ -536,6 +535,7 @@ export function StudioApp() {
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={editHistory.recordEdit}
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
onAddMediaOverlay={handleAddMediaOverlay}
/>
)
}
@@ -31,7 +31,10 @@ import {
EMPTY_COLOR_GRADING_SCOPE_RESULT,
type ColorGradingScope,
} from "./studioColorGradingScope";
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
import type {
AddMediaOverlayHandler,
BackgroundRemovalProgress,
} from "./editor/propertyPanelTypes";
import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
@@ -71,6 +74,7 @@ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
onToggleElementHidden?: ToggleHiddenHandler;
onAddMediaOverlay?: AddMediaOverlayHandler;
}
// fallow-ignore-next-line complexity
@@ -88,6 +92,7 @@ export function StudioRightPanel({
domEditSaveTimestampRef,
recordEdit,
onToggleElementHidden,
onAddMediaOverlay,
}: StudioRightPanelProps) {
const {
rightWidth,
@@ -372,6 +377,7 @@ export function StudioRightPanel({
onRemoveTextField={handleDomRemoveTextField}
onAskAgent={handleAskAgent}
onImportAssets={handleImportFiles}
onAddMediaOverlay={onAddMediaOverlay}
fontAssets={fontAssets}
onImportFonts={handleImportFonts}
previewIframeRef={previewIframeRef}
@@ -812,10 +812,10 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
// design_handoff scrollable-open-section: collapsed headers before/after the
// open group render in normal document flow and never move (no sticky, no
// stacking offsets) — only the open group's own body content scrolls, in a
// dedicated region between the two fixed header stacks. Worked example: 6
// groups [text, style, layout, motion, grade, media], motion open (index 3)
// dedicated region between the two fixed header stacks. Worked example: 7
// groups [text, style, layout, motion, grade, effects, media], motion open (index 3)
// -> text/style/layout render as fixed collapsed headers before it, motion
// renders as an open header + scrollable body, grade/media render as fixed
// renders as an open header + scrollable body, grade/effects/media render as fixed
// collapsed headers after it — in exactly that DOM order, nothing sticky.
describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)", () => {
it(
@@ -839,13 +839,14 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
});
// Filter to just the group entries (drop any non-group nulls).
const groupTitles = titles.filter((t): t is string => t !== null);
expect(groupTitles).toHaveLength(6);
expect(groupTitles).toHaveLength(7);
expect(groupTitles[0]).toContain("Text");
expect(groupTitles[1]).toContain("Style");
expect(groupTitles[2]).toContain("Layout");
expect(groupTitles[3]).toContain("Motion");
expect(groupTitles[4]).toContain("Grade");
expect(groupTitles[5]).toContain("Media");
expect(groupTitles[5]).toContain("Effects");
expect(groupTitles[6]).toContain("Media");
// The open group (Motion, index 3) is the one wrapped in
// data-flat-group-open, sitting between the before/after collapsed
@@ -880,14 +881,15 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
const collapsedRows = Array.from(
host.querySelectorAll<HTMLButtonElement>('[data-flat-group-collapsed="true"]'),
);
expect(collapsedRows).toHaveLength(6);
expect(collapsedRows).toHaveLength(7);
const titlesInOrder = collapsedRows.map((el) => el.textContent ?? "");
expect(titlesInOrder[0]).toContain("Text");
expect(titlesInOrder[1]).toContain("Style");
expect(titlesInOrder[2]).toContain("Layout");
expect(titlesInOrder[3]).toContain("Motion");
expect(titlesInOrder[4]).toContain("Grade");
expect(titlesInOrder[5]).toContain("Media");
expect(titlesInOrder[5]).toContain("Effects");
expect(titlesInOrder[6]).toContain("Media");
const body = host.querySelector('[data-flat-panel-body="true"]');
expect(body?.querySelector(".overflow-y-auto")).toBeNull();
@@ -23,6 +23,15 @@ import {
FlatColorGradingAccessory,
FlatColorGradingSection,
} from "./propertyPanelFlatColorGradingSection";
import {
activeColorGradingEffectCount,
FlatEffectsAccessory,
FlatEffectsSection,
} from "./propertyPanelFlatEffectsSection";
import {
deriveMediaOverlayPlacement,
FlatOverlaysSection,
} from "./propertyPanelFlatOverlaysSection";
type EditingSections = ReturnType<typeof resolveEditingSections>;
@@ -34,11 +43,7 @@ type FlatGroupDescriptor = {
content: ReactNode;
};
// Type-only fallback for the Motion effect-card callbacks. Used solely to
// satisfy FlatMotionSection's required-callback shape when the effect list is
// gated off (showEffects === false, so none of these are ever invoked). Keeps
// the gated-off path free of `!` non-null assertions — the real, narrowed
// handlers flow through only when the double-gate below passes.
// Required callback shape for the gated-off Motion effect list.
const EMPTY_GSAP_EFFECT_HANDLERS = {
onAddAnimation: () => {},
onUpdateProperty: () => {},
@@ -48,15 +53,7 @@ const EMPTY_GSAP_EFFECT_HANDLERS = {
onRemoveProperty: () => {},
};
/**
* The flat "Ledger" inspector shell (design_handoff_studio_inspector).
*
* Extracted from PropertyPanel so that file stays under the 600-LOC gate
* (same one-directional-import precedent as FlatTextSection). Rendered only
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open group state.
*
* The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion.
*/
/** The flat inspector shell with one shared open-group state. */
// fallow-ignore-next-line complexity
export function PropertyPanelFlat({
element,
@@ -93,6 +90,7 @@ export function PropertyPanelFlat({
onAskAgent,
onToggleElementHidden,
onImportAssets,
onAddMediaOverlay,
onImportFonts,
recordingState,
recordingDuration,
@@ -113,10 +111,7 @@ export function PropertyPanelFlat({
currentTime,
animIdForProp,
gsapRuntimeValues,
// Renamed: PropertyPanel.tsx still computes/passes these for its own legacy
// (non-flat) panel, but the flat path recomputes its own basis below via
// deriveElementTiming so it agrees with Motion's Timing row — ignore the
// parent's naive `elDuration ?? 1` fallback.
// The flat path derives timing consistently with its Motion section.
elStart: _elStart,
elDuration: _elDuration,
onCommitAnimatedProperty,
@@ -164,6 +159,7 @@ export function PropertyPanelFlat({
| "onAskAgent"
| "onToggleElementHidden"
| "onImportAssets"
| "onAddMediaOverlay"
| "onImportFonts"
| "fontAssets"
| "gsapAnimations"
@@ -187,9 +183,6 @@ export function PropertyPanelFlat({
| "recordingDuration"
| "onToggleRecording"
> &
// Layout-group values (Plan 3a Task 5). All are derived locals or handlers in
// PropertyPanel; compose their exact shapes from FlatLayoutSection's own props
// via Pick so a signature change there propagates here instead of drifting.
Pick<
Parameters<typeof FlatLayoutSection>[0],
| "displayX"
@@ -227,12 +220,7 @@ export function PropertyPanelFlat({
onCopyElementInfo: () => void;
currentTime: number;
}) {
// Lazy initializer: pick whichever group actually renders for this element
// (Text if text-editable, else Style if style-editable, else none open) so a
// style-only element doesn't start with everything collapsed. Only runs on
// mount — PropertyPanel.tsx keys <PropertyPanelFlat> by element identity so
// switching the selection re-mounts this component and re-derives the
// default instead of preserving stale state across unrelated elements.
// PropertyPanel keys this component by selection, so the default is per element.
const [openGroupId, setOpenGroupId] = useState<string>(() =>
isTextEditableSelection(element)
? "text"
@@ -243,33 +231,16 @@ export function PropertyPanelFlat({
: "layout",
);
// Tracks which group(s) are actively transitioning this toggle cycle, so
// their header/body gets the fast entrance animation (hf-flat-group-enter)
// and no one else's does. Deliberately NOT derived from remounting alone:
// FlatGroupHeader instances are keyed by group id and React normally
// preserves them across re-renders, but toggling a non-adjacent group still
// shifts the untouched collapsed siblings between the before/after-open
// slices below, and Chromium restarts a CSS animation on that kind of
// position shift even though nothing about the sibling actually changed.
// Gating on these ids (cleared shortly after the 120ms CSS animation
// finishes) keeps the animation scoped to only the groups that actually
// just toggled. Two ids, not one: the clicked (newly-opening/closing) group
// AND whichever group was open immediately before the click and got
// implicitly closed by it — both freshly-mounted headers need to animate.
// Animate only the groups that changed during this toggle cycle.
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelBodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
return () => {
if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
};
}, []);
// Grade group state. Called unconditionally (React rules-of-hooks) even when
// sections.colorGrading is false — unlike the legacy ColorGradingSection,
// which is only mounted when the section is active, PropertyPanelFlat is not
// remounted per-section so the hook must run every render. Shares one state
// object between the group's header accessory (compare/status/reset) and its
// body (the FlatColorGradingSection controls).
const colorGradingController = useColorGradingController({
projectId,
element,
@@ -281,9 +252,7 @@ export function PropertyPanelFlat({
const isTextEditable = isTextEditableSelection(element);
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
const toggleOpen = (groupId: string) => {
// Capture what was open BEFORE this click (this render's closure over
// openGroupId), so the group that's about to be implicitly closed can be
// tracked too — not just the one the user clicked.
const isOpening = openGroupId !== groupId;
const previousOpenGroupId = openGroupId;
setOpenGroupId((current) => (current === groupId ? "" : groupId));
const implicitlyClosedId =
@@ -291,34 +260,20 @@ export function PropertyPanelFlat({
setJustToggledIds(implicitlyClosedId ? [groupId, implicitlyClosedId] : [groupId]);
if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
justToggledTimeoutRef.current = setTimeout(() => setJustToggledIds([]), 200);
if (isOpening) {
requestAnimationFrame(() =>
panelBodyRef.current
?.querySelector<HTMLElement>('[data-flat-group-open="true"]')
?.scrollIntoView?.({ block: "start" }),
);
}
};
// Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
// must agree with Motion's Timing row (FlatTimingRow), which infers the
// range from animations when there's no explicit data-duration. Computed
// here (not threaded from PropertyPanel) both to keep that file under its
// 600-LOC gate and because element/gsapAnimations are already in scope.
const { start: elStart, duration: elDuration } = deriveElementTiming(element, gsapAnimations);
// Trivial percentage→time seek, derived here rather than threaded from
// PropertyPanel (keeps that file under its 600-LOC gate).
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
// Playhead position within the SAME corrected elStart/elDuration basis as
// seekFromKfPct above — recomputed here (not threaded as `currentPct` from
// PropertyPanel, which still derives it against its own naive basis for the
// legacy panel) so KeyframeNavigation's diamond active-state and prev/next
// arrow targeting agree with where a keyframe click actually seeks to
// (follow-up fix to 684ec4e87, which corrected the seek basis but left this
// one still naive).
// Use the same timing basis for seeking and active keyframe state.
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
// Motion group double-gate — reproduces the legacy PropertyPanel gate exactly:
// • Timing (sections.timing) shows via resolveEditingSections, same as today.
// • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND
// all five edit handlers are present (identical to PropertyPanel's legacy
// `<GsapAnimationSection>` guard).
// Computing the narrowed handler bundle inside the `&&`-guarded ternary lets
// TypeScript prove each handler non-undefined without a `!` assertion; the
// noop bundle only fills the type when the gate is off (never invoked, since
// FlatMotionSection guards every call behind showEffects).
// Match the legacy Motion gate while preserving TypeScript narrowing.
const showMotionTiming = Boolean(sections.timing);
const gsapEffectHandlers =
STUDIO_GSAP_PANEL_ENABLED &&
@@ -347,9 +302,6 @@ export function PropertyPanelFlat({
const showMotionEffects = gsapEffectHandlers !== null;
const showMotionGroup = showMotionTiming || showMotionEffects;
// Ordered group descriptors — one per FlatGroup this panel renders, gated by
// the same conditions the inline JSX used. Split below into before-open/
// open/after-open regions for the one-open accordion.
const groups: FlatGroupDescriptor[] = [];
if (isTextEditable) {
groups.push({
@@ -372,8 +324,6 @@ export function PropertyPanelFlat({
});
}
if (showEditableSections) {
// Number.isFinite guard (not `|| 1`): opacity 0 is a real value — an
// invisible element must summarize as 0%, not 100%.
const opacityValue = parseFloat(styles.opacity ?? "1");
const opacityPct = Math.round((Number.isFinite(opacityValue) ? opacityValue : 1) * 100);
groups.push({
@@ -398,8 +348,6 @@ export function PropertyPanelFlat({
groups.push({
id: "layout",
title: "Layout",
// No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
// (wheel/arrow keys only) — advertising "drag values to scrub" here lies.
summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
content: (
<FlatLayoutSection
@@ -470,15 +418,56 @@ export function PropertyPanelFlat({
assets={assets}
onImportAssets={onImportAssets}
onCommitColorGrading={colorGradingController.commitColorGrading}
onPreviewColorGrading={colorGradingController.previewColorGrading}
applyScope={colorGradingController.applyScope}
applyBusy={colorGradingController.applyBusy}
onSetApplyScope={colorGradingController.setApplyScope}
onApplyToScope={() => void colorGradingController.applyToScope()}
onApplyScopeAvailable={Boolean(onApplyColorGradingScope)}
mediaMetadata={colorGradingController.mediaMetadata}
presetPreviews={colorGradingController.presetPreviews}
onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
/>
),
});
const activeEffects = activeColorGradingEffectCount(colorGradingController.grading);
const effectsProps = {
grading: colorGradingController.grading,
onCommitColorGrading: colorGradingController.commitColorGrading,
};
groups.push({
id: "effects",
title: "Effects",
accessory: <FlatEffectsAccessory {...effectsProps} />,
summary: activeEffects ? `${activeEffects} active` : "none",
content: (
<FlatEffectsSection
{...effectsProps}
previews={colorGradingController.effectPreviews}
presetPreviews={colorGradingController.presetPreviews}
onPreviewColorGrading={colorGradingController.previewColorGrading}
onRequestEffectPreviews={colorGradingController.requestEffectPreviews}
onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
/>
),
});
if (onAddMediaOverlay) {
groups.push({
id: "overlays",
title: "Overlays",
summary: "add layer",
content: (
<FlatOverlaysSection
onAddOverlay={(blockName) =>
onAddMediaOverlay(
blockName,
deriveMediaOverlayPlacement(element, { start: elStart, duration: elDuration }),
)
}
/>
),
});
}
}
if (sections.media) {
groups.push({
@@ -499,13 +488,6 @@ export function PropertyPanelFlat({
});
}
// Fixed-headers + scrollable-open-section layout (design_handoff
// scrollable-open-section, replaces the prior sticky-stacking mechanism):
// collapsed headers before/after the open group render in normal document
// flow and never move. Only the open group's own body content scrolls, in
// a dedicated region between the two fixed header stacks. When no group is
// open, every group is just a collapsed header — there's no scrollable
// middle region at all, since nothing is expanded.
const openIndex = groups.findIndex((g) => g.id === openGroupId);
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
const openGroup = openIndex === -1 ? null : groups[openIndex];
@@ -532,7 +514,11 @@ export function PropertyPanelFlat({
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
/>
</DesignPanelInputProvider>
<div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
ref={panelBodyRef}
data-flat-panel-body="true"
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
>
{beforeOpen.map((g) => (
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
<FlatGroupHeader
@@ -546,7 +532,7 @@ export function PropertyPanelFlat({
))}
{openGroup && (
<DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
<div data-flat-group-open="true" className="flex min-h-0 flex-1 flex-col">
<div data-flat-group-open="true" className="flex min-h-[180px] flex-none flex-col">
<FlatGroupHeader
title={openGroup.title}
isOpen
@@ -5,15 +5,15 @@ describe("patchMediaColorGradingInHtml", () => {
it("adds color grading to video and image tags only", () => {
const { html, count } = patchMediaColorGradingInHtml(
`<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
`{"preset":"warm-daylight"}`,
`{"preset":"clean-studio"}`,
);
expect(count).toBe(2);
expect(html).toContain(
`video id="v" data-color-grading="{&quot;preset&quot;:&quot;warm-daylight&quot;}"`,
`video id="v" data-color-grading="{&quot;preset&quot;:&quot;clean-studio&quot;}"`,
);
expect(html).toContain(
`img id="i" data-color-grading="{&quot;preset&quot;:&quot;warm-daylight&quot;}"`,
`img id="i" data-color-grading="{&quot;preset&quot;:&quot;clean-studio&quot;}"`,
);
expect(html).toContain(`<audio id="a"></audio>`);
});
@@ -215,39 +215,98 @@ function neutralPropsBase() {
grading: neutralGrading(),
assets: [] as string[],
onCommitColorGrading: vi.fn(),
onPreviewColorGrading: vi.fn(),
applyScope: "source-file" as const,
applyBusy: false,
onSetApplyScope: vi.fn(),
onApplyToScope: vi.fn(),
onApplyScopeAvailable: true,
mediaMetadata: null,
presetPreviews: {
status: "ready" as const,
images: { "bright-pop": "data:image/png;base64,bright" },
width: 160,
height: 90,
},
onRequestPresetPreviews: vi.fn(),
};
}
describe("FlatColorGradingSection — Preset + LUT", () => {
it("renders the Preset dropdown with id/label pairs and fires onCommitColorGrading on change", () => {
it("previews looks without committing, restores on leave, and commits only on click", () => {
const onCommitColorGrading = vi.fn();
const onPreviewColorGrading = vi.fn();
const base = neutralGrading();
const grading = {
...base,
intensity: 0.4,
adjust: { ...base.adjust, contrast: 0.3 },
details: { ...base.details, vignette: 0.2 },
effects: { ...base.effects, pixelate: 0.5 },
palette: ["#112233", "#ffffff"],
lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
};
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
onPreviewColorGrading={onPreviewColorGrading}
/>,
);
const presetSelect = host.querySelector<HTMLSelectElement>(
'[data-flat-grade-preset="true"] select',
const brightPop = host.querySelector<HTMLButtonElement>(
'[data-flat-grade-preset="bright-pop"]',
);
if (!presetSelect) throw new Error("expected a preset select");
expect(presetSelect.value).toBe("neutral");
// The visible "Preset" label is a sibling span outside FlatSelectRow
// (label="" there, to avoid rendering it twice) — the select still
// needs its own accessible name via the dedicated ariaLabel prop.
expect(presetSelect.getAttribute("aria-label")).toBe("Preset");
if (!brightPop) throw new Error("expected a Bright Pop look button");
act(() => {
presetSelect.value = "bright-pop";
presetSelect.dispatchEvent(new Event("change", { bubbles: true }));
brightPop.dispatchEvent(new MouseEvent("pointerover", { bubbles: true }));
});
expect(onPreviewColorGrading.mock.calls.at(-1)?.[0].preset).toBe("bright-pop");
expect(onPreviewColorGrading.mock.calls.at(-1)?.[1]).toEqual({
animatedPreview: { kind: "presets", id: "bright-pop" },
});
expect(onCommitColorGrading).not.toHaveBeenCalled();
act(() => brightPop.dispatchEvent(new MouseEvent("pointerout", { bubbles: true })));
expect(onPreviewColorGrading).toHaveBeenLastCalledWith(null);
act(() => brightPop.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("bright-pop");
expect(onCommitColorGrading.mock.calls[0][0]).toMatchObject({
preset: "bright-pop",
intensity: 1,
effects: { pixelate: 0.5 },
palette: ["#112233", "#ffffff"],
lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
});
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).not.toBe(0.3);
expect(onCommitColorGrading.mock.calls[0][0].details.vignette).not.toBe(0.2);
act(() => root.unmount());
});
it("keeps effect-bearing saved styles out of Grade", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
const presets = host.querySelector('[data-flat-grade-preset-group="presets"]');
expect(presets?.querySelector('[data-flat-grade-preset="bright-pop"]')).not.toBeNull();
expect(presets?.querySelector('[data-flat-grade-preset="vhs-playback"]')).toBeNull();
expect(presets?.querySelector('[data-flat-grade-preset="editorial-halftone"]')).toBeNull();
expect(host.querySelector('[data-flat-grade-preset="neutral"]')?.textContent).toContain(
"Neutral",
);
act(() => root.unmount());
});
it("shows exact selected-media preview images and requests a fresh batch on mount", () => {
const onRequestPresetPreviews = vi.fn();
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
onRequestPresetPreviews={onRequestPresetPreviews}
/>,
);
expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1);
expect(
host.querySelector<HTMLImageElement>('[data-flat-grade-preview="bright-pop"]')?.src,
).toContain("data:image/png;base64,bright");
expect(host.textContent).not.toContain("VHS Playback");
act(() => root.unmount());
});
@@ -410,7 +469,7 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
act(() => root.unmount());
});
it("does NOT force intensity to revive when the Strength slider itself is dragged — it writes the value directly", () => {
it("does NOT force intensity to revive when the Amount slider itself is dragged — it writes the value directly", () => {
const onCommitColorGrading = vi.fn();
const grading = { ...neutralGrading(), intensity: 0 };
const { host, root } = renderInto(
@@ -420,7 +479,7 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
onCommitColorGrading={onCommitColorGrading}
/>,
);
const strengthRow = findRowByText(host, "div", "Strength", "startsWith");
const strengthRow = findRowByText(host, "div", "Amount", "startsWith");
// min=0, max=100, step=1, ratio=0.4 -> raw=40 -> commit(40) -> intensity = 40/100 = 0.4
dragSliderTrack(strengthRow, 40, 100);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
@@ -492,32 +551,6 @@ describe("FlatColorGradingSection — Vignette and Grain", () => {
});
});
describe("FlatColorGradingSection — Effects", () => {
it("renders Blur and Pixelate sliders under an Effects micro-label", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
expect(host.textContent).toContain("Effects");
const rows = host.querySelectorAll('[data-flat-grade-effect="true"]');
expect(rows).toHaveLength(2);
act(() => root.unmount());
});
it("commits a dragged Pixelate value on slider track pointerdown, scaled from percent to the 0..1 effect range", () => {
const onCommitColorGrading = vi.fn();
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const pixelateRow = findRowByText(host, '[data-flat-grade-effect="true"]', "Pixelate");
// min=0, max=100, step=1, ratio=0.75 -> raw=75 -> commit(75) -> effects.pixelate = 75/100 = 0.75
dragSliderTrack(pixelateRow, 75, 100);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].effects.pixelate).toBe(0.75);
act(() => root.unmount());
});
});
describe("FlatColorGradingSection — HDR banner and Apply scope", () => {
it("shows the HDR banner only when mediaMetadata reports an HDR source", () => {
const { host, root } = renderInto(
@@ -1,19 +1,23 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
HF_COLOR_GRADING_PRESETS,
HF_COLOR_GRADING_GRADE_PRESETS,
isHfColorGradingActive,
normalizeHfColorGrading,
type HfColorGradingAdjustKey,
type HfColorGradingDetailKey,
type HfColorGradingEffectKey,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { LUT_EXT } from "../../utils/mediaTypes";
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { resolveValueTier } from "./propertyPanelValueTier";
import type { ColorGradingControllerState, MediaMetadata } from "./useColorGradingController";
import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives";
import type {
ColorGradingControllerState,
ColorGradingPresetPreviews,
ColorGradingPreviewOptions,
MediaMetadata,
} from "./useColorGradingController";
import { presetPreviewHandlers } from "./propertyPanelPresetPreview";
const STATUS_DOT_CLASS: Record<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
active: "bg-emerald-400",
@@ -123,8 +127,6 @@ export function FlatColorGradingAccessory({
);
}
const PRESET_OPTIONS = HF_COLOR_GRADING_PRESETS.map((p) => ({ value: p.id, label: p.label }));
const ADJUST_SLIDERS: Array<{
key: HfColorGradingAdjustKey;
label: string;
@@ -182,11 +184,6 @@ const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [
];
const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"];
const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string }> = [
{ key: "blur", label: "Blur" },
{ key: "pixelate", label: "Pixelate" },
];
function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
if (metadata?.color.dynamicRange !== "hdr") return null;
const details = [
@@ -231,23 +228,32 @@ export function FlatColorGradingSection({
assets,
onImportAssets,
onCommitColorGrading,
onPreviewColorGrading,
applyScope,
applyBusy,
onSetApplyScope,
onApplyToScope,
onApplyScopeAvailable,
mediaMetadata,
presetPreviews,
onRequestPresetPreviews,
}: {
grading: NormalizedHfColorGrading;
assets: string[];
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
onPreviewColorGrading: (
next: NormalizedHfColorGrading | null,
options?: ColorGradingPreviewOptions,
) => void;
applyScope: "source-file" | "project";
applyBusy: boolean;
onSetApplyScope: (scope: "source-file" | "project") => void;
onApplyToScope: () => void;
onApplyScopeAvailable: boolean;
mediaMetadata: MediaMetadata | null;
presetPreviews: ColorGradingPresetPreviews;
onRequestPresetPreviews: () => void;
}) {
const track = useTrackDesignInput();
const lutInputRef = useRef<HTMLInputElement>(null);
@@ -260,10 +266,21 @@ export function FlatColorGradingSection({
const lut = grading.lut;
const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null;
const applyPreset = (presetId: string) => {
const next = normalizeHfColorGrading({ preset: presetId, intensity: 1, lut: grading.lut });
if (next) onCommitColorGrading(next);
useEffect(() => {
if (
presetPreviews.status !== "loading" &&
HF_COLOR_GRADING_GRADE_PRESETS.some((preset) => !presetPreviews.images[preset.id])
) {
onRequestPresetPreviews();
}
}, [onRequestPresetPreviews, presetPreviews.images, presetPreviews.status]);
const resolvePreset = (presetId: string) => {
const resolved = normalizeHfColorGrading({ preset: presetId, lut: grading.lut });
return resolved ? { ...resolved, effects: grading.effects, palette: grading.palette } : grading;
};
useEffect(() => () => onPreviewColorGrading(null), [onPreviewColorGrading]);
const updateIntensity = (value: number) => {
onCommitColorGrading({ ...grading, intensity: value / 100 });
};
@@ -319,22 +336,60 @@ export function FlatColorGradingSection({
return (
<div className="space-y-1.5">
<HdrBanner metadata={mediaMetadata} />
<div data-flat-grade-preset="true" className="flex min-h-[30px] items-center justify-between">
<span className="text-[11px] text-panel-text-2">Preset</span>
<FlatSelectRow
label=""
ariaLabel="Preset"
value={grading.preset ?? "neutral"}
options={PRESET_OPTIONS}
tier={resolveValueTier(
grading.preset === "neutral" ? undefined : (grading.preset ?? undefined),
"neutral",
)}
onChange={applyPreset}
/>
<div data-flat-grade-presets="true" className="space-y-1.5">
<div data-flat-grade-preset-group="presets" className={FLAT_PREVIEW_GRID}>
{HF_COLOR_GRADING_GRADE_PRESETS.map((preset) => {
const label = preset.label;
const selected = grading.preset === preset.id;
const preview = presetPreviews.images[preset.id];
return (
<button
key={preset.id}
type="button"
data-flat-grade-preset={preset.id}
aria-pressed={selected}
{...presetPreviewHandlers({
id: preset.id,
label,
resolve: () => resolvePreset(preset.id),
onPreview: onPreviewColorGrading,
onCommit: onCommitColorGrading,
onTrack: (name) => track("button", `Apply ${name}`),
})}
className={`min-w-0 overflow-hidden border text-left text-[10px] transition-colors ${
selected
? "border-panel-accent bg-panel-accent/10 text-panel-text-0"
: "border-panel-hairline bg-panel-bg-soft text-panel-text-3 hover:border-panel-border-input hover:text-panel-text-1"
}`}
>
<span
data-flat-grade-preview-frame={preset.id}
className="flex w-full items-center justify-center overflow-hidden bg-black/20"
style={{ aspectRatio: `${presetPreviews.width} / ${presetPreviews.height}` }}
>
{preview ? (
<img
data-flat-grade-preview={preset.id}
src={preview}
alt=""
draggable={false}
className="block h-full w-full object-contain"
/>
) : (
<span
data-flat-grade-preview-placeholder={presetPreviews.status}
className="h-full w-full bg-panel-bg-soft"
/>
)}
</span>
<span className="block truncate px-2 py-1.5">{label}</span>
</button>
);
})}
</div>
</div>
<FlatSlider
label="Strength"
label="Amount"
value={Math.round(grading.intensity * 100)}
min={0}
max={100}
@@ -462,7 +517,7 @@ export function FlatColorGradingSection({
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Finishing
Finish
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1">{renderDetailSlider("vignette")}</div>
@@ -497,42 +552,6 @@ export function FlatColorGradingSection({
)}
</div>
<div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Effects
</div>
{EFFECT_SLIDERS.map((slider) => {
const value = grading.effects[slider.key];
const isSet = value > 1e-6;
return (
<div key={slider.key} data-flat-grade-effect="true">
<FlatSlider
label={slider.label}
value={Math.round(value * 100)}
min={0}
max={100}
tier={isSet ? "explicitCustom" : "default"}
displayValue={`${Math.round(value * 100)}%`}
onCommit={(next) =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
effects: { ...grading.effects, [slider.key]: next / 100 },
})
}
onReset={() =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
effects: { ...grading.effects, [slider.key]: 0 },
})
}
/>
</div>
);
})}
</div>
{onApplyScopeAvailable && (
<div className="flex items-center justify-between gap-2 border-t border-panel-hairline pt-1.5">
<span className="flex items-center gap-1.5 text-[11px] text-panel-text-2">
@@ -0,0 +1,96 @@
import {
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
type HfColorGradingEffectKey,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { FlatSelectRow } from "./propertyPanelFlatSelectRow";
import { FlatSlider } from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle";
import {
DEFAULT_EFFECTS,
type EffectControl,
type EffectSpec,
} from "./propertyPanelFlatEffectSpecs";
type CommitEffect = (key: HfColorGradingEffectKey, value: number) => void;
function defaultValueFor(control: EffectControl, effect: EffectSpec): number {
return (
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[effect.key][control.key] ?? DEFAULT_EFFECTS[control.key]
);
}
function EffectSliderControl({
control,
value,
defaultValue,
onCommit,
}: {
control: Extract<EffectControl, { kind: "slider" }>;
value: number;
defaultValue: number;
onCommit: CommitEffect;
}) {
const scale = control.scale ?? 100;
const sliderValue = value * scale;
const displayValue = control.format
? control.format(value)
: `${Number.isInteger(sliderValue) ? sliderValue : sliderValue.toFixed(1)}${control.unit ?? "%"}`;
return (
<FlatSlider
label={control.label}
value={sliderValue}
min={control.min ?? 0}
max={control.max ?? 100}
step={control.step ?? 1}
tier={Math.abs(value - defaultValue) > 0.0001 ? "explicitCustom" : "default"}
displayValue={displayValue}
onCommit={(next) => onCommit(control.key, next / scale)}
onReset={() => onCommit(control.key, defaultValue)}
/>
);
}
export function FlatEffectControl({
control,
effect,
effects,
onCommit,
}: {
control: EffectControl;
effect: EffectSpec;
effects: NormalizedHfColorGrading["effects"];
onCommit: CommitEffect;
}) {
const value = effects[control.key];
const defaultValue = defaultValueFor(control, effect);
if (control.kind === "toggle") {
return (
<FlatToggle
label={control.label}
checked={value >= 0.5}
onChange={(next) => onCommit(control.key, next ? 1 : 0)}
/>
);
}
if (control.kind === "select") {
return (
<FlatSelectRow
label={control.label}
value={String(Math.round(value))}
options={control.options}
tier={value === defaultValue ? "default" : "explicitCustom"}
onChange={(next) => onCommit(control.key, Number.parseInt(next, 10))}
onReset={() => onCommit(control.key, defaultValue)}
/>
);
}
return (
<EffectSliderControl
control={control}
value={value}
defaultValue={defaultValue}
onCommit={onCommit}
/>
);
}
@@ -0,0 +1,305 @@
import {
getHfColorGradingCapabilities,
normalizeHfColorGrading,
type HfColorGradingActiveEffectKey,
type HfColorGradingEffectKey,
type HfColorGradingPresetId,
} from "@hyperframes/core/color-grading";
type SliderControl = {
kind: "slider";
key: HfColorGradingEffectKey;
label: string;
min?: number;
max?: number;
step?: number;
scale?: number;
unit?: string;
format?: (value: number) => string;
};
export type EffectControl =
| SliderControl
| { kind: "toggle"; key: HfColorGradingEffectKey; label: string }
| {
kind: "select";
key: HfColorGradingEffectKey;
label: string;
options: Array<{ value: string; label: string }>;
};
export type EffectSpec = {
key: HfColorGradingActiveEffectKey;
label: string;
showMaster?: false;
masterLabel?: string;
masterFormat?: (value: number) => string;
max?: number;
settings?: readonly EffectControl[];
palette?: "mono" | "art";
};
type EffectGroup = {
label: string;
effects: readonly EffectSpec[];
presets?: readonly HfColorGradingPresetId[];
};
const EFFECT_CONTROL_LIMITS = new Map(
getHfColorGradingCapabilities().effects.flatMap((effect) =>
effect.controls.map((control) => [control.key, control] as const),
),
);
function controlRange(key: HfColorGradingEffectKey, scale: number) {
const control = EFFECT_CONTROL_LIMITS.get(key);
return control ? { min: control.min * scale, max: control.max * scale, scale } : { scale };
}
const percent = (key: HfColorGradingEffectKey, label: string): SliderControl => ({
kind: "slider",
key,
label,
...controlRange(key, 100),
});
const degrees = (key: HfColorGradingEffectKey, label: string, max: number): SliderControl => ({
kind: "slider",
key,
label,
...controlRange(key, max),
unit: "deg",
});
function enumOptions(key: HfColorGradingEffectKey, labels: readonly string[]) {
const control = EFFECT_CONTROL_LIMITS.get(key);
const first = control?.min ?? 0;
const count = (control?.max ?? labels.length - 1) - first + 1;
if (labels.length !== count) throw new Error(`${key} labels do not match Core capabilities`);
return labels.map((label, index) => ({ value: String(first + index), label }));
}
const ASCII_STYLES = enumOptions("asciiStyle", [
"Standard",
"Dense",
"Minimal",
"Blocks",
"Braille",
"Technical",
"Matrix",
"Hatching",
]);
const SCREEN_SHAPES = enumOptions("monoScreenShape", [
"Circle",
"Square",
"Diamond",
"Triangle",
"Line",
]);
export const EFFECT_GROUPS: readonly EffectGroup[] = [
{
label: "Essentials",
effects: [
{
key: "blur",
label: "Blur",
masterLabel: "Radius",
masterFormat: (value) => `${(0.75 + Math.pow(value, 1.35) * 32).toFixed(1)}px`,
},
{
key: "pixelate",
label: "Pixelate",
masterLabel: "Cell Size",
masterFormat: (value) => `${Math.round(1 + value * 47)}px`,
},
{
key: "bloom",
label: "Bloom",
masterLabel: "Intensity",
max: controlRange("bloom", 100).max,
settings: [
{
kind: "slider",
key: "bloomRadius",
label: "Radius",
...controlRange("bloomRadius", 1),
unit: "px",
},
],
},
],
},
{
label: "Retro & Glitch",
presets: ["creator-camcorder", "vhs-playback", "home-movie-8mm"],
effects: [
{ key: "chromaBleed", label: "Chroma Softening", masterLabel: "Smear" },
{
key: "tapeDamage",
label: "Tape Damage",
showMaster: false,
settings: [
percent("tapeTracking", "Tracking"),
percent("tapeNoise", "Noise"),
percent("tapeSpeed", "Speed"),
],
},
{ key: "filmArtifacts", label: "Film Artifacts", masterLabel: "Density" },
{
key: "scanlines",
label: "Scanlines",
masterLabel: "Opacity",
settings: [
{
...percent("scanlineCount", "Line Count"),
format: (value) => `${Math.round(50 + value * 450)}`,
},
percent("scanlineSoftness", "Softness"),
],
},
{ key: "crtCurvature", label: "CRT Curvature", masterLabel: "Curvature" },
{
key: "chromaticAberration",
label: "Channel Separation",
masterLabel: "Separation",
settings: [degrees("chromaticAngle", "Angle", 360)],
},
{
key: "digitalGlitch",
label: "Digital Glitch",
showMaster: false,
settings: [
percent("digitalGlitchColorSplit", "Color Split"),
percent("digitalGlitchLineTear", "Line Tear"),
percent("digitalGlitchPixelate", "Pixelation"),
percent("digitalGlitchBlockAmount", "Block Amount"),
percent("digitalGlitchBlockDisplacement", "Displacement"),
percent("digitalGlitchBlockOpacity", "Block Opacity"),
percent("digitalGlitchSpeed", "Speed"),
],
},
],
},
{
label: "Print",
presets: ["editorial-halftone", "two-ink-print"],
effects: [
{
key: "halftone",
label: "Halftone",
showMaster: false,
settings: [percent("halftoneSize", "Dot Size")],
},
{
key: "twoInkPrint",
label: "Two-Ink Print",
showMaster: false,
settings: [percent("twoInkPrintSize", "Dot Size")],
},
{
key: "dither",
label: "Ordered Dither",
showMaster: false,
palette: "mono",
settings: [
{
...percent("ditherSize", "Point Size"),
format: (value) => `${(1 + value * 4).toFixed(1)}px`,
},
],
},
{
key: "monoScreen",
label: "Mono Screen",
showMaster: false,
palette: "mono",
settings: [
{
...percent("monoScreenSize", "Cell Size"),
format: (value) => `${Math.round(4 + value * 14)}px`,
},
degrees("monoScreenAngle", "Angle", 90),
percent("monoScreenSpread", "Spread"),
{ kind: "select", key: "monoScreenShape", label: "Shape", options: SCREEN_SHAPES },
{ kind: "toggle", key: "monoScreenInvert", label: "Invert" },
],
},
],
},
{
label: "Art",
effects: [
{
key: "ascii",
label: "ASCII",
showMaster: false,
palette: "mono",
settings: [
{
...percent("asciiSize", "Character Size"),
format: (value) => `${Math.round(4 + value * 76)}px`,
},
{ kind: "select", key: "asciiStyle", label: "Style", options: ASCII_STYLES },
{ kind: "toggle", key: "asciiInvert", label: "Invert" },
{ kind: "toggle", key: "asciiColor", label: "Use Source Color" },
percent("asciiRotation", "Edge Rotation"),
],
},
{
key: "engraving",
label: "Engraving",
showMaster: false,
palette: "art",
settings: [
percent("engravingSpacing", "Spacing"),
percent("engravingMinThickness", "Min Thickness"),
percent("engravingMaxThickness", "Max Thickness"),
degrees("engravingAngle", "Angle", 180),
percent("engravingContrast", "Contrast"),
percent("engravingSharpness", "Sharpness"),
percent("engravingWave", "Wave"),
percent("engravingWaveFrequency", "Wave Frequency"),
],
},
{
key: "crosshatch",
label: "Crosshatch",
showMaster: false,
palette: "art",
settings: [
percent("crosshatchSpacing", "Spacing"),
percent("crosshatchThickness", "Thickness"),
degrees("crosshatchAngle", "Angle", 180),
percent("crosshatchContrast", "Contrast"),
percent("crosshatchEdges", "Edge Detail"),
percent("crosshatchLineWeight", "Line Variation"),
percent("crosshatchWave", "Wave"),
percent("crosshatchWaveFrequency", "Wave Frequency"),
],
},
{
key: "kuwahara",
label: "Kuwahara Paint",
showMaster: false,
settings: [
{
...percent("kuwaharaRadius", "Radius"),
format: (value) => `${Math.round(2 + value * 14)}px`,
},
percent("kuwaharaSharpness", "Sharpness"),
{
...percent("kuwaharaSaturation", "Saturation"),
format: (value) => `${Math.round(value * 200)}%`,
},
],
},
],
},
];
export const EFFECT_SPECS = EFFECT_GROUPS.flatMap((group) => group.effects);
const DEFAULT_GRADING = normalizeHfColorGrading("neutral");
if (!DEFAULT_GRADING) throw new Error("Missing neutral color grading preset");
export const DEFAULT_EFFECTS = DEFAULT_GRADING.effects;
@@ -0,0 +1,331 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
HF_COLOR_GRADING_PALETTES,
getHfColorGradingCapabilities,
normalizeHfColorGrading,
} from "@hyperframes/core/color-grading";
import {
activeColorGradingEffectCount,
FlatEffectsAccessory,
FlatEffectsSection,
} from "./propertyPanelFlatEffectsSection";
import { EFFECT_SPECS } from "./propertyPanelFlatEffectSpecs";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function neutralGrading() {
const grading = normalizeHfColorGrading("neutral");
if (!grading) throw new Error("expected neutral grading");
return grading;
}
function renderInto(node: React.ReactElement) {
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
act(() => root.render(node));
return { host, root };
}
function sectionProps(
grading: ReturnType<typeof neutralGrading>,
onCommitColorGrading: ReturnType<typeof vi.fn> = vi.fn(),
) {
return {
grading,
previews: {
status: "ready" as const,
images: { pixelate: "data:image/png;base64,pixelate" },
width: 160,
height: 90,
},
presetPreviews: {
status: "ready" as const,
images: { "vhs-playback": "data:image/png;base64,vhs" },
width: 160,
height: 90,
},
onCommitColorGrading,
onPreviewColorGrading: vi.fn(),
onRequestEffectPreviews: vi.fn(),
onRequestPresetPreviews: vi.fn(),
};
}
describe("FlatEffectsSection", () => {
it("places missing composite treatments in their effect family without a Styles section", () => {
const onCommit = vi.fn();
const props = sectionProps(neutralGrading(), onCommit);
const { host, root } = renderInto(<FlatEffectsSection {...props} />);
expect(host.textContent).not.toContain("Styles");
expect(props.onRequestPresetPreviews).not.toHaveBeenCalled();
const retro = host.querySelector<HTMLButtonElement>(
'[data-flat-effect-group="Retro & Glitch"]',
);
act(() => retro?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(props.onRequestPresetPreviews).toHaveBeenCalledTimes(1);
expect(host.querySelector('[data-flat-effect-preset-preview="vhs-playback"]')).not.toBeNull();
const vhs = host.querySelector<HTMLButtonElement>('[data-flat-effect-preset="vhs-playback"]');
act(() => vhs?.dispatchEvent(new MouseEvent("pointerover", { bubbles: true })));
expect(props.onPreviewColorGrading.mock.calls.at(-1)?.[0]).toMatchObject({
preset: "vhs-playback",
effects: { tapeDamage: 0.82, scanlineCount: 0.17 },
});
expect(onCommit).not.toHaveBeenCalled();
act(() => vhs?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCommit.mock.calls[0][0].preset).toBe("vhs-playback");
const print = host.querySelector<HTMLButtonElement>('[data-flat-effect-group="Print"]');
act(() => print?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.querySelector('[data-flat-effect-preset="editorial-halftone"]')).not.toBeNull();
expect(host.querySelector('[data-flat-effect-preset="two-ink-print"]')).not.toBeNull();
act(() => root.unmount());
});
it("offers every shader master once in four intentional families", () => {
const { host, root } = renderInto(<FlatEffectsSection {...sectionProps(neutralGrading())} />);
const renderedKeys: Array<string | null> = [];
for (const label of ["Essentials", "Retro & Glitch", "Print", "Art"]) {
const tab = host.querySelector<HTMLButtonElement>(`[data-flat-effect-group="${label}"]`);
expect(tab).not.toBeNull();
act(() => tab?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
renderedKeys.push(
...Array.from(host.querySelectorAll("[data-flat-effect-option]"), (element) =>
element.getAttribute("data-flat-effect-option"),
),
);
}
expect(renderedKeys.sort()).toEqual([...HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS].sort());
const capabilities = new Map(
getHfColorGradingCapabilities().effects.map((effect) => [effect.key, effect]),
);
for (const effect of EFFECT_SPECS) {
expect(Boolean(effect.palette)).toBe(capabilities.get(effect.key)?.supportsPalette);
}
expect(host.querySelectorAll('[data-flat-slider-track="true"]')).toHaveLength(0);
act(() => root.unmount());
});
it("requests exact cards and auditions a chosen default without persisting", () => {
const props = sectionProps(neutralGrading());
const { host, root } = renderInto(<FlatEffectsSection {...props} />);
expect(props.onRequestEffectPreviews).toHaveBeenCalledTimes(1);
expect(props.onRequestEffectPreviews).toHaveBeenCalledWith(["blur", "pixelate", "bloom"]);
expect(host.querySelector('[data-flat-effect-preview="pixelate"]')).not.toBeNull();
const pixelate = host.querySelector<HTMLButtonElement>('[data-flat-effect-option="pixelate"]');
act(() => pixelate?.dispatchEvent(new MouseEvent("pointerover", { bubbles: true })));
expect(props.onPreviewColorGrading.mock.calls.at(-1)?.[0].effects.pixelate).toBe(
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.pixelate.pixelate,
);
expect(props.onPreviewColorGrading.mock.calls.at(-1)?.[1]).toEqual({
animatedPreview: { kind: "effects", id: "pixelate" },
});
expect(props.onCommitColorGrading).not.toHaveBeenCalled();
act(() => pixelate?.dispatchEvent(new MouseEvent("pointerout", { bubbles: true })));
expect(props.onPreviewColorGrading).toHaveBeenLastCalledWith(null);
act(() => root.unmount());
});
it("applies a useful canonical default when an effect is chosen", () => {
const onCommit = vi.fn();
const grading = { ...neutralGrading(), intensity: 0 };
const { host, root } = renderInto(<FlatEffectsSection {...sectionProps(grading, onCommit)} />);
const pixelate = host.querySelector<HTMLButtonElement>('[data-flat-effect-option="pixelate"]');
act(() => pixelate?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCommit).toHaveBeenCalledTimes(1);
expect(onCommit.mock.calls[0][0].effects.pixelate).toBe(
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.pixelate.pixelate,
);
expect(onCommit.mock.calls[0][0].intensity).toBe(0);
act(() => root.unmount());
});
it("uses a complete treatment card as a fresh starting point while preserving the LUT", () => {
const onCommit = vi.fn();
const base = neutralGrading();
const grading = {
...base,
adjust: { ...base.adjust, exposure: 0.4 },
effects: { ...base.effects, blur: 0.6 },
palette: ["#112233", "#ffffff"],
lut: { src: "assets/luts/custom.cube", intensity: 0.5 },
};
const { host, root } = renderInto(<FlatEffectsSection {...sectionProps(grading, onCommit)} />);
const toggle = host.querySelector<HTMLButtonElement>('[data-flat-effects-add-toggle="true"]');
act(() => toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const retro = host.querySelector<HTMLButtonElement>(
'[data-flat-effect-group="Retro & Glitch"]',
);
act(() => retro?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const vhs = host.querySelector<HTMLButtonElement>('[data-flat-effect-preset="vhs-playback"]');
act(() => vhs?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const committed = onCommit.mock.calls[0][0];
expect(committed).toMatchObject({
preset: "vhs-playback",
lut: { src: "assets/luts/custom.cube", intensity: 0.5 },
});
expect(committed.effects.tapeDamage).toBeGreaterThan(0);
expect(committed.effects.blur).toBe(0);
expect(committed.palette).toBeNull();
expect(committed.adjust.exposure).not.toBe(0.4);
act(() => root.unmount());
});
it("shows only the selected active effect controls and converts degrees to shader units", () => {
const onCommit = vi.fn();
const grading = normalizeHfColorGrading({
effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.chromaticAberration,
});
if (!grading) throw new Error("expected chromatic grading");
const { host, root } = renderInto(<FlatEffectsSection {...sectionProps(grading, onCommit)} />);
expect(host.textContent).toContain("Angle");
const effect = host.querySelector('[data-flat-effect-editor="chromaticAberration"]');
if (!effect) throw new Error("expected chromatic effect");
const rows = effect.querySelectorAll('[data-flat-slider-track="true"]');
const angleTrack = rows[1] as HTMLElement | undefined;
if (!angleTrack) throw new Error("expected angle slider");
Object.defineProperty(angleTrack, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
angleTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
angleTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
expect(onCommit.mock.calls.at(-1)?.[0].effects.chromaticAngle).toBe(0.5);
act(() => root.unmount());
});
it("offers native ASCII styles, binary controls, and a bounded custom palette", () => {
const onCommit = vi.fn();
const grading = normalizeHfColorGrading({
effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.ascii,
});
if (!grading) throw new Error("expected ASCII grading");
const { host, root } = renderInto(<FlatEffectsSection {...sectionProps(grading, onCommit)} />);
expect(
host.querySelector<HTMLSelectElement>('select[aria-label="Style"]')?.options,
).toHaveLength(8);
expect(host.querySelectorAll('[data-flat-toggle="true"]')).toHaveLength(2);
expect(host.querySelector('[data-flat-effect-editor="ascii"]')?.textContent).not.toContain(
"Mix",
);
expect(host.querySelectorAll("[data-flat-effects-palette-preset]")).toHaveLength(
HF_COLOR_GRADING_PALETTES.length,
);
const synthwave = host.querySelector<HTMLButtonElement>(
'[data-flat-effects-palette-preset="synthwave"]',
);
act(() => synthwave?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCommit.mock.calls.at(-1)?.[0].palette).toEqual(
HF_COLOR_GRADING_PALETTES.find(({ id }) => id === "synthwave")?.colors,
);
const addPalette = host.querySelector<HTMLButtonElement>(
'[data-flat-effects-add-palette="true"]',
);
act(() => addPalette?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCommit.mock.calls.at(-1)?.[0].palette).toEqual(["#000000", "#ffffff"]);
act(() => root.unmount());
});
it("resets the selected effect to its authored defaults", () => {
const onCommit = vi.fn();
const grading = normalizeHfColorGrading({
effects: { ...HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.digitalGlitch, digitalGlitch: 0.9 },
});
if (!grading) throw new Error("expected digital glitch grading");
const { host, root } = renderInto(<FlatEffectsSection {...sectionProps(grading, onCommit)} />);
expect(
host.querySelector('[data-flat-effect-editor="digitalGlitch"]')?.textContent,
).not.toContain("Mix");
const reset = host.querySelector<HTMLButtonElement>('[title="Reset Digital Glitch"]');
act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCommit.mock.calls.at(-1)?.[0].effects).toMatchObject(
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.digitalGlitch,
);
act(() => root.unmount());
});
it("removes only the selected effect and preserves another active effect", () => {
const onCommit = vi.fn();
const grading = normalizeHfColorGrading({
effects: {
...HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.blur,
...HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.pixelate,
},
});
if (!grading) throw new Error("expected multi-effect grading");
const { host, root } = renderInto(<FlatEffectsSection {...sectionProps(grading, onCommit)} />);
const remove = host.querySelector<HTMLButtonElement>('[title="Remove Blur"]');
act(() => remove?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCommit.mock.calls.at(-1)?.[0].effects.blur).toBe(0);
expect(onCommit.mock.calls.at(-1)?.[0].effects.pixelate).toBe(
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.pixelate.pixelate,
);
act(() => root.unmount());
});
it("closes the catalog when an already active effect is selected", () => {
const grading = normalizeHfColorGrading({
effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.blur,
});
if (!grading) throw new Error("expected blur grading");
const props = sectionProps(grading);
const { host, root } = renderInto(<FlatEffectsSection {...props} />);
expect(props.onRequestEffectPreviews).not.toHaveBeenCalled();
const toggle = host.querySelector<HTMLButtonElement>('[data-flat-effects-add-toggle="true"]');
act(() => toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
expect(props.onRequestEffectPreviews).toHaveBeenCalledWith(["blur", "pixelate", "bloom"]);
const blur = host.querySelector<HTMLButtonElement>('[data-flat-effect-option="blur"]');
act(() => blur?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
act(() => root.unmount());
});
it("requests only the visible family when the catalog tab changes", () => {
const props = sectionProps(neutralGrading());
const { host, root } = renderInto(<FlatEffectsSection {...props} />);
const art = host.querySelector<HTMLButtonElement>('[data-flat-effect-group="Art"]');
act(() => art?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(props.onRequestEffectPreviews).toHaveBeenLastCalledWith([
"ascii",
"engraving",
"crosshatch",
"kuwahara",
]);
act(() => root.unmount());
});
});
describe("FlatEffectsAccessory", () => {
it("counts active masters and resets only effects and palette", () => {
const base = neutralGrading();
const grading = {
...base,
adjust: { ...base.adjust, contrast: 0.2 },
effects: { ...base.effects, blur: 0.4, ascii: 1 },
palette: ["#112233", "#ffffff"],
};
expect(activeColorGradingEffectCount(grading)).toBe(2);
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatEffectsAccessory grading={grading} onCommitColorGrading={onCommit} />,
);
const reset = host.querySelector<HTMLButtonElement>('[data-flat-effects-reset="true"]');
act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCommit.mock.calls[0][0].adjust.contrast).toBe(0.2);
expect(onCommit.mock.calls[0][0].effects.blur).toBe(0);
expect(onCommit.mock.calls[0][0].effects.ascii).toBe(0);
expect(onCommit.mock.calls[0][0].palette).toBeNull();
act(() => root.unmount());
});
});
@@ -0,0 +1,503 @@
import { useEffect, useState } from "react";
import {
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
HF_COLOR_GRADING_EFFECT_PRESETS,
HF_COLOR_GRADING_PALETTES,
normalizeHfColorGrading,
type HfColorGradingActiveEffectKey,
type HfColorGradingEffectKey,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Plus, RotateCcw, X } from "../../icons/SystemIcons";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives";
import type {
ColorGradingPresetPreviews,
ColorGradingPreviewOptions,
} from "./useColorGradingController";
import {
DEFAULT_EFFECTS,
EFFECT_GROUPS,
EFFECT_SPECS,
type EffectControl,
type EffectSpec,
} from "./propertyPanelFlatEffectSpecs";
import { FlatEffectControl } from "./propertyPanelFlatEffectControl";
import { presetPreviewHandlers } from "./propertyPanelPresetPreview";
export function activeColorGradingEffectCount(grading: NormalizedHfColorGrading): number {
return EFFECT_SPECS.filter((effect) => grading.effects[effect.key] > 0.0001).length;
}
export function FlatEffectsAccessory({
grading,
onCommitColorGrading,
}: {
grading: NormalizedHfColorGrading;
onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
}) {
const track = useTrackDesignInput();
if (!activeColorGradingEffectCount(grading)) return null;
return (
<button
type="button"
data-flat-effects-reset="true"
title="Reset effects"
onClick={(event) => {
event.stopPropagation();
track("button", "Reset effects");
onCommitColorGrading({ ...grading, effects: { ...DEFAULT_EFFECTS }, palette: null });
}}
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
>
<RotateCcw size={12} />
</button>
);
}
export function FlatEffectsSection({
grading,
previews,
presetPreviews,
onCommitColorGrading,
onPreviewColorGrading,
onRequestEffectPreviews,
onRequestPresetPreviews,
}: {
grading: NormalizedHfColorGrading;
previews: ColorGradingPresetPreviews;
presetPreviews: ColorGradingPresetPreviews;
onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
onPreviewColorGrading: (
next: NormalizedHfColorGrading | null,
options?: ColorGradingPreviewOptions,
) => void;
onRequestEffectPreviews: (effects: readonly HfColorGradingActiveEffectKey[]) => void;
onRequestPresetPreviews: () => void;
}) {
const track = useTrackDesignInput();
const activeEffects = EFFECT_SPECS.filter((effect) => grading.effects[effect.key] > 0.0001);
const [catalogOpen, setCatalogOpen] = useState(activeEffects.length === 0);
const [catalogGroup, setCatalogGroup] = useState(EFFECT_GROUPS[0].label);
const [selectedKey, setSelectedKey] = useState<HfColorGradingActiveEffectKey | null>(
activeEffects[0]?.key ?? null,
);
const selectedEffect =
activeEffects.find((effect) => effect.key === selectedKey) ?? activeEffects[0] ?? null;
useEffect(() => {
if (!catalogOpen) return;
const group = EFFECT_GROUPS.find((candidate) => candidate.label === catalogGroup);
if (!group) return;
const effectKeys = group.effects.map((effect) => effect.key);
if (previews.status !== "loading" && effectKeys.some((effect) => !previews.images[effect])) {
onRequestEffectPreviews(effectKeys);
}
if (
group.presets?.some((preset) => !presetPreviews.images[preset]) &&
presetPreviews.status !== "loading"
) {
onRequestPresetPreviews();
}
}, [
catalogGroup,
catalogOpen,
onRequestEffectPreviews,
onRequestPresetPreviews,
presetPreviews.images,
presetPreviews.status,
previews.images,
previews.status,
]);
useEffect(() => () => onPreviewColorGrading(null), [onPreviewColorGrading]);
const commitEffects = (effects: NormalizedHfColorGrading["effects"]) => {
onCommitColorGrading({
...grading,
effects,
});
};
const commitEffect = (key: HfColorGradingEffectKey, value: number) => {
commitEffects({ ...grading.effects, [key]: value });
};
const resolveEffect = (effect: EffectSpec): NormalizedHfColorGrading => ({
...grading,
effects: {
...grading.effects,
...HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[effect.key],
},
});
const applyEffect = (effect: EffectSpec) => {
track("button", `Add ${effect.label}`);
onCommitColorGrading(resolveEffect(effect));
setSelectedKey(effect.key);
setCatalogOpen(false);
};
const resolvePreset = (presetId: string) =>
normalizeHfColorGrading({ preset: presetId, lut: grading.lut }) ?? grading;
const removeEffect = (effect: EffectSpec) => {
track("button", `Remove ${effect.label}`);
commitEffect(effect.key, 0);
setSelectedKey(null);
};
const renderPalette = (kind: "mono" | "art") => {
const fallback = kind === "art" ? ["#1a1a1a", "#f5f5dc"] : ["#000000", "#ffffff"];
const palette = grading.palette;
return (
<div data-flat-effects-palette="true" className="space-y-2 py-1">
<div className="grid grid-cols-3 gap-1">
{HF_COLOR_GRADING_PALETTES.map((preset) => {
const selected =
palette?.length === preset.colors.length &&
palette.every((color, index) => color === preset.colors[index]);
return (
<button
key={preset.id}
type="button"
title={`${preset.group}: ${preset.label}`}
data-flat-effects-palette-preset={preset.id}
aria-pressed={selected}
onClick={() => {
track("button", `Use ${preset.label} palette`);
onCommitColorGrading({ ...grading, palette: [...preset.colors] });
}}
className={`min-w-0 border p-1 text-left ${
selected
? "border-panel-accent bg-panel-accent/10"
: "border-panel-hairline hover:border-panel-border-input"
}`}
>
<span className="mb-1 flex h-3 overflow-hidden rounded-[2px]">
{preset.colors.map((color) => (
<span key={color} className="flex-1" style={{ backgroundColor: color }} />
))}
</span>
<span className="block truncate text-[8px] text-panel-text-3">{preset.label}</span>
</button>
);
})}
</div>
{!palette ? (
<button
type="button"
data-flat-effects-add-palette="true"
onClick={() => {
track("button", "Customize effect palette");
onCommitColorGrading({ ...grading, palette: fallback });
}}
className="flex min-h-[28px] items-center gap-1 text-[10px] font-medium text-panel-accent hover:text-panel-accent/80"
>
<Plus size={11} /> Custom palette
</button>
) : (
<>
<div className="flex items-center justify-between">
<span className="text-[11px] text-panel-text-3">Custom palette</span>
<button
type="button"
title="Use default palette"
onClick={() => onCommitColorGrading({ ...grading, palette: null })}
className="text-panel-text-4 hover:text-panel-text-1"
>
<RotateCcw size={11} />
</button>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{palette.map((color, index) => (
<span key={`${index}-${color}`} className="group/swatch relative">
<input
type="color"
aria-label={`Palette color ${index + 1}`}
value={color}
onChange={(event) => {
const nextPalette = [...palette];
nextPalette[index] = event.target.value;
onCommitColorGrading({ ...grading, palette: nextPalette });
}}
className="h-6 w-6 cursor-pointer rounded-sm border border-panel-border-input bg-transparent p-0"
/>
{palette.length > 2 && (
<button
type="button"
aria-label={`Remove palette color ${index + 1}`}
onClick={() =>
onCommitColorGrading({
...grading,
palette: palette.filter((_, colorIndex) => colorIndex !== index),
})
}
className="absolute -right-1 -top-1 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-panel-bg text-panel-text-2 shadow group-hover/swatch:flex"
>
<X size={8} />
</button>
)}
</span>
))}
{palette.length < 6 && (
<button
type="button"
aria-label="Add palette color"
onClick={() =>
onCommitColorGrading({
...grading,
palette: [...palette, palette.at(-1) ?? "#ffffff"],
})
}
className="flex h-6 w-6 items-center justify-center rounded-sm border border-panel-border-input text-panel-text-4 hover:text-panel-text-1"
>
<Plus size={11} />
</button>
)}
</div>
</>
)}
</div>
);
};
return (
<div className="space-y-2" data-flat-effects-section="true">
{activeEffects.length > 0 && (
<div data-flat-effects-active-list="true" className="space-y-1">
{activeEffects.map((effect) => {
const selected = selectedEffect?.key === effect.key;
return (
<button
key={effect.key}
type="button"
data-flat-effect-active={effect.key}
aria-pressed={selected}
onClick={() => setSelectedKey(effect.key)}
className={`flex min-h-[30px] w-full items-center justify-between border px-2 text-left text-[10px] transition-colors ${
selected
? "border-panel-accent bg-panel-accent/10 text-panel-text-0"
: "border-panel-hairline bg-panel-bg-soft text-panel-text-3 hover:border-panel-border-input hover:text-panel-text-1"
}`}
>
<span>{effect.label}</span>
<span className="text-[9px] text-panel-text-5">
{selected ? "Editing" : "Active"}
</span>
</button>
);
})}
</div>
)}
{selectedEffect && (
<div
data-flat-effect-editor={selectedEffect.key}
className="space-y-1 border-l-2 border-panel-border-input pl-2.5"
>
<div className="flex min-h-[26px] items-center justify-between gap-2">
<span className="text-[11px] font-medium text-panel-text-1">
{selectedEffect.label}
</span>
<span className="flex items-center gap-2">
<button
type="button"
title={`Reset ${selectedEffect.label}`}
onClick={() => applyEffect(selectedEffect)}
className="text-panel-text-4 hover:text-panel-text-1"
>
<RotateCcw size={11} />
</button>
<button
type="button"
title={`Remove ${selectedEffect.label}`}
onClick={() => removeEffect(selectedEffect)}
className="text-panel-text-4 hover:text-red-300"
>
<X size={11} />
</button>
</span>
</div>
{selectedEffect.showMaster !== false && (
<FlatSlider
label={selectedEffect.masterLabel ?? "Mix"}
value={grading.effects[selectedEffect.key] * 100}
min={0}
max={selectedEffect.max ?? 100}
tier="explicitCustom"
displayValue={
selectedEffect.masterFormat?.(grading.effects[selectedEffect.key]) ??
`${Math.round(grading.effects[selectedEffect.key] * 100)}%`
}
onCommit={(next) => commitEffect(selectedEffect.key, next / 100)}
onReset={() =>
commitEffect(
selectedEffect.key,
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[selectedEffect.key][selectedEffect.key] ??
1,
)
}
/>
)}
{selectedEffect.settings?.map((control: EffectControl) => (
<FlatEffectControl
key={control.key}
control={control}
effect={selectedEffect}
effects={grading.effects}
onCommit={commitEffect}
/>
))}
{selectedEffect.palette && renderPalette(selectedEffect.palette)}
</div>
)}
<button
type="button"
data-flat-effects-add-toggle="true"
aria-expanded={catalogOpen}
onClick={() => setCatalogOpen((open) => !open)}
className="flex min-h-[30px] items-center gap-1 text-[10px] font-medium text-panel-accent hover:text-panel-accent/80"
>
<Plus size={11} /> Add effect
</button>
{catalogOpen && (
<div data-flat-effects-catalog="true" className="space-y-2">
<div
role="tablist"
aria-label="Effect families"
className="grid grid-cols-4 gap-px overflow-hidden border border-panel-hairline bg-panel-hairline"
>
{EFFECT_GROUPS.map((group) => (
<button
key={group.label}
type="button"
role="tab"
aria-selected={catalogGroup === group.label}
data-flat-effect-group={group.label}
onClick={() => setCatalogGroup(group.label)}
className={`min-h-[25px] min-w-0 truncate bg-panel-bg px-2 text-[9px] ${
catalogGroup === group.label
? "font-medium text-panel-accent"
: "text-panel-text-4 hover:text-panel-text-1"
}`}
>
{group.label}
</button>
))}
</div>
{EFFECT_GROUPS.filter((group) => group.label === catalogGroup).map((group) => (
<section key={group.label} className="space-y-1" role="tabpanel">
<div className={FLAT_PREVIEW_GRID}>
{group.presets?.map((presetId) => {
const preset = HF_COLOR_GRADING_EFFECT_PRESETS.find(
(candidate) => candidate.id === presetId,
);
if (!preset) return null;
const selected = grading.preset === preset.id;
const preview = presetPreviews.images[preset.id];
return (
<button
key={preset.id}
type="button"
data-flat-effect-preset={preset.id}
aria-pressed={selected}
{...presetPreviewHandlers({
id: preset.id,
label: preset.label,
resolve: () => resolvePreset(preset.id),
onPreview: onPreviewColorGrading,
onCommit: onCommitColorGrading,
onTrack: (name) => track("button", `Apply ${name}`),
})}
className={`min-w-0 overflow-hidden border text-left text-[10px] transition-colors ${
selected
? "border-panel-accent bg-panel-accent/10 text-panel-text-0"
: "border-panel-hairline bg-panel-bg-soft text-panel-text-3 hover:border-panel-border-input hover:text-panel-text-1"
}`}
>
<span
className="flex w-full items-center justify-center overflow-hidden bg-black/20"
style={{
aspectRatio: `${presetPreviews.width} / ${presetPreviews.height}`,
}}
>
{preview ? (
<img
data-flat-effect-preset-preview={preset.id}
src={preview}
alt=""
draggable={false}
className="block h-full w-full object-contain"
/>
) : (
<span
data-flat-effect-preset-placeholder={presetPreviews.status}
className="h-full w-full bg-panel-bg-soft"
/>
)}
</span>
<span className="block truncate px-2 py-1.5">{preset.label}</span>
</button>
);
})}
{group.effects.map((effect) => {
const active = grading.effects[effect.key] > 0.0001;
const preview = previews.images[effect.key];
return (
<button
key={effect.key}
type="button"
data-flat-effect-option={effect.key}
aria-pressed={active}
title={`Preview ${effect.label}`}
onPointerEnter={() =>
onPreviewColorGrading(resolveEffect(effect), {
animatedPreview: { kind: "effects", id: effect.key },
})
}
onPointerLeave={() => onPreviewColorGrading(null)}
onFocus={() => onPreviewColorGrading(resolveEffect(effect))}
onBlur={() => onPreviewColorGrading(null)}
onClick={() => {
if (active) {
setSelectedKey(effect.key);
setCatalogOpen(false);
} else {
applyEffect(effect);
}
}}
className={`min-w-0 overflow-hidden border text-left text-[10px] transition-colors ${
active
? "border-panel-accent bg-panel-accent/10 text-panel-text-1"
: "border-panel-hairline bg-panel-bg-soft text-panel-text-3 hover:border-panel-border-input hover:text-panel-text-1"
}`}
>
<span
data-flat-effect-preview-frame={effect.key}
className="flex w-full items-center justify-center overflow-hidden bg-black/20"
style={{ aspectRatio: `${previews.width} / ${previews.height}` }}
>
{preview ? (
<img
data-flat-effect-preview={effect.key}
src={preview}
alt=""
draggable={false}
className="block h-full w-full object-contain"
/>
) : (
<span
data-flat-effect-preview-placeholder={previews.status}
className="h-full w-full bg-panel-bg-soft"
/>
)}
</span>
<span className="block truncate px-2 py-1.5">{effect.label}</span>
</button>
);
})}
</div>
</section>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { RegistryItem } from "@hyperframes/core/registry";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
deriveMediaOverlayPlacement,
filterMediaTreatmentOverlays,
FlatOverlaysSection,
MEDIA_TREATMENT_OVERLAY_TAG,
} from "./propertyPanelFlatOverlaysSection";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function registryItem(
name: string,
tags: string[],
type: RegistryItem["type"] = "hyperframes:component",
preview?: RegistryItem["preview"],
): RegistryItem {
const base = {
name,
title: name,
description: `${name} description`,
tags,
preview,
files: [{ path: `${name}.html`, target: `${name}.html`, type: "hyperframes:snippet" as const }],
};
if (type === "hyperframes:component") return { ...base, type };
if (type === "hyperframes:block") {
return { ...base, type, dimensions: { width: 1920, height: 1080 }, duration: 2 };
}
return { ...base, type, dimensions: { width: 1920, height: 1080 }, duration: 2 };
}
afterEach(() => {
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
describe("FlatOverlaysSection", () => {
it("places an overlay over the selected media in its own composition", () => {
expect(
deriveMediaOverlayPlacement(
{
sourceFile: "compositions/scene.html",
dataAttributes: { "track-index": "2" },
},
{ start: 1.5, duration: 3 },
),
).toEqual({
start: 1.5,
duration: 3,
track: 3,
compositionPath: "compositions/scene.html",
});
});
it("keeps only tagged Registry overlay blocks", () => {
const overlay = registryItem(
"camcorder-hud",
[MEDIA_TREATMENT_OVERLAY_TAG],
"hyperframes:block",
);
const ordinary = registryItem("caption", ["caption"]);
const taggedComponent = registryItem("scene", [MEDIA_TREATMENT_OVERLAY_TAG]);
expect(
filterMediaTreatmentOverlays([overlay, ordinary, taggedComponent]).map(({ name }) => name),
).toEqual(["camcorder-hud"]);
});
it("loads tagged overlays and delegates installation by Registry name", async () => {
const items = [
registryItem("camcorder-hud", [MEDIA_TREATMENT_OVERLAY_TAG], "hyperframes:block", {
poster: "https://example.com/camcorder.png",
video: "https://example.com/camcorder.mp4",
}),
registryItem("ordinary-component", ["overlay"]),
];
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => items }));
const onAddOverlay = vi.fn().mockResolvedValue(undefined);
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => {
root.render(<FlatOverlaysSection onAddOverlay={onAddOverlay} />);
await Promise.resolve();
await Promise.resolve();
});
expect(host.querySelector('[data-flat-overlay="ordinary-component"]')).toBeNull();
const add = host.querySelector<HTMLButtonElement>('[data-flat-overlay="camcorder-hud"]');
expect(add).not.toBeNull();
expect(
host.querySelector<HTMLImageElement>('[data-flat-overlay-preview="camcorder-hud"]')?.src,
).toBe("https://example.com/camcorder.png");
expect(host.querySelector('[data-flat-overlays="true"]')?.className).toContain("auto-fill");
act(() => add?.dispatchEvent(new MouseEvent("pointerover", { bubbles: true })));
expect(host.querySelector<HTMLVideoElement>("video")?.src).toBe(
"https://example.com/camcorder.mp4",
);
await act(async () => {
add?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(onAddOverlay).toHaveBeenCalledWith("camcorder-hud");
act(() => root.unmount());
});
});
@@ -0,0 +1,108 @@
import { useState } from "react";
import type { RegistryItem } from "@hyperframes/core/registry";
import { useBlockCatalog } from "../../hooks/useBlockCatalog";
import { Film, Plus } from "../../icons/SystemIcons";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import type { DomEditSelection } from "./domEditing";
import { FLAT_PREVIEW_GRID } from "./propertyPanelFlatPrimitives";
import type { ElementTiming } from "./propertyPanelFlatTimingDerivation";
export const MEDIA_TREATMENT_OVERLAY_TAG = "media-treatment-overlay";
export function filterMediaTreatmentOverlays(items: readonly RegistryItem[]): RegistryItem[] {
return items.filter(
(item) =>
item.type === "hyperframes:block" &&
item.tags?.includes(MEDIA_TREATMENT_OVERLAY_TAG) === true,
);
}
export function deriveMediaOverlayPlacement(
element: Pick<DomEditSelection, "dataAttributes" | "sourceFile">,
timing: Pick<ElementTiming, "start" | "duration">,
) {
const authoredTrack = Number.parseInt(element.dataAttributes["track-index"] ?? "", 10);
return {
start: timing.start,
...(timing.duration > 0 ? { duration: timing.duration } : {}),
...(Number.isFinite(authoredTrack) ? { track: authoredTrack + 1 } : {}),
compositionPath: element.sourceFile,
};
}
export function FlatOverlaysSection({
onAddOverlay,
}: {
onAddOverlay: (name: string) => Promise<void>;
}) {
const track = useTrackDesignInput();
const { blocks, loading, error } = useBlockCatalog();
const overlays = filterMediaTreatmentOverlays(blocks);
const [adding, setAdding] = useState<string | null>(null);
const [previewing, setPreviewing] = useState<string | null>(null);
if (loading) {
return <div className="py-4 text-center text-[10px] text-panel-text-4">Loading overlays</div>;
}
if (error) {
return <div className="py-4 text-center text-[10px] text-red-300">{error}</div>;
}
const busy = adding !== null;
return (
<div data-flat-overlays="true" className={FLAT_PREVIEW_GRID}>
{overlays.map((overlay) => (
<button
key={overlay.name}
type="button"
data-flat-overlay={overlay.name}
aria-label={`Add ${overlay.title}`}
disabled={busy}
title={overlay.description}
onPointerEnter={() => setPreviewing(overlay.name)}
onPointerLeave={() => setPreviewing(null)}
onFocus={() => setPreviewing(overlay.name)}
onBlur={() => setPreviewing(null)}
onClick={() => {
track("button", `Add ${overlay.title}`);
setAdding(overlay.name);
void onAddOverlay(overlay.name).finally(() => setAdding(null));
}}
className="group min-w-0 overflow-hidden border border-panel-hairline bg-panel-bg-soft text-left transition-colors hover:border-panel-border-input hover:bg-panel-bg disabled:cursor-wait disabled:opacity-50"
>
<span className="relative flex aspect-video w-full items-center justify-center overflow-hidden bg-black/20">
{previewing === overlay.name && overlay.preview?.video ? (
<video
src={overlay.preview.video}
poster={overlay.preview.poster}
autoPlay
muted
loop
playsInline
className="block h-full w-full object-cover"
/>
) : overlay.preview?.poster ? (
<img
data-flat-overlay-preview={overlay.name}
src={overlay.preview.poster}
alt=""
draggable={false}
loading="lazy"
className="block h-full w-full object-cover"
/>
) : (
<Film size={15} className="text-panel-text-4" />
)}
<Plus
size={12}
className="absolute right-1.5 top-1.5 text-white opacity-70 drop-shadow group-hover:text-panel-accent group-hover:opacity-100"
/>
</span>
<span className="block truncate px-2 py-1.5 text-[10px] text-panel-text-2">
{adding === overlay.name ? "Adding…" : overlay.title}
</span>
</button>
))}
</div>
);
}
@@ -8,6 +8,8 @@ import {
type PropertyValueTier,
} from "./propertyPanelValueTier";
export const FLAT_PREVIEW_GRID = "grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-1";
/* ------------------------------------------------------------------ */
/* FlatRow — single-column label/value property row */
/* ------------------------------------------------------------------ */
@@ -0,0 +1,36 @@
import type { NormalizedHfColorGrading } from "@hyperframes/core/color-grading";
import type { ColorGradingPreviewOptions } from "./useColorGradingController";
export function presetPreviewHandlers({
id,
label,
resolve,
onPreview,
onCommit,
onTrack,
}: {
id: string;
label: string;
resolve: () => NormalizedHfColorGrading;
onPreview: (
grading: NormalizedHfColorGrading | null,
options?: ColorGradingPreviewOptions,
) => void;
onCommit: (grading: NormalizedHfColorGrading) => void;
onTrack: (label: string) => void;
}) {
return {
title: `Preview ${label}`,
onPointerEnter: () =>
onPreview(resolve(), {
animatedPreview: { kind: "presets" as const, id },
}),
onPointerLeave: () => onPreview(null),
onFocus: () => onPreview(resolve()),
onBlur: () => onPreview(null),
onClick: () => {
onTrack(label);
onCommit(resolve());
},
};
}
@@ -19,6 +19,18 @@ export interface BackgroundRemovalResult {
provider?: string;
}
export interface MediaOverlayPlacement {
start: number;
duration?: number;
track?: number;
compositionPath?: string;
}
export type AddMediaOverlayHandler = (
blockName: string,
placement: MediaOverlayPlacement,
) => Promise<void>;
export interface PropertyPanelProps {
projectId: string;
projectDir: string | null;
@@ -69,6 +81,7 @@ export interface PropertyPanelProps {
onAskAgent: () => void;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
onAddMediaOverlay?: AddMediaOverlayHandler;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
@@ -13,9 +13,9 @@ function brightPopGrading() {
return next;
}
function warmDaylightGrading() {
const next = normalizeHfColorGrading({ preset: "warm-daylight", intensity: 1 });
if (!next) throw new Error("expected warm-daylight preset to normalize");
function cleanStudioGrading() {
const next = normalizeHfColorGrading({ preset: "clean-studio", intensity: 1 });
if (!next) throw new Error("expected clean-studio preset to normalize");
return next;
}
@@ -60,14 +60,17 @@ function HookHost({
onState,
onSetAttributeLive,
element,
previewIframeRef,
}: {
onState: (state: ReturnType<typeof useColorGradingController>) => void;
onSetAttributeLive: (attr: string, value: string | null) => void;
element: DomEditSelection;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
}) {
const state = useColorGradingController({
projectId: "proj",
element,
previewIframeRef,
onSetAttributeLive,
});
onState(state);
@@ -77,6 +80,7 @@ function HookHost({
function renderHook(
onSetAttributeLive: (attr: string, value: string | null) => void,
initialElement: DomEditSelection = makeElement(),
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
) {
const host = document.createElement("div");
document.body.append(host);
@@ -89,6 +93,7 @@ function renderHook(
onState: (s: ReturnType<typeof useColorGradingController>) => (latest = s),
onSetAttributeLive,
element,
previewIframeRef,
}),
);
});
@@ -107,6 +112,43 @@ function renderHook(
};
}
type PreviewWindow = Window & {
__hf?: {
colorGrading?: {
renderPreviews?: ReturnType<typeof vi.fn>;
startPreviewPlayback?: ReturnType<typeof vi.fn>;
};
};
__player?: { play: ReturnType<typeof vi.fn> };
};
function createPreviewFrame() {
const iframe = document.body.appendChild(document.createElement("iframe"));
const contentWindow = iframe.contentWindow as PreviewWindow | null;
if (!contentWindow) throw new Error("expected iframe contentWindow");
return { contentWindow, iframe };
}
async function flushPreviewRequest() {
act(() => vi.advanceTimersByTime(0));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function colorGradingMessages(calls: ReadonlyArray<ReadonlyArray<unknown>>) {
return calls
.map(([message]) => message)
.filter(
(message): message is { action: string; grading: unknown } =>
typeof message === "object" &&
message !== null &&
"action" in message &&
message.action === "set-color-grading",
);
}
describe("useColorGradingController", () => {
it("starts with the neutral (inactive) grading and idle compare state", () => {
const { root, getState } = renderHook(vi.fn());
@@ -115,6 +157,77 @@ describe("useColorGradingController", () => {
act(() => root.unmount());
});
it("requests one exact preset batch from the selected media runtime", async () => {
vi.useFakeTimers();
const devicePixelRatio = vi.spyOn(window, "devicePixelRatio", "get").mockReturnValue(2);
const { contentWindow, iframe } = createPreviewFrame();
const renderPreviews = vi.fn().mockResolvedValue({
width: 160,
height: 90,
images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,bright" }],
});
contentWindow.__hf = { colorGrading: { renderPreviews } };
const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
act(() => getState().requestPresetPreviews());
await flushPreviewRequest();
expect(renderPreviews).toHaveBeenCalledTimes(1);
expect(renderPreviews.mock.calls[0]?.[1]).toHaveLength(18);
expect(renderPreviews.mock.calls[0]?.[2]).toEqual({ maxDimension: 320 });
expect(getState().presetPreviews).toEqual({
status: "ready",
images: { "bright-pop": "data:image/png;base64,bright" },
width: 160,
height: 90,
});
act(() => root.unmount());
devicePixelRatio.mockRestore();
vi.useRealTimers();
});
it("requests exact effect families and retains earlier family images", async () => {
vi.useFakeTimers();
const { contentWindow, iframe } = createPreviewFrame();
const renderPreviews = vi
.fn()
.mockImplementation(async (_target: unknown, candidates: Array<{ id: string }>) => ({
width: 160,
height: 90,
images: candidates.map(({ id }) => ({ id, dataUrl: `data:image/png;base64,${id}` })),
}));
contentWindow.__hf = { colorGrading: { renderPreviews } };
const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
act(() => getState().requestEffectPreviews(["blur", "pixelate", "bloom"]));
await flushPreviewRequest();
expect(renderPreviews).toHaveBeenCalledTimes(1);
expect(renderPreviews.mock.calls[0]?.[1].map(({ id }: { id: string }) => id)).toEqual([
"blur",
"pixelate",
"bloom",
]);
act(() => getState().requestEffectPreviews(["kuwahara"]));
await flushPreviewRequest();
expect(renderPreviews).toHaveBeenCalledTimes(2);
expect(getState().effectPreviews).toEqual({
status: "ready",
images: {
blur: "data:image/png;base64,blur",
pixelate: "data:image/png;base64,pixelate",
bloom: "data:image/png;base64,bloom",
kuwahara: "data:image/png;base64,kuwahara",
},
width: 160,
height: 90,
});
act(() => root.unmount());
vi.useRealTimers();
});
it("commitColorGrading updates grading state synchronously and schedules a debounced persist", async () => {
vi.useFakeTimers();
const onSetAttributeLive = vi.fn();
@@ -135,6 +248,86 @@ describe("useColorGradingController", () => {
vi.useRealTimers();
});
it("previews through the runtime only and restores the committed grade without persisting", () => {
vi.useFakeTimers();
const onSetAttributeLive = vi.fn();
const { contentWindow, iframe } = createPreviewFrame();
const postMessage = vi.spyOn(contentWindow, "postMessage");
const { root, getState } = renderHook(onSetAttributeLive, makeElement(), {
current: iframe,
});
act(() => getState().previewColorGrading(brightPopGrading()));
act(() => getState().previewColorGrading(null));
const gradingMessages = colorGradingMessages(postMessage.mock.calls);
expect(gradingMessages).toHaveLength(2);
expect(gradingMessages[0]?.grading).toMatchObject({ preset: "bright-pop" });
expect(gradingMessages[1]?.grading).toBeNull();
expect(getState().grading.preset).toBe("neutral");
act(() => vi.advanceTimersByTime(500));
expect(onSetAttributeLive).not.toHaveBeenCalled();
act(() => root.unmount());
vi.useRealTimers();
});
it("animates only the selected video card without starting the project player", async () => {
vi.useFakeTimers();
const { contentWindow, iframe } = createPreviewFrame();
const stopPlayback = vi.fn();
const renderPreviews = vi.fn().mockResolvedValue({
width: 320,
height: 180,
images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,animated" }],
});
const startPreviewPlayback = vi.fn(() => stopPlayback);
contentWindow.__hf = { colorGrading: { renderPreviews, startPreviewPlayback } };
contentWindow.__player = { play: vi.fn() };
const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
act(() =>
getState().previewColorGrading(brightPopGrading(), {
animatedPreview: { kind: "presets", id: "bright-pop" },
}),
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(startPreviewPlayback).toHaveBeenCalledTimes(1);
expect(renderPreviews).toHaveBeenCalledWith(
expect.anything(),
[{ id: "bright-pop", grading: expect.objectContaining({ preset: "bright-pop" }) }],
{ maxDimension: 160, useMediaTime: true },
);
expect(contentWindow.__player.play).not.toHaveBeenCalled();
expect(getState().presetPreviews.images["bright-pop"]).toBe("data:image/png;base64,animated");
act(() => getState().previewColorGrading(null));
expect(stopPlayback).toHaveBeenCalledTimes(1);
act(() => root.unmount());
vi.useRealTimers();
});
it("restores a just-committed look even before React renders the new state", () => {
vi.useFakeTimers();
const { contentWindow, iframe } = createPreviewFrame();
const postMessage = vi.spyOn(contentWindow, "postMessage");
const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
const brightPop = brightPopGrading();
act(() => {
getState().commitColorGrading(brightPop);
getState().previewColorGrading(null);
});
const gradingMessages = colorGradingMessages(postMessage.mock.calls);
expect(gradingMessages.at(-1)?.grading).toMatchObject({ preset: "bright-pop" });
act(() => root.unmount());
vi.useRealTimers();
});
it("reverts to the last confirmed-good grading via the real onSettled(false) signal (matches runDomEditCommit, which never rejects)", async () => {
// The actual Studio commit runner (runDomEditCommit) catches persist
// failures internally and always resolves — it reports outcome only
@@ -253,7 +446,7 @@ describe("useColorGradingController", () => {
});
},
)
// Edit B (warm-daylight): settles immediately and successfully.
// Edit B (clean-studio): settles immediately and successfully.
.mockImplementationOnce(
(_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
onSettled?.(true);
@@ -272,13 +465,13 @@ describe("useColorGradingController", () => {
// B commits on the SAME element before A's persist has settled.
act(() => {
getState().commitColorGrading(warmDaylightGrading());
getState().commitColorGrading(cleanStudioGrading());
});
act(() => {
vi.advanceTimersByTime(400);
});
expect(onSetAttributeLive).toHaveBeenCalledTimes(2); // B's persist has already settled (mock resolves sync)
expect(getState().grading.preset).toBe("warm-daylight");
expect(getState().grading.preset).toBe("clean-studio");
// NOW A's stale persist finally settles as a FAILURE — must not revert
// `grading` (which now correctly shows B's newer edit) back to the
@@ -292,20 +485,32 @@ describe("useColorGradingController", () => {
await Promise.resolve();
await Promise.resolve();
});
expect(getState().grading.preset).toBe("warm-daylight");
expect(getState().grading.preset).toBe("clean-studio");
act(() => root.unmount());
vi.useRealTimers();
});
it("resetGrading returns to the neutral preset", () => {
it("resetGrading resets Grade fields without clearing Effects or Palette", () => {
const { root, getState } = renderHook(vi.fn());
const grading = normalizeHfColorGrading({
preset: "bright-pop",
lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
effects: { pixelate: 0.5 },
palette: ["#112233", "#ffffff"],
});
if (!grading) throw new Error("expected grading to normalize");
act(() => {
getState().commitColorGrading(brightPopGrading());
getState().commitColorGrading(grading);
});
act(() => {
getState().resetGrading();
});
expect(getState().grading.preset).toBe("neutral");
expect(getState().grading).toMatchObject({
preset: "neutral",
lut: null,
effects: { pixelate: 0.5 },
palette: ["#112233", "#ffffff"],
});
act(() => root.unmount());
});
@@ -4,6 +4,7 @@ import {
isHfColorGradingActive,
normalizeHfColorGrading,
serializeHfColorGrading,
type HfColorGradingActiveEffectKey,
type HfColorGradingTarget,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
@@ -18,6 +19,13 @@ import {
acceptStudioRuntimeMessage,
postRuntimeControlMessage,
} from "../../player/lib/runtimeProtocol";
import {
useColorGradingPreviews,
type ColorGradingPresetPreviews,
type ColorGradingPreviewOptions,
} from "./useColorGradingPreviews";
export type { ColorGradingPresetPreviews, ColorGradingPreviewOptions };
const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
@@ -152,7 +160,15 @@ export interface ColorGradingControllerState {
applyBusy: boolean;
runtimeStatus: RuntimeColorGradingStatus;
mediaMetadata: MediaMetadata | null;
presetPreviews: ColorGradingPresetPreviews;
effectPreviews: ColorGradingPresetPreviews;
requestPresetPreviews: () => void;
requestEffectPreviews: (effects: readonly HfColorGradingActiveEffectKey[]) => void;
commitColorGrading: (next: NormalizedHfColorGrading) => void;
previewColorGrading: (
next: NormalizedHfColorGrading | null,
options?: ColorGradingPreviewOptions,
) => void;
commitCompare: (enabled: boolean) => void;
setApplyScope: (scope: "source-file" | "project") => void;
applyToScope: () => Promise<void>;
@@ -195,20 +211,11 @@ export function useColorGradingController({
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
const pendingPersistGradingRef = useRef<NormalizedHfColorGrading | null>(null);
// Identity the pending edit was made FOR, snapshotted at schedule time —
// read by a global flush instead of identityKeyRef.current, which may no
// longer describe this edit's element by the time the flush runs.
// Capture the edited element because selection can change before persistence.
const pendingPersistIdentityRef = useRef<string | null>(null);
// The last grading value actually confirmed saved — distinct from `grading`
// (the optimistic value shown immediately on commit). A rejected persist
// reverts to this instead of leaving the UI permanently showing a value
// that was never written.
// Keep a confirmed value so an optimistic edit can be reverted on failure.
const confirmedGradingRef = useRef(grading);
// Monotonic per-commit version — guards against TWO edits on the SAME
// element racing (not just a selection change). If edit A's persist is
// still in flight when edit B commits, A's eventual settle must not stamp
// confirmedGradingRef with its now-superseded value or revert `grading`
// out from under B's newer optimistic state.
// Prevent an older same-element write from settling over a newer edit.
const gradingVersionRef = useRef(0);
const statusTimersRef = useRef<number[]>([]);
const latestGradingRef = useRef(grading);
@@ -216,22 +223,7 @@ export function useColorGradingController({
latestGradingRef.current = grading;
compareEnabledRef.current = compareEnabled;
// Reset all per-element STATE when the selection changes to a different
// element — unlike the legacy ColorGradingSection (remounted via a
// `key={selectionIdentityKey(element)}` from its parent), this hook is
// called unconditionally on every render, so nothing naturally remounts it.
// Without this, switching selection reuses the previous element's grading/
// compare/mediaMetadata state. Adjusting state during render (comparing
// against a ref) is React's documented pattern for STATE updates
// specifically — it resolves in the same render pass instead of flashing
// stale state for one frame, and is safe to repeat if React discards and
// re-runs this render, since every value here is a pure function of
// `element`. It must NOT be used for side effects or for consuming
// shared mutable state (like the pending-persist timer/value) — a
// discarded render would have already consumed them with no corresponding
// effect ever running to compensate. That part happens below, in an
// effect's cleanup, which is guaranteed to run only for a render that
// actually committed.
// Reset derived state during render to avoid flashing the previous selection.
const identityKey = selectionIdentityKey(element);
const identityKeyRef = useRef(identityKey);
if (identityKeyRef.current !== identityKey) {
@@ -248,22 +240,9 @@ export function useColorGradingController({
setMediaMetadata(null);
}
// Flushes — never discards — a still-pending edit when selection moves to
// a different element, and cancels the debounce timer. Implemented as an
// effect CLEANUP (not the render-phase block above, and not a queued ref
// consumed by a separate effect): a cleanup only ever runs for the
// specific effect instance that actually committed for `identityKey`, so
// there's no window where a discarded/interrupted render could have
// already consumed pendingPersistValueRef without this ever running to
// compensate. The cleanup's closure captures onSetAttributeLive/target
// bound to the OUTGOING identity, since it runs before the next effect
// instance (for the NEW identity) is established.
// Flush a pending write through the outgoing selection's committed callback.
useEffect(() => {
return () => {
// Stale runtime-status timers scheduled for the OUTGOING element must
// not fire after this: readRuntimeColorGradingStatus closes over
// `target`, so an old timer firing post-switch would stamp the NEW
// element's runtimeStatus with the OLD element's answer.
for (const timer of statusTimersRef.current) clearTimeout(timer);
statusTimersRef.current = [];
if (persistTimerRef.current) {
@@ -316,11 +295,7 @@ export function useColorGradingController({
})
.then((result) => {
if (controller.signal.aborted) return;
// Only cache a definitive answer from a successful response — a non-OK
// status is a transient/server failure, not a stable "no metadata"
// result, and caching it would suppress the HDR banner for this asset
// for the page's whole lifetime. Leave the key absent so the next
// selection retries.
// Cache only successful responses so transient failures remain retryable.
if (!result.ok) {
setMediaMetadata(null);
return;
@@ -329,8 +304,6 @@ export function useColorGradingController({
setMediaMetadata(result.metadata);
})
.catch(() => {
// Same reasoning as the non-OK branch above: don't cache a network-
// level fetch failure either.
if (!controller.signal.aborted) setMediaMetadata(null);
});
return () => controller.abort();
@@ -364,13 +337,6 @@ export function useColorGradingController({
onSettled?: (ok: boolean) => void,
) => void | Promise<void>,
) => {
// Two guards, not one: identity (selection moved to a DIFFERENT
// element — that element already got its own confirmedGradingRef
// baseline from the reset block) and version (a NEWER edit landed on
// the SAME element — e.g. the user dragged Exposure, then Contrast,
// before Exposure's persist settled; Exposure settling afterward must
// not stamp confirmedGradingRef with its now-superseded value or
// revert `grading` out from under Contrast's newer optimistic state).
const applySettled = (ok: boolean) => {
if (identityKeyRef.current !== attemptIdentityKey) return;
if (!isLatestAttempt()) return;
@@ -378,25 +344,13 @@ export function useColorGradingController({
confirmedGradingRef.current = attemptedGrading;
return;
}
// Persist failed — the optimistic grading was never actually saved.
// Revert to the last confirmed-good value instead of leaving the
// control showing an unsaved state as if it succeeded.
const reverted = confirmedGradingRef.current;
latestGradingRef.current = reverted;
setGrading(reverted);
setRuntimeStatus({ state: "unavailable", message: "Save failed — reverted" });
};
// `onSettled` is the real signal the underlying commit runner
// (runDomEditCommit) intentionally swallows persist failures so a
// caller `await`-ing this promise never sees a rejection; a rejection
// handler here alone would be dead code against the actual Studio
// callback. The `.catch` below is a fallback for any OTHER
// implementation of onSetAttributeLive that rejects instead.
// `setAttributeLive` is passed in by the caller rather than read from a
// ref: a debounced persist for element A must call the callback that
// was live when A's edit was SCHEDULED, not whatever the ref holds by
// the time the timer fires — a "latest" ref would let a stale timer
// wrongly target a since-selected element B's callback.
// The callback is the primary result signal; the rejection path supports
// other setAttributeLive implementations.
const result = setAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null, applySettled);
return trackStudioPendingEdit(
Promise.resolve(result).then(
@@ -416,20 +370,10 @@ export function useColorGradingController({
if (pendingPersistValueRef.current === undefined) return undefined;
const value = pendingPersistValueRef.current;
const attemptedGrading = pendingPersistGradingRef.current ?? latestGradingRef.current;
// Snapshotted at schedule time, not identityKeyRef.current read fresh
// here — an async flush could otherwise tag the attempt with whatever
// identity is "current" by then, not the one this edit was made for.
const attemptIdentityKey = pendingPersistIdentityRef.current ?? identityKeyRef.current;
pendingPersistValueRef.current = undefined;
pendingPersistGradingRef.current = null;
pendingPersistIdentityRef.current = null;
// A flush cancels the pending debounce timer above, so this becomes the
// one-and-only in-flight attempt for this element — bump the version so
// it still registers as "the latest attempt" against the same guard a
// regular debounced commit uses, instead of unconditionally claiming
// that title. Without this, a newer edit landing after the flush starts
// but before it settles would have its own eventual settle silently
// lose the version race to this unconditionally-"latest" flush.
const isLatestAttempt = bumpDomEditCommitVersion(gradingVersionRef);
return persistColorGradingValue(
value,
@@ -458,6 +402,14 @@ export function useColorGradingController({
},
[previewIframeRef, target],
);
const previewController = useColorGradingPreviews({
grading,
gradingRef: latestGradingRef,
identityKey,
target,
previewIframeRef,
postColorGrading,
});
const postCompare = useCallback(
(enabled: boolean) => {
@@ -509,6 +461,7 @@ export function useColorGradingController({
const commitColorGrading = useCallback(
(nextGrading: NormalizedHfColorGrading) => {
latestGradingRef.current = nextGrading;
setGrading(nextGrading);
setRuntimeStatus({ state: "pending", message: "Updating shader" });
postColorGrading(nextGrading);
@@ -524,14 +477,7 @@ export function useColorGradingController({
: null;
pendingPersistGradingRef.current = nextGrading;
pendingPersistIdentityRef.current = identityKeyRef.current;
// Captured now (edit time), not read fresh inside the timer — the
// timer fires 350ms later and may run after selection has already
// moved on, at which point identityKeyRef.current would no longer
// describe the element this edit was actually made for.
const attemptIdentityKey = identityKeyRef.current;
// Bumps a monotonic version and hands back a checker bound to THIS
// specific commit — reused from the same primitive the DOM-attribute
// commit runner uses for the identical same-target-rapid-edits race.
const isLatestAttempt = bumpDomEditCommitVersion(gradingVersionRef);
persistTimerRef.current = setTimeout(() => {
const value = pendingPersistValueRef.current;
@@ -587,10 +533,22 @@ export function useColorGradingController({
applyBusy,
runtimeStatus,
mediaMetadata,
presetPreviews: previewController.presetPreviews,
effectPreviews: previewController.effectPreviews,
requestPresetPreviews: previewController.requestPresetPreviews,
requestEffectPreviews: previewController.requestEffectPreviews,
commitColorGrading,
previewColorGrading: previewController.previewColorGrading,
commitCompare,
setApplyScope,
applyToScope,
resetGrading: () => commitColorGrading(defaultColorGrading()),
resetGrading: () => {
const neutral = defaultColorGrading();
commitColorGrading({
...neutral,
effects: latestGradingRef.current.effects,
palette: latestGradingRef.current.palette,
});
},
};
}
@@ -0,0 +1,307 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import {
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
HF_COLOR_GRADING_PRESETS,
normalizeHfColorGrading,
type HfColorGradingActiveEffectKey,
type HfColorGradingTarget,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
export interface ColorGradingPresetPreviews {
status: "idle" | "loading" | "ready" | "unavailable";
images: Record<string, string>;
width: number;
height: number;
}
type ColorGradingPreviewKind = "presets" | "effects";
export interface ColorGradingPreviewOptions {
animatedPreview: { kind: ColorGradingPreviewKind; id: string };
}
type RuntimeColorGradingPreview = {
renderPreviews: (
target: HfColorGradingTarget,
candidates: Array<{ id: string; grading: unknown }>,
options: { maxDimension: number; useMediaTime?: boolean },
) => Promise<{
width: number;
height: number;
images: Array<{ id: string; dataUrl: string | null }>;
} | null>;
startPreviewPlayback?: (target: HfColorGradingTarget) => (() => void) | null;
};
type PreviewRequest = {
kind: ColorGradingPreviewKind;
version: number;
effects?: readonly HfColorGradingActiveEffectKey[];
};
const emptyPreviews = (): Record<ColorGradingPreviewKind, ColorGradingPresetPreviews> => ({
presets: { status: "idle", images: {}, width: 16, height: 9 },
effects: { status: "idle", images: {}, width: 16, height: 9 },
});
function readRuntime(
iframe: HTMLIFrameElement | null | undefined,
): RuntimeColorGradingPreview | null {
try {
const runtime = (
iframe?.contentWindow as
| (Window & { __hf?: { colorGrading?: Partial<RuntimeColorGradingPreview> } })
| null
| undefined
)?.__hf?.colorGrading;
return runtime?.renderPreviews
? {
renderPreviews: runtime.renderPreviews,
startPreviewPlayback: runtime.startPreviewPlayback,
}
: null;
} catch {
return null;
}
}
function previewMaxDimension(): number {
return Math.min(320, Math.ceil(160 * Math.max(1, window.devicePixelRatio || 1)));
}
function toPreviewColorGrading(grading: NormalizedHfColorGrading): unknown {
const { enabled: _enabled, ...previewGrading } = grading;
return previewGrading;
}
function previewCandidates(
request: PreviewRequest,
lut: NormalizedHfColorGrading["lut"],
): Array<{ id: string; grading: unknown }> {
if (request.kind === "presets") {
return HF_COLOR_GRADING_PRESETS.map((preset) => {
const resolved = normalizeHfColorGrading({ preset: preset.id, lut });
return { id: preset.id, grading: resolved ? toPreviewColorGrading(resolved) : null };
});
}
return (request.effects ?? HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS).map((effect) => {
const resolved = normalizeHfColorGrading({
effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[effect],
});
return { id: effect, grading: resolved ? toPreviewColorGrading(resolved) : null };
});
}
async function renderRequestedPreviews(
runtime: RuntimeColorGradingPreview,
target: HfColorGradingTarget,
request: PreviewRequest,
lut: NormalizedHfColorGrading["lut"],
) {
const batch = await runtime.renderPreviews(target, previewCandidates(request, lut), {
maxDimension: previewMaxDimension(),
});
if (!batch) return null;
const images = Object.fromEntries(
batch.images.flatMap((image) => (image.dataUrl ? [[image.id, image.dataUrl]] : [])),
);
return Object.keys(images).length ? { ...batch, images } : null;
}
export function useColorGradingPreviews({
grading,
gradingRef,
identityKey,
target,
previewIframeRef,
postColorGrading,
}: {
grading: NormalizedHfColorGrading;
gradingRef: RefObject<NormalizedHfColorGrading>;
identityKey: string;
target: HfColorGradingTarget;
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
postColorGrading: (grading: NormalizedHfColorGrading) => void;
}) {
const [state, setState] = useState(() => ({
identityKey,
previews: emptyPreviews(),
}));
const [request, setRequest] = useState<PreviewRequest | null>(null);
const animatedRef = useRef<{ stopPlayback: () => void; timer: number | null } | null>(null);
if (state.identityKey !== identityKey) {
setState({ identityKey, previews: emptyPreviews() });
setRequest(null);
}
const previews = state.identityKey === identityKey ? state.previews : emptyPreviews();
useEffect(() => {
if (!request) return;
const { kind } = request;
const iframe = previewIframeRef?.current;
if (!iframe) {
setState((current) => ({
...current,
previews: {
...current.previews,
[kind]: { status: "unavailable", images: {}, width: 16, height: 9 },
},
}));
return;
}
let cancelled = false;
let complete = false;
let inFlight = false;
const timers: number[] = [];
setState((current) => ({
...current,
previews: {
...current.previews,
[kind]: { ...current.previews[kind], status: "loading" },
},
}));
const attempt = async () => {
if (cancelled || complete || inFlight) return;
const runtime = readRuntime(iframe);
if (!runtime) return;
inFlight = true;
try {
const result = await renderRequestedPreviews(runtime, target, request, grading.lut);
if (cancelled || !result) return;
complete = true;
setState((current) => ({
...current,
previews: {
...current.previews,
[kind]: {
status: "ready",
images:
kind === "effects"
? { ...current.previews[kind].images, ...result.images }
: result.images,
width: result.width,
height: result.height,
},
},
}));
} catch {
// Timed retries handle runtime and media readiness races.
} finally {
inFlight = false;
}
};
const onMessage = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return;
const data = event.data as { source?: unknown; type?: unknown } | null;
if (data?.source === "hf-preview" && data.type === "ready") void attempt();
};
iframe.addEventListener("load", attempt);
window.addEventListener("message", onMessage);
for (const delay of [0, 100, 350, 1000]) {
timers.push(window.setTimeout(() => void attempt(), delay));
}
timers.push(
window.setTimeout(() => {
if (cancelled || complete) return;
setState((current) => ({
...current,
previews: {
...current.previews,
[kind]: { status: "unavailable", images: {}, width: 16, height: 9 },
},
}));
}, 1600),
);
return () => {
cancelled = true;
for (const timer of timers) window.clearTimeout(timer);
iframe.removeEventListener("load", attempt);
window.removeEventListener("message", onMessage);
};
}, [grading.lut, previewIframeRef, request, target]);
const stopAnimatedPreview = useCallback(() => {
const session = animatedRef.current;
if (!session) return;
animatedRef.current = null;
if (session.timer !== null) window.clearTimeout(session.timer);
session.stopPlayback();
}, []);
const previewColorGrading = useCallback(
(next: NormalizedHfColorGrading | null, options?: ColorGradingPreviewOptions) => {
stopAnimatedPreview();
postColorGrading(next ?? gradingRef.current);
if (!next || !options) return;
const runtime = readRuntime(previewIframeRef?.current);
const stopPlayback = runtime?.startPreviewPlayback?.(target);
if (!runtime || !stopPlayback) return;
const session = { stopPlayback, timer: null as number | null };
animatedRef.current = session;
const renderFrame = async () => {
try {
const batch = await runtime.renderPreviews(
target,
[{ id: options.animatedPreview.id, grading: toPreviewColorGrading(next) }],
{ maxDimension: previewMaxDimension(), useMediaTime: true },
);
const image = batch?.images[0]?.dataUrl;
if (animatedRef.current !== session || !batch || !image) return;
setState((current) => ({
...current,
previews: {
...current.previews,
[options.animatedPreview.kind]: {
...current.previews[options.animatedPreview.kind],
status: "ready",
images: {
...current.previews[options.animatedPreview.kind].images,
[options.animatedPreview.id]: image,
},
width: batch.width,
height: batch.height,
},
},
}));
} catch {
// A later frame can recover after media/runtime readiness races.
} finally {
if (animatedRef.current === session) {
session.timer = window.setTimeout(() => void renderFrame(), 100);
}
}
};
void renderFrame();
},
[gradingRef, postColorGrading, previewIframeRef, stopAnimatedPreview, target],
);
useEffect(() => stopAnimatedPreview, [identityKey, stopAnimatedPreview]);
const requestPreviews = useCallback(
(kind: ColorGradingPreviewKind, effects?: readonly HfColorGradingActiveEffectKey[]) => {
setRequest((current) => ({
kind,
version: (current?.version ?? 0) + 1,
effects,
}));
},
[],
);
const requestPresetPreviews = useCallback(() => requestPreviews("presets"), [requestPreviews]);
const requestEffectPreviews = useCallback(
(effects: readonly HfColorGradingActiveEffectKey[]) => requestPreviews("effects", effects),
[requestPreviews],
);
return {
presetPreviews: previews.presets,
effectPreviews: previews.effects,
requestPresetPreviews,
requestEffectPreviews,
previewColorGrading,
};
}
+31 -13
View File
@@ -6,33 +6,51 @@ import {
resolveBlockCategory,
} from "../utils/blockCategories";
export type CatalogItem = RegistryItem & {
type CatalogItem = RegistryItem & {
category: BlockCategory;
};
let catalogCache: CatalogItem[] | null = null;
let catalogRequest: Promise<CatalogItem[]> | null = null;
function loadCatalog(): Promise<CatalogItem[]> {
catalogRequest ??= fetch("/api/registry/blocks")
.then((response) => {
if (!response.ok) throw new Error("Failed to load catalog");
return response.json() as Promise<RegistryItem[]>;
})
.then((items) => {
catalogCache = items
.map((item) => ({ ...item, category: resolveBlockCategory(item.tags) }))
.sort((a, b) => {
const aIndex = BLOCK_CATEGORIES.findIndex((category) => category.id === a.category);
const bIndex = BLOCK_CATEGORIES.findIndex((category) => category.id === b.category);
return (aIndex === -1 ? 99 : aIndex) - (bIndex === -1 ? 99 : bIndex);
});
return catalogCache;
})
.catch((error: unknown) => {
catalogRequest = null;
throw error;
});
return catalogRequest;
}
export function useBlockCatalog() {
const [blocks, setBlocks] = useState<CatalogItem[]>([]);
const [loading, setLoading] = useState(true);
const [blocks, setBlocks] = useState<CatalogItem[]>(() => catalogCache ?? []);
const [loading, setLoading] = useState(catalogCache === null);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [category, setCategory] = useState<BlockCategory | null>(null);
// fallow-ignore-next-line complexity
useEffect(() => {
if (catalogCache) return;
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/registry/blocks");
if (!res.ok) throw new Error("Failed to load catalog");
const data = (await res.json()) as RegistryItem[];
const items = await loadCatalog();
if (cancelled) return;
const items = data
.map((b) => ({ ...b, category: resolveBlockCategory(b.tags) }))
.sort((a, b) => {
const ia = BLOCK_CATEGORIES.findIndex((c) => c.id === a.category);
const ib = BLOCK_CATEGORIES.findIndex((c) => c.id === b.category);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
setBlocks(items);
} catch (err) {
if (cancelled) return;
@@ -9,6 +9,7 @@ import { addBlockToProject } from "../utils/blockInstaller";
import type { BlockParam } from "@hyperframes/core/registry";
import type { EditHistoryKind } from "../utils/editHistory";
import type { RightPanelTab } from "../utils/studioHelpers";
import type { MediaOverlayPlacement } from "../components/editor/propertyPanelTypes";
interface BlockCtxDeps {
activeCompPath: string | null;
@@ -46,6 +47,7 @@ export interface UseBlockHandlersResult {
>;
handleAddBlock: (blockName: string) => void;
handleTimelineBlockDrop: (blockName: string, placement: { start: number; track: number }) => void;
handleAddMediaOverlay: (blockName: string, placement: MediaOverlayPlacement) => Promise<void>;
handlePreviewBlockDrop: (blockName: string, position: { left: number; top: number }) => void;
}
@@ -151,6 +153,25 @@ export function useBlockHandlers({
[projectId, blockCtx, previewIframeRef, runBlockInstall],
);
const handleAddMediaOverlay = useCallback(
async (blockName: string, placement: MediaOverlayPlacement) => {
if (!projectId) return;
const { compositionPath, ...timelinePlacement } = placement;
await runBlockInstall(blockName, () =>
addBlockToProject({
projectId,
blockName,
...blockCtx,
activeCompPath: compositionPath ?? blockCtx.activeCompPath,
placement: timelinePlacement,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
}),
);
},
[projectId, blockCtx, previewIframeRef, runBlockInstall],
);
const handlePreviewBlockDrop = useCallback(
(blockName: string, position: { left: number; top: number }) => {
if (!projectId) return;
@@ -173,6 +194,7 @@ export function useBlockHandlers({
setActiveBlockParams,
handleAddBlock,
handleTimelineBlockDrop,
handleAddMediaOverlay,
handlePreviewBlockDrop,
};
}
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { addBlockToProject } from "./blockInstaller";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("addBlockToProject", () => {
it("uses an explicit selected-media duration and track for a Registry overlay block", async () => {
const componentPath = "compositions/camcorder-hud.html";
const targetPath = "compositions/scene.html";
const files = new Map([
[
componentPath,
`<body><div data-composition-id="camcorder-hud"><div class="hud"></div></div></body>`,
],
[
targetPath,
`<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>`,
],
]);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
written: [componentPath],
block: {
name: "camcorder-hud",
title: "Camcorder HUD",
description: "HUD",
type: "hyperframes:block",
files: [],
dimensions: { width: 1920, height: 1080 },
duration: 4,
},
}),
}),
);
const writeProjectFile = vi.fn(async (path: string, content: string) => {
files.set(path, content);
});
const result = await addBlockToProject({
projectId: "project",
blockName: "camcorder-hud",
activeCompPath: targetPath,
placement: { start: 2.5, duration: 4.25, track: 3 },
timelineElements: [],
readProjectFile: async (path) => files.get(path) ?? "",
writeProjectFile,
recordEdit: vi.fn().mockResolvedValue(undefined),
refreshFileTree: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
showToast: vi.fn(),
});
expect(result?.block.name).toBe("camcorder-hud");
const source = files.get(targetPath);
expect(source).toContain('data-composition-src="compositions/camcorder-hud.html"');
expect(source).toContain('data-start="2.5"');
expect(source).toContain('data-duration="4.25"');
expect(source).toContain('data-track-index="3"');
});
});
+162 -109
View File
@@ -30,7 +30,7 @@ interface AddBlockOptions {
projectId: string;
blockName: string;
activeCompPath: string | null;
placement?: { start: number; track: number };
placement?: { start: number; duration?: number; track?: number };
visualPosition?: { left: number; top: number };
previewIframe?: HTMLIFrameElement | null;
currentTime?: number;
@@ -56,6 +56,121 @@ function buildUniqueCompositionId(baseName: string, existingIds: Iterable<string
return `${baseName}_${i}`;
}
async function installRegistryItem({
projectId,
blockName,
showToast,
}: Pick<AddBlockOptions, "projectId" | "blockName" | "showToast">): Promise<{
block: RegistryItem;
compositionFile: string;
} | null> {
const response = await fetch(`/api/projects/${projectId}/registry/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blockName }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Install failed" }));
showToast((error as { error?: string }).error || "Failed to install block");
return null;
}
const { written, block } = (await response.json()) as {
written: string[];
block: RegistryItem;
};
const compositionFile = written.find((file) => file.endsWith(".html")) ?? written[0];
if (!compositionFile) {
showToast("Installed but no composition file was written");
return null;
}
return { block, compositionFile };
}
async function makeComponentBackgroundTransparent(
block: RegistryItem,
compositionFile: string,
readProjectFile: AddBlockOptions["readProjectFile"],
writeProjectFile: AddBlockOptions["writeProjectFile"],
): Promise<void> {
if (block.type !== "hyperframes:component") return;
const content = await readProjectFile(compositionFile);
const transparentContent = content.replace(
/background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g,
"background: transparent;",
);
if (transparentContent !== content) await writeProjectFile(compositionFile, transparentContent);
}
function resolveBlockPlacement({
block,
placement,
timelineElements,
currentTime,
}: {
block: RegistryItem;
placement: AddBlockOptions["placement"];
timelineElements: TimelineElement[];
currentTime: number;
}) {
const isBlock = block.type === "hyperframes:block";
const {
start: placementStart = currentTime,
duration: placementDuration,
track: placementTrack,
} = placement ?? {};
const start = Number(formatTimelineAttributeNumber(placementStart));
const blockDuration = "duration" in block ? (block as { duration: number }).duration : undefined;
const duration =
placementDuration ??
blockDuration ??
timelineElements.reduce(
(max, element) => Math.max(max, (element.start ?? 0) + (element.duration ?? 0)),
10,
);
const nextTrack = Math.max(0, ...timelineElements.map((element) => element.track)) + 1;
const track = placementTrack ?? (isBlock ? 0 : nextTrack);
return { duration, isBlock, start, track };
}
function buildSubCompositionHtml({
id,
compositionFile,
start,
duration,
track,
width,
height,
left,
top,
zIndex,
}: {
id: string;
compositionFile: string;
start: number;
duration: number;
track: number;
width: number;
height: number;
left: number;
top: number;
zIndex: number;
}): string {
return [
`<div`,
` id="${id}"`,
` data-hf-id="hf-${generateId()}"`,
` data-composition-id="${id}"`,
` data-composition-src="${compositionFile}"`,
` data-start="${formatTimelineAttributeNumber(start)}"`,
` data-duration="${formatTimelineAttributeNumber(duration)}"`,
` data-track-index="${track}"`,
` data-width="${width}"`,
` data-height="${height}"`,
` style="position: absolute; left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px; z-index: ${zIndex}"`,
`></div>`,
].join("\n");
}
export async function addBlockToProject(
opts: AddBlockOptions,
): Promise<{ block: RegistryItem; compositionPath: string } | null> {
@@ -75,115 +190,53 @@ export async function addBlockToProject(
} = opts;
try {
const res = await fetch(`/api/projects/${projectId}/registry/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blockName }),
const installed = await installRegistryItem({ projectId, blockName, showToast });
if (!installed) return null;
const { block, compositionFile } = installed;
await makeComponentBackgroundTransparent(
block,
compositionFile,
readProjectFile,
writeProjectFile,
);
const targetPath = activeCompPath || "index.html";
const originalContent = await readProjectFile(targetPath);
const relevantElements = timelineElements.filter(
(element) => (element.sourceFile || targetPath) === targetPath,
);
const { duration, isBlock, start, track } = resolveBlockPlacement({
block,
placement,
timelineElements: relevantElements,
currentTime: opts.currentTime ?? 0,
});
const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
const subComposition = buildSubCompositionHtml({
id: buildUniqueCompositionId(block.name, collectHtmlIds(originalContent)),
compositionFile,
start,
duration,
track,
width,
height,
left: visualPosition ? Math.round(visualPosition.left) : 0,
top: visualPosition ? Math.round(visualPosition.top) : 0,
zIndex: getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1,
});
const patchedContent = extendRootDurationInSource(
insertTimelineAssetIntoSource(originalContent, subComposition),
start + duration,
);
await saveProjectFilesWithHistory({
projectId,
label: `Add ${isBlock ? "block" : "component"}: ${block.title}`,
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Install failed" }));
showToast((err as { error?: string }).error || "Failed to install block");
return null;
}
const { written, block } = (await res.json()) as {
written: string[];
block: RegistryItem;
};
const compositionFile = written.find((f) => f.endsWith(".html")) ?? written[0];
if (!compositionFile) {
showToast("Installed but no composition file was written");
return null;
}
if (block.type === "hyperframes:component") {
const compContent = await readProjectFile(compositionFile);
const transparentContent = compContent.replace(
/background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g,
"background: transparent;",
);
if (transparentContent !== compContent) {
await writeProjectFile(compositionFile, transparentContent);
}
}
{
const targetPath = activeCompPath || "index.html";
const originalContent = await readProjectFile(targetPath);
const existingIds = collectHtmlIds(originalContent);
const compId = buildUniqueCompositionId(block.name, existingIds);
const resolvedTargetPath = targetPath || "index.html";
const relevantElements = timelineElements.filter(
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
);
const isBlock = block.type === "hyperframes:block";
const { width: hostWidth, height: hostHeight } =
resolveTimelineAssetCompositionSize(originalContent);
const hostDims = { left: 0, top: 0, width: hostWidth, height: hostHeight };
const currentTime = opts.currentTime ?? 0;
const start = placement
? Number(formatTimelineAttributeNumber(placement.start))
: Number(formatTimelineAttributeNumber(currentTime));
const blockDuration =
"duration" in block ? (block as { duration: number }).duration : undefined;
const duration =
blockDuration ??
relevantElements.reduce(
(max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)),
10,
);
const track =
placement?.track ??
(isBlock
? 0
: relevantElements.length > 0
? Math.max(...relevantElements.map((te) => te.track)) + 1
: 1);
const zIndex = getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1;
const width = hostDims.width;
const height = hostDims.height;
const left = visualPosition ? Math.round(visualPosition.left) : 0;
const top = visualPosition ? Math.round(visualPosition.top) : 0;
const subCompHtml = [
`<div`,
// A stable id (+ hf-id) is what authored sub-comps carry; without it the
// timeline can't dedup the host and renders duplicate clips that multiply
// on every interaction. Matches the authored-comp shape.
` id="${compId}"`,
` data-hf-id="hf-${generateId()}"`,
` data-composition-id="${compId}"`,
` data-composition-src="${compositionFile}"`,
` data-start="${formatTimelineAttributeNumber(start)}"`,
` data-duration="${formatTimelineAttributeNumber(duration)}"`,
` data-track-index="${track}"`,
` data-width="${width}"`,
` data-height="${height}"`,
` style="position: absolute; left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px; z-index: ${zIndex}"`,
`></div>`,
].join("\n");
let patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml);
patchedContent = extendRootDurationInSource(patchedContent, start + duration);
await saveProjectFilesWithHistory({
projectId,
label: `Add ${isBlock ? "block" : "component"}: ${block.title}`,
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
}
await refreshFileTree();
reloadPreview();