Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]")]) {
Expand Down
27 changes: 27 additions & 0 deletions packages/studio-server/src/routes/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FlatMediaSection
projectDir={null}
element={makeImageElement("assets/portrait.jpg")}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>,
);
});
expect(host.textContent).toContain("transparent PNG");
const removeBgButton = host.querySelector<HTMLButtonElement>(
'[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(
<FlatMediaSection
projectDir={null}
element={makeImageElement(
"http://localhost:3012/api/projects/demo/preview/assets/portrait.jpg",
)}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={vi.fn()}
onRemoveBackground={onRemoveBackground}
/>,
);
});
const removeBgButton = host.querySelector<HTMLButtonElement>(
'[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(
<FlatMediaSection
projectDir={null}
element={makeImageElement("https://cdn.example.com/portrait.jpg")}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={vi.fn()}
onRemoveBackground={vi.fn()}
/>,
);
});
const removeBgButton = host.querySelector<HTMLButtonElement>(
'[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(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={vi.fn()}
onRemoveBackground={onRemoveBackground}
/>,
);
});
const removeBgButton = host.querySelector<HTMLButtonElement>(
'[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());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<BackgroundRemovalProgress | null>(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);
Expand Down Expand Up @@ -138,7 +143,7 @@ export function FlatMediaSection({
<span className="flex min-w-0 items-center gap-2">
<span className="h-5 w-8 flex-shrink-0 rounded-[3px] bg-panel-surface" />
<span className="min-w-0 truncate font-mono text-[11px] text-panel-text-0">
{srcAttr}
{displaySrc}
</span>
</span>
<button
Expand Down
Loading
Loading