diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index b335355898..1913b43916 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -1205,7 +1205,19 @@ export async function bundleToSingleHtml( // Keep the project-relative URL; render/check servers already expose it. if (isExternalSvgFragmentUse(el, attr, value)) continue; const inlined = maybeInlineRelativeAssetUrl(value, projectDir); - if (inlined) el.setAttribute(attr, inlined); + if (inlined) { + // Preview/export bundles inline relative media into data URLs. Keep the + // project-relative path so Studio tools (remove-background, copy path) + // can still resolve the authored asset after inlining. + if ( + attr === "src" && + !el.hasAttribute("data-hf-authored-src") && + ["img", "video", "audio", "source"].includes(el.tagName.toLowerCase()) + ) { + el.setAttribute("data-hf-authored-src", value); + } + el.setAttribute(attr, inlined); + } } } for (const el of [...document.querySelectorAll("[srcset]")]) { 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..13a376a262 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -439,3 +439,181 @@ 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()); + }); + + it("preserves Remove BG for an inlined data-URL image via data-hf-authored-src", async () => { + const onRemoveBackground = vi + .fn() + .mockResolvedValue({ outputPath: "assets/cutouts/portrait-cutout.png" }); + const el = document.createElement("img"); + el.setAttribute("src", "data:image/jpeg;base64,abc"); + el.setAttribute("data-hf-authored-src", "assets/portrait.jpg"); + const element = makeImageElement("data:image/jpeg;base64,abc"); + element.element = el; + 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); + expect(host.textContent).toContain("assets/portrait.jpg"); + await act(async () => { + removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onRemoveBackground).toHaveBeenCalledWith( + "assets/portrait.jpg", + expect.objectContaining({ createBackgroundPlate: false }), + ); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 98650a31b9..01448e6abe 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"; @@ -80,24 +80,29 @@ export function FlatMediaSection({ const objectPosition = styles["object-position"] || "center"; const srcAttr = el.getAttribute("src") ?? ""; + const authoredSrc = el.getAttribute("data-hf-authored-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 sourceFile = element.sourceFile || "index.html"; const projectSrc = - srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) - ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) - : ""; + resolveProjectAssetPath(authoredSrc, sourceFile) ?? + resolveProjectAssetPath(srcAttr, sourceFile) ?? + ""; + const displaySrc = projectSrc || authoredSrc || srcAttr; + const absoluteSrc = + projectDir && displaySrc && !displaySrc.startsWith("http") && !displaySrc.startsWith("data:") + ? `${projectDir}/${displaySrc}` + : displaySrc; const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc); useEffect(() => { setRemoveProgress(null); setCreatePlate(false); - }, [srcAttr]); + }, [srcAttr, authoredSrc]); const applyCutoutResult = async (result: BackgroundRemovalResult) => { await onSetHtmlAttribute("src", result.outputPath); @@ -138,7 +143,7 @@ export function FlatMediaSection({ - {srcAttr} + {displaySrc}