diff --git a/docs/sdk/guides/editing-affordances.mdx b/docs/sdk/guides/editing-affordances.mdx index f99496f34..b18c92694 100644 --- a/docs/sdk/guides/editing-affordances.mdx +++ b/docs/sdk/guides/editing-affordances.mdx @@ -86,7 +86,7 @@ interface EditingSectionApplicability { } ``` -`sections.colorGrading` is element-level only: it tells you this particular element supports color grading controls. Whether to show the color grading panel at all is still governed by your own feature flag — AND the two conditions together. +`sections.colorGrading` is element-level only: it tells you this particular element supports color grading controls. Studio shows the color grading panel for supported media elements. ## End-to-end example diff --git a/packages/core/src/editing/affordances.test.ts b/packages/core/src/editing/affordances.test.ts index 3c6ede3a9..1b2007084 100644 --- a/packages/core/src/editing/affordances.test.ts +++ b/packages/core/src/editing/affordances.test.ts @@ -127,9 +127,9 @@ describe("resolveEditingAffordances — sections", () => { expect(s).toMatchObject({ media: true, colorGrading: false }); }); - it("img: colorGrading but not media", () => { + it("img: media + colorGrading", () => { const s = resolveEditingAffordances(baseFacts({ tag: "img" })).sections; - expect(s).toMatchObject({ media: false, colorGrading: true }); + expect(s).toMatchObject({ media: true, colorGrading: true }); }); it("editable text on a plain element: text section", () => { diff --git a/packages/core/src/editing/affordances.ts b/packages/core/src/editing/affordances.ts index c294933d1..ecf77c37b 100644 --- a/packages/core/src/editing/affordances.ts +++ b/packages/core/src/editing/affordances.ts @@ -182,7 +182,7 @@ function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities { export function resolveEditingSections(facts: EditableElementFacts): EditingSectionApplicability { return { text: facts.hasEditableText && !facts.isCompositionHost && !facts.isInsideLockedComposition, - media: facts.tag === "video" || facts.tag === "audio", + media: facts.tag === "video" || facts.tag === "audio" || facts.tag === "img", colorGrading: facts.tag === "video" || facts.tag === "img", timing: facts.hasTimingStart || facts.animationCount > 0, animation: facts.animationCount > 0, diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 83a52c723..d894c5526 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -487,7 +487,12 @@ export function StudioApp() { refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime} inspectorButtonActive={inspectorButtonActive} inspectorPanelActive={inspectorPanelActive} - onExport={() => void renderQueue.startRender(undefined)} + onExport={() => { + void (async () => { + await previewPersistence.waitForPendingDomEditSaves(); + await renderQueue.startRender(undefined); + })(); + }} /> {previewPersistence.domEditSaveQueuePaused && ( 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; @@ -82,6 +177,7 @@ export function StudioRightPanel({ previewIframeRef, projectId, activeCompPath, + showToast, compositionDimensions, waitForPendingDomEditSaves, renderQueue, @@ -136,8 +232,10 @@ export function StudioRightPanel({ projectDir, handleImportFiles, handleImportFonts, + refreshFileTree, readProjectFile, writeProjectFile, + fileTree, } = useFileManagerContext(); // Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately, @@ -172,6 +270,14 @@ export function StudioRightPanel({ startPercent: number; height: number; } | null>(null); + const backgroundRemovalAbortRef = useRef(null); + + useEffect( + () => () => { + backgroundRemovalAbortRef.current?.abort(); + }, + [], + ); const renderJobs = renderQueue.jobs as RenderJob[]; const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers"; @@ -233,6 +339,128 @@ export function StudioRightPanel({ splitDragRef.current = null; }, []); + 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) { + showToast( + `Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + return { changedFiles: 0, changedElements: 0 }; + } + }, + [ + activeCompPath, + domEditSaveTimestampRef, + domEditSelection?.sourceFile, + fileTree, + projectId, + readProjectFile, + recordEdit, + reloadPreview, + showToast, + waitForPendingDomEditSaves, + writeProjectFile, + ], + ); + + const handleRemoveBackground = useCallback( + async ( + inputPath: string, + options: { + createBackgroundPlate?: boolean; + quality?: "fast" | "balanced" | "best"; + onProgress?: (progress: BackgroundRemovalProgress) => void; + }, + ) => { + const response = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + inputPath, + createBackgroundPlate: options.createBackgroundPlate === true, + quality: options.quality ?? "balanced", + }), + }, + ); + const data = (await response.json().catch(() => ({}))) as { + jobId?: string; + error?: string; + }; + if (!response.ok || !data.jobId) { + throw new Error(data.error || `Background removal failed (${response.status})`); + } + showToast("Removing background...", "info"); + backgroundRemovalAbortRef.current?.abort(); + const controller = new AbortController(); + backgroundRemovalAbortRef.current = controller; + try { + const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal); + await refreshFileTree(); + showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info"); + return result; + } finally { + if (backgroundRemovalAbortRef.current === controller) { + backgroundRemovalAbortRef.current = null; + } + } + }, + [projectId, refreshFileTree, showToast], + ); + const propertyPanel = (
{captionEditMode ? ( ) : ( <> -
+
{STUDIO_INSPECTOR_PANELS_ENABLED && ( <> @@ -390,7 +620,7 @@ export function StudioRightPanel({
-
+
{rightPanelTab === "block-params" && activeBlockParams ? ( ) : layersPaneOpen && designPaneOpen ? ( -
+
- {message} + + {message} + {onDismiss && ( + )} {onReset && (
-
+
-
-
+
+
{suffix && {suffix}} @@ -263,7 +405,7 @@ function ColorGradingSliderControl({ disabled={disabled} aria-label={`Decrease ${label}`} onClick={() => nudge(-1)} - className="flex h-7 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" + 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}`} > @@ -273,7 +415,7 @@ function ColorGradingSliderControl({ disabled={disabled} aria-label={`Increase ${label}`} onClick={() => nudge(1)} - className="flex h-7 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" + 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}`} > @@ -284,6 +426,15 @@ function ColorGradingSliderControl({ ); } +function normalizedDefaultValue(slider: { defaultValue?: number; scale: number }): number { + return (slider.defaultValue ?? 0) / slider.scale; +} + +function visibleIntensity(grading: NormalizedHfColorGrading): number { + // Earlier drafts could persist 0% strength; the next manual edit should revive visible grading. + return grading.intensity === 0 ? 1 : grading.intensity; +} + export function ColorGradingControls({ grading, assets, @@ -296,21 +447,37 @@ export function ColorGradingControls({ onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void; }) { const lutInputRef = useRef(null); + const [lutOpen, setLutOpen] = useState(false); + const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null); const lutAssets = useMemo( () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)), [assets], ); const selectedLut = grading.lut?.src ?? ""; const selectedProjectLut = selectedLut ? (selectedLut.split("/").pop() ?? selectedLut) : null; + const detailSettingsSliders = + detailSettings === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS; + const vignetteSettingsActive = VIGNETTE_TUNE_SLIDERS.some( + (slider) => Math.abs(grading.details[slider.key] - normalizedDefaultValue(slider)) > 0.0001, + ); + const grainSettingsActive = GRAIN_TUNE_SLIDERS.some( + (slider) => Math.abs(grading.details[slider.key] - normalizedDefaultValue(slider)) > 0.0001, + ); const applyPreset = (preset: string) => { - const next = normalizeHfColorGrading({ preset, intensity: 1 }); + const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut }); if (next) onCommitColorGrading(next); }; + const updateFilterIntensity = (value: number) => { + onCommitColorGrading({ + ...grading, + intensity: value / 100, + }); + }; const applyLut = (src: string | null, intensity = 1) => { onCommitColorGrading({ ...grading, - intensity: 1, + intensity: visibleIntensity(grading), lut: src ? { src, intensity } : null, }); }; @@ -326,7 +493,7 @@ export function ColorGradingControls({ }; return ( -
+
- {grading.lut && ( -
- {selectedProjectLut && ( -
- - - Uploaded LUT - {` · ${selectedProjectLut}`} - +
+ + {lutOpen && ( +
+
+ + + { + void importLuts(event.currentTarget.files); + event.currentTarget.value = ""; + }} + /> +
+ {grading.lut && ( +
+ {selectedProjectLut && ( +
+ + + Uploaded LUT + {` · ${selectedProjectLut}`} + +
+ )} + updateLutIntensity(100)} + />
)} - updateLutIntensity(100)} - />
)}
-
- {SLIDERS.map((slider, index) => { - const value = grading.adjust[slider.key] * slider.scale; - const isExposure = slider.key === "exposure"; - return ( -
+
+ Adjust +
+ {ADJUST_SLIDERS.map((slider) => { + const value = grading.adjust[slider.key] * slider.scale; + const isExposure = slider.key === "exposure"; + return ( { onCommitColorGrading({ ...grading, - intensity: 1, + intensity: visibleIntensity(grading), adjust: { ...grading.adjust, [slider.key]: next / slider.scale, @@ -458,7 +653,7 @@ export function ColorGradingControls({ onReset={() => { onCommitColorGrading({ ...grading, - intensity: 1, + intensity: visibleIntensity(grading), adjust: { ...grading.adjust, [slider.key]: 0, @@ -466,9 +661,159 @@ 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, + }, + }); + }} + /> + ); + })} +
+ {detailSettings && ( +
+
+ + {detailSettings === "vignette" ? "Vignette settings" : "Grain settings"} + +
- ); - })} +
+ {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, + }, + }); + }} + /> + ); + })} +
+
+ )} +
+ +
+ Effects +
+ {EFFECT_SLIDERS.map((slider) => { + const value = grading.effects[slider.key] * slider.scale; + return ( + { + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + effects: { + ...grading.effects, + [slider.key]: next / slider.scale, + }, + }); + }} + onReset={() => { + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + effects: { + ...grading.effects, + [slider.key]: 0, + }, + }); + }} + /> + ); + })} +
); diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx index d9730d86d..e0b2ca546 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx @@ -16,17 +16,101 @@ import { type NormalizedHfColorGrading, } from "@hyperframes/core/color-grading"; import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons"; +import { + addStudioPendingEditFlushListener, + trackStudioPendingEdit, +} from "../../utils/studioPendingEdits"; import type { DomEditSelection } from "./domEditing"; import { ColorGradingControls } from "./propertyPanelColorGradingControls"; import { Section } from "./propertyPanelPrimitives"; const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, ""); +const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const; +const MEDIA_METADATA_CACHE = new Map(); interface RuntimeColorGradingStatus { state: "missing" | "inactive" | "pending" | "active" | "unavailable"; message: string; } +interface MediaMetadata { + kind: "video" | "image" | "audio" | "unknown"; + color: { + dynamicRange: "hdr" | "sdr" | "unknown"; + hdrTransfer: "pq" | "hlg" | "unknown" | null; + label: string; + isHdr: boolean; + codecName?: string; + profile?: string; + pixelFormat?: string; + colorSpace?: string; + colorTransfer?: string; + colorPrimaries?: string; + }; + probeError?: string; +} + +interface MediaMetadataResponse { + path: string; + metadata: MediaMetadata; +} + +function stripQueryAndHash(value: string): string { + return value.replace(/[?#].*$/, ""); +} + +function stripPreviewAssetPath(src: string, projectId: string): string | null { + let pathname = src; + try { + pathname = new URL(src, window.location.href).pathname; + } catch { + return null; + } + const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`; + const genericMarker = "/preview/"; + const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker; + const index = pathname.indexOf(marker); + if (index < 0) return null; + const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, ""); + if (!assetPath || assetPath.startsWith("comp/")) return null; + return assetPath; +} + +function resolveProjectAssetPath( + sourceFile: string, + src: string, + projectId: string, +): string | null { + const trimmed = stripQueryAndHash(src.trim()); + if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null; + if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId); + if (trimmed.startsWith("/")) { + return stripPreviewAssetPath(trimmed, projectId); + } + + const sourceDir = sourceFile.includes("/") + ? sourceFile.slice(0, sourceFile.lastIndexOf("/")) + : ""; + const parts = `${sourceDir}/${trimmed}`.split("/"); + const normalized: string[] = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") { + normalized.pop(); + continue; + } + normalized.push(part); + } + return normalized.join("/") || null; +} + +function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null { + if (element.tagName !== "video" && element.tagName !== "img") return null; + const media = element.element as HTMLImageElement | HTMLVideoElement; + const src = media.getAttribute("src") || media.currentSrc || ""; + return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId); +} + function defaultColorGrading(): NormalizedHfColorGrading { const grading = normalizeHfColorGrading("neutral"); if (!grading) throw new Error("Missing neutral color grading preset"); @@ -34,10 +118,9 @@ function defaultColorGrading(): NormalizedHfColorGrading { } function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading { - const grading = - normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? - defaultColorGrading(); - return { ...grading, intensity: 1 }; + return ( + normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading() + ); } function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown { @@ -80,13 +163,45 @@ function StatusPill({ status }: { status: RuntimeColorGradingStatus }) { ? "bg-red-400" : "bg-panel-text-5"; return ( -
+
{status.message}
); } +function HdrMediaWarning({ metadata }: { metadata: MediaMetadata | null }) { + if (metadata?.color.dynamicRange !== "hdr") return null; + const details = [ + metadata.color.codecName, + metadata.color.profile, + metadata.color.pixelFormat, + metadata.color.colorPrimaries, + metadata.color.colorTransfer, + ] + .filter(Boolean) + .join(" · "); + + return ( +
+
+ {metadata.color.label} source + + SDR preview + +
+

+ These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this + is not true HDR color grading yet. +

+ {details &&

{details}

} +
+ ); +} + function HoldBeforeButton({ active, disabled, @@ -153,29 +268,46 @@ function HoldBeforeButton({ } export function ColorGradingSection({ + projectId, element, assets, previewIframeRef, onImportAssets, onSetAttributeLive, + onApplyScope, }: { + projectId: string; element: DomEditSelection; assets: string[]; previewIframeRef?: RefObject; onImportAssets?: (files: FileList, dir?: string) => Promise; onSetAttributeLive: (attr: string, value: string | null) => void | Promise; + onApplyScope?: ( + scope: "source-file" | "project", + value: string | null, + ) => Promise<{ changedFiles: number; changedElements: number }>; }) { const [grading, setGrading] = useState(() => readColorGradingFromElement(element)); const [compareEnabled, setCompareEnabled] = useState(false); + const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file"); + const [applyBusy, setApplyBusy] = useState(false); const [runtimeStatus, setRuntimeStatus] = useState(() => ({ state: "pending", message: "Waiting for runtime", })); + const selectedAssetPath = useMemo( + () => selectedMediaAssetPath(element, projectId), + [element, projectId], + ); + const [mediaMetadata, setMediaMetadata] = useState(null); const persistTimerRef = useRef | null>(null); const pendingPersistValueRef = useRef(undefined); + const statusTimersRef = useRef([]); const onSetAttributeLiveRef = useRef(onSetAttributeLive); + const latestGradingRef = useRef(grading); const compareEnabledRef = useRef(compareEnabled); onSetAttributeLiveRef.current = onSetAttributeLive; + latestGradingRef.current = grading; compareEnabledRef.current = compareEnabled; const target = useMemo( (): HfColorGradingTarget => ({ @@ -191,33 +323,75 @@ export function ColorGradingSection({ setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target)); }, [previewIframeRef, target]); + useEffect(() => { + setMediaMetadata(null); + if (!selectedAssetPath) return; + const cacheKey = `${projectId}:${selectedAssetPath}`; + if (MEDIA_METADATA_CACHE.has(cacheKey)) { + setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null); + return; + } + const controller = new AbortController(); + fetch( + `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent( + selectedAssetPath, + )}`, + { signal: controller.signal }, + ) + .then((response) => (response.ok ? response.json() : null)) + .then((data: MediaMetadataResponse | null) => { + if (controller.signal.aborted) return; + const metadata = data?.metadata ?? null; + MEDIA_METADATA_CACHE.set(cacheKey, metadata); + setMediaMetadata(metadata); + }) + .catch(() => { + if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null); + }); + return () => controller.abort(); + }, [projectId, selectedAssetPath]); + + const clearStatusTimers = useCallback(() => { + for (const timer of statusTimersRef.current) clearTimeout(timer); + statusTimersRef.current = []; + }, []); + + const scheduleRuntimeStatusRefresh = useCallback(() => { + clearStatusTimers(); + statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) => + window.setTimeout(refreshRuntimeStatus, delay), + ); + }, [clearStatusTimers, refreshRuntimeStatus]); + useEffect(() => { refreshRuntimeStatus(); }, [refreshRuntimeStatus]); - useEffect(() => { - const iframe = previewIframeRef?.current; - if (!iframe) return; - const refresh = () => { - window.setTimeout(refreshRuntimeStatus, 50); - }; - iframe.addEventListener("load", refresh); - const timer = window.setTimeout(refreshRuntimeStatus, 80); - return () => { - iframe.removeEventListener("load", refresh); - window.clearTimeout(timer); - }; - }, [previewIframeRef, refreshRuntimeStatus]); + const persistColorGradingValue = useCallback((value: string | null) => { + return trackStudioPendingEdit( + onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null), + ); + }, []); + + const flushPendingPersist = useCallback(() => { + if (persistTimerRef.current) { + clearTimeout(persistTimerRef.current); + persistTimerRef.current = null; + } + if (pendingPersistValueRef.current === undefined) return undefined; + const value = pendingPersistValueRef.current; + pendingPersistValueRef.current = undefined; + return persistColorGradingValue(value); + }, [persistColorGradingValue]); + + useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]); useEffect(() => { return () => { - if (persistTimerRef.current) clearTimeout(persistTimerRef.current); - if (pendingPersistValueRef.current !== undefined) { - void onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, pendingPersistValueRef.current); - pendingPersistValueRef.current = undefined; - } + clearStatusTimers(); + void flushPendingPersist(); }; - }, []); + }, [clearStatusTimers, flushPendingPersist]); const postColorGrading = useCallback( (nextGrading: NormalizedHfColorGrading) => { @@ -255,6 +429,31 @@ export function ColorGradingSection({ [previewIframeRef, target], ); + useEffect(() => { + const iframe = previewIframeRef?.current; + if (!iframe) return; + const refreshAndReplay = () => { + const nextGrading = latestGradingRef.current; + const active = isHfColorGradingActive(nextGrading); + if (active) postColorGrading(nextGrading); + postCompare(compareEnabledRef.current && active); + scheduleRuntimeStatusRefresh(); + }; + const onMessage = (event: MessageEvent) => { + if (event.source !== iframe.contentWindow) return; + const data = event.data as { source?: unknown; type?: unknown } | null; + if (data?.source === "hf-preview" && data.type === "ready") refreshAndReplay(); + }; + iframe.addEventListener("load", refreshAndReplay); + window.addEventListener("message", onMessage); + const timer = window.setTimeout(refreshAndReplay, 80); + return () => { + iframe.removeEventListener("load", refreshAndReplay); + window.removeEventListener("message", onMessage); + window.clearTimeout(timer); + }; + }, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]); + useEffect( () => () => { postCompare(false); @@ -272,7 +471,7 @@ export function ColorGradingSection({ postCompare(active); if (!active) setCompareEnabled(false); } - window.setTimeout(refreshRuntimeStatus, 50); + scheduleRuntimeStatusRefresh(); if (persistTimerRef.current) clearTimeout(persistTimerRef.current); pendingPersistValueRef.current = isHfColorGradingActive(nextGrading) ? serializeHfColorGrading(nextGrading) @@ -280,10 +479,11 @@ export function ColorGradingSection({ persistTimerRef.current = setTimeout(() => { const value = pendingPersistValueRef.current; pendingPersistValueRef.current = undefined; - void onSetAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null); + persistTimerRef.current = null; + void persistColorGradingValue(value ?? null); }, 350); }, - [onSetAttributeLive, postColorGrading, postCompare, refreshRuntimeStatus], + [persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], ); const commitCompare = useCallback( @@ -292,14 +492,25 @@ export function ColorGradingSection({ setCompareEnabled(nextEnabled); if (nextEnabled) postColorGrading(grading); postCompare(nextEnabled); - window.setTimeout(refreshRuntimeStatus, 50); + scheduleRuntimeStatusRefresh(); }, - [grading, postColorGrading, postCompare, refreshRuntimeStatus], + [grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], ); + const applyToScope = useCallback(async () => { + if (!onApplyScope || applyBusy) return; + setApplyBusy(true); + try { + const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null; + await onApplyScope(applyScope, value); + } finally { + setApplyBusy(false); + } + }, [applyBusy, applyScope, grading, onApplyScope]); + return (
} accessory={
@@ -316,19 +527,46 @@ export function ColorGradingSection({ commitColorGrading(defaultColorGrading()); }} className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1" - title="Reset grading" + title="Reset color grading" >
} > + + {onApplyScope && ( +
+ + +
+ )}
); } diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 20d90e0fc..2d161c331 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -18,7 +18,19 @@ export interface PropertyPanelProps { 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; @@ -88,6 +100,22 @@ export interface PropertyPanelProps { 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; +} + /* ------------------------------------------------------------------ */ /* Font types & constants (shared by font and section modules) */ /* ------------------------------------------------------------------ */ diff --git a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx index 899170fd1..7c62292f0 100644 --- a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx @@ -1,7 +1,9 @@ -import { useState } from "react"; -import { Check, ClipboardList, Film, Music } from "../../icons/SystemIcons"; +import { useEffect, useState } from "react"; +import { Check, ClipboardList, Film, Music, Scissors } from "../../icons/SystemIcons"; import type { DomEditSelection } from "./domEditing"; import { + type BackgroundRemovalProgress, + type BackgroundRemovalResult, formatNumericValue, formatTimingValue, LABEL, @@ -17,6 +19,7 @@ export function MediaSection({ onSetStyle, onSetAttribute, onSetHtmlAttribute, + onRemoveBackground, }: { projectDir: string | null; element: DomEditSelection; @@ -24,8 +27,19 @@ export function MediaSection({ onSetStyle: (prop: string, value: string) => void | Promise; onSetAttribute: (attr: string, value: string) => void | Promise; onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; + onRemoveBackground?: ( + inputPath: string, + options: { + createBackgroundPlate?: boolean; + quality?: "fast" | "balanced" | "best"; + onProgress?: (progress: BackgroundRemovalProgress) => void; + }, + ) => Promise; }) { const isVideo = element.tagName === "video"; + const isAudio = element.tagName === "audio"; + const isImage = element.tagName === "img"; + const isVisualMedia = isVideo || isImage; const el = element.element; const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1; @@ -53,15 +67,64 @@ export function MediaSection({ const srcAttr = el.getAttribute("src") ?? ""; const [copied, setCopied] = useState(false); + const [removeBusy, setRemoveBusy] = useState(false); + const [removeProgress, setRemoveProgress] = useState(null); + const [createPlate, setCreatePlate] = useState(false); + const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced"); const absoluteSrc = projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr; + const projectSrc = + srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) + ? srcAttr.replace(/^\.\//, "").replace(/[?#].*$/, "") + : ""; + const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc); + const panelTitle = isImage ? "Image" : isVideo ? "Video" : "Audio"; + + useEffect(() => { + setRemoveProgress(null); + setCreatePlate(false); + }, [srcAttr]); + + const applyCutoutResult = async (result: BackgroundRemovalResult) => { + await onSetHtmlAttribute("src", result.outputPath); + if (isVideo) { + await onSetAttribute("has-audio", ""); + await onSetHtmlAttribute("muted", "true"); + } + }; + + const runBackgroundRemoval = async () => { + if (!onRemoveBackground || !projectSrc || removeBusy) return; + setRemoveBusy(true); + setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" }); + try { + const result = await onRemoveBackground(projectSrc, { + createBackgroundPlate: isVideo && createPlate, + quality, + onProgress: setRemoveProgress, + }); + await applyCutoutResult(result); + setRemoveProgress({ + status: "complete", + progress: 100, + stage: "Applied cutout", + ...result, + }); + } catch (error) { + setRemoveProgress({ + status: "failed", + progress: 0, + stage: "Failed", + error: error instanceof Error ? error.message : String(error), + }); + } finally { + setRemoveBusy(false); + } + }; return ( -
: } - > +
: }>
{srcAttr && (
@@ -90,103 +153,192 @@ export function MediaSection({
)} -
- Volume - `${Math.round(next)}%`} - onCommit={(next) => { - void onSetAttribute("volume", formatNumericValue(next / 100)); - }} - /> -
- -
- Playback rate - `${formatNumericValue(next / 100)}x`} - onCommit={(next) => { - void onSetAttribute("playback-rate", formatNumericValue(next / 100)); - }} - /> -
- -
- Media start - formatTimingValue(next / 100)} - onCommit={(next) => { - void onSetAttribute("media-start", (next / 100).toFixed(2)); - }} - /> -
- -
-
- Loop - { - void onSetHtmlAttribute("loop", next === "on" ? "true" : null); - }} - options={[ - { label: "On", value: "on" }, - { label: "Off", value: "off" }, - ]} - /> -
-
- Muted - { - void onSetHtmlAttribute("muted", next === "on" ? "true" : null); - }} - options={[ - { label: "On", value: "on" }, - { label: "Off", value: "off" }, - ]} - /> -
-
- - {isVideo && ( -
- Has audio track - { - if (next === "yes") { - void onSetAttribute("has-audio", "true"); - void onSetHtmlAttribute("muted", null); - } else { - void onSetAttribute("has-audio", ""); - void onSetHtmlAttribute("muted", "true"); + {isVisualMedia && ( +
+
+
+
Cutout
+
+ Create transparent {isVideo ? "WebM video" : "PNG image"} +
+
+ +
+ +
+ setQuality(next as typeof quality)} + options={["fast", "balanced", "best"]} + /> + {isVideo ? ( +
+ BG plate + setCreatePlate(next === "on")} + options={[ + { label: "On", value: "on" }, + { label: "Off", value: "off" }, + ]} + /> + + Optional hole-cut background copy. + +
+ ) : ( +
+ )} +
+ + {removeProgress && ( +
+
+ + {removeProgress.error ?? removeProgress.stage ?? "Processing"} + + {Math.round(removeProgress.progress)}% +
+
+
+
+
+ )} + + {removeProgress?.status === "complete" && removeProgress.outputPath && ( +
+ Applied {removeProgress.outputPath} +
+ )}
)} - {isVideo && ( + {(isVideo || isAudio) && ( + <> +
+ Volume + `${Math.round(next)}%`} + onCommit={(next) => { + void onSetAttribute("volume", formatNumericValue(next / 100)); + }} + /> +
+ +
+ Playback rate + `${formatNumericValue(next / 100)}x`} + onCommit={(next) => { + void onSetAttribute("playback-rate", formatNumericValue(next / 100)); + }} + /> +
+ +
+ Media start + formatTimingValue(next / 100)} + onCommit={(next) => { + void onSetAttribute("media-start", (next / 100).toFixed(2)); + }} + /> +
+ +
+
+ Loop + { + void onSetHtmlAttribute("loop", next === "on" ? "true" : null); + }} + options={[ + { label: "On", value: "on" }, + { label: "Off", value: "off" }, + ]} + /> +
+
+ Muted + { + void onSetHtmlAttribute("muted", next === "on" ? "true" : null); + }} + options={[ + { label: "On", value: "on" }, + { label: "Off", value: "off" }, + ]} + /> +
+
+ + {isVideo && ( +
+ Has audio track + { + if (next === "yes") { + void onSetAttribute("has-audio", "true"); + void onSetHtmlAttribute("muted", null); + } else { + void onSetAttribute("has-audio", ""); + void onSetHtmlAttribute("muted", "true"); + } + }} + options={[ + { label: "Yes", value: "yes" }, + { label: "No", value: "no" }, + ]} + /> +
+ )} + + )} + + {isVisualMedia && ( <>
{ + await flushStudioPendingEdits(); await domEditSaveQueueRef.current?.waitForIdle(); }, []); diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts new file mode 100644 index 000000000..e9230a1a2 --- /dev/null +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import { + addStudioPendingEditFlushListener, + flushStudioPendingEdits, + trackStudioPendingEdit, +} from "./studioPendingEdits"; + +describe("studio pending edit flush", () => { + it("waits for mounted panels to persist pending local edits", async () => { + const persist = vi.fn(async () => undefined); + const remove = addStudioPendingEditFlushListener(persist); + + try { + await flushStudioPendingEdits(); + expect(persist).toHaveBeenCalledTimes(1); + } finally { + remove(); + } + }); + + it("waits for edits already started by unmounted panels", async () => { + const steps: string[] = []; + let resolvePersist!: () => void; + trackStudioPendingEdit( + new Promise((resolve) => { + resolvePersist = resolve; + }).then(() => { + steps.push("persisted"); + }), + ); + + const flushed = flushStudioPendingEdits().then(() => { + steps.push("flushed"); + }); + await Promise.resolve(); + expect(steps).toEqual([]); + + resolvePersist(); + await flushed; + expect(steps).toEqual(["persisted", "flushed"]); + }); +}); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts new file mode 100644 index 000000000..db415617c --- /dev/null +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -0,0 +1,45 @@ +export const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits"; + +interface StudioFlushPendingEditsDetail { + promises: Array>; +} + +const pendingEditPromises = new Set>(); + +export function trackStudioPendingEdit( + result: Promise | unknown, +): Promise | undefined { + if (!result) return undefined; + const promise = Promise.resolve(result); + pendingEditPromises.add(promise); + promise.then( + () => pendingEditPromises.delete(promise), + () => pendingEditPromises.delete(promise), + ); + return promise; +} + +export async function flushStudioPendingEdits(): Promise { + const detail: StudioFlushPendingEditsDetail = { promises: [] }; + window.dispatchEvent( + new CustomEvent(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }), + ); + while (detail.promises.length > 0 || pendingEditPromises.size > 0) { + const promises = [...detail.promises, ...pendingEditPromises]; + detail.promises = []; + await Promise.allSettled(promises); + } +} + +export function addStudioPendingEditFlushListener( + handler: () => Promise | unknown, +): () => void { + const listener = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail?.promises) return; + const promise = trackStudioPendingEdit(handler()); + if (promise) detail.promises.push(promise); + }; + window.addEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener); + return () => window.removeEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener); +} diff --git a/packages/studio/tests/e2e/design-panel-qa-matrix.md b/packages/studio/tests/e2e/design-panel-qa-matrix.md index b75e78fd3..f9d01bc09 100644 --- a/packages/studio/tests/e2e/design-panel-qa-matrix.md +++ b/packages/studio/tests/e2e/design-panel-qa-matrix.md @@ -136,8 +136,7 @@ reload survival. - Fill color picker: opens with a hex input reflecting the current color; persist path verified green by the headless harness (fill style op); scripted popup commit was flaky (focus-sensitive popup), verified manually instead. -- Color grading section absent for img/video: expected (flag `VITE_STUDIO_ENABLE_COLOR_GRADING` - defaults off). +- Color grading section appears for img/video elements. - Automation notes: media/timing cells must run with the playhead inside the clip window (a data-start edit hides the element at t=0, which is correct but confuses naive re-runs); commit fires on Enter/blur only when the draft differs from the last value.