feat(studio): add cutout sub-block to FlatMediaSection

This commit is contained in:
Vance Ingalls
2026-07-14 15:50:44 -07:00
parent 0e0cb4846d
commit 138d45faac
2 changed files with 105 additions and 4 deletions
@@ -80,3 +80,52 @@ describe("FlatMediaSection — source row", () => {
act(() => root.unmount()); 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());
});
});
@@ -6,7 +6,9 @@ import {
type BackgroundRemovalResult, type BackgroundRemovalResult,
stripQueryAndHash, stripQueryAndHash,
} from "./propertyPanelHelpers"; } from "./propertyPanelHelpers";
import { FlatSelectRow, FlatToggle } from "./propertyPanelFlatPrimitives";
// fallow-ignore-next-line complexity
export function FlatMediaSection({ export function FlatMediaSection({
projectDir, projectDir,
element, element,
@@ -43,10 +45,8 @@ export function FlatMediaSection({
const srcAttr = el.getAttribute("src") ?? ""; const srcAttr = el.getAttribute("src") ?? "";
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [removeBusy, setRemoveBusy] = useState(false); const [removeBusy, setRemoveBusy] = useState(false);
// oxlint-disable-next-line no-unused-vars -- rendered by the progress bar added in Task 3
const [removeProgress, setRemoveProgress] = useState<BackgroundRemovalProgress | null>(null); const [removeProgress, setRemoveProgress] = useState<BackgroundRemovalProgress | null>(null);
const [createPlate, setCreatePlate] = useState(false); const [createPlate, setCreatePlate] = useState(false);
// oxlint-disable-next-line no-unused-vars -- wired into the Quality FlatSelectRow in Task 3
const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced"); const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced");
const absoluteSrc = const absoluteSrc =
@@ -55,7 +55,6 @@ export function FlatMediaSection({
srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr)
? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
: ""; : "";
// oxlint-disable-next-line no-unused-vars -- gates the Remove BG button added in Task 3
const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc); const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
useEffect(() => { useEffect(() => {
@@ -71,7 +70,6 @@ export function FlatMediaSection({
} }
}; };
// oxlint-disable-next-line no-unused-vars -- called by the Remove BG button added in Task 3
const runBackgroundRemoval = async () => { const runBackgroundRemoval = async () => {
if (!onRemoveBackground || !projectSrc || removeBusy) return; if (!onRemoveBackground || !projectSrc || removeBusy) return;
setRemoveBusy(true); setRemoveBusy(true);
@@ -120,6 +118,60 @@ export function FlatMediaSection({
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</button> </button>
</div> </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>
)}
</div> </div>
); );
} }