diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx
index 298773af51..a3d854c7b4 100644
--- a/packages/studio/src/components/editor/PropertyPanel.test.tsx
+++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx
@@ -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
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,
+ );
+});
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx
index 762d50c198..cdb1c9b2a1 100644
--- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx
+++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx
@@ -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
;
@@ -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(() =>
- isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout",
+ isTextEditableSelection(element)
+ ? "text"
+ : showEditableSections
+ ? "style"
+ : sections.media
+ ? "media"
+ : "layout",
);
const [pinnedGroupIds, setPinnedGroupIds] = useState([]);
@@ -427,15 +433,24 @@ export function PropertyPanelFlat({
/>
)}
{sections.media && (
-
+ toggleOpen("media")}
+ onTogglePin={() => togglePin("media")}
+ summary={element.tagName}
+ >
+
+
)}
{showEditableSections && (
{
+ document.body.innerHTML = "";
+});
+
+function makeVideoElement(overrides: Partial = {}): 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 = {}) {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ const element = makeVideoElement(overrides);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ 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('[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(
+ ,
+ );
+ });
+ expect(host.textContent).toContain("transparent WebM");
+ const removeBgButton = host.querySelector(
+ '[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(
+ '[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(
+ ,
+ );
+ });
+ 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(
+ ,
+ );
+ });
+ 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(
+ ,
+ );
+ });
+ 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(
+ ,
+ );
+ });
+ 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(
+ ,
+ );
+ });
+ const loopToggle = host.querySelector(
+ '[data-flat-toggle="true"][aria-label="Loop"]',
+ );
+ act(() => loopToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+ expect(onSetHtmlAttribute).toHaveBeenCalledWith("loop", "true");
+
+ const hasAudioToggle = host.querySelector(
+ '[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(
+ ,
+ );
+ });
+ const mutedToggle = host.querySelector(
+ '[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(
+ ,
+ );
+ });
+ const hasAudioToggle = host.querySelector(
+ '[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(
+ ,
+ );
+ });
+ const hasAudioToggle = host.querySelector(
+ '[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(
+ ,
+ );
+ });
+ 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(
+ ,
+ );
+ });
+ 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());
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx
new file mode 100644
index 0000000000..b3bcca8334
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx
@@ -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;
+ onSetStyle: (prop: string, value: string) => void | Promise;
+ onSetAttribute: (attr: string, value: string) => void | Promise;
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise;
+ onRemoveBackground?: (
+ inputPath: string,
+ options: {
+ createBackgroundPlate?: boolean;
+ quality?: "fast" | "balanced" | "best";
+ onProgress?: (progress: BackgroundRemovalProgress) => void;
+ },
+ ) => Promise;
+}) {
+ const isVideo = element.tagName === "video";
+ const isAudio = element.tagName === "audio";
+ const isImage = element.tagName === "img";
+ const isVisualMedia = isVideo || isImage;
+ const el = element.element;
+
+ const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
+ 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(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 (
+
+
+
+
+
+ {srcAttr}
+
+
+
+
+ {isVisualMedia && (
+
+
+
+ Cutout
+
+ transparent {isVideo ? "WebM" : "PNG"}
+
+
+
+
+
setQuality(next as typeof quality)}
+ />
+ {isVideo && (
+
+ )}
+ {removeProgress && (
+
+
+
+ {removeProgress.error ?? removeProgress.stage ?? "Processing"}
+
+ {Math.round(removeProgress.progress)}%
+
+
+
+ )}
+
+ )}
+ {(isVideo || isAudio) && (
+ <>
+
void onSetAttribute("volume", formatNumericValue(next / 100))}
+ />
+
+ void onSetAttribute("playback-rate", formatNumericValue(next / 100))
+ }
+ />
+ void onSetAttribute("media-start", (next / 100).toFixed(2))}
+ />
+ void onSetHtmlAttribute("loop", next ? "true" : null)}
+ />
+ void onSetHtmlAttribute("muted", next ? "true" : null)}
+ />
+ {isVideo && (
+ {
+ if (next) {
+ void onSetAttribute("has-audio", "true");
+ void onSetHtmlAttribute("muted", null);
+ } else {
+ void onSetAttribute("has-audio", "");
+ void onSetHtmlAttribute("muted", "true");
+ }
+ }}
+ />
+ )}
+ >
+ )}
+ {isVisualMedia && (
+ <>
+ void onSetStyle("object-fit", next)}
+ />
+ void onSetStyle("object-position", next)}
+ />
+ >
+ )}
+
+ );
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx
index 2eea86d44d..954ea8052f 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx
@@ -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(
+ ,
+ );
+ const label = host.querySelector('[data-flat-toggle-label="true"]');
+ expect(label?.className).toContain("text-panel-text-3");
+ const pill = host.querySelector('[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();
+ 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('[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(
+ ,
+ );
+ const pill = host.querySelector('[data-flat-toggle="true"]');
+ expect(pill?.disabled).toBe(true);
+ act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+ expect(onChange).not.toHaveBeenCalled();
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx
index 6cc2217358..e6dc9f1f78 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx
@@ -375,3 +375,49 @@ export function FlatSelectRow({
);
}
+
+/* ------------------------------------------------------------------ */
+/* FlatToggle — 24×14 pill switch */
+/* ------------------------------------------------------------------ */
+
+export function FlatToggle({
+ label,
+ checked,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ disabled?: boolean;
+ onChange: (next: boolean) => void;
+}) {
+ return (
+
+
+ {label}
+
+
+
+ );
+}