@@ -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>;