mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
Merge pull request #2755 from heygen-com/feat/media-treatment-studio
feat(studio): add media treatment inspector
This commit is contained in:
@@ -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="{"preset":"warm-daylight"}"`,
|
||||
`video id="v" data-color-grading="{"preset":"clean-studio"}"`,
|
||||
);
|
||||
expect(html).toContain(
|
||||
`img id="i" data-color-grading="{"preset":"warm-daylight"}"`,
|
||||
`img id="i" data-color-grading="{"preset":"clean-studio"}"`,
|
||||
);
|
||||
expect(html).toContain(`<audio id="a"></audio>`);
|
||||
});
|
||||
|
||||
+73
-40
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user