feat(studio): add color grading inspector

This commit is contained in:
ukimsanov
2026-07-06 13:40:20 -07:00
parent a4989dbe75
commit 139cf568b4
19 changed files with 1490 additions and 285 deletions
@@ -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="{&quot;preset&quot;:&quot;natural-lift&quot;}"`,
);
expect(html).toContain(
`img id="i" data-color-grading="{&quot;preset&quot;:&quot;natural-lift&quot;}"`,
);
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="{&quot;adjust&quot;:{&quot;exposure&quot;: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="{&quot;adjust&quot;:{&quot;contrast&quot;: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="{&quot;preset&quot;:&quot;clean-studio&quot;}"></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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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"],
@@ -3,9 +3,19 @@ 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,
Minus,
Plus,
RotateCcw,
Settings,
X,
} from "../../icons/SystemIcons";
import { LUT_EXT } from "../../utils/mediaTypes";
import { LABEL } from "./propertyPanelHelpers";
@@ -13,7 +23,7 @@ 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,13 +44,119 @@ 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: "%" },
];
const DETAIL_SLIDERS: Array<{
key: HfColorGradingDetailKey;
label: string;
min: number;
max: number;
step: number;
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,
},
];
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 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",
);
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
@@ -75,6 +191,7 @@ function ColorGradingSliderControl({
disabled,
onCommit,
onReset,
settings,
}: {
label: string;
value: number;
@@ -88,6 +205,11 @@ function ColorGradingSliderControl({
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);
@@ -166,9 +288,29 @@ function ColorGradingSliderControl({
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">
<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"
@@ -178,7 +320,7 @@ function ColorGradingSliderControl({
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"
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} />
@@ -186,7 +328,7 @@ function ColorGradingSliderControl({
)}
</div>
<div className="relative h-7 min-w-0">
<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"
@@ -224,8 +366,8 @@ function ColorGradingSliderControl({
/>
</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">
<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}
@@ -252,7 +394,7 @@ function ColorGradingSliderControl({
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"
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>}
@@ -263,7 +405,7 @@ function ColorGradingSliderControl({
disabled={disabled}
aria-label={`Decrease ${label}`}
onClick={() => nudge(-1)}
className="flex h-7 w-5 items-center justify-center text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
className="flex h-5 w-5 items-center justify-center text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Decrease ${label}`}
>
<Minus size={11} />
@@ -273,7 +415,7 @@ function ColorGradingSliderControl({
disabled={disabled}
aria-label={`Increase ${label}`}
onClick={() => nudge(1)}
className="flex h-7 w-5 items-center justify-center border-l border-panel-border text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
className="flex h-5 w-5 items-center justify-center border-l border-panel-border text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Increase ${label}`}
>
<Plus size={11} />
@@ -284,6 +426,15 @@ function ColorGradingSliderControl({
);
}
function normalizedDefaultValue(slider: { defaultValue?: number; scale: number }): number {
return (slider.defaultValue ?? 0) / slider.scale;
}
function visibleIntensity(grading: NormalizedHfColorGrading): number {
// Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
return grading.intensity === 0 ? 1 : grading.intensity;
}
export function ColorGradingControls({
grading,
assets,
@@ -296,21 +447,37 @@ export function ColorGradingControls({
onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void;
}) {
const lutInputRef = useRef<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,
});
};
@@ -326,7 +493,7 @@ export function ColorGradingControls({
};
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 +508,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 +643,7 @@ export function ColorGradingControls({
onCommit={(next) => {
onCommitColorGrading({
...grading,
intensity: 1,
intensity: visibleIntensity(grading),
adjust: {
...grading.adjust,
[slider.key]: next / slider.scale,
@@ -458,7 +653,7 @@ export function ColorGradingControls({
onReset={() => {
onCommitColorGrading({
...grading,
intensity: 1,
intensity: visibleIntensity(grading),
adjust: {
...grading.adjust,
[slider.key]: 0,
@@ -466,9 +661,159 @@ export function ColorGradingControls({
});
}}
/>
);
})}
</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) => {
const value = grading.details[slider.key] * slider.scale;
const defaultValue = slider.defaultValue ?? 0;
return (
<ColorGradingSliderControl
key={slider.key}
label={slider.label}
value={Math.round(value)}
min={slider.min}
max={slider.max}
step={slider.step}
neutral={defaultValue}
suffix={slider.suffix}
displayValue={`${Math.round(value)}%`}
settings={{
active: slider.key === "vignette" ? vignetteSettingsActive : grainSettingsActive,
label: `${slider.label} settings`,
onClick: () =>
setDetailSettings((current) =>
current === slider.key ? null : (slider.key as "vignette" | "grain"),
),
}}
onCommit={(next) => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: {
...grading.details,
[slider.key]: next / slider.scale,
},
});
}}
onReset={() => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: {
...grading.details,
[slider.key]: defaultValue / slider.scale,
},
});
}}
/>
);
})}
</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) => {
const value = grading.details[slider.key] * slider.scale;
const defaultValue = slider.defaultValue ?? 0;
return (
<ColorGradingSliderControl
key={slider.key}
label={slider.label}
value={Math.round(value)}
min={slider.min}
max={slider.max}
step={slider.step}
neutral={defaultValue}
suffix={slider.suffix}
displayValue={`${Math.round(value)}%`}
onCommit={(next) => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: {
...grading.details,
[slider.key]: next / slider.scale,
},
});
}}
onReset={() => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: {
...grading.details,
[slider.key]: defaultValue / slider.scale,
},
});
}}
/>
);
})}
</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,101 @@ import {
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons";
import {
addStudioPendingEditFlushListener,
trackStudioPendingEdit,
} from "../../utils/studioPendingEdits";
import type { DomEditSelection } from "./domEditing";
import { ColorGradingControls } from "./propertyPanelColorGradingControls";
import { Section } from "./propertyPanelPrimitives";
const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
const MEDIA_METADATA_CACHE = new Map<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 stripQueryAndHash(value: string): string {
return value.replace(/[?#].*$/, "");
}
function stripPreviewAssetPath(src: string, projectId: string): string | null {
let pathname = src;
try {
pathname = new URL(src, window.location.href).pathname;
} catch {
return null;
}
const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`;
const genericMarker = "/preview/";
const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker;
const index = pathname.indexOf(marker);
if (index < 0) return null;
const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, "");
if (!assetPath || assetPath.startsWith("comp/")) return null;
return assetPath;
}
function resolveProjectAssetPath(
sourceFile: string,
src: string,
projectId: string,
): string | null {
const trimmed = stripQueryAndHash(src.trim());
if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null;
if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId);
if (trimmed.startsWith("/")) {
return stripPreviewAssetPath(trimmed, projectId);
}
const sourceDir = sourceFile.includes("/")
? sourceFile.slice(0, sourceFile.lastIndexOf("/"))
: "";
const parts = `${sourceDir}/${trimmed}`.split("/");
const normalized: string[] = [];
for (const part of parts) {
if (!part || part === ".") continue;
if (part === "..") {
normalized.pop();
continue;
}
normalized.push(part);
}
return normalized.join("/") || null;
}
function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null {
if (element.tagName !== "video" && element.tagName !== "img") return null;
const media = element.element as HTMLImageElement | HTMLVideoElement;
const src = media.getAttribute("src") || media.currentSrc || "";
return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId);
}
function defaultColorGrading(): NormalizedHfColorGrading {
const grading = normalizeHfColorGrading("neutral");
if (!grading) throw new Error("Missing neutral color grading preset");
@@ -34,10 +118,9 @@ function defaultColorGrading(): NormalizedHfColorGrading {
}
function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
const grading =
normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ??
defaultColorGrading();
return { ...grading, intensity: 1 };
return (
normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading()
);
}
function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown {
@@ -80,13 +163,45 @@ function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
? "bg-red-400"
: "bg-panel-text-5";
return (
<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 +268,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 +323,75 @@ export function ColorGradingSection({
setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target));
}, [previewIframeRef, target]);
useEffect(() => {
setMediaMetadata(null);
if (!selectedAssetPath) return;
const cacheKey = `${projectId}:${selectedAssetPath}`;
if (MEDIA_METADATA_CACHE.has(cacheKey)) {
setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null);
return;
}
const controller = new AbortController();
fetch(
`/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent(
selectedAssetPath,
)}`,
{ signal: controller.signal },
)
.then((response) => (response.ok ? response.json() : null))
.then((data: MediaMetadataResponse | null) => {
if (controller.signal.aborted) return;
const metadata = data?.metadata ?? null;
MEDIA_METADATA_CACHE.set(cacheKey, metadata);
setMediaMetadata(metadata);
})
.catch(() => {
if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null);
});
return () => controller.abort();
}, [projectId, selectedAssetPath]);
const clearStatusTimers = useCallback(() => {
for (const timer of statusTimersRef.current) clearTimeout(timer);
statusTimersRef.current = [];
}, []);
const scheduleRuntimeStatusRefresh = useCallback(() => {
clearStatusTimers();
statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) =>
window.setTimeout(refreshRuntimeStatus, delay),
);
}, [clearStatusTimers, refreshRuntimeStatus]);
useEffect(() => {
refreshRuntimeStatus();
}, [refreshRuntimeStatus]);
useEffect(() => {
const iframe = previewIframeRef?.current;
if (!iframe) return;
const refresh = () => {
window.setTimeout(refreshRuntimeStatus, 50);
};
iframe.addEventListener("load", refresh);
const timer = window.setTimeout(refreshRuntimeStatus, 80);
return () => {
iframe.removeEventListener("load", refresh);
window.clearTimeout(timer);
};
}, [previewIframeRef, refreshRuntimeStatus]);
const persistColorGradingValue = useCallback((value: string | null) => {
return trackStudioPendingEdit(
onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null),
);
}, []);
const flushPendingPersist = useCallback(() => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
if (pendingPersistValueRef.current === undefined) return undefined;
const value = pendingPersistValueRef.current;
pendingPersistValueRef.current = undefined;
return persistColorGradingValue(value);
}, [persistColorGradingValue]);
useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]);
useEffect(() => {
return () => {
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
if (pendingPersistValueRef.current !== undefined) {
void onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, pendingPersistValueRef.current);
pendingPersistValueRef.current = undefined;
}
clearStatusTimers();
void flushPendingPersist();
};
}, []);
}, [clearStatusTimers, flushPendingPersist]);
const postColorGrading = useCallback(
(nextGrading: NormalizedHfColorGrading) => {
@@ -255,6 +429,31 @@ export function ColorGradingSection({
[previewIframeRef, target],
);
useEffect(() => {
const iframe = previewIframeRef?.current;
if (!iframe) return;
const refreshAndReplay = () => {
const nextGrading = latestGradingRef.current;
const active = isHfColorGradingActive(nextGrading);
if (active) postColorGrading(nextGrading);
postCompare(compareEnabledRef.current && active);
scheduleRuntimeStatusRefresh();
};
const onMessage = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return;
const data = event.data as { source?: unknown; type?: unknown } | null;
if (data?.source === "hf-preview" && data.type === "ready") refreshAndReplay();
};
iframe.addEventListener("load", refreshAndReplay);
window.addEventListener("message", onMessage);
const timer = window.setTimeout(refreshAndReplay, 80);
return () => {
iframe.removeEventListener("load", refreshAndReplay);
window.removeEventListener("message", onMessage);
window.clearTimeout(timer);
};
}, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]);
useEffect(
() => () => {
postCompare(false);
@@ -272,7 +471,7 @@ export function ColorGradingSection({
postCompare(active);
if (!active) setCompareEnabled(false);
}
window.setTimeout(refreshRuntimeStatus, 50);
scheduleRuntimeStatusRefresh();
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
? serializeHfColorGrading(nextGrading)
@@ -280,10 +479,11 @@ export function ColorGradingSection({
persistTimerRef.current = setTimeout(() => {
const value = pendingPersistValueRef.current;
pendingPersistValueRef.current = undefined;
void onSetAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null);
persistTimerRef.current = null;
void persistColorGradingValue(value ?? null);
}, 350);
},
[onSetAttributeLive, postColorGrading, postCompare, refreshRuntimeStatus],
[persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
);
const commitCompare = useCallback(
@@ -292,14 +492,25 @@ export function ColorGradingSection({
setCompareEnabled(nextEnabled);
if (nextEnabled) postColorGrading(grading);
postCompare(nextEnabled);
window.setTimeout(refreshRuntimeStatus, 50);
scheduleRuntimeStatusRefresh();
},
[grading, postColorGrading, postCompare, refreshRuntimeStatus],
[grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
);
const applyToScope = useCallback(async () => {
if (!onApplyScope || applyBusy) return;
setApplyBusy(true);
try {
const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null;
await onApplyScope(applyScope, value);
} finally {
setApplyBusy(false);
}
}, [applyBusy, applyScope, grading, onApplyScope]);
return (
<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 +527,46 @@ export function ColorGradingSection({
commitColorGrading(defaultColorGrading());
}}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1"
title="Reset grading"
title="Reset color grading"
>
<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>
);
}
@@ -18,7 +18,19 @@ export interface PropertyPanelProps {
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;
@@ -88,6 +100,22 @@ export interface PropertyPanelProps {
onToggleRecording?: () => void;
}
export interface BackgroundRemovalProgress {
status: "processing" | "complete" | "failed";
progress: number;
stage?: string;
outputPath?: string;
backgroundOutputPath?: string;
error?: string;
provider?: string;
}
export interface BackgroundRemovalResult {
outputPath: string;
backgroundOutputPath?: string;
provider?: string;
}
/* ------------------------------------------------------------------ */
/* Font types & constants (shared by font and section modules) */
/* ------------------------------------------------------------------ */
@@ -1,7 +1,9 @@
import { useState } from "react";
import { Check, ClipboardList, Film, Music } from "../../icons/SystemIcons";
import { useEffect, useState } from "react";
import { Check, ClipboardList, Film, Music, Scissors } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import {
type BackgroundRemovalProgress,
type BackgroundRemovalResult,
formatNumericValue,
formatTimingValue,
LABEL,
@@ -17,6 +19,7 @@ export function MediaSection({
onSetStyle,
onSetAttribute,
onSetHtmlAttribute,
onRemoveBackground,
}: {
projectDir: string | null;
element: DomEditSelection;
@@ -24,8 +27,19 @@ export function MediaSection({
onSetStyle: (prop: string, value: string) => void | Promise<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 +67,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)
? srcAttr.replace(/^\.\//, "").replace(/[?#].*$/, "")
: "";
const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
const panelTitle = isImage ? "Image" : isVideo ? "Video" : "Audio";
useEffect(() => {
setRemoveProgress(null);
setCreatePlate(false);
}, [srcAttr]);
const applyCutoutResult = async (result: BackgroundRemovalResult) => {
await onSetHtmlAttribute("src", result.outputPath);
if (isVideo) {
await onSetAttribute("has-audio", "");
await onSetHtmlAttribute("muted", "true");
}
};
const runBackgroundRemoval = async () => {
if (!onRemoveBackground || !projectSrc || removeBusy) return;
setRemoveBusy(true);
setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" });
try {
const result = await onRemoveBackground(projectSrc, {
createBackgroundPlate: isVideo && createPlate,
quality,
onProgress: setRemoveProgress,
});
await applyCutoutResult(result);
setRemoveProgress({
status: "complete",
progress: 100,
stage: "Applied cutout",
...result,
});
} catch (error) {
setRemoveProgress({
status: "failed",
progress: 0,
stage: "Failed",
error: error instanceof Error ? error.message : String(error),
});
} finally {
setRemoveBusy(false);
}
};
return (
<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 +153,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