fix(studio): split color grading inspector files

This commit is contained in:
ukimsanov
2026-07-06 13:56:37 -07:00
parent 139cf568b4
commit 30a944de34
13 changed files with 759 additions and 652 deletions
-3
View File
@@ -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. */}
<div
className={`flex flex-1 min-h-0${
viewModeValue.viewMode === "storyboard" ? " hidden" : ""
@@ -28,103 +28,16 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { usePlayerStore } from "../player";
import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type {
BackgroundRemovalProgress,
BackgroundRemovalResult,
} from "./editor/propertyPanelHelpers";
import { waitForMediaJob } from "./studioMediaJobs";
import {
applyColorGradingScopeUpdate,
EMPTY_COLOR_GRADING_SCOPE_RESULT,
type ColorGradingScope,
} from "./studioColorGradingScope";
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
const MIN_INSPECTOR_SPLIT_PERCENT = 20;
const MAX_INSPECTOR_SPLIT_PERCENT = 75;
const MEDIA_JOB_RECONNECT_TIMEOUT_MS = 15_000;
function hasRelativeLutSource(value: string | null): boolean {
if (!value) return false;
try {
const parsed = JSON.parse(value) as { lut?: { src?: unknown } } | null;
const src = typeof parsed?.lut?.src === "string" ? parsed.lut.src.trim() : "";
return Boolean(src && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(src));
} catch {
return false;
}
}
function waitForMediaJob(
jobId: string,
onProgress?: (progress: BackgroundRemovalProgress) => void,
signal?: AbortSignal,
): Promise<BackgroundRemovalResult> {
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<string, string> = {};
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: {
@@ -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 (
@@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="grid min-w-0 gap-0.5 rounded-md bg-panel-input/30 px-1.5 py-1">
<div className="flex min-w-0 items-center gap-1">
<span className={`${LABEL} min-w-0 flex-1 truncate`}>{label}</span>
{settings && (
<button
type="button"
disabled={disabled}
aria-label={settings.label}
onClick={(event) => {
event.stopPropagation();
settings.onClick();
}}
className={`relative flex h-5 w-5 flex-shrink-0 items-center justify-center rounded transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40 ${
settings.active ? "text-studio-accent" : "text-panel-text-5"
}`}
title={settings.label}
>
<Settings size={11} />
{settings.active && (
<span className="absolute right-0.5 top-0.5 h-1 w-1 rounded-full bg-studio-accent" />
)}
</button>
)}
{onReset && (
<button
type="button"
disabled={disabled}
aria-label={`Reset ${label}`}
onClick={(event) => {
event.stopPropagation();
onReset();
}}
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-panel-text-5 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Reset ${label}`}
>
<RotateCcw size={11} />
</button>
)}
</div>
<div className="relative h-5 min-w-0">
<div
data-color-grading-slider-track="true"
className="pointer-events-none absolute inset-y-0 z-0"
style={{ left: SLIDER_THUMB_RADIUS, right: SLIDER_THUMB_RADIUS }}
>
{ticks.map((tick) => (
<div
key={tick}
data-color-grading-slider-tick="true"
className="absolute top-1/2 h-3 w-px -translate-y-1/2 bg-panel-text-3"
style={{ left: `${tickPercent(tick, min, max)}%` }}
title={String(tick / scale)}
/>
))}
<div className="absolute left-0 right-0 top-1/2 z-10 h-0.5 -translate-y-1/2 rounded-full bg-panel-border" />
<div
className="absolute top-1/2 z-20 h-0.5 -translate-y-1/2 rounded-full bg-studio-accent"
style={{ left: `${fillLeft}%`, width: `${fillWidth}%` }}
/>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={draft}
disabled={disabled}
aria-label={label}
onChange={(event) => 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}
/>
</div>
<div className="flex min-w-0 items-center justify-end gap-1">
<div className="flex flex-shrink-0 items-center rounded-md bg-panel-input px-1.5 py-px">
<input
type="number"
value={inputValue}
min={min / scale}
max={max / scale}
step={step / scale}
disabled={disabled}
onChange={(event) =>
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 && <span className="ml-0.5 text-[10px] text-panel-text-5">{suffix}</span>}
</div>
<div className="flex flex-shrink-0 overflow-hidden rounded-md bg-panel-input">
<button
type="button"
disabled={disabled}
aria-label={`Decrease ${label}`}
onClick={() => nudge(-1)}
className="flex h-5 w-5 items-center justify-center text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Decrease ${label}`}
>
<Minus size={11} />
</button>
<button
type="button"
disabled={disabled}
aria-label={`Increase ${label}`}
onClick={() => nudge(1)}
className="flex h-5 w-5 items-center justify-center border-l border-panel-border text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Increase ${label}`}
>
<Plus size={11} />
</button>
</div>
</div>
</div>
);
}
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 (
<ColorGradingSliderControl
key={slider.key}
label={slider.label}
value={value}
min={slider.min}
max={slider.max}
step={slider.step}
neutral={slider.defaultValue ?? 0}
suffix={slider.suffix}
displayValue={`${value}%`}
settings={settings}
onCommit={(next) => commitDetailSlider(slider, next)}
onReset={() => resetDetailSlider(slider)}
/>
);
};
return (
<div className="space-y-3">
@@ -669,51 +437,16 @@ export function ColorGradingControls({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Finishing</span>
<div className="grid min-w-0 grid-cols-2 gap-1.5">
{AMOUNT_DETAIL_SLIDERS.map((slider) => {
const value = grading.details[slider.key] * slider.scale;
const defaultValue = slider.defaultValue ?? 0;
return (
<ColorGradingSliderControl
key={slider.key}
label={slider.label}
value={Math.round(value)}
min={slider.min}
max={slider.max}
step={slider.step}
neutral={defaultValue}
suffix={slider.suffix}
displayValue={`${Math.round(value)}%`}
settings={{
active: slider.key === "vignette" ? vignetteSettingsActive : grainSettingsActive,
label: `${slider.label} settings`,
onClick: () =>
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"),
),
}),
)}
</div>
{detailSettings && (
<div className="grid min-w-0 gap-1.5 rounded-md border border-panel-border bg-panel-input/40 p-1.5 shadow-xl shadow-black/20">
@@ -732,43 +465,7 @@ export function ColorGradingControls({
</button>
</div>
<div className="grid min-w-0 grid-cols-2 gap-1.5">
{detailSettingsSliders.map((slider) => {
const value = grading.details[slider.key] * slider.scale;
const defaultValue = slider.defaultValue ?? 0;
return (
<ColorGradingSliderControl
key={slider.key}
label={slider.label}
value={Math.round(value)}
min={slider.min}
max={slider.max}
step={slider.step}
neutral={defaultValue}
suffix={slider.suffix}
displayValue={`${Math.round(value)}%`}
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,
},
});
}}
/>
);
})}
{detailSettingsSliders.map((slider) => renderDetailSlider(slider))}
</div>
</div>
)}
@@ -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,
@@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="grid min-w-0 gap-0.5 rounded-md bg-panel-input/30 px-1.5 py-1">
<div className="flex min-w-0 items-center gap-1">
<span className={`${LABEL} min-w-0 flex-1 truncate`}>{label}</span>
{settings && (
<button
type="button"
disabled={disabled}
aria-label={settings.label}
onClick={(event) => {
event.stopPropagation();
settings.onClick();
}}
className={`relative flex h-5 w-5 flex-shrink-0 items-center justify-center rounded transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40 ${
settings.active ? "text-studio-accent" : "text-panel-text-5"
}`}
title={settings.label}
>
<Settings size={11} />
{settings.active && (
<span className="absolute right-0.5 top-0.5 h-1 w-1 rounded-full bg-studio-accent" />
)}
</button>
)}
{onReset && (
<button
type="button"
disabled={disabled}
aria-label={`Reset ${label}`}
onClick={(event) => {
event.stopPropagation();
onReset();
}}
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-panel-text-5 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Reset ${label}`}
>
<RotateCcw size={11} />
</button>
)}
</div>
<div className="relative h-5 min-w-0">
<div
data-color-grading-slider-track="true"
className="pointer-events-none absolute inset-y-0 z-0"
style={{ left: SLIDER_THUMB_RADIUS, right: SLIDER_THUMB_RADIUS }}
>
{ticks.map((tick) => (
<div
key={tick}
data-color-grading-slider-tick="true"
className="absolute top-1/2 h-3 w-px -translate-y-1/2 bg-panel-text-3"
style={{ left: `${tickPercent(tick, min, max)}%` }}
title={String(tick / scale)}
/>
))}
<div className="absolute left-0 right-0 top-1/2 z-10 h-0.5 -translate-y-1/2 rounded-full bg-panel-border" />
<div
className="absolute top-1/2 z-20 h-0.5 -translate-y-1/2 rounded-full bg-studio-accent"
style={{ left: `${fillLeft}%`, width: `${fillWidth}%` }}
/>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={draft}
disabled={disabled}
aria-label={label}
onChange={(event) => 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}
/>
</div>
<div className="flex min-w-0 items-center justify-end gap-1">
<div className="flex flex-shrink-0 items-center rounded-md bg-panel-input px-1.5 py-px">
<input
type="number"
value={inputValue}
min={min / scale}
max={max / scale}
step={step / scale}
disabled={disabled}
onChange={(event) =>
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 && <span className="ml-0.5 text-[10px] text-panel-text-5">{suffix}</span>}
</div>
<div className="flex flex-shrink-0 overflow-hidden rounded-md bg-panel-input">
<button
type="button"
disabled={disabled}
aria-label={`Decrease ${label}`}
onClick={() => nudge(-1)}
className="flex h-5 w-5 items-center justify-center text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Decrease ${label}`}
>
<Minus size={11} />
</button>
<button
type="button"
disabled={disabled}
aria-label={`Increase ${label}`}
onClick={() => nudge(1)}
className="flex h-5 w-5 items-center justify-center border-l border-panel-border text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Increase ${label}`}
>
<Plus size={11} />
</button>
</div>
</div>
</div>
);
}
@@ -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<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
onApplyColorGradingScope?: (
scope: "source-file" | "project",
value: string | null,
) => Promise<{ changedFiles: number; changedElements: number }>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onRemoveBackground?: (
inputPath: string,
options: {
createBackgroundPlate?: boolean;
quality?: "fast" | "balanced" | "best";
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => Promise<BackgroundRemovalResult>;
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<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
onAskAgent: () => void;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
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<import("@hyperframes/parsers/gsap-parser").ArcPathSegment>,
) => 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<void>;
/** 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<string, number | string>,
) => Promise<void>;
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",
@@ -12,6 +12,7 @@ import {
} from "./propertyPanelHelpers";
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
// fallow-ignore-next-line complexity
export function MediaSection({
projectDir,
element,
@@ -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<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
onApplyColorGradingScope?: (
scope: "source-file" | "project",
value: string | null,
) => Promise<{ changedFiles: number; changedElements: number }>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onRemoveBackground?: (
inputPath: string,
options: {
createBackgroundPlate?: boolean;
quality?: "fast" | "balanced" | "best";
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => Promise<BackgroundRemovalResult>;
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<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
onAskAgent: () => void;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
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<ArcPathSegment>,
) => 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<void>;
onCommitAnimatedProperties?: (
selection: DomEditSelection,
props: Record<string, number | string>,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
recordingState?: "idle" | "recording" | "preview";
recordingDuration?: number;
onToggleRecording?: () => void;
}
@@ -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<string>;
type ProjectFileWriter = (path: string, content: string) => Promise<void>;
type ShowToast = (message: string, tone?: "error" | "info") => void;
type RecordEdit = (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
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<number>;
waitForPendingDomEditSaves: () => Promise<void>;
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<string, string>; changedElements: number }> {
const snapshots = await Promise.all(
Array.from(new Set(paths)).map(async (path) => ({
path,
before: await readProjectFile(path),
})),
);
const files: Record<string, string> = {};
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<ColorGradingScopeResult> {
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 };
}
@@ -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<BackgroundRemovalResult> {
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);
}
};
});
}
@@ -78,6 +78,24 @@ function shouldReloadForStudioFileChange(
return Date.now() - domEditSaveTimestampRef.current >= 4000;
}
// fallow-ignore-next-line complexity
async function clearLegacyStudioMotionFile(
readOptionalProjectFile: (path: string) => Promise<string>,
writeProjectFile: (path: string, content: string) => Promise<void>,
): Promise<void> {
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);
@@ -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<Promise<unknown>>;