Skip to content
Closed
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
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,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(
<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());
});
});
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 @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<MediaSection
projectDir={null}
element={makeImageElement("assets/portrait.jpg")}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>,
);
});
expect(host.textContent).toContain("transparent PNG image");
const removeBgButton = host.querySelector<HTMLButtonElement>('[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(
<MediaSection
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-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());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -177,6 +174,7 @@ export function MediaSection({
</div>
<button
type="button"
data-media-remove-bg="true"
disabled={!canRemoveBackground || removeBusy}
onClick={(event) => {
event.stopPropagation();
Expand Down
Loading
Loading