diff --git a/packages/studio-server/src/routes/media.test.ts b/packages/studio-server/src/routes/media.test.ts
index 99a38ab980..968d8af6e6 100644
--- a/packages/studio-server/src/routes/media.test.ts
+++ b/packages/studio-server/src/routes/media.test.ts
@@ -228,4 +228,31 @@ describe("registerMediaRoutes", () => {
expect(response.status).toBe(403);
expect(startBackgroundRemoval).not.toHaveBeenCalled();
});
+
+ it("starts an image cutout job with default PNG output for JPG input", async () => {
+ const { app, projectDir, startBackgroundRemoval } = createAdapter(completeJob);
+
+ const response = await app.request("http://localhost/projects/demo/media/remove-background", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ inputPath: "assets/photo.jpg" }),
+ });
+ const data = (await response.json()) as {
+ jobId: string;
+ outputPath: string;
+ };
+
+ expect(response.status).toBe(200);
+ expect(data.outputPath).toBe("assets/cutouts/photo-cutout.png");
+ expect(startBackgroundRemoval).toHaveBeenCalledWith(
+ expect.objectContaining({
+ project: { id: "demo", dir: projectDir },
+ inputAssetPath: "assets/photo.jpg",
+ outputAssetPath: "assets/cutouts/photo-cutout.png",
+ quality: "balanced",
+ device: "auto",
+ jobId: data.jobId,
+ }),
+ );
+ });
});
diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx
index 88a8f90140..953efeb0fd 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx
@@ -439,3 +439,139 @@ describe("FlatMediaSection — fit/position", () => {
act(() => root.unmount());
});
});
+
+describe("FlatMediaSection — image cutout", () => {
+ function makeImageElement(src: string): DomEditSelection {
+ const el = document.createElement("img");
+ el.setAttribute("src", src);
+ return {
+ element: el,
+ id: "hero",
+ selector: "#hero",
+ label: "Hero",
+ tagName: "img",
+ sourceFile: "index.html",
+ compositionPath: "index.html",
+ isCompositionHost: false,
+ isInsideLockedComposition: false,
+ boundingBox: { x: 0, y: 0, width: 800, height: 600 },
+ textContent: "",
+ dataAttributes: {},
+ inlineStyles: {},
+ computedStyles: {},
+ textFields: [],
+ capabilities: {
+ canSelect: true,
+ canEditStyles: true,
+ canCrop: true,
+ canMove: true,
+ canResize: true,
+ canApplyManualOffset: true,
+ canApplyManualSize: true,
+ canApplyManualRotation: true,
+ },
+ } as DomEditSelection;
+ }
+
+ it("enables Remove BG for a project-local image and sends the normalized path", async () => {
+ const onRemoveBackground = vi
+ .fn()
+ .mockResolvedValue({ outputPath: "assets/cutouts/portrait-cutout.png" });
+ const onSetHtmlAttribute = vi.fn();
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ expect(host.textContent).toContain("transparent PNG");
+ const removeBgButton = host.querySelector(
+ '[data-flat-media-remove-bg="true"]',
+ );
+ expect(removeBgButton).not.toBeNull();
+ expect(removeBgButton?.disabled).toBe(false);
+ await act(async () => {
+ removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(onRemoveBackground).toHaveBeenCalledWith(
+ "assets/portrait.jpg",
+ expect.objectContaining({ quality: "balanced" }),
+ );
+ expect(onSetHtmlAttribute).toHaveBeenCalledWith("src", "assets/cutouts/portrait-cutout.png");
+ act(() => root.unmount());
+ });
+
+ it("enables Remove BG when src is a Studio preview URL for an image", async () => {
+ const onRemoveBackground = vi
+ .fn()
+ .mockResolvedValue({ outputPath: "assets/cutouts/portrait-cutout.png" });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ const removeBgButton = host.querySelector(
+ '[data-flat-media-remove-bg="true"]',
+ );
+ expect(removeBgButton?.disabled).toBe(false);
+ await act(async () => {
+ removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(onRemoveBackground).toHaveBeenCalledWith(
+ "assets/portrait.jpg",
+ expect.objectContaining({ quality: "balanced" }),
+ );
+ act(() => root.unmount());
+ });
+
+ it("disables Remove BG for remote image URLs", () => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ const removeBgButton = host.querySelector(
+ '[data-flat-media-remove-bg="true"]',
+ );
+ expect(removeBgButton?.disabled).toBe(true);
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx
index 98650a31b9..b04b6bcd44 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx
@@ -8,8 +8,8 @@ import {
formatNumericValue,
formatTimingValue,
parseNumericValue,
- stripQueryAndHash,
} from "./propertyPanelHelpers";
+import { resolveProjectAssetPath } from "../../utils/projectAssetPath";
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle";
import { AutomationToggle } from "./propertyPanelFxControls";
@@ -88,10 +88,7 @@ export function FlatMediaSection({
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 projectSrc = resolveProjectAssetPath(srcAttr, element.sourceFile || "index.html") ?? "";
const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
useEffect(() => {
diff --git a/packages/studio/src/components/editor/propertyPanelMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.test.tsx
new file mode 100644
index 0000000000..a3fb683339
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelMediaSection.test.tsx
@@ -0,0 +1,121 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { MediaSection } from "./propertyPanelMediaSection";
+import type { DomEditSelection } from "./domEditing";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+function makeImageElement(src: string): DomEditSelection {
+ const el = document.createElement("img");
+ el.setAttribute("src", src);
+ return {
+ element: el,
+ id: "hero",
+ selector: "#hero",
+ label: "Hero",
+ tagName: "img",
+ sourceFile: "index.html",
+ compositionPath: "index.html",
+ isCompositionHost: false,
+ isInsideLockedComposition: false,
+ boundingBox: { x: 0, y: 0, width: 800, height: 600 },
+ textContent: "",
+ dataAttributes: {},
+ inlineStyles: {},
+ computedStyles: {},
+ textFields: [],
+ capabilities: {
+ canSelect: true,
+ canEditStyles: true,
+ canCrop: true,
+ canMove: true,
+ canResize: true,
+ canApplyManualOffset: true,
+ canApplyManualSize: true,
+ canApplyManualRotation: true,
+ },
+ } as DomEditSelection;
+}
+
+describe("MediaSection — image cutout", () => {
+ it("enables Remove BG for a project-local image and sends the normalized path", async () => {
+ const onRemoveBackground = vi
+ .fn()
+ .mockResolvedValue({ outputPath: "assets/cutouts/portrait-cutout.png" });
+ const onSetHtmlAttribute = vi.fn();
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ expect(host.textContent).toContain("transparent PNG image");
+ const removeBgButton = host.querySelector('[data-media-remove-bg="true"]');
+ expect(removeBgButton).not.toBeNull();
+ expect(removeBgButton?.disabled).toBe(false);
+ await act(async () => {
+ removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(onRemoveBackground).toHaveBeenCalledWith(
+ "assets/portrait.jpg",
+ expect.objectContaining({ quality: "balanced" }),
+ );
+ expect(onSetHtmlAttribute).toHaveBeenCalledWith("src", "assets/cutouts/portrait-cutout.png");
+ act(() => root.unmount());
+ });
+
+ it("enables Remove BG when src is a Studio preview URL for an image", async () => {
+ const onRemoveBackground = vi
+ .fn()
+ .mockResolvedValue({ outputPath: "assets/cutouts/portrait-cutout.png" });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ const removeBgButton = host.querySelector('[data-media-remove-bg="true"]');
+ expect(removeBgButton?.disabled).toBe(false);
+ await act(async () => {
+ removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(onRemoveBackground).toHaveBeenCalledWith(
+ "assets/portrait.jpg",
+ expect.objectContaining({ quality: "balanced" }),
+ );
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx
index be2606da75..19e6300733 100644
--- a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx
@@ -9,8 +9,8 @@ import {
LABEL,
parseNumericValue,
RESPONSIVE_GRID,
- stripQueryAndHash,
} from "./propertyPanelHelpers";
+import { resolveProjectAssetPath } from "../../utils/projectAssetPath";
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import {
@@ -86,10 +86,7 @@ export function MediaSection({
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 projectSrc = resolveProjectAssetPath(srcAttr, element.sourceFile || "index.html") ?? "";
const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
const panelTitle = isImage ? "Image" : isVideo ? "Video" : "Audio";
@@ -177,6 +174,7 @@ export function MediaSection({