diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 0e0ec6c774..ad131e3a62 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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[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} /> ) } diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 6d12e5491c..178407f679 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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; }) => Promise; 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} diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 84080056a2..be3912ea33 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -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('[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(); diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 4733fdfd55..ef1393a60d 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -23,6 +23,15 @@ import { FlatColorGradingAccessory, FlatColorGradingSection, } from "./propertyPanelFlatColorGradingSection"; +import { + activeColorGradingEffectCount, + FlatEffectsAccessory, + FlatEffectsSection, +} from "./propertyPanelFlatEffectsSection"; +import { + deriveMediaOverlayPlacement, + FlatOverlaysSection, +} from "./propertyPanelFlatOverlaysSection"; type EditingSections = ReturnType; @@ -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[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 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(() => 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([]); const justToggledTimeoutRef = useRef | null>(null); + const panelBodyRef = useRef(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('[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 - // `` 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: ( 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: , + summary: activeEffects ? `${activeEffects} active` : "none", + content: ( + ), }); + if (onAddMediaOverlay) { + groups.push({ + id: "overlays", + title: "Overlays", + summary: "add layer", + content: ( + + 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)} /> -
+
{beforeOpen.map((g) => ( -
+
{ it("adds color grading to video and image tags only", () => { const { html, count } = patchMediaColorGradingInHtml( `
`, - `{"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(``); }); diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx index b2030f9c0d..92e2b29c83 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx @@ -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( , ); - const presetSelect = host.querySelector( - '[data-flat-grade-preset="true"] select', + const brightPop = host.querySelector( + '[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(); + 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( + , + ); + expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1); + expect( + host.querySelector('[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(); - 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( - , - ); - 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( diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx index a124f70cdb..7a7348cf9a 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx @@ -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 = { 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; 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(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 (
-
- Preset - +
+
+ {HF_COLOR_GRADING_GRADE_PRESETS.map((preset) => { + const label = preset.label; + const selected = grading.preset === preset.id; + const preview = presetPreviews.images[preset.id]; + return ( + + ); + })} +
- Finishing + Finish
{renderDetailSlider("vignette")}
@@ -497,42 +552,6 @@ export function FlatColorGradingSection({ )}
-
-
- Effects -
- {EFFECT_SLIDERS.map((slider) => { - const value = grading.effects[slider.key]; - const isSet = value > 1e-6; - return ( -
- - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - effects: { ...grading.effects, [slider.key]: next / 100 }, - }) - } - onReset={() => - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - effects: { ...grading.effects, [slider.key]: 0 }, - }) - } - /> -
- ); - })} -
- {onApplyScopeAvailable && (
diff --git a/packages/studio/src/components/editor/propertyPanelFlatEffectControl.tsx b/packages/studio/src/components/editor/propertyPanelFlatEffectControl.tsx new file mode 100644 index 0000000000..c4d0aca67a --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatEffectControl.tsx @@ -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; + 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 ( + 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 ( + = 0.5} + onChange={(next) => onCommit(control.key, next ? 1 : 0)} + /> + ); + } + if (control.kind === "select") { + return ( + onCommit(control.key, Number.parseInt(next, 10))} + onReset={() => onCommit(control.key, defaultValue)} + /> + ); + } + return ( + + ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatEffectSpecs.ts b/packages/studio/src/components/editor/propertyPanelFlatEffectSpecs.ts new file mode 100644 index 0000000000..45e4d76de3 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatEffectSpecs.ts @@ -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; diff --git a/packages/studio/src/components/editor/propertyPanelFlatEffectsSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatEffectsSection.test.tsx new file mode 100644 index 0000000000..3b10d5eb11 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatEffectsSection.test.tsx @@ -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, + onCommitColorGrading: ReturnType = 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(); + expect(host.textContent).not.toContain("Styles"); + expect(props.onRequestPresetPreviews).not.toHaveBeenCalled(); + const retro = host.querySelector( + '[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('[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('[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(); + const renderedKeys: Array = []; + for (const label of ["Essentials", "Retro & Glitch", "Print", "Art"]) { + const tab = host.querySelector(`[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(); + 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('[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(); + const pixelate = host.querySelector('[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(); + const toggle = host.querySelector('[data-flat-effects-add-toggle="true"]'); + act(() => toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const retro = host.querySelector( + '[data-flat-effect-group="Retro & Glitch"]', + ); + act(() => retro?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const vhs = host.querySelector('[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(); + 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(); + expect( + host.querySelector('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( + '[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( + '[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(); + expect( + host.querySelector('[data-flat-effect-editor="digitalGlitch"]')?.textContent, + ).not.toContain("Mix"); + const reset = host.querySelector('[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(); + const remove = host.querySelector('[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(); + expect(props.onRequestEffectPreviews).not.toHaveBeenCalled(); + const toggle = host.querySelector('[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('[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(); + const art = host.querySelector('[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( + , + ); + const reset = host.querySelector('[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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatEffectsSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatEffectsSection.tsx new file mode 100644 index 0000000000..bba513fb1c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatEffectsSection.tsx @@ -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 ( + + ); +} + +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( + 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 ( +
+
+ {HF_COLOR_GRADING_PALETTES.map((preset) => { + const selected = + palette?.length === preset.colors.length && + palette.every((color, index) => color === preset.colors[index]); + return ( + + ); + })} +
+ {!palette ? ( + + ) : ( + <> +
+ Custom palette + +
+
+ {palette.map((color, index) => ( + + { + 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 && ( + + )} + + ))} + {palette.length < 6 && ( + + )} +
+ + )} +
+ ); + }; + + return ( +
+ {activeEffects.length > 0 && ( +
+ {activeEffects.map((effect) => { + const selected = selectedEffect?.key === effect.key; + return ( + + ); + })} +
+ )} + + {selectedEffect && ( +
+
+ + {selectedEffect.label} + + + + + +
+ {selectedEffect.showMaster !== false && ( + 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) => ( + + ))} + {selectedEffect.palette && renderPalette(selectedEffect.palette)} +
+ )} + + + + {catalogOpen && ( +
+
+ {EFFECT_GROUPS.map((group) => ( + + ))} +
+ {EFFECT_GROUPS.filter((group) => group.label === catalogGroup).map((group) => ( +
+
+ {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 ( + + ); + })} + {group.effects.map((effect) => { + const active = grading.effects[effect.key] > 0.0001; + const preview = previews.images[effect.key]; + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx new file mode 100644 index 0000000000..1537b3f807 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx @@ -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(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-flat-overlay="ordinary-component"]')).toBeNull(); + const add = host.querySelector('[data-flat-overlay="camcorder-hud"]'); + expect(add).not.toBeNull(); + expect( + host.querySelector('[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("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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatOverlaysSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatOverlaysSection.tsx new file mode 100644 index 0000000000..7dad96cdb4 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatOverlaysSection.tsx @@ -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, + timing: Pick, +) { + 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; +}) { + const track = useTrackDesignInput(); + const { blocks, loading, error } = useBlockCatalog(); + const overlays = filterMediaTreatmentOverlays(blocks); + const [adding, setAdding] = useState(null); + const [previewing, setPreviewing] = useState(null); + + if (loading) { + return
Loading overlays…
; + } + if (error) { + return
{error}
; + } + + const busy = adding !== null; + return ( +
+ {overlays.map((overlay) => ( + + ))} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 7429768c52..29c167e57d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -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 */ /* ------------------------------------------------------------------ */ diff --git a/packages/studio/src/components/editor/propertyPanelPresetPreview.ts b/packages/studio/src/components/editor/propertyPanelPresetPreview.ts new file mode 100644 index 0000000000..601e25ef50 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelPresetPreview.ts @@ -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()); + }, + }; +} diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts index 722e3a252d..e50a011c4b 100644 --- a/packages/studio/src/components/editor/propertyPanelTypes.ts +++ b/packages/studio/src/components/editor/propertyPanelTypes.ts @@ -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; + export interface PropertyPanelProps { projectId: string; projectDir: string | null; @@ -69,6 +81,7 @@ export interface PropertyPanelProps { onAskAgent: () => void; onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise; onImportAssets?: (files: FileList, dir?: string) => Promise; + onAddMediaOverlay?: AddMediaOverlayHandler; fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; previewIframeRef?: RefObject; diff --git a/packages/studio/src/components/editor/useColorGradingController.test.ts b/packages/studio/src/components/editor/useColorGradingController.test.ts index 4b4d9376d3..d16fe5f620 100644 --- a/packages/studio/src/components/editor/useColorGradingController.test.ts +++ b/packages/studio/src/components/editor/useColorGradingController.test.ts @@ -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) => void; onSetAttributeLive: (attr: string, value: string | null) => void; element: DomEditSelection; + previewIframeRef?: React.RefObject; }) { 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, ) { const host = document.createElement("div"); document.body.append(host); @@ -89,6 +93,7 @@ function renderHook( onState: (s: ReturnType) => (latest = s), onSetAttributeLive, element, + previewIframeRef, }), ); }); @@ -107,6 +112,43 @@ function renderHook( }; } +type PreviewWindow = Window & { + __hf?: { + colorGrading?: { + renderPreviews?: ReturnType; + startPreviewPlayback?: ReturnType; + }; + }; + __player?: { play: ReturnType }; +}; + +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>) { + 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()); }); diff --git a/packages/studio/src/components/editor/useColorGradingController.ts b/packages/studio/src/components/editor/useColorGradingController.ts index d35d60c10c..e1994401d6 100644 --- a/packages/studio/src/components/editor/useColorGradingController.ts +++ b/packages/studio/src/components/editor/useColorGradingController.ts @@ -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; @@ -195,20 +211,11 @@ export function useColorGradingController({ const persistTimerRef = useRef | null>(null); const pendingPersistValueRef = useRef(undefined); const pendingPersistGradingRef = useRef(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(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([]); 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, ) => { - // 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, + }); + }, }; } diff --git a/packages/studio/src/components/editor/useColorGradingPreviews.ts b/packages/studio/src/components/editor/useColorGradingPreviews.ts new file mode 100644 index 0000000000..2c88013918 --- /dev/null +++ b/packages/studio/src/components/editor/useColorGradingPreviews.ts @@ -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; + 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 => ({ + 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 } }) + | 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; + identityKey: string; + target: HfColorGradingTarget; + previewIframeRef?: RefObject; + postColorGrading: (grading: NormalizedHfColorGrading) => void; +}) { + const [state, setState] = useState(() => ({ + identityKey, + previews: emptyPreviews(), + })); + const [request, setRequest] = useState(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, + }; +} diff --git a/packages/studio/src/hooks/useBlockCatalog.ts b/packages/studio/src/hooks/useBlockCatalog.ts index 74a7eadee1..7efaee4d4e 100644 --- a/packages/studio/src/hooks/useBlockCatalog.ts +++ b/packages/studio/src/hooks/useBlockCatalog.ts @@ -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 | null = null; + +function loadCatalog(): Promise { + catalogRequest ??= fetch("/api/registry/blocks") + .then((response) => { + if (!response.ok) throw new Error("Failed to load catalog"); + return response.json() as Promise; + }) + .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([]); - const [loading, setLoading] = useState(true); + const [blocks, setBlocks] = useState(() => catalogCache ?? []); + const [loading, setLoading] = useState(catalogCache === null); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [category, setCategory] = useState(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; diff --git a/packages/studio/src/hooks/useBlockHandlers.ts b/packages/studio/src/hooks/useBlockHandlers.ts index 886c219ec4..368de32027 100644 --- a/packages/studio/src/hooks/useBlockHandlers.ts +++ b/packages/studio/src/hooks/useBlockHandlers.ts @@ -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; 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, }; } diff --git a/packages/studio/src/utils/blockInstaller.test.ts b/packages/studio/src/utils/blockInstaller.test.ts new file mode 100644 index 0000000000..8f75ee35ea --- /dev/null +++ b/packages/studio/src/utils/blockInstaller.test.ts @@ -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, + `
`, + ], + [ + targetPath, + `
`, + ], + ]); + 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"'); + }); +}); diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index 89cd45b2eb..4406381845 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -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): 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 { + 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 [ + `
`, + ].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 = [ - `
`, - ].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();