mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
Merge pull request #1987 from heygen-com/feat/studio-color-grading-inspector
feat(studio): add color grading inspector
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -487,7 +486,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 && (
|
||||
<SaveQueuePausedBanner
|
||||
@@ -501,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" : ""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -27,6 +28,13 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
||||
import { useDomEditContext } from "../contexts/DomEditContext";
|
||||
import { usePlayerStore } from "../player";
|
||||
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;
|
||||
@@ -82,6 +90,7 @@ export function StudioRightPanel({
|
||||
previewIframeRef,
|
||||
projectId,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
compositionDimensions,
|
||||
waitForPendingDomEditSaves,
|
||||
renderQueue,
|
||||
@@ -136,8 +145,10 @@ export function StudioRightPanel({
|
||||
projectDir,
|
||||
handleImportFiles,
|
||||
handleImportFonts,
|
||||
refreshFileTree,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
fileTree,
|
||||
} = useFileManagerContext();
|
||||
|
||||
// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
|
||||
@@ -172,6 +183,14 @@ export function StudioRightPanel({
|
||||
startPercent: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
backgroundRemovalAbortRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderJobs = renderQueue.jobs as RenderJob[];
|
||||
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
|
||||
@@ -233,6 +252,90 @@ export function StudioRightPanel({
|
||||
splitDragRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleApplyColorGradingScope = useCallback(
|
||||
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 EMPTY_COLOR_GRADING_SCOPE_RESULT;
|
||||
}),
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
domEditSelection?.sourceFile,
|
||||
fileTree,
|
||||
projectId,
|
||||
readProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
waitForPendingDomEditSaves,
|
||||
writeProjectFile,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRemoveBackground = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
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 = (
|
||||
<PropertyPanel
|
||||
projectId={projectId}
|
||||
@@ -246,7 +349,9 @@ export function StudioRightPanel({
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
onSetAttributeLive={handleDomAttributeLiveCommit}
|
||||
onApplyColorGradingScope={handleApplyColorGradingScope}
|
||||
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
|
||||
onRemoveBackground={handleRemoveBackground}
|
||||
onSetManualOffset={handleDomPathOffsetCommit}
|
||||
onSetManualSize={handleDomBoxSizeCommit}
|
||||
onSetManualRotation={handleDomRotationCommit}
|
||||
@@ -325,14 +430,14 @@ export function StudioRightPanel({
|
||||
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
|
||||
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
|
||||
style={{ width: rightWidth }}
|
||||
>
|
||||
{captionEditMode ? (
|
||||
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
|
||||
{STUDIO_INSPECTOR_PANELS_ENABLED && (
|
||||
<>
|
||||
<Tooltip label="Element styles and properties" side="bottom">
|
||||
@@ -390,7 +495,7 @@ export function StudioRightPanel({
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{rightPanelTab === "block-params" && activeBlockParams ? (
|
||||
<BlockParamsPanel
|
||||
blockName={activeBlockParams.blockName}
|
||||
@@ -406,7 +511,7 @@ export function StudioRightPanel({
|
||||
onPersistNotes={onPersistSlideshowNotes}
|
||||
/>
|
||||
) : layersPaneOpen && designPaneOpen ? (
|
||||
<div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
|
||||
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div
|
||||
className="min-h-[120px] overflow-hidden"
|
||||
style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
|
||||
|
||||
@@ -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 (
|
||||
@@ -14,7 +15,7 @@ export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
|
||||
style={onDismiss ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center gap-3 overflow-hidden rounded-2xl pl-4 pr-2 py-3 text-[12px]"
|
||||
className="relative flex max-w-[min(420px,calc(100vw-48px))] items-center gap-3 overflow-hidden rounded-2xl py-3 pl-4 pr-2 text-[12px]"
|
||||
style={{
|
||||
background: isError
|
||||
? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
|
||||
@@ -29,7 +30,11 @@ export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
|
||||
].join(", "),
|
||||
}}
|
||||
>
|
||||
<span className={isError ? "text-red-200" : "text-neutral-200"}>{message}</span>
|
||||
<span
|
||||
className={`min-w-0 break-words leading-5 ${isError ? "text-red-200" : "text-neutral-200"}`}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -22,11 +22,7 @@ import { TextSection, StyleSections } from "./propertyPanelSections";
|
||||
import { GsapAnimationSection } from "./GsapAnimationSection";
|
||||
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import {
|
||||
STUDIO_COLOR_GRADING_ENABLED,
|
||||
STUDIO_GSAP_PANEL_ENABLED,
|
||||
STUDIO_KEYFRAMES_ENABLED,
|
||||
} from "./manualEditingAvailability";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
||||
import { usePlayerStore, liveTime } from "../../player";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
@@ -57,7 +53,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetAttributeLive,
|
||||
onApplyColorGradingScope,
|
||||
onSetHtmlAttribute,
|
||||
onRemoveBackground,
|
||||
onSetManualOffset,
|
||||
onSetManualSize,
|
||||
onSetManualRotation,
|
||||
@@ -352,6 +350,24 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetAttribute={onSetAttribute}
|
||||
/>
|
||||
)}
|
||||
{sections.colorGrading && (
|
||||
<ColorGradingSection
|
||||
key={[
|
||||
element.id ?? "",
|
||||
element.hfId ?? "",
|
||||
element.selector ?? "",
|
||||
String(element.selectorIndex ?? ""),
|
||||
].join("|")}
|
||||
projectId={projectId}
|
||||
element={element}
|
||||
assets={assets}
|
||||
previewIframeRef={previewIframeRef}
|
||||
onImportAssets={onImportAssets}
|
||||
onSetAttributeLive={onSetAttributeLive}
|
||||
onApplyScope={onApplyColorGradingScope}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sections.media && (
|
||||
<MediaSection
|
||||
projectDir={projectDir}
|
||||
@@ -360,22 +376,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetStyle={onSetStyle}
|
||||
onSetAttribute={onSetAttribute}
|
||||
onSetHtmlAttribute={onSetHtmlAttribute}
|
||||
/>
|
||||
)}
|
||||
|
||||
{STUDIO_COLOR_GRADING_ENABLED && sections.colorGrading && (
|
||||
<ColorGradingSection
|
||||
key={[
|
||||
element.id ?? "",
|
||||
element.hfId ?? "",
|
||||
element.selector ?? "",
|
||||
String(element.selectorIndex ?? ""),
|
||||
].join("|")}
|
||||
element={element}
|
||||
assets={assets}
|
||||
previewIframeRef={previewIframeRef}
|
||||
onImportAssets={onImportAssets}
|
||||
onSetAttributeLive={onSetAttributeLive}
|
||||
onRemoveBackground={onRemoveBackground}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { patchMediaColorGradingInHtml } from "./colorGradingScopePatch";
|
||||
|
||||
describe("patchMediaColorGradingInHtml", () => {
|
||||
it("adds color grading to video and image tags only", () => {
|
||||
const { html, count } = patchMediaColorGradingInHtml(
|
||||
`<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
|
||||
`{"preset":"natural-lift"}`,
|
||||
);
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(html).toContain(
|
||||
`video id="v" data-color-grading="{"preset":"natural-lift"}"`,
|
||||
);
|
||||
expect(html).toContain(
|
||||
`img id="i" data-color-grading="{"preset":"natural-lift"}"`,
|
||||
);
|
||||
expect(html).toContain(`<audio id="a"></audio>`);
|
||||
});
|
||||
|
||||
it("replaces existing color grading without touching other attributes", () => {
|
||||
const { html, count } = patchMediaColorGradingInHtml(
|
||||
`<video muted data-color-grading='old' playsinline></video>`,
|
||||
`{"adjust":{"exposure":0.2}}`,
|
||||
);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(html).toBe(
|
||||
`<video muted data-color-grading="{"adjust":{"exposure":0.2}}" playsinline></video>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps quoted greater-than characters inside media attributes", () => {
|
||||
const { html, count } = patchMediaColorGradingInHtml(
|
||||
`<img alt="before > after" src="photo.jpg">`,
|
||||
`{"adjust":{"contrast":0.1}}`,
|
||||
);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(html).toBe(
|
||||
`<img alt="before > after" src="photo.jpg" data-color-grading="{"adjust":{"contrast":0.1}}">`,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes color grading when value is empty", () => {
|
||||
const { html, count } = patchMediaColorGradingInHtml(
|
||||
`<video data-color-grading="old"></video><img alt="">`,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(html).toBe(`<video></video><img alt="">`);
|
||||
});
|
||||
|
||||
it("does not patch media-looking text inside scripts, styles, or comments", () => {
|
||||
const { html, count } = patchMediaColorGradingInHtml(
|
||||
[
|
||||
`<script>const tpl = '<video id="script-video"></video>';</script>`,
|
||||
`<style>.icon::before { content: "<img>"; }</style>`,
|
||||
`<!-- <img id="commented"> -->`,
|
||||
`<video id="real"></video>`,
|
||||
].join(""),
|
||||
`{"preset":"clean-studio"}`,
|
||||
);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(html).toContain(`<script>const tpl = '<video id="script-video"></video>';</script>`);
|
||||
expect(html).toContain(`<style>.icon::before { content: "<img>"; }</style>`);
|
||||
expect(html).toContain(`<!-- <img id="commented"> -->`);
|
||||
expect(html).toContain(
|
||||
`<video id="real" data-color-grading="{"preset":"clean-studio"}"></video>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const MEDIA_TAG_RE = /<\s*(video|img)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
||||
const COLOR_GRADING_ATTR_RE = /\sdata-color-grading=(["'])([\s\S]*?)\1/i;
|
||||
const IGNORED_HTML_RANGE_RE = /<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>/gi;
|
||||
|
||||
interface TextRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function collectIgnoredRanges(html: string): TextRange[] {
|
||||
const ranges: TextRange[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
IGNORED_HTML_RANGE_RE.lastIndex = 0;
|
||||
while ((match = IGNORED_HTML_RANGE_RE.exec(html)) !== null) {
|
||||
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isInsideRange(offset: number, ranges: TextRange[]): boolean {
|
||||
return ranges.some((range) => offset >= range.start && offset < range.end);
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function patchMediaTag(tag: string, value: string | null): string {
|
||||
if (value === null || value === "") {
|
||||
return tag.replace(COLOR_GRADING_ATTR_RE, "");
|
||||
}
|
||||
|
||||
const nextAttr = ` data-color-grading="${escapeHtmlAttribute(value)}"`;
|
||||
if (COLOR_GRADING_ATTR_RE.test(tag)) {
|
||||
return tag.replace(COLOR_GRADING_ATTR_RE, nextAttr);
|
||||
}
|
||||
return tag.replace(/\s*\/?>$/, (end) => `${nextAttr}${end}`);
|
||||
}
|
||||
|
||||
export function patchMediaColorGradingInHtml(
|
||||
html: string,
|
||||
value: string | null,
|
||||
): { html: string; count: number } {
|
||||
let count = 0;
|
||||
const ignoredRanges = collectIgnoredRanges(html);
|
||||
const patched = html.replace(MEDIA_TAG_RE, (tag, _tagName, offset: number) => {
|
||||
if (isInsideRange(offset, ignoredRanges)) return tag;
|
||||
const next = patchMediaTag(tag, value);
|
||||
if (next !== tag) count += 1;
|
||||
return next;
|
||||
});
|
||||
return { html: patched, count };
|
||||
}
|
||||
@@ -24,18 +24,6 @@ describe("manual editing availability", () => {
|
||||
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps color grading off by default", async () => {
|
||||
const availability = await loadAvailabilityWithEnv({});
|
||||
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("enables color grading with an explicit env flag", async () => {
|
||||
const availability = await loadAvailabilityWithEnv({
|
||||
VITE_STUDIO_ENABLE_COLOR_GRADING: "1",
|
||||
});
|
||||
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it("disables preview selection when the inspector panel flag is explicitly off", async () => {
|
||||
const availability = await loadAvailabilityWithEnv({
|
||||
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
|
||||
|
||||
@@ -64,12 +64,6 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
true,
|
||||
);
|
||||
|
||||
export const STUDIO_COLOR_GRADING_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_COLOR_GRADING", "VITE_STUDIO_COLOR_GRADING_ENABLED"],
|
||||
false,
|
||||
);
|
||||
|
||||
export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
HF_COLOR_GRADING_PRESETS,
|
||||
normalizeHfColorGrading,
|
||||
type HfColorGradingAdjustKey,
|
||||
type HfColorGradingDetailKey,
|
||||
type HfColorGradingEffectKey,
|
||||
type NormalizedHfColorGrading,
|
||||
} from "@hyperframes/core/color-grading";
|
||||
import { Minus, Plus, RotateCcw } 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 SLIDERS: Array<{
|
||||
const ADJUST_SLIDERS: Array<{
|
||||
key: HfColorGradingAdjustKey;
|
||||
label: string;
|
||||
min: number;
|
||||
@@ -34,254 +35,133 @@ const SLIDERS: Array<{
|
||||
suffix: "%",
|
||||
},
|
||||
{ key: "shadows", label: "Shadows", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{ key: "whites", label: "Whites", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{ key: "blacks", label: "Blacks", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{
|
||||
key: "whites",
|
||||
label: "White Point",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
scale: 100,
|
||||
suffix: "%",
|
||||
},
|
||||
{
|
||||
key: "blacks",
|
||||
label: "Black Point",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
scale: 100,
|
||||
suffix: "%",
|
||||
},
|
||||
{ key: "temperature", label: "Warmth", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{ key: "tint", label: "Tint", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{ key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
];
|
||||
|
||||
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,
|
||||
}: {
|
||||
const DETAIL_SLIDERS: Array<{
|
||||
key: HfColorGradingDetailKey;
|
||||
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;
|
||||
}) {
|
||||
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;
|
||||
scale: number;
|
||||
suffix: string;
|
||||
defaultValue?: number;
|
||||
}> = [
|
||||
{ key: "vignette", label: "Vignette", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{
|
||||
key: "vignetteMidpoint",
|
||||
label: "Midpoint",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
scale: 100,
|
||||
suffix: "%",
|
||||
defaultValue: 50,
|
||||
},
|
||||
{
|
||||
key: "vignetteRoundness",
|
||||
label: "Roundness",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
scale: 100,
|
||||
suffix: "%",
|
||||
},
|
||||
{
|
||||
key: "vignetteFeather",
|
||||
label: "Feather",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
scale: 100,
|
||||
suffix: "%",
|
||||
defaultValue: 65,
|
||||
},
|
||||
{ key: "grain", label: "Grain", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{
|
||||
key: "grainSize",
|
||||
label: "Grain Size",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
scale: 100,
|
||||
suffix: "%",
|
||||
defaultValue: 25,
|
||||
},
|
||||
{
|
||||
key: "grainRoughness",
|
||||
label: "Roughness",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
scale: 100,
|
||||
suffix: "%",
|
||||
defaultValue: 50,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
type DetailSlider = (typeof DETAIL_SLIDERS)[number];
|
||||
type SliderSettings = {
|
||||
active?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const clampDraft = useCallback(
|
||||
(nextValue: number) => clampNumber(nextValue, min, max),
|
||||
[max, min],
|
||||
);
|
||||
const EFFECT_SLIDERS: Array<{
|
||||
key: HfColorGradingEffectKey;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
scale: number;
|
||||
suffix: string;
|
||||
}> = [
|
||||
{ key: "blur", label: "Blur", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
{ key: "pixelate", label: "Pixelate", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
|
||||
];
|
||||
|
||||
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 AMOUNT_DETAIL_SLIDERS = DETAIL_SLIDERS.filter(
|
||||
(slider) => slider.key === "vignette" || slider.key === "grain",
|
||||
);
|
||||
const VIGNETTE_TUNE_SLIDERS = DETAIL_SLIDERS.filter(
|
||||
(slider) =>
|
||||
slider.key === "vignetteMidpoint" ||
|
||||
slider.key === "vignetteRoundness" ||
|
||||
slider.key === "vignetteFeather",
|
||||
);
|
||||
const GRAIN_TUNE_SLIDERS = DETAIL_SLIDERS.filter(
|
||||
(slider) => slider.key === "grainSize" || slider.key === "grainRoughness",
|
||||
);
|
||||
|
||||
const commitDraft = useCallback(
|
||||
(nextValue: number) => {
|
||||
const clamped = setLocalDraft(nextValue);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
if (clamped !== valueRef.current) onCommit(clamped);
|
||||
},
|
||||
[onCommit, setLocalDraft],
|
||||
);
|
||||
function normalizedDefaultValue(slider: { defaultValue?: number; scale: number }): number {
|
||||
return (slider.defaultValue ?? 0) / slider.scale;
|
||||
}
|
||||
|
||||
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-1.5 rounded-md bg-panel-input/30 p-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={`${LABEL} min-w-0 flex-1 truncate`}>{label}</span>
|
||||
{onReset && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={`Reset ${label}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onReset();
|
||||
}}
|
||||
className="flex h-6 w-6 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-7 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.5">
|
||||
<div className="flex flex-shrink-0 items-center rounded-md bg-panel-input px-1.5 py-1">
|
||||
<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-5 w-[38px] bg-transparent text-right text-[11px] 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-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"
|
||||
title={`Decrease ${label}`}
|
||||
>
|
||||
<Minus size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title={`Increase ${label}`}
|
||||
>
|
||||
<Plus size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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({
|
||||
@@ -296,21 +176,37 @@ export function ColorGradingControls({
|
||||
onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void;
|
||||
}) {
|
||||
const lutInputRef = useRef<HTMLInputElement>(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,
|
||||
});
|
||||
};
|
||||
@@ -324,9 +220,48 @@ 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-4">
|
||||
<div className="space-y-3">
|
||||
<label className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Preset</span>
|
||||
<select
|
||||
@@ -341,97 +276,125 @@ export function ColorGradingControls({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ColorGradingSliderControl
|
||||
label="Preset strength"
|
||||
value={Math.round(grading.intensity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
neutral={0}
|
||||
suffix="%"
|
||||
displayValue={`${Math.round(grading.intensity * 100)}%`}
|
||||
onCommit={updateFilterIntensity}
|
||||
onReset={() => updateFilterIntensity(100)}
|
||||
/>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>LUT Filter</span>
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_28px] gap-2">
|
||||
<select
|
||||
value={selectedLut}
|
||||
onChange={(event) => {
|
||||
const nextSrc = event.target.value;
|
||||
applyLut(
|
||||
nextSrc || null,
|
||||
nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
|
||||
);
|
||||
}}
|
||||
className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none"
|
||||
title="Uploaded .cube LUT filter"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{lutAssets.length > 0 && (
|
||||
<optgroup label="Uploaded LUTs">
|
||||
{lutAssets.map((asset) => (
|
||||
<option key={asset} value={asset}>
|
||||
{asset.split("/").pop() ?? asset}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!onImportAssets}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
lutInputRef.current?.click();
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Import .cube LUT"
|
||||
aria-label="Import .cube LUT"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<input
|
||||
ref={lutInputRef}
|
||||
type="file"
|
||||
accept=".cube"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void importLuts(event.currentTarget.files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{grading.lut && (
|
||||
<div className="grid gap-2">
|
||||
{selectedProjectLut && (
|
||||
<div className="flex min-w-0 items-start gap-2 text-[10px] leading-4 text-panel-text-3">
|
||||
<span className="mt-[5px] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-studio-accent" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium text-panel-text-2">Uploaded LUT</span>
|
||||
{` · ${selectedProjectLut}`}
|
||||
</span>
|
||||
<div className="min-w-0 rounded-md border border-panel-border/70 bg-panel-input/15">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 px-2 text-left text-[11px] font-medium text-panel-text-3 transition-colors hover:bg-panel-hover/60 hover:text-panel-text-1"
|
||||
onClick={() => setLutOpen((value) => !value)}
|
||||
aria-expanded={lutOpen}
|
||||
>
|
||||
{lutOpen ? (
|
||||
<ChevronDown size={11} className="flex-shrink-0 text-panel-text-5" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="flex-shrink-0 text-panel-text-5" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">Custom LUT</span>
|
||||
{grading.lut && (
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-studio-accent" />
|
||||
)}
|
||||
</button>
|
||||
{lutOpen && (
|
||||
<div className="grid gap-1.5 border-t border-panel-border/60 p-1.5">
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_28px] gap-2">
|
||||
<select
|
||||
value={selectedLut}
|
||||
onChange={(event) => {
|
||||
const nextSrc = event.target.value;
|
||||
applyLut(
|
||||
nextSrc || null,
|
||||
nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
|
||||
);
|
||||
}}
|
||||
className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none"
|
||||
title="Uploaded .cube LUT"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{lutAssets.length > 0 && (
|
||||
<optgroup label="Uploaded LUTs">
|
||||
{lutAssets.map((asset) => (
|
||||
<option key={asset} value={asset}>
|
||||
{asset.split("/").pop() ?? asset}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!onImportAssets}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
lutInputRef.current?.click();
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Import .cube LUT"
|
||||
aria-label="Import .cube LUT"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<input
|
||||
ref={lutInputRef}
|
||||
type="file"
|
||||
accept=".cube"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void importLuts(event.currentTarget.files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{grading.lut && (
|
||||
<div className="grid gap-2">
|
||||
{selectedProjectLut && (
|
||||
<div className="flex min-w-0 items-start gap-2 text-[10px] leading-4 text-panel-text-3">
|
||||
<span className="mt-[5px] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-studio-accent" />
|
||||
<span className="min-w-0 flex-1 truncate" title={selectedProjectLut}>
|
||||
<span className="font-medium text-panel-text-2">Uploaded LUT</span>
|
||||
{` · ${selectedProjectLut}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ColorGradingSliderControl
|
||||
label="LUT Strength"
|
||||
value={Math.round((grading.lut.intensity ?? 1) * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
neutral={0}
|
||||
suffix="%"
|
||||
displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`}
|
||||
onCommit={updateLutIntensity}
|
||||
onReset={() => updateLutIntensity(100)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ColorGradingSliderControl
|
||||
label="LUT Strength"
|
||||
value={Math.round((grading.lut.intensity ?? 1) * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
neutral={0}
|
||||
suffix="%"
|
||||
displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`}
|
||||
onCommit={updateLutIntensity}
|
||||
onReset={() => updateLutIntensity(100)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 grid-cols-2 gap-3">
|
||||
{SLIDERS.map((slider, index) => {
|
||||
const value = grading.adjust[slider.key] * slider.scale;
|
||||
const isExposure = slider.key === "exposure";
|
||||
return (
|
||||
<div
|
||||
key={slider.key}
|
||||
className={
|
||||
SLIDERS.length % 2 === 1 && index === SLIDERS.length - 1 ? "col-span-2" : ""
|
||||
}
|
||||
>
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Adjust</span>
|
||||
<div className="grid min-w-0 grid-cols-2 gap-1.5">
|
||||
{ADJUST_SLIDERS.map((slider) => {
|
||||
const value = grading.adjust[slider.key] * slider.scale;
|
||||
const isExposure = slider.key === "exposure";
|
||||
return (
|
||||
<ColorGradingSliderControl
|
||||
key={slider.key}
|
||||
label={slider.label}
|
||||
value={Math.round(value)}
|
||||
min={slider.min}
|
||||
@@ -448,7 +411,7 @@ export function ColorGradingControls({
|
||||
onCommit={(next) => {
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
intensity: 1,
|
||||
intensity: visibleIntensity(grading),
|
||||
adjust: {
|
||||
...grading.adjust,
|
||||
[slider.key]: next / slider.scale,
|
||||
@@ -458,7 +421,7 @@ export function ColorGradingControls({
|
||||
onReset={() => {
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
intensity: 1,
|
||||
intensity: visibleIntensity(grading),
|
||||
adjust: {
|
||||
...grading.adjust,
|
||||
[slider.key]: 0,
|
||||
@@ -466,9 +429,88 @@ export function ColorGradingControls({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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) =>
|
||||
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">
|
||||
<div className="flex min-w-0 items-center gap-2 px-0.5">
|
||||
<span className={`${LABEL} min-w-0 flex-1 truncate`}>
|
||||
{detailSettings === "vignette" ? "Vignette settings" : "Grain settings"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close settings"
|
||||
title="Close settings"
|
||||
onClick={() => setDetailSettings(null)}
|
||||
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"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid min-w-0 grid-cols-2 gap-1.5">
|
||||
{detailSettingsSliders.map((slider) => renderDetailSlider(slider))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Effects</span>
|
||||
<div className="grid min-w-0 grid-cols-2 gap-1.5">
|
||||
{EFFECT_SLIDERS.map((slider) => {
|
||||
const value = grading.effects[slider.key] * slider.scale;
|
||||
return (
|
||||
<ColorGradingSliderControl
|
||||
key={slider.key}
|
||||
label={slider.label}
|
||||
value={Math.round(value)}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
neutral={0}
|
||||
suffix={slider.suffix}
|
||||
displayValue={`${Math.round(value)}%`}
|
||||
onCommit={(next) => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,17 +16,99 @@ 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 { stripQueryAndHash } from "./propertyPanelHelpers";
|
||||
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<string, MediaMetadata | null>();
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
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 +116,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 +161,45 @@ function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
|
||||
? "bg-red-400"
|
||||
: "bg-panel-text-5";
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 rounded bg-panel-input px-2 py-1 text-[10px] font-medium text-panel-text-3">
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-1.5 rounded bg-panel-input px-2 py-1 text-[10px] font-medium text-panel-text-3"
|
||||
title={status.message}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${dotClass}`} />
|
||||
<span className="truncate">{status.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-4 text-amber-100">
|
||||
<div className="mb-1 flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="font-semibold">{metadata.color.label} source</span>
|
||||
<span className="rounded bg-amber-400/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-100">
|
||||
SDR preview
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-amber-100/80">
|
||||
These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this
|
||||
is not true HDR color grading yet.
|
||||
</p>
|
||||
{details && <p className="mt-1 truncate text-[10px] text-amber-100/55">{details}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoldBeforeButton({
|
||||
active,
|
||||
disabled,
|
||||
@@ -153,29 +266,46 @@ function HoldBeforeButton({
|
||||
}
|
||||
|
||||
export function ColorGradingSection({
|
||||
projectId,
|
||||
element,
|
||||
assets,
|
||||
previewIframeRef,
|
||||
onImportAssets,
|
||||
onSetAttributeLive,
|
||||
onApplyScope,
|
||||
}: {
|
||||
projectId: string;
|
||||
element: DomEditSelection;
|
||||
assets: string[];
|
||||
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
|
||||
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
||||
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
|
||||
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<RuntimeColorGradingStatus>(() => ({
|
||||
state: "pending",
|
||||
message: "Waiting for runtime",
|
||||
}));
|
||||
const selectedAssetPath = useMemo(
|
||||
() => selectedMediaAssetPath(element, projectId),
|
||||
[element, projectId],
|
||||
);
|
||||
const [mediaMetadata, setMediaMetadata] = useState<MediaMetadata | null>(null);
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
|
||||
const statusTimersRef = useRef<number[]>([]);
|
||||
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 +321,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 +427,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 +469,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 +477,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 +490,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 (
|
||||
<Section
|
||||
title="Color Grading"
|
||||
title="Color grading"
|
||||
icon={<Palette size={15} />}
|
||||
accessory={
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
@@ -316,19 +525,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"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HdrMediaWarning metadata={mediaMetadata} />
|
||||
<ColorGradingControls
|
||||
grading={grading}
|
||||
assets={assets}
|
||||
onImportAssets={onImportAssets}
|
||||
onCommitColorGrading={commitColorGrading}
|
||||
/>
|
||||
{onApplyScope && (
|
||||
<div className="mt-4 grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<select
|
||||
value={applyScope}
|
||||
onChange={(event) => setApplyScope(event.currentTarget.value as typeof applyScope)}
|
||||
disabled={applyBusy}
|
||||
className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Choose where to copy these color grading settings"
|
||||
>
|
||||
<option value="source-file">Current file media</option>
|
||||
<option value="project">All project media</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={applyBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void applyToScope();
|
||||
}}
|
||||
className="h-8 rounded-md bg-panel-input px-3 text-[11px] font-medium text-panel-text-2 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Copy these color grading settings to the selected scope"
|
||||
>
|
||||
{applyBusy ? "Applying" : "Apply"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,91 +1,21 @@
|
||||
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>;
|
||||
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
||||
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 type {
|
||||
BackgroundRemovalProgress,
|
||||
BackgroundRemovalResult,
|
||||
PropertyPanelProps,
|
||||
} from "./propertyPanelTypes";
|
||||
|
||||
export function stripQueryAndHash(value: string): string {
|
||||
const queryIndex = value.indexOf("?");
|
||||
const hashIndex = value.indexOf("#");
|
||||
if (queryIndex < 0) return hashIndex < 0 ? value : value.slice(0, hashIndex);
|
||||
if (hashIndex < 0) return value.slice(0, queryIndex);
|
||||
return value.slice(0, Math.min(queryIndex, hashIndex));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -322,23 +252,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;
|
||||
}
|
||||
|
||||
@@ -490,11 +421,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",
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
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,
|
||||
parseNumericValue,
|
||||
RESPONSIVE_GRID,
|
||||
stripQueryAndHash,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function MediaSection({
|
||||
projectDir,
|
||||
element,
|
||||
@@ -17,6 +21,7 @@ export function MediaSection({
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetHtmlAttribute,
|
||||
onRemoveBackground,
|
||||
}: {
|
||||
projectDir: string | null;
|
||||
element: DomEditSelection;
|
||||
@@ -24,8 +29,19 @@ export function MediaSection({
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
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>;
|
||||
}) {
|
||||
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 +69,64 @@ export function MediaSection({
|
||||
|
||||
const srcAttr = el.getAttribute("src") ?? "";
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const [removeProgress, setRemoveProgress] = useState<BackgroundRemovalProgress | null>(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)
|
||||
? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
|
||||
: "";
|
||||
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 (
|
||||
<Section
|
||||
title={isVideo ? "Video" : "Audio"}
|
||||
icon={isVideo ? <Film size={15} /> : <Music size={15} />}
|
||||
>
|
||||
<Section title={panelTitle} icon={isAudio ? <Music size={15} /> : <Film size={15} />}>
|
||||
<div className="space-y-4">
|
||||
{srcAttr && (
|
||||
<div className="min-w-0">
|
||||
@@ -90,103 +155,192 @@ export function MediaSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Volume</span>
|
||||
<SliderControl
|
||||
value={volumePercent}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
displayValue={`${volumePercent}%`}
|
||||
formatDisplayValue={(next) => `${Math.round(next)}%`}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("volume", formatNumericValue(next / 100));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Playback rate</span>
|
||||
<SliderControl
|
||||
value={playbackRate * 100}
|
||||
min={25}
|
||||
max={300}
|
||||
step={5}
|
||||
displayValue={`${formatNumericValue(playbackRate)}x`}
|
||||
formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("playback-rate", formatNumericValue(next / 100));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Media start</span>
|
||||
<SliderControl
|
||||
value={Math.round(mediaStart * 100)}
|
||||
min={0}
|
||||
max={mediaStartMax * 100}
|
||||
step={10}
|
||||
displayValue={formatTimingValue(mediaStart)}
|
||||
formatDisplayValue={(next) => formatTimingValue(next / 100)}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("media-start", (next / 100).toFixed(2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Loop</span>
|
||||
<SegmentedControl
|
||||
value={hasLoop ? "on" : "off"}
|
||||
onChange={(next) => {
|
||||
void onSetHtmlAttribute("loop", next === "on" ? "true" : null);
|
||||
}}
|
||||
options={[
|
||||
{ label: "On", value: "on" },
|
||||
{ label: "Off", value: "off" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Muted</span>
|
||||
<SegmentedControl
|
||||
value={hasMuted ? "on" : "off"}
|
||||
onChange={(next) => {
|
||||
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
|
||||
}}
|
||||
options={[
|
||||
{ label: "On", value: "on" },
|
||||
{ label: "Off", value: "off" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isVideo && (
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Has audio track</span>
|
||||
<SegmentedControl
|
||||
value={hasAudio ? "yes" : "no"}
|
||||
onChange={(next) => {
|
||||
if (next === "yes") {
|
||||
void onSetAttribute("has-audio", "true");
|
||||
void onSetHtmlAttribute("muted", null);
|
||||
} else {
|
||||
void onSetAttribute("has-audio", "");
|
||||
void onSetHtmlAttribute("muted", "true");
|
||||
{isVisualMedia && (
|
||||
<div className="grid min-w-0 max-w-full gap-2 overflow-hidden rounded-md bg-panel-input/30 p-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className={LABEL}>Cutout</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-panel-text-4">
|
||||
Create transparent {isVideo ? "WebM video" : "PNG image"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRemoveBackground || removeBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void runBackgroundRemoval();
|
||||
}}
|
||||
className="flex h-8 flex-shrink-0 items-center gap-1.5 rounded-md bg-panel-input px-2.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
canRemoveBackground
|
||||
? "Remove background and save a transparent asset"
|
||||
: "Select a project-local image or video asset"
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<Scissors size={13} />
|
||||
<span>{removeBusy ? "Working" : "Remove BG"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SelectField
|
||||
label="Quality"
|
||||
value={quality}
|
||||
onChange={(next) => setQuality(next as typeof quality)}
|
||||
options={["fast", "balanced", "best"]}
|
||||
/>
|
||||
{isVideo ? (
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>BG plate</span>
|
||||
<SegmentedControl
|
||||
value={createPlate ? "on" : "off"}
|
||||
onChange={(next) => setCreatePlate(next === "on")}
|
||||
options={[
|
||||
{ label: "On", value: "on" },
|
||||
{ label: "Off", value: "off" },
|
||||
]}
|
||||
/>
|
||||
<span className="text-[10px] leading-tight text-panel-text-4">
|
||||
Optional hole-cut background copy.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{removeProgress && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-panel-text-4">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{removeProgress.error ?? removeProgress.stage ?? "Processing"}
|
||||
</span>
|
||||
<span>{Math.round(removeProgress.progress)}%</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-panel-border">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
removeProgress.status === "failed" ? "bg-red-400" : "bg-studio-accent"
|
||||
}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, removeProgress.progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{removeProgress?.status === "complete" && removeProgress.outputPath && (
|
||||
<div
|
||||
className="truncate text-[10px] font-medium text-panel-text-3"
|
||||
title={removeProgress.outputPath}
|
||||
>
|
||||
Applied {removeProgress.outputPath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideo && (
|
||||
{(isVideo || isAudio) && (
|
||||
<>
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Volume</span>
|
||||
<SliderControl
|
||||
value={volumePercent}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
displayValue={`${volumePercent}%`}
|
||||
formatDisplayValue={(next) => `${Math.round(next)}%`}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("volume", formatNumericValue(next / 100));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Playback rate</span>
|
||||
<SliderControl
|
||||
value={playbackRate * 100}
|
||||
min={25}
|
||||
max={300}
|
||||
step={5}
|
||||
displayValue={`${formatNumericValue(playbackRate)}x`}
|
||||
formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("playback-rate", formatNumericValue(next / 100));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Media start</span>
|
||||
<SliderControl
|
||||
value={Math.round(mediaStart * 100)}
|
||||
min={0}
|
||||
max={mediaStartMax * 100}
|
||||
step={10}
|
||||
displayValue={formatTimingValue(mediaStart)}
|
||||
formatDisplayValue={(next) => formatTimingValue(next / 100)}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("media-start", (next / 100).toFixed(2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Loop</span>
|
||||
<SegmentedControl
|
||||
value={hasLoop ? "on" : "off"}
|
||||
onChange={(next) => {
|
||||
void onSetHtmlAttribute("loop", next === "on" ? "true" : null);
|
||||
}}
|
||||
options={[
|
||||
{ label: "On", value: "on" },
|
||||
{ label: "Off", value: "off" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Muted</span>
|
||||
<SegmentedControl
|
||||
value={hasMuted ? "on" : "off"}
|
||||
onChange={(next) => {
|
||||
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
|
||||
}}
|
||||
options={[
|
||||
{ label: "On", value: "on" },
|
||||
{ label: "Off", value: "off" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isVideo && (
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Has audio track</span>
|
||||
<SegmentedControl
|
||||
value={hasAudio ? "yes" : "no"}
|
||||
onChange={(next) => {
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isVisualMedia && (
|
||||
<>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<SelectField
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
|
||||
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
// ── Types ──
|
||||
@@ -77,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({
|
||||
@@ -135,6 +154,7 @@ export function usePreviewPersistence({
|
||||
}, []);
|
||||
|
||||
const waitForPendingDomEditSaves = useCallback(async () => {
|
||||
await flushStudioPendingEdits();
|
||||
await domEditSaveQueueRef.current?.waitForIdle();
|
||||
}, []);
|
||||
|
||||
@@ -185,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) ──
|
||||
@@ -210,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);
|
||||
|
||||
@@ -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<void>((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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
|
||||
|
||||
interface StudioFlushPendingEditsDetail {
|
||||
promises: Array<Promise<unknown>>;
|
||||
}
|
||||
|
||||
const pendingEditPromises = new Set<Promise<unknown>>();
|
||||
|
||||
export function trackStudioPendingEdit(
|
||||
result: Promise<unknown> | unknown,
|
||||
): Promise<unknown> | 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<void> {
|
||||
const detail: StudioFlushPendingEditsDetail = { promises: [] };
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<StudioFlushPendingEditsDetail>(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> | unknown,
|
||||
): () => void {
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<StudioFlushPendingEditsDetail>).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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user