Merge pull request #2123 from heygen-com/studio-flat-04-media

feat(studio): flat inspector — Media group
This commit is contained in:
Vance Ingalls
2026-07-14 16:03:24 -07:00
committed by GitHub
6 changed files with 946 additions and 13 deletions
@@ -615,3 +615,119 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
RENDER_TIMEOUT_MS,
);
});
// Media fixtures (Plan 4 Task 7): the three tag values resolveEditingSections
// turns `media` on for (video/audio/img). Each carries no text fields (so the
// Text group never renders) and sets `element` to a real media node so the
// FlatMediaSection reads a live media element. `as never` casts around the
// element-type mismatch with baseElement()'s HTMLDivElement.
function videoElement() {
return {
...baseElement(),
id: "s1-bg",
selector: "#s1-bg",
label: "S1 Background",
tagName: "video",
textFields: [],
element: document.createElement("video"),
};
}
function imageElement() {
return {
...baseElement(),
id: "s1-img",
selector: "#s1-img",
label: "S1 Image",
tagName: "img",
textFields: [],
element: document.createElement("img"),
};
}
function audioElement() {
return {
...baseElement(),
id: "s1-audio",
selector: "#s1-audio",
label: "S1 Audio",
tagName: "audio",
textFields: [],
element: document.createElement("audio"),
};
}
// All FlatGroup titles currently mounted (open row + every collapsed row).
function flatGroupTitles(host: HTMLElement): string[] {
const open = Array.from(
host.querySelectorAll('[data-flat-group-open="true"] .text-panel-text-0'),
).map((el) => el.textContent ?? "");
const collapsed = Array.from(
host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
).map((el) => el.textContent ?? "");
return [...open, ...collapsed];
}
describe("PropertyPanel — Media group (Plan 4)", () => {
it(
"renders the flat Media group and not the legacy MediaSection, for a video element",
async () => {
const { host, root } = await renderPanel(true, videoElement() as never);
// A Media FlatGroup exists (open or collapsed).
expect(flatGroupTitles(host)).toContain("Media");
// The legacy MediaSection renders its rows inside a `Section` whose
// data-panel-section slug is the media title ("video"/"image"/"audio").
// On the flat path it's fully replaced, so none of those may appear.
expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"one-open accordion: opening Media closes whichever other group was open, and vice versa (5-way exclusivity)",
async () => {
// videoElement() has canEditStyles: true and no text fields, so Style is
// the default-open group; Layout and Media render collapsed alongside it.
const { host, root } = await renderPanel(true, videoElement() as never);
expect(openGroupText(host)).toContain("Style");
// Opening Media closes Style.
openFlatGroup(host, "Media");
const afterMedia = openGroupText(host);
expect(afterMedia).toContain("Media");
expect(afterMedia).not.toContain("Style");
// Reverse direction: opening Layout closes Media — same shared openGroupId.
openFlatGroup(host, "Layout");
const afterLayout = openGroupText(host);
expect(afterLayout).toContain("Layout");
expect(afterLayout).not.toContain("Media");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"gates the Media group exactly like the legacy MediaSection: present for video/img/audio, absent for a plain div/text element",
async () => {
for (const fixture of [videoElement, imageElement, audioElement]) {
const { host, root } = await renderPanel(true, fixture() as never);
expect(flatGroupTitles(host)).toContain("Media");
act(() => root.unmount());
}
// baseElement() is a plain <div> with text — sections.media is false, so
// no Media group (flat or legacy) may render.
const { host, root } = await renderPanel(true);
expect(flatGroupTitles(host)).not.toContain("Media");
expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
@@ -11,12 +11,12 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
import { MediaSection } from "./propertyPanelMediaSection";
type EditingSections = ReturnType<typeof resolveEditingSections>;
@@ -41,8 +41,8 @@ const EMPTY_GSAP_EFFECT_HANDLERS = {
* (same one-directional-import precedent as FlatTextSection). Rendered only
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state.
*
* The Text/Style/Layout/Motion groups share the one-open accordion. The legacy
* Media and Color-Grading sections render unchanged below the flat groups.
* The Text/Style/Layout/Motion/Media groups share the one-open accordion. The
* legacy Color-Grading section renders unchanged below the flat groups.
*/
// fallow-ignore-next-line complexity
export function PropertyPanelFlat({
@@ -215,7 +215,13 @@ export function PropertyPanelFlat({
// switching the selection re-mounts this component and re-derives the
// default instead of preserving stale state across unrelated elements.
const [openGroupId, setOpenGroupId] = useState<string>(() =>
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout",
isTextEditableSelection(element)
? "text"
: showEditableSections
? "style"
: sections.media
? "media"
: "layout",
);
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
@@ -427,15 +433,24 @@ export function PropertyPanelFlat({
/>
)}
{sections.media && (
<MediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
<FlatGroup
title="Media"
isOpen={openGroupId === "media" || pinnedGroupIds.includes("media")}
isPinned={pinnedGroupIds.includes("media")}
onToggleOpen={() => toggleOpen("media")}
onTogglePin={() => togglePin("media")}
summary={element.tagName}
>
<FlatMediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
</FlatGroup>
)}
{showEditableSections && (
<StyleSections
@@ -0,0 +1,433 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
import type { DomEditSelection } from "./domEditing";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function makeVideoElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
const el = document.createElement("video");
el.setAttribute("src", "assets/intro-loop.mp4");
return {
element: el,
id: "s1-bg",
selector: "#s1-bg",
label: "S1 Background",
tagName: "video",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
textContent: "",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
...overrides,
} as DomEditSelection;
}
function renderSection(overrides: Partial<DomEditSelection> = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const element = makeVideoElement(overrides);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
return { host, root };
}
describe("FlatMediaSection — source row", () => {
it("renders the source path and copies it to clipboard on click", () => {
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
});
const { host, root } = renderSection();
expect(host.textContent).toContain("assets/intro-loop.mp4");
const copyButton = host.querySelector<HTMLButtonElement>('[data-flat-media-copy="true"]');
expect(copyButton).not.toBeNull();
act(() => copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("assets/intro-loop.mp4");
act(() => root.unmount());
});
});
describe("FlatMediaSection — cutout", () => {
it("shows the WebM label for video and fires background removal on click", async () => {
const onRemoveBackground = vi.fn().mockResolvedValue({ outputPath: "assets/intro-loop.webm" });
const onSetHtmlAttribute = vi.fn();
const onSetAttribute = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const element = makeVideoElement();
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>,
);
});
expect(host.textContent).toContain("transparent WebM");
const removeBgButton = host.querySelector<HTMLButtonElement>(
'[data-flat-media-remove-bg="true"]',
);
expect(removeBgButton).not.toBeNull();
await act(async () => {
removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(onRemoveBackground).toHaveBeenCalled();
act(() => root.unmount());
});
it("toggles BG plate via FlatToggle", () => {
const { host, root } = renderSection();
const plateToggle = host.querySelector<HTMLButtonElement>(
'[data-flat-toggle="true"][aria-label="BG plate"]',
);
expect(plateToggle).not.toBeNull();
expect(plateToggle?.getAttribute("aria-checked")).toBe("false");
act(() => plateToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(plateToggle?.getAttribute("aria-checked")).toBe("true");
act(() => root.unmount());
});
});
describe("FlatMediaSection — volume/rate/media-start", () => {
it("renders volume at its stored percentage and commits a new value on drag", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
expect(host.textContent).toContain("50%");
act(() => root.unmount());
});
it("commits a new volume value on slider track pointerdown", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
Object.defineProperty(volumeTrack, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
});
// min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5"
expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5");
act(() => root.unmount());
});
it("commits a new rate value on slider track pointerdown", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
const rateTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[1];
Object.defineProperty(rateTrack, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
});
// min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3"
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3");
act(() => root.unmount());
});
it("commits a new media-start value on slider track pointerdown", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
const mediaStartTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[2];
Object.defineProperty(mediaStartTrack, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
mediaStartTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
});
// no source-duration set -> mediaStartMax=Math.max(30, Math.ceil(0+10))=30 -> max=3000
// ratio=1.0 -> raw=3000 -> commit(3000) -> (3000/100).toFixed(2) = "30.00"
expect(onSetAttribute).toHaveBeenCalledWith("media-start", "30.00");
act(() => root.unmount());
});
});
describe("FlatMediaSection — loop/muted/has-audio", () => {
it("toggles loop via onSetHtmlAttribute and shows has-audio-track for video", () => {
const onSetHtmlAttribute = vi.fn();
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
/>,
);
});
const loopToggle = host.querySelector<HTMLButtonElement>(
'[data-flat-toggle="true"][aria-label="Loop"]',
);
act(() => loopToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onSetHtmlAttribute).toHaveBeenCalledWith("loop", "true");
const hasAudioToggle = host.querySelector<HTMLButtonElement>(
'[data-flat-toggle="true"][aria-label="Has audio track"]',
);
expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true");
act(() => root.unmount());
});
it("toggles muted via onSetHtmlAttribute", () => {
const onSetHtmlAttribute = vi.fn();
const onSetAttribute = vi.fn();
const element = makeVideoElement();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
/>,
);
});
const mutedToggle = host.querySelector<HTMLButtonElement>(
'[data-flat-toggle="true"][aria-label="Muted"]',
);
expect(mutedToggle?.getAttribute("aria-checked")).toBe("false");
act(() => mutedToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true");
act(() => root.unmount());
});
it("enables has-audio-track and clears muted on click", () => {
const onSetHtmlAttribute = vi.fn();
const onSetAttribute = vi.fn();
const element = makeVideoElement();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
/>,
);
});
const hasAudioToggle = host.querySelector<HTMLButtonElement>(
'[data-flat-toggle="true"][aria-label="Has audio track"]',
);
expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("false");
act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "true");
expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", null);
act(() => root.unmount());
});
it("disables has-audio-track and sets muted on click", () => {
const onSetHtmlAttribute = vi.fn();
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
/>,
);
});
const hasAudioToggle = host.querySelector<HTMLButtonElement>(
'[data-flat-toggle="true"][aria-label="Has audio track"]',
);
expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true");
act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "");
expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true");
act(() => root.unmount());
});
});
describe("FlatMediaSection — fit/position", () => {
it("commits object-fit and object-position changes", () => {
const onSetStyle = vi.fn();
const { host, root } = (() => {
const element = makeVideoElement();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{ "object-fit": "cover", "object-position": "center" }}
onSetStyle={onSetStyle}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
return { host, root };
})();
const selects = host.querySelectorAll("select");
const fitSelect = Array.from(selects).find((s) => s.value === "cover");
expect(fitSelect).not.toBeUndefined();
act(() => {
if (fitSelect) {
fitSelect.value = "contain";
fitSelect.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(onSetStyle).toHaveBeenCalledWith("object-fit", "contain");
act(() => root.unmount());
});
it("commits an object-position change", () => {
const onSetStyle = vi.fn();
const element = makeVideoElement();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{ "object-fit": "cover", "object-position": "center" }}
onSetStyle={onSetStyle}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
const selects = host.querySelectorAll("select");
const positionSelect = Array.from(selects).find((s) => s.value === "center");
expect(positionSelect).not.toBeUndefined();
act(() => {
if (positionSelect) {
positionSelect.value = "left top";
positionSelect.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(onSetStyle).toHaveBeenCalledWith("object-position", "left top");
act(() => root.unmount());
});
});
@@ -0,0 +1,281 @@
import { useEffect, useState } from "react";
import { Check, ClipboardList } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import {
type BackgroundRemovalProgress,
type BackgroundRemovalResult,
formatNumericValue,
formatTimingValue,
parseNumericValue,
stripQueryAndHash,
} from "./propertyPanelHelpers";
import { FlatSelectRow, FlatSlider, FlatToggle } from "./propertyPanelFlatPrimitives";
// fallow-ignore-next-line complexity
export function FlatMediaSection({
projectDir,
element,
styles,
onSetStyle,
onSetAttribute,
onSetHtmlAttribute,
onRemoveBackground,
}: {
projectDir: string | null;
element: DomEditSelection;
styles: Record<string, string>;
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;
const volumePercent = Math.round(volume * 100);
const mediaStart =
Number.parseFloat(
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
) || 0;
const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
const sourceDuration =
Number.parseFloat(element.dataAttributes["source-duration"] ?? "") ||
(el as HTMLMediaElement).duration ||
0;
const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10));
const hasLoop = el.hasAttribute("loop");
const hasMuted = el.hasAttribute("muted");
const hasAudio = element.dataAttributes["has-audio"] === "true";
const objectFit = styles["object-fit"] || "contain";
const objectPosition = styles["object-position"] || "center";
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);
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 (
<div className="space-y-1.5">
<div className="flex min-h-8 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<span className="h-5 w-8 flex-shrink-0 rounded-[3px] bg-panel-surface" />
<span className="min-w-0 truncate font-mono text-[11px] text-panel-text-0">
{srcAttr}
</span>
</span>
<button
type="button"
data-flat-media-copy="true"
onClick={() => {
void navigator.clipboard.writeText(absoluteSrc).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
className="flex flex-shrink-0 items-center gap-1 text-[10px] text-panel-text-3 hover:text-panel-text-1"
>
{copied ? <Check size={11} /> : <ClipboardList size={11} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
{isVisualMedia && (
<div className="ml-[1px] border-l-2 border-panel-border-input py-1 pl-[10px]">
<div className="flex min-h-6 items-center justify-between">
<span className="flex items-baseline gap-[7px]">
<span className="text-[11px] font-semibold text-panel-text-1">Cutout</span>
<span className="font-mono text-[9px] text-panel-text-4">
transparent {isVideo ? "WebM" : "PNG"}
</span>
</span>
<button
type="button"
data-flat-media-remove-bg="true"
disabled={!canRemoveBackground || removeBusy}
onClick={() => void runBackgroundRemoval()}
className="flex items-center gap-1 text-[10px] font-medium text-panel-accent disabled:cursor-not-allowed disabled:opacity-50"
title={
canRemoveBackground
? "Remove background and save a transparent asset"
: "Select a project-local image or video asset"
}
>
{removeBusy ? "Working" : "Remove BG"}
</button>
</div>
<FlatSelectRow
label="Quality"
value={quality}
options={["fast", "balanced", "best"]}
tier="explicitDefault"
onChange={(next) => setQuality(next as typeof quality)}
/>
{isVideo && (
<FlatToggle label="BG plate" checked={createPlate} onChange={setCreatePlate} />
)}
{removeProgress && (
<div className="mt-1 space-y-1">
<div className="flex items-center justify-between 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-hover">
<div
className={`h-full rounded-full ${
removeProgress.status === "failed" ? "bg-red-400" : "bg-panel-accent"
}`}
style={{ width: `${Math.max(0, Math.min(100, removeProgress.progress))}%` }}
/>
</div>
</div>
)}
</div>
)}
{(isVideo || isAudio) && (
<>
<FlatSlider
label="Volume"
value={volumePercent}
min={0}
max={100}
tier={volumePercent === 100 ? "default" : "explicitCustom"}
displayValue={`${volumePercent}%`}
onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
/>
<FlatSlider
label="Rate"
value={playbackRate * 100}
min={25}
max={300}
tier={playbackRate === 1 ? "default" : "explicitCustom"}
displayValue={`${formatNumericValue(playbackRate)}x`}
onCommit={(next) =>
void onSetAttribute("playback-rate", formatNumericValue(next / 100))
}
/>
<FlatSlider
label="Media start"
value={Math.round(mediaStart * 100)}
min={0}
max={mediaStartMax * 100}
tier={mediaStart === 0 ? "default" : "explicitCustom"}
displayValue={formatTimingValue(mediaStart)}
onCommit={(next) => void onSetAttribute("media-start", (next / 100).toFixed(2))}
/>
<FlatToggle
label="Loop"
checked={hasLoop}
onChange={(next) => void onSetHtmlAttribute("loop", next ? "true" : null)}
/>
<FlatToggle
label="Muted"
checked={hasMuted}
onChange={(next) => void onSetHtmlAttribute("muted", next ? "true" : null)}
/>
{isVideo && (
<FlatToggle
label="Has audio track"
checked={hasAudio}
onChange={(next) => {
if (next) {
void onSetAttribute("has-audio", "true");
void onSetHtmlAttribute("muted", null);
} else {
void onSetAttribute("has-audio", "");
void onSetHtmlAttribute("muted", "true");
}
}}
/>
)}
</>
)}
{isVisualMedia && (
<>
<FlatSelectRow
label="Fit"
value={objectFit}
options={["contain", "cover", "fill", "none", "scale-down"]}
tier={objectFit === "contain" ? "default" : "explicitCustom"}
onChange={(next) => void onSetStyle("object-fit", next)}
/>
<FlatSelectRow
label="Position"
value={objectPosition}
options={[
"center",
"top",
"bottom",
"left",
"right",
"left top",
"right top",
"left bottom",
"right bottom",
]}
tier={objectPosition === "center" ? "default" : "explicitCustom"}
onChange={(next) => void onSetStyle("object-position", next)}
/>
</>
)}
</div>
);
}
@@ -9,6 +9,7 @@ import {
FlatSegmentedRow,
FlatSelectRow,
FlatSlider,
FlatToggle,
PinnedZoneDivider,
} from "./propertyPanelFlatPrimitives";
@@ -286,3 +287,44 @@ describe("FlatSelectRow", () => {
act(() => root.unmount());
});
});
describe("FlatToggle", () => {
it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => {
const onChange = vi.fn();
const { host, root } = renderInto(
<FlatToggle label="Loop" checked={false} onChange={onChange} />,
);
const label = host.querySelector('[data-flat-toggle-label="true"]');
expect(label?.className).toContain("text-panel-text-3");
const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
expect(pill).not.toBeNull();
act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onChange).toHaveBeenCalledWith(true);
act(() => root.unmount());
});
it("renders the on state with an emphasized label and mint knob, and fires onChange(false) on click", () => {
const onChange = vi.fn();
const { host, root } = renderInto(<FlatToggle label="Loop" checked onChange={onChange} />);
const label = host.querySelector('[data-flat-toggle-label="true"]');
expect(label?.className).toContain("text-panel-text-2");
const knob = host.querySelector('[data-flat-toggle-knob="true"]');
expect(knob?.className).toContain("bg-panel-accent");
const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onChange).toHaveBeenCalledWith(false);
act(() => root.unmount());
});
it("does not fire onChange when disabled", () => {
const onChange = vi.fn();
const { host, root } = renderInto(
<FlatToggle label="Loop" checked={false} disabled onChange={onChange} />,
);
const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
expect(pill?.disabled).toBe(true);
act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onChange).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
@@ -375,3 +375,49 @@ export function FlatSelectRow({
</div>
);
}
/* ------------------------------------------------------------------ */
/* FlatToggle — 24×14 pill switch */
/* ------------------------------------------------------------------ */
export function FlatToggle({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
return (
<div className="flex min-h-[30px] items-center justify-between">
<span
data-flat-toggle-label="true"
className={`text-[11px] ${checked ? "text-panel-text-2" : "text-panel-text-3"}`}
>
{label}
</span>
<button
type="button"
data-flat-toggle="true"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-[14px] w-6 flex-shrink-0 rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-panel-accent/35" : "bg-panel-hover"
}`}
>
<span
data-flat-toggle-knob="true"
className={`absolute top-0.5 h-2.5 w-2.5 rounded-full transition-all ${
checked ? "right-0.5 bg-panel-accent" : "left-0.5 bg-panel-text-4"
}`}
/>
</button>
</div>
);
}