From 0ba9f06cdb930f429e324dddf1eb6d679dabaf5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 15 Sep 2026 21:31:07 +0000 Subject: [PATCH] fix(studio): make image background removal reliable Normalize project-local media src (including Studio preview URLs) so Remove BG works for images in both property panels, and cover the JPG-to-PNG default cutout path on the media route. --- .../studio-server/src/routes/media.test.ts | 27 ++++ .../propertyPanelFlatMediaSection.test.tsx | 136 ++++++++++++++++++ .../editor/propertyPanelFlatMediaSection.tsx | 7 +- .../editor/propertyPanelMediaSection.test.tsx | 121 ++++++++++++++++ .../editor/propertyPanelMediaSection.tsx | 8 +- .../studio/src/utils/projectAssetPath.test.ts | 65 +++++++++ packages/studio/src/utils/projectAssetPath.ts | 96 +++++++++++++ 7 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelMediaSection.test.tsx create mode 100644 packages/studio/src/utils/projectAssetPath.test.ts create mode 100644 packages/studio/src/utils/projectAssetPath.ts 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({