diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index d894c5526..d49543c25 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -64,7 +64,6 @@ export function StudioApp() { const initialUrlStateRef = useRef(readStudioUrlStateFromWindow()); const viewModeValue = useViewModeState(); - // sessionStorage-backed: fires once per tab, survives HMR remounts useEffect(() => { if (resolving || waitingForServer) return; if (hasFiredSessionStart()) return; @@ -506,8 +505,6 @@ export function StudioApp() { onSelectComposition={handleSelectComposition} /> )} - {/* Timeline stage stays mounted (just hidden) in storyboard mode, - so preview/player/gesture/render state survives the toggle. */}
void, - signal?: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new DOMException("Background removal was cancelled", "AbortError")); - return; - } - const events = new EventSource(`/api/media-jobs/${encodeURIComponent(jobId)}/progress`); - let settled = false; - let reconnectTimer: number | null = null; - - const clearReconnectTimer = () => { - if (reconnectTimer === null) return; - window.clearTimeout(reconnectTimer); - reconnectTimer = null; - }; - const finish = (callback: () => void) => { - if (settled) return; - settled = true; - clearReconnectTimer(); - signal?.removeEventListener("abort", handleAbort); - events.close(); - callback(); - }; - const handleAbort = () => { - finish(() => reject(new DOMException("Background removal was cancelled", "AbortError"))); - }; - signal?.addEventListener("abort", handleAbort, { once: true }); - - events.addEventListener("progress", (event) => { - let progress: BackgroundRemovalProgress; - try { - progress = JSON.parse((event as MessageEvent).data) as BackgroundRemovalProgress; - } catch { - finish(() => reject(new Error("Invalid background-removal progress event"))); - return; - } - clearReconnectTimer(); - onProgress?.(progress); - if (progress.status === "complete") { - if (!progress.outputPath) { - finish(() => reject(new Error("Background removal finished without an output path"))); - return; - } - const outputPath = progress.outputPath; - finish(() => { - resolve({ - outputPath, - backgroundOutputPath: progress.backgroundOutputPath, - provider: progress.provider, - }); - }); - return; - } - if (progress.status === "failed") { - finish(() => reject(new Error(progress.error || "Background removal failed"))); - } - }); - events.onopen = clearReconnectTimer; - events.onerror = () => { - if (events.readyState === EventSource.CLOSED) { - finish(() => reject(new Error("Lost connection to background-removal job"))); - return; - } - if (reconnectTimer === null) { - reconnectTimer = window.setTimeout(() => { - finish(() => reject(new Error("Lost connection to background-removal job"))); - }, MEDIA_JOB_RECONNECT_TIMEOUT_MS); - } - }; - }); -} export interface StudioRightPanelProps { designPanelActive: boolean; @@ -340,66 +253,27 @@ export function StudioRightPanel({ }, []); const handleApplyColorGradingScope = useCallback( - async (scope: "source-file" | "project", value: string | null) => { - try { - await waitForPendingDomEditSaves(); - if (scope === "project" && hasRelativeLutSource(value)) { - showToast( - "Project-wide color grading cannot copy relative LUT paths. Apply to this file or use a URL/data LUT.", - "error", - ); - return { changedFiles: 0, changedElements: 0 }; - } - const selectedSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html"; - const paths = - scope === "source-file" - ? [selectedSourceFile] - : fileTree.filter((path) => /\.html?$/i.test(path)); - const snapshots = await Promise.all( - Array.from(new Set(paths)).map( - async (path) => [path, await readProjectFile(path)] as const, - ), - ); - const files: Record = {}; - let changedElements = 0; - - for (const [path, before] of snapshots) { - const result = patchMediaColorGradingInHtml(before, value); - if (result.html !== before) { - files[path] = result.html; - changedElements += result.count; - } - } - - if (Object.keys(files).length === 0) { - showToast("No color grading changed", "info"); - return { changedFiles: 0, changedElements: 0 }; - } - - domEditSaveTimestampRef.current = Date.now(); - const changedPaths = await saveProjectFilesWithHistory({ - projectId, - label: value ? "Apply color grading" : "Clear color grading", - kind: "manual", - files, - readFile: readProjectFile, - writeFile: writeProjectFile, - recordEdit, - }); - reloadPreview(); - showToast( - `${value ? "Applied" : "Cleared"} color grading on ${changedElements} media item${changedElements === 1 ? "" : "s"}`, - "info", - ); - return { changedFiles: changedPaths.length, changedElements }; - } catch (error) { + async (scope: ColorGradingScope, value: string | null) => + applyColorGradingScopeUpdate({ + scope, + value, + selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html", + fileTree, + projectId, + domEditSaveTimestampRef, + waitForPendingDomEditSaves, + readProjectFile, + writeProjectFile, + recordEdit, + reloadPreview, + showToast, + }).catch((error) => { showToast( `Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`, "error", ); - return { changedFiles: 0, changedElements: 0 }; - } - }, + return EMPTY_COLOR_GRADING_SCOPE_RESULT; + }), [ activeCompPath, domEditSaveTimestampRef, @@ -416,6 +290,7 @@ export function StudioRightPanel({ ); const handleRemoveBackground = useCallback( + // fallow-ignore-next-line complexity async ( inputPath: string, options: { diff --git a/packages/studio/src/components/StudioToast.tsx b/packages/studio/src/components/StudioToast.tsx index e365048c1..ec4f08639 100644 --- a/packages/studio/src/components/StudioToast.tsx +++ b/packages/studio/src/components/StudioToast.tsx @@ -4,6 +4,7 @@ interface StudioToastProps { onDismiss?: () => void; } +// fallow-ignore-next-line complexity export function StudioToast({ message, tone, onDismiss }: StudioToastProps) { const isError = tone === "error"; return ( diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx index 2b2693601..138130a10 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { HF_COLOR_GRADING_PRESETS, normalizeHfColorGrading, @@ -7,21 +7,12 @@ import { type HfColorGradingEffectKey, type NormalizedHfColorGrading, } from "@hyperframes/core/color-grading"; -import { - ChevronDown, - ChevronRight, - Minus, - Plus, - RotateCcw, - Settings, - X, -} from "../../icons/SystemIcons"; +import { ChevronDown, ChevronRight, Plus, X } from "../../icons/SystemIcons"; import { LUT_EXT } from "../../utils/mediaTypes"; import { LABEL } from "./propertyPanelHelpers"; +import { ColorGradingSliderControl } from "./propertyPanelColorGradingSlider"; const LUT_UPLOAD_DIR = "assets/luts"; -const SLIDER_THUMB_SIZE = 10; -const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2; const ADJUST_SLIDERS: Array<{ key: HfColorGradingAdjustKey; @@ -131,6 +122,13 @@ const DETAIL_SLIDERS: Array<{ }, ]; +type DetailSlider = (typeof DETAIL_SLIDERS)[number]; +type SliderSettings = { + active?: boolean; + label: string; + onClick: () => void; +}; + const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string; @@ -157,275 +155,6 @@ const GRAIN_TUNE_SLIDERS = DETAIL_SLIDERS.filter( (slider) => slider.key === "grainSize" || slider.key === "grainRoughness", ); -function clampNumber(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) return min; - return Math.min(max, Math.max(min, value)); -} - -function formatNumericInput(value: number, scale: number): string { - const scaled = value / scale; - return scale === 100 ? scaled.toFixed(2) : String(Math.round(scaled)); -} - -function parseNumericInput(value: string, scale: number): number | null { - const parsed = Number(value); - if (!Number.isFinite(parsed)) return null; - return parsed * scale; -} - -function tickPercent(value: number, min: number, max: number): number { - if (max <= min) return 0; - return ((value - min) / (max - min)) * 100; -} - -function ColorGradingSliderControl({ - label, - value, - min, - max, - step, - neutral = min, - scale = 1, - suffix = "", - displayValue, - disabled, - onCommit, - onReset, - settings, -}: { - label: string; - value: number; - min: number; - max: number; - step: number; - neutral?: number; - scale?: number; - suffix?: string; - displayValue: string; - disabled?: boolean; - onCommit: (nextValue: number) => void; - onReset?: () => void; - settings?: { - active?: boolean; - label: string; - onClick: () => void; - }; -}) { - const [draftState, setDraftState] = useState<{ value: number; source: number } | null>(null); - const [inputDraft, setInputDraft] = useState<{ value: string; source: number } | null>(null); - const commitTimerRef = useRef | null>(null); - const valueRef = useRef(value); - valueRef.current = value; - - useEffect( - () => () => { - if (commitTimerRef.current) clearTimeout(commitTimerRef.current); - }, - [], - ); - - const clampDraft = useCallback( - (nextValue: number) => clampNumber(nextValue, min, max), - [max, min], - ); - - const setLocalDraft = useCallback( - (nextValue: number) => { - const clamped = clampDraft(nextValue); - const source = valueRef.current; - setDraftState({ value: clamped, source }); - setInputDraft({ value: formatNumericInput(clamped, scale), source }); - return clamped; - }, - [clampDraft, scale], - ); - - const commitDraft = useCallback( - (nextValue: number) => { - const clamped = setLocalDraft(nextValue); - if (commitTimerRef.current) clearTimeout(commitTimerRef.current); - if (clamped !== valueRef.current) onCommit(clamped); - }, - [onCommit, setLocalDraft], - ); - - const scheduleCommit = useCallback( - (nextValue: number) => { - const clamped = setLocalDraft(nextValue); - if (commitTimerRef.current) clearTimeout(commitTimerRef.current); - commitTimerRef.current = setTimeout(() => { - if (clamped !== valueRef.current) onCommit(clamped); - }, 40); - }, - [onCommit, setLocalDraft], - ); - - const draft = draftState?.source === value ? draftState.value : value; - const inputValue = - inputDraft?.source === value ? inputDraft.value : formatNumericInput(draft, scale); - - const commitInputDraft = useCallback(() => { - const parsed = parseNumericInput(inputValue, scale); - if (parsed === null) { - setInputDraft(null); - return; - } - commitDraft(parsed); - }, [commitDraft, inputValue, scale]); - - const nudge = useCallback( - (direction: -1 | 1) => { - commitDraft(draft + step * direction); - }, - [commitDraft, draft, step], - ); - - const range = max - min; - const valuePercent = range === 0 ? 0 : ((draft - min) / range) * 100; - const neutralPercent = range === 0 ? 0 : ((neutral - min) / range) * 100; - const fillLeft = Math.min(valuePercent, neutralPercent); - const fillWidth = Math.abs(valuePercent - neutralPercent); - const ticks = Array.from(new Set([min, neutral, max])).sort((a, b) => a - b); - - return ( -
-
- {label} - {settings && ( - - )} - {onReset && ( - - )} -
- -
-
- {ticks.map((tick) => ( -
- ))} -
-
-
- scheduleCommit(Number(event.currentTarget.value))} - onMouseUp={() => commitDraft(draft)} - onTouchEnd={() => commitDraft(draft)} - onBlur={() => commitDraft(draft)} - className="hf-color-grading-range absolute left-0 right-0 top-1/2 z-30 min-w-0 w-full -translate-y-1/2" - title={displayValue} - /> -
- -
-
- - setInputDraft({ value: event.currentTarget.value, source: valueRef.current }) - } - onBlur={commitInputDraft} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.currentTarget.blur(); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - nudge(1); - return; - } - if (event.key === "ArrowDown") { - event.preventDefault(); - nudge(-1); - } - }} - className="hf-color-grading-number h-4 w-[36px] bg-transparent text-right text-[10px] font-medium tabular-nums text-panel-text-1 outline-none disabled:cursor-not-allowed" - title={displayValue} - /> - {suffix && {suffix}} -
-
- - -
-
-
- ); -} - function normalizedDefaultValue(slider: { defaultValue?: number; scale: number }): number { return (slider.defaultValue ?? 0) / slider.scale; } @@ -491,6 +220,45 @@ export function ColorGradingControls({ const firstLut = uploaded.find((asset) => LUT_EXT.test(asset)); if (firstLut) applyLut(firstLut, 1); }; + const commitDetailSlider = (slider: DetailSlider, next: number) => { + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + details: { + ...grading.details, + [slider.key]: next / slider.scale, + }, + }); + }; + const resetDetailSlider = (slider: DetailSlider) => { + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + details: { + ...grading.details, + [slider.key]: normalizedDefaultValue(slider), + }, + }); + }; + const renderDetailSlider = (slider: DetailSlider, settings?: SliderSettings) => { + const value = Math.round(grading.details[slider.key] * slider.scale); + return ( + commitDetailSlider(slider, next)} + onReset={() => resetDetailSlider(slider)} + /> + ); + }; return (
@@ -669,51 +437,16 @@ export function ColorGradingControls({
Finishing
- {AMOUNT_DETAIL_SLIDERS.map((slider) => { - const value = grading.details[slider.key] * slider.scale; - const defaultValue = slider.defaultValue ?? 0; - return ( - - setDetailSettings((current) => - current === slider.key ? null : (slider.key as "vignette" | "grain"), - ), - }} - onCommit={(next) => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - details: { - ...grading.details, - [slider.key]: next / slider.scale, - }, - }); - }} - onReset={() => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - details: { - ...grading.details, - [slider.key]: defaultValue / slider.scale, - }, - }); - }} - /> - ); - })} + {AMOUNT_DETAIL_SLIDERS.map((slider) => + renderDetailSlider(slider, { + active: slider.key === "vignette" ? vignetteSettingsActive : grainSettingsActive, + label: `${slider.label} settings`, + onClick: () => + setDetailSettings((current) => + current === slider.key ? null : (slider.key as "vignette" | "grain"), + ), + }), + )}
{detailSettings && (
@@ -732,43 +465,7 @@ export function ColorGradingControls({
- {detailSettingsSliders.map((slider) => { - const value = grading.details[slider.key] * slider.scale; - const defaultValue = slider.defaultValue ?? 0; - return ( - { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - details: { - ...grading.details, - [slider.key]: next / slider.scale, - }, - }); - }} - onReset={() => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - details: { - ...grading.details, - [slider.key]: defaultValue / slider.scale, - }, - }); - }} - /> - ); - })} + {detailSettingsSliders.map((slider) => renderDetailSlider(slider))}
)} diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx index e0b2ca546..84da1db22 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx @@ -76,6 +76,7 @@ function stripPreviewAssetPath(src: string, projectId: string): string | null { return assetPath; } +// fallow-ignore-next-line complexity function resolveProjectAssetPath( sourceFile: string, src: string, diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSlider.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSlider.tsx new file mode 100644 index 000000000..6c73cf664 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSlider.tsx @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Minus, Plus, RotateCcw, Settings } from "../../icons/SystemIcons"; +import { LABEL } from "./propertyPanelHelpers"; + +const SLIDER_THUMB_SIZE = 10; +const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2; + +function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} + +function formatNumericInput(value: number, scale: number): string { + const scaled = value / scale; + return scale === 100 ? scaled.toFixed(2) : String(Math.round(scaled)); +} + +function parseNumericInput(value: string, scale: number): number | null { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return parsed * scale; +} + +function tickPercent(value: number, min: number, max: number): number { + if (max <= min) return 0; + return ((value - min) / (max - min)) * 100; +} + +export function ColorGradingSliderControl({ + label, + value, + min, + max, + step, + neutral = min, + scale = 1, + suffix = "", + displayValue, + disabled, + onCommit, + onReset, + settings, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + neutral?: number; + scale?: number; + suffix?: string; + displayValue: string; + disabled?: boolean; + onCommit: (nextValue: number) => void; + onReset?: () => void; + settings?: { + active?: boolean; + label: string; + onClick: () => void; + }; +}) { + const [draftState, setDraftState] = useState<{ value: number; source: number } | null>(null); + const [inputDraft, setInputDraft] = useState<{ value: string; source: number } | null>(null); + const commitTimerRef = useRef | null>(null); + const valueRef = useRef(value); + valueRef.current = value; + + useEffect( + () => () => { + if (commitTimerRef.current) clearTimeout(commitTimerRef.current); + }, + [], + ); + + const clampDraft = useCallback( + (nextValue: number) => clampNumber(nextValue, min, max), + [max, min], + ); + + const setLocalDraft = useCallback( + (nextValue: number) => { + const clamped = clampDraft(nextValue); + const source = valueRef.current; + setDraftState({ value: clamped, source }); + setInputDraft({ value: formatNumericInput(clamped, scale), source }); + return clamped; + }, + [clampDraft, scale], + ); + + const commitDraft = useCallback( + (nextValue: number) => { + const clamped = setLocalDraft(nextValue); + if (commitTimerRef.current) clearTimeout(commitTimerRef.current); + if (clamped !== valueRef.current) onCommit(clamped); + }, + [onCommit, setLocalDraft], + ); + + const scheduleCommit = useCallback( + (nextValue: number) => { + const clamped = setLocalDraft(nextValue); + if (commitTimerRef.current) clearTimeout(commitTimerRef.current); + commitTimerRef.current = setTimeout(() => { + if (clamped !== valueRef.current) onCommit(clamped); + }, 40); + }, + [onCommit, setLocalDraft], + ); + + const draft = draftState?.source === value ? draftState.value : value; + const inputValue = + inputDraft?.source === value ? inputDraft.value : formatNumericInput(draft, scale); + + const commitInputDraft = useCallback(() => { + const parsed = parseNumericInput(inputValue, scale); + if (parsed === null) { + setInputDraft(null); + return; + } + commitDraft(parsed); + }, [commitDraft, inputValue, scale]); + + const nudge = useCallback( + (direction: -1 | 1) => { + commitDraft(draft + step * direction); + }, + [commitDraft, draft, step], + ); + + const range = max - min; + const valuePercent = range === 0 ? 0 : ((draft - min) / range) * 100; + const neutralPercent = range === 0 ? 0 : ((neutral - min) / range) * 100; + const fillLeft = Math.min(valuePercent, neutralPercent); + const fillWidth = Math.abs(valuePercent - neutralPercent); + const ticks = Array.from(new Set([min, neutral, max])).sort((a, b) => a - b); + + return ( +
+
+ {label} + {settings && ( + + )} + {onReset && ( + + )} +
+ +
+
+ {ticks.map((tick) => ( +
+ ))} +
+
+
+ scheduleCommit(Number(event.currentTarget.value))} + onMouseUp={() => commitDraft(draft)} + onTouchEnd={() => commitDraft(draft)} + onBlur={() => commitDraft(draft)} + className="hf-color-grading-range absolute left-0 right-0 top-1/2 z-30 min-w-0 w-full -translate-y-1/2" + title={displayValue} + /> +
+ +
+
+ + setInputDraft({ value: event.currentTarget.value, source: valueRef.current }) + } + onBlur={commitInputDraft} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + nudge(1); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + nudge(-1); + } + }} + className="hf-color-grading-number h-4 w-[36px] bg-transparent text-right text-[10px] font-medium tabular-nums text-panel-text-1 outline-none disabled:cursor-not-allowed" + title={displayValue} + /> + {suffix && {suffix}} +
+
+ + +
+
+
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 2d161c331..556a89ee0 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -1,120 +1,14 @@ import { parseCssColor, type ParsedColor } from "./colorValue"; import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog"; import type { DomEditSelection } from "./domEditing"; -import type { ImportedFontAsset } from "./fontAssets"; import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import { roundToCenti } from "../../utils/rounding"; -export interface PropertyPanelProps { - projectId: string; - projectDir: string | null; - assets: string[]; - element: DomEditSelection | null; - multiSelectCount?: number; - copiedAgentPrompt: boolean; - onClearSelection: () => void; - /** Dissolve the selected data-hf-group wrapper (shown only for group selections). */ - onUngroup?: () => void; - onSetStyle: (prop: string, value: string) => void | Promise; - onSetAttribute: (attr: string, value: string) => void | Promise; - onSetAttributeLive: (attr: string, value: string | null) => void | Promise; - onApplyColorGradingScope?: ( - scope: "source-file" | "project", - value: string | null, - ) => Promise<{ changedFiles: number; changedElements: number }>; - onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; - onRemoveBackground?: ( - inputPath: string, - options: { - createBackgroundPlate?: boolean; - quality?: "fast" | "balanced" | "best"; - onProgress?: (progress: BackgroundRemovalProgress) => void; - }, - ) => Promise; - onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void; - onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void; - onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void; - onSetText: (value: string, fieldKey?: string) => void; - onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; - onAddTextField: (afterFieldKey?: string) => string | Promise | null; - onRemoveTextField: (fieldKey: string) => void; - onAskAgent: () => void; - onImportAssets?: (files: FileList, dir?: string) => Promise; - fontAssets?: ImportedFontAsset[]; - onImportFonts?: (files: FileList | File[]) => Promise; - previewIframeRef?: React.RefObject; - gsapAnimations?: import("@hyperframes/parsers/gsap-parser").GsapAnimation[]; - gsapMultipleTimelines?: boolean; - gsapUnsupportedTimelinePattern?: boolean; - onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void; - onUpdateGsapMeta?: ( - animId: string, - updates: { duration?: number; ease?: string; position?: number }, - ) => void; - onDeleteGsapAnimation?: (animId: string) => void; - onAddGsapProperty?: (animId: string, prop: string) => void; - onRemoveGsapProperty?: (animId: string, prop: string) => void; - onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void; - onAddGsapFromProperty?: (animId: string, prop: string) => void; - onRemoveGsapFromProperty?: (animId: string, prop: string) => void; - onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void; - onSetArcPath?: ( - animId: string, - config: { - enabled: boolean; - autoRotate?: boolean | number; - segments?: import("@hyperframes/parsers/gsap-parser").ArcPathSegment[]; - }, - ) => void; - onUpdateArcSegment?: ( - animId: string, - segmentIndex: number, - update: Partial, - ) => void; - /** Unroll computed (helper/loop) tweens into literal tweens for direct editing. */ - onUnroll?: (animationId: string) => void; - onAddKeyframe?: ( - animationId: string, - percentage: number, - property: string, - value: number | string, - ) => void; - onRemoveKeyframe?: (animationId: string, percentage: number) => void; - onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void; - onSetAllKeyframeEases?: (animationId: string, ease: string) => void; - onConvertToKeyframes?: (animationId: string, duration?: number) => void; - onCommitAnimatedProperty?: ( - selection: DomEditSelection, - property: string, - value: number | string, - ) => Promise; - /** Batched variant: commit several props into ONE keyframe (e.g. the 3D cube's - * rotationX/Y/Z) so multi-axis edits don't race into adjacent duplicates. */ - onCommitAnimatedProperties?: ( - selection: DomEditSelection, - props: Record, - ) => Promise; - onSeekToTime?: (time: number) => void; - recordingState?: "idle" | "recording" | "preview"; - recordingDuration?: number; - onToggleRecording?: () => void; -} - -export interface BackgroundRemovalProgress { - status: "processing" | "complete" | "failed"; - progress: number; - stage?: string; - outputPath?: string; - backgroundOutputPath?: string; - error?: string; - provider?: string; -} - -export interface BackgroundRemovalResult { - outputPath: string; - backgroundOutputPath?: string; - provider?: string; -} +export type { + BackgroundRemovalProgress, + BackgroundRemovalResult, + PropertyPanelProps, +} from "./propertyPanelTypes"; /* ------------------------------------------------------------------ */ /* Font types & constants (shared by font and section modules) */ @@ -350,23 +244,24 @@ export function normalizeTextMetricValue( } function splitCssFunctions(value: string): string[] { + const source = value.trim(); const functions: string[] = []; - let current = ""; - // fallow-ignore-next-line code-duplication -- pre-existing; surfaced in this file's diff by an unrelated line shift let depth = 0; + let start = 0; - for (const char of value.trim()) { + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; if (char === "(") depth += 1; if (char === ")") depth = Math.max(0, depth - 1); if (/\s/.test(char) && depth === 0) { - if (current.trim()) functions.push(current.trim()); - current = ""; - continue; + const part = source.slice(start, index).trim(); + if (part) functions.push(part); + start = index + 1; } - current += char; } - if (current.trim()) functions.push(current.trim()); + const lastPart = source.slice(start).trim(); + if (lastPart) functions.push(lastPart); return functions; } @@ -518,11 +413,11 @@ export function extractBackgroundImageUrl(value: string | undefined): string { // ── GSAP runtime value readers (used by PropertyPanel) ──────────────────── -// fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift // Core transform channels the panel ALWAYS reads live — even before a just-set // value (e.g. rotationX) has re-parsed into `gsapAnimations`. Without this the // cube + fields drop the prop and flicker to 0 on every commit; gsap.getProperty // reflects the in-place instant patch, so it's the true current value. +// fallow-ignore-next-line complexity const ALWAYS_READ_CHANNELS = [ "x", "y", diff --git a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx index 7c62292f0..ee9410ac4 100644 --- a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx @@ -12,6 +12,7 @@ import { } from "./propertyPanelHelpers"; import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives"; +// fallow-ignore-next-line complexity export function MediaSection({ projectDir, element, diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts new file mode 100644 index 000000000..cb918cb90 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelTypes.ts @@ -0,0 +1,111 @@ +import type { RefObject } from "react"; +import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-parser"; +import type { DomEditSelection } from "./domEditing"; +import type { ImportedFontAsset } from "./fontAssets"; + +export interface BackgroundRemovalProgress { + status: "processing" | "complete" | "failed"; + progress: number; + stage?: string; + outputPath?: string; + backgroundOutputPath?: string; + error?: string; + provider?: string; +} + +export interface BackgroundRemovalResult { + outputPath: string; + backgroundOutputPath?: string; + provider?: string; +} + +export interface PropertyPanelProps { + projectId: string; + projectDir: string | null; + assets: string[]; + element: DomEditSelection | null; + multiSelectCount?: number; + copiedAgentPrompt: boolean; + onClearSelection: () => void; + onUngroup?: () => void; + onSetStyle: (prop: string, value: string) => void | Promise; + onSetAttribute: (attr: string, value: string) => void | Promise; + onSetAttributeLive: (attr: string, value: string | null) => void | Promise; + onApplyColorGradingScope?: ( + scope: "source-file" | "project", + value: string | null, + ) => Promise<{ changedFiles: number; changedElements: number }>; + onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; + onRemoveBackground?: ( + inputPath: string, + options: { + createBackgroundPlate?: boolean; + quality?: "fast" | "balanced" | "best"; + onProgress?: (progress: BackgroundRemovalProgress) => void; + }, + ) => Promise; + onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void; + onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void; + onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void; + onSetText: (value: string, fieldKey?: string) => void; + onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; + onAddTextField: (afterFieldKey?: string) => string | Promise | null; + onRemoveTextField: (fieldKey: string) => void; + onAskAgent: () => void; + onImportAssets?: (files: FileList, dir?: string) => Promise; + fontAssets?: ImportedFontAsset[]; + onImportFonts?: (files: FileList | File[]) => Promise; + previewIframeRef?: RefObject; + gsapAnimations?: GsapAnimation[]; + gsapMultipleTimelines?: boolean; + gsapUnsupportedTimelinePattern?: boolean; + onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void; + onUpdateGsapMeta?: ( + animId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => void; + onDeleteGsapAnimation?: (animId: string) => void; + onAddGsapProperty?: (animId: string, prop: string) => void; + onRemoveGsapProperty?: (animId: string, prop: string) => void; + onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void; + onAddGsapFromProperty?: (animId: string, prop: string) => void; + onRemoveGsapFromProperty?: (animId: string, prop: string) => void; + onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void; + onSetArcPath?: ( + animId: string, + config: { + enabled: boolean; + autoRotate?: boolean | number; + segments?: ArcPathSegment[]; + }, + ) => void; + onUpdateArcSegment?: ( + animId: string, + segmentIndex: number, + update: Partial, + ) => void; + onUnroll?: (animationId: string) => void; + onAddKeyframe?: ( + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => void; + onRemoveKeyframe?: (animationId: string, percentage: number) => void; + onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void; + onSetAllKeyframeEases?: (animationId: string, ease: string) => void; + onConvertToKeyframes?: (animationId: string, duration?: number) => void; + onCommitAnimatedProperty?: ( + selection: DomEditSelection, + property: string, + value: number | string, + ) => Promise; + onCommitAnimatedProperties?: ( + selection: DomEditSelection, + props: Record, + ) => Promise; + onSeekToTime?: (time: number) => void; + recordingState?: "idle" | "recording" | "preview"; + recordingDuration?: number; + onToggleRecording?: () => void; +} diff --git a/packages/studio/src/components/studioColorGradingScope.ts b/packages/studio/src/components/studioColorGradingScope.ts new file mode 100644 index 000000000..eeb03c887 --- /dev/null +++ b/packages/studio/src/components/studioColorGradingScope.ts @@ -0,0 +1,124 @@ +import type { MutableRefObject } from "react"; +import type { EditHistoryKind } from "../utils/editHistory"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch"; +import { hasRelativeLutSource } from "./studioMediaJobs"; + +export type ColorGradingScope = "source-file" | "project"; +export type ColorGradingScopeResult = { changedFiles: number; changedElements: number }; + +type ProjectFileReader = (path: string) => Promise; +type ProjectFileWriter = (path: string, content: string) => Promise; +type ShowToast = (message: string, tone?: "error" | "info") => void; +type RecordEdit = (entry: { + label: string; + kind: EditHistoryKind; + files: Record; +}) => Promise; + +export const EMPTY_COLOR_GRADING_SCOPE_RESULT: ColorGradingScopeResult = { + changedFiles: 0, + changedElements: 0, +}; + +interface ApplyColorGradingScopeOptions { + scope: ColorGradingScope; + value: string | null; + selectedSourceFile: string; + fileTree: string[]; + projectId: string; + domEditSaveTimestampRef: MutableRefObject; + waitForPendingDomEditSaves: () => Promise; + readProjectFile: ProjectFileReader; + writeProjectFile: ProjectFileWriter; + recordEdit: RecordEdit; + reloadPreview: () => void; + showToast: ShowToast; +} + +function colorGradingScopePaths( + scope: ColorGradingScope, + selectedSourceFile: string, + fileTree: string[], +): string[] { + return scope === "source-file" + ? [selectedSourceFile] + : fileTree.filter((path) => /\.html?$/i.test(path)); +} + +async function patchColorGradingScopeFiles( + paths: string[], + value: string | null, + readProjectFile: ProjectFileReader, +): Promise<{ files: Record; changedElements: number }> { + const snapshots = await Promise.all( + Array.from(new Set(paths)).map(async (path) => ({ + path, + before: await readProjectFile(path), + })), + ); + const files: Record = {}; + let changedElements = 0; + + for (const { path, before } of snapshots) { + const result = patchMediaColorGradingInHtml(before, value); + if (result.html !== before) { + files[path] = result.html; + changedElements += result.count; + } + } + + return { files, changedElements }; +} + +// fallow-ignore-next-line complexity +export async function applyColorGradingScopeUpdate({ + scope, + value, + selectedSourceFile, + fileTree, + projectId, + domEditSaveTimestampRef, + waitForPendingDomEditSaves, + readProjectFile, + writeProjectFile, + recordEdit, + reloadPreview, + showToast, +}: ApplyColorGradingScopeOptions): Promise { + await waitForPendingDomEditSaves(); + if (scope === "project" && hasRelativeLutSource(value)) { + showToast( + "Project-wide color grading cannot copy relative LUT paths. Apply to this file or use a URL/data LUT.", + "error", + ); + return EMPTY_COLOR_GRADING_SCOPE_RESULT; + } + + const { files, changedElements } = await patchColorGradingScopeFiles( + colorGradingScopePaths(scope, selectedSourceFile, fileTree), + value, + readProjectFile, + ); + if (Object.keys(files).length === 0) { + showToast("No color grading changed", "info"); + return EMPTY_COLOR_GRADING_SCOPE_RESULT; + } + + domEditSaveTimestampRef.current = Date.now(); + const changedPaths = await saveProjectFilesWithHistory({ + projectId, + label: value ? "Apply color grading" : "Clear color grading", + kind: "manual", + files, + readFile: readProjectFile, + writeFile: writeProjectFile, + recordEdit, + }); + reloadPreview(); + showToast( + `${value ? "Applied" : "Cleared"} color grading on ${changedElements} media item${changedElements === 1 ? "" : "s"}`, + "info", + ); + return { changedFiles: changedPaths.length, changedElements }; +} diff --git a/packages/studio/src/components/studioMediaJobs.ts b/packages/studio/src/components/studioMediaJobs.ts new file mode 100644 index 000000000..d6e85d88f --- /dev/null +++ b/packages/studio/src/components/studioMediaJobs.ts @@ -0,0 +1,123 @@ +import type { + BackgroundRemovalProgress, + BackgroundRemovalResult, +} from "./editor/propertyPanelTypes"; + +const MEDIA_JOB_RECONNECT_TIMEOUT_MS = 15_000; +const ABSOLUTE_OR_ROOT_SOURCE_RE = /^(?:[a-z][a-z0-9+.-]*:|\/)/i; + +function parseSerializedColorGrading(value: string): { lut?: { src?: unknown } } | null { + try { + return JSON.parse(value) as { lut?: { src?: unknown } } | null; + } catch { + return null; + } +} + +function readLutSource(value: string | null): string { + const src = value ? parseSerializedColorGrading(value)?.lut?.src : null; + return typeof src === "string" ? src.trim() : ""; +} + +export function hasRelativeLutSource(value: string | null): boolean { + const src = readLutSource(value); + return src !== "" && !ABSOLUTE_OR_ROOT_SOURCE_RE.test(src); +} + +function parseProgressEvent(event: Event): BackgroundRemovalProgress | Error { + try { + return JSON.parse((event as MessageEvent).data) as BackgroundRemovalProgress; + } catch { + return new Error("Invalid background-removal progress event"); + } +} + +function getCompleteProgressResult( + progress: BackgroundRemovalProgress, +): BackgroundRemovalResult | Error { + if (!progress.outputPath) return new Error("Background removal finished without an output path"); + return { + outputPath: progress.outputPath, + backgroundOutputPath: progress.backgroundOutputPath, + provider: progress.provider, + }; +} + +function getTerminalProgressResult( + progress: BackgroundRemovalProgress, +): BackgroundRemovalResult | Error | null { + switch (progress.status) { + case "complete": + return getCompleteProgressResult(progress); + case "failed": + return new Error(progress.error || "Background removal failed"); + default: + return null; + } +} + +export function waitForMediaJob( + jobId: string, + onProgress?: (progress: BackgroundRemovalProgress) => void, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Background removal was cancelled", "AbortError")); + return; + } + const events = new EventSource(`/api/media-jobs/${encodeURIComponent(jobId)}/progress`); + let settled = false; + let reconnectTimer: number | null = null; + + const clearReconnectTimer = () => { + if (reconnectTimer === null) return; + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + }; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearReconnectTimer(); + signal?.removeEventListener("abort", handleAbort); + events.close(); + callback(); + }; + const finishReject = (error: Error) => finish(() => reject(error)); + const finishResolve = (result: BackgroundRemovalResult) => finish(() => resolve(result)); + const handleAbort = () => { + finishReject(new DOMException("Background removal was cancelled", "AbortError")); + }; + signal?.addEventListener("abort", handleAbort, { once: true }); + + // fallow-ignore-next-line complexity + events.addEventListener("progress", (event) => { + const progress = parseProgressEvent(event); + if (progress instanceof Error) { + finishReject(progress); + return; + } + clearReconnectTimer(); + onProgress?.(progress); + const terminalResult = getTerminalProgressResult(progress); + if (!terminalResult) return; + if (terminalResult instanceof Error) { + finishReject(terminalResult); + } else { + finishResolve(terminalResult); + } + }); + events.onopen = clearReconnectTimer; + events.onerror = () => { + if (events.readyState === EventSource.CLOSED) { + finishReject(new Error("Lost connection to background-removal job")); + return; + } + if (reconnectTimer === null) { + reconnectTimer = window.setTimeout(() => { + finishReject(new Error("Lost connection to background-removal job")); + }, MEDIA_JOB_RECONNECT_TIMEOUT_MS); + } + }; + }); +} diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index e66cf1b99..657fb5dad 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -78,6 +78,24 @@ function shouldReloadForStudioFileChange( return Date.now() - domEditSaveTimestampRef.current >= 4000; } +// fallow-ignore-next-line complexity +async function clearLegacyStudioMotionFile( + readOptionalProjectFile: (path: string) => Promise, + writeProjectFile: (path: string, content: string) => Promise, +): Promise { + const content = await readOptionalProjectFile(STUDIO_MOTION_PATH).catch(() => null); + if (!content) return; + try { + const parsed = JSON.parse(content) as { motions?: unknown[] }; + if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return; + } catch { + return; + } + await writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] })).catch( + () => {}, + ); +} + // ── Hook ── export function usePreviewPersistence({ @@ -187,20 +205,7 @@ export function usePreviewPersistence({ // could still fire alongside the new seek-reapply runtime. Empty the file so // the legacy codepath no-ops. useMountEffect(() => { - _readOptionalProjectFile(STUDIO_MOTION_PATH) - .then((content) => { - if (!content) return; - try { - const parsed = JSON.parse(content) as { motions?: unknown[] }; - if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return; - } catch { - return; - } - return _writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] })); - }) - .catch(() => { - /* best-effort migration — ignore failures */ - }); + void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile); }); // ── Listen for external file changes (HMR / SSE) ── @@ -212,8 +217,10 @@ export function usePreviewPersistence({ pendingTimelineEditPathRef, domEditSaveTimestampRef, ) - ) + ) { + // fallow-ignore-next-line code-duplication reloadPreview(); + } }; if (import.meta.hot) { import.meta.hot.on("hf:file-change", handler); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index db415617c..911907f96 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -1,4 +1,4 @@ -export const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits"; +const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits"; interface StudioFlushPendingEditsDetail { promises: Array>;