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} { + 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()); + }); + + 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-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/propertyPanelMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx index be2606da75..26b5eec9b8 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 { @@ -78,25 +78,30 @@ export function MediaSection({ const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10)); 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); const panelTitle = isImage ? "Image" : isVideo ? "Video" : "Audio"; useEffect(() => { setRemoveProgress(null); setCreatePlate(false); - }, [srcAttr]); + }, [srcAttr, authoredSrc]); const applyCutoutResult = async (result: BackgroundRemovalResult) => { await onSetHtmlAttribute("src", result.outputPath); @@ -139,7 +144,7 @@ export function MediaSection({ return ( : }> - {srcAttr && ( + {displaySrc && ( Source @@ -177,6 +182,7 @@ export function MediaSection({ { event.stopPropagation(); diff --git a/packages/studio/src/utils/projectAssetPath.test.ts b/packages/studio/src/utils/projectAssetPath.test.ts new file mode 100644 index 0000000000..44e616a8b2 --- /dev/null +++ b/packages/studio/src/utils/projectAssetPath.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { resolveProjectAssetPath } from "./projectAssetPath"; + +describe("resolveProjectAssetPath", () => { + it("normalizes relative and ./ paths", () => { + expect(resolveProjectAssetPath("assets/portrait.jpg")).toBe("assets/portrait.jpg"); + expect(resolveProjectAssetPath("./assets/portrait.jpg")).toBe("assets/portrait.jpg"); + expect(resolveProjectAssetPath("assets/./nested/../portrait.jpg")).toBe("assets/portrait.jpg"); + }); + + it("resolves relative paths against the composition source file", () => { + expect(resolveProjectAssetPath("../shared/hero.png", "scenes/intro.html")).toBe( + "shared/hero.png", + ); + expect(resolveProjectAssetPath("./hero.png", "scenes/intro.html")).toBe("scenes/hero.png"); + }); + + it("strips query and hash", () => { + expect(resolveProjectAssetPath("assets/portrait.jpg?v=2#top")).toBe("assets/portrait.jpg"); + expect( + resolveProjectAssetPath( + "http://localhost:3012/api/projects/demo/preview/assets/portrait.jpg?cache=1#x", + ), + ).toBe("assets/portrait.jpg"); + }); + + it("safely URI-decodes without crashing on bad encoding", () => { + expect( + resolveProjectAssetPath( + "http://localhost:3012/api/projects/demo/preview/assets/my%20file%20(1).jpg", + ), + ).toBe("assets/my file (1).jpg"); + expect(resolveProjectAssetPath("assets/bad%zz.jpg")).toBe("assets/bad%zz.jpg"); + }); + + it("converts Studio preview URLs to project-relative paths", () => { + expect( + resolveProjectAssetPath( + "http://localhost:3012/api/projects/demo/preview/assets/portrait.jpg", + ), + ).toBe("assets/portrait.jpg"); + expect(resolveProjectAssetPath("/api/projects/abc123/preview/assets/logo.png")).toBe( + "assets/logo.png", + ); + }); + + it("rejects external http(s), data, blob, file, and protocol-relative URLs", () => { + expect(resolveProjectAssetPath("https://cdn.example.com/photo.jpg")).toBeNull(); + expect(resolveProjectAssetPath("http://example.com/assets/photo.jpg")).toBeNull(); + expect(resolveProjectAssetPath("data:image/png;base64,abc")).toBeNull(); + expect(resolveProjectAssetPath("blob:http://localhost/abc")).toBeNull(); + expect(resolveProjectAssetPath("file:///Users/me/photo.jpg")).toBeNull(); + expect(resolveProjectAssetPath("//cdn.example.com/photo.jpg")).toBeNull(); + }); + + it("rejects non-preview /api paths", () => { + expect(resolveProjectAssetPath("/api/media/photo.jpg")).toBeNull(); + expect(resolveProjectAssetPath("/api/projects/demo/files/assets/photo.jpg")).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(resolveProjectAssetPath("")).toBeNull(); + expect(resolveProjectAssetPath(" ")).toBeNull(); + }); +}); diff --git a/packages/studio/src/utils/projectAssetPath.ts b/packages/studio/src/utils/projectAssetPath.ts new file mode 100644 index 0000000000..c822a9cd57 --- /dev/null +++ b/packages/studio/src/utils/projectAssetPath.ts @@ -0,0 +1,96 @@ +/** + * Resolve a media element `src` to a project-relative asset path for Studio + * media APIs (metadata, remove-background). + * + * Accepts authored relative paths and Studio preview URLs (absolute or + * `/api/projects//preview/...`). Rejects external http(s), data, blob, + * file, protocol-relative, and other `/api` paths that are not project + * preview assets. + * + * Pure — unit-tested. + */ + +const PREVIEW_PREFIX = /^\/api\/projects\/[^/]+\/preview\//; + +function stripQueryAndHash(value: string): string { + const queryIndex = value.indexOf("?"); + const hashIndex = value.indexOf("#"); + if (queryIndex < 0) return hashIndex < 0 ? value : value.slice(0, hashIndex); + if (hashIndex < 0) return value.slice(0, queryIndex); + return value.slice(0, Math.min(queryIndex, hashIndex)); +} + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeRelativeSegments(path: string): string { + const parts = path.split("/"); + const normalized: string[] = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") { + normalized.pop(); + continue; + } + normalized.push(part); + } + return normalized.join("/"); +} + +/** + * @param src - Raw `src` attribute or resolved URL from the preview DOM. + * @param sourceFile - Composition file that authored the relative `src` + * (used to resolve `./` / `../` against the project root). Defaults to + * `index.html` at the project root. + * @returns Project-relative path (no leading `./`), or `null` when the src + * is not a project-local asset. + */ +export function resolveProjectAssetPath(src: string, sourceFile = "index.html"): string | null { + const trimmed = src.trim(); + if (!trimmed) return null; + + // Reject non-project schemes and protocol-relative URLs before any parsing. + if (/^(?:data:|blob:|file:)/i.test(trimmed)) return null; + if (trimmed.startsWith("//")) return null; + + let path = trimmed; + let fromPreviewUrl = false; + + if (/^https?:\/\//i.test(path)) { + try { + path = new URL(path).pathname; + } catch { + return null; + } + } + + path = stripQueryAndHash(path); + + if (PREVIEW_PREFIX.test(path)) { + path = path.replace(PREVIEW_PREFIX, ""); + fromPreviewUrl = true; + } else if (path.startsWith("/")) { + // Root-relative but not a Studio preview asset (e.g. /api/media/...). + return null; + } + + path = safeDecodeURIComponent(path); + if (!path) return null; + + // Preview URLs are already project-root relative. Authored relative paths + // resolve against the composition file's directory. + if (!fromPreviewUrl) { + const sourceDir = sourceFile.includes("/") + ? sourceFile.slice(0, sourceFile.lastIndexOf("/")) + : ""; + path = sourceDir ? `${sourceDir}/${path}` : path; + } + + const normalized = normalizeRelativeSegments(path); + return normalized || null; +}