diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx new file mode 100644 index 000000000..8791748ac --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; +import type { DomEditSelection } from "./domEditing"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function makeVideoElement(overrides: Partial = {}): DomEditSelection { + const el = document.createElement("video"); + el.setAttribute("src", "assets/intro-loop.mp4"); + return { + element: el, + id: "s1-bg", + selector: "#s1-bg", + label: "S1 Background", + tagName: "video", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 1920, height: 1080 }, + textContent: "", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderSection(overrides: Partial = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeVideoElement(overrides); + act(() => { + root.render( + , + ); + }); + return { host, root }; +} + +describe("FlatMediaSection — source row", () => { + it("renders the source path and copies it to clipboard on click", () => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + const { host, root } = renderSection(); + expect(host.textContent).toContain("assets/intro-loop.mp4"); + const copyButton = host.querySelector('[data-flat-media-copy="true"]'); + expect(copyButton).not.toBeNull(); + act(() => copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("assets/intro-loop.mp4"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx new file mode 100644 index 000000000..5d249da27 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import { Check, ClipboardList } from "../../icons/SystemIcons"; +import type { DomEditSelection } from "./domEditing"; +import { + type BackgroundRemovalProgress, + type BackgroundRemovalResult, + stripQueryAndHash, +} from "./propertyPanelHelpers"; + +export function FlatMediaSection({ + projectDir, + element, + // oxlint-disable-next-line no-unused-vars -- wired into the Fit/Position rows in Task 6 + styles, + // oxlint-disable-next-line no-unused-vars -- wired into the Fit/Position rows in Task 6 + onSetStyle, + onSetAttribute, + onSetHtmlAttribute, + onRemoveBackground, +}: { + projectDir: string | null; + element: DomEditSelection; + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; + onSetAttribute: (attr: string, value: string) => void | Promise; + onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; + onRemoveBackground?: ( + inputPath: string, + options: { + createBackgroundPlate?: boolean; + quality?: "fast" | "balanced" | "best"; + onProgress?: (progress: BackgroundRemovalProgress) => void; + }, + ) => Promise; +}) { + const isVideo = element.tagName === "video"; + // oxlint-disable-next-line no-unused-vars -- wired into the Volume/Rate/Muted gate in Task 4 + const isAudio = element.tagName === "audio"; + const isImage = element.tagName === "img"; + const isVisualMedia = isVideo || isImage; + const el = element.element; + + const srcAttr = el.getAttribute("src") ?? ""; + const [copied, setCopied] = useState(false); + const [removeBusy, setRemoveBusy] = useState(false); + // oxlint-disable-next-line no-unused-vars -- rendered by the progress bar added in Task 3 + const [removeProgress, setRemoveProgress] = useState(null); + const [createPlate, setCreatePlate] = useState(false); + // oxlint-disable-next-line no-unused-vars -- wired into the Quality FlatSelectRow in Task 3 + const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced"); + + 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) + : ""; + // oxlint-disable-next-line no-unused-vars -- gates the Remove BG button added in Task 3 + const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc); + + useEffect(() => { + setRemoveProgress(null); + setCreatePlate(false); + }, [srcAttr]); + + const applyCutoutResult = async (result: BackgroundRemovalResult) => { + await onSetHtmlAttribute("src", result.outputPath); + if (isVideo) { + await onSetAttribute("has-audio", ""); + await onSetHtmlAttribute("muted", "true"); + } + }; + + // oxlint-disable-next-line no-unused-vars -- called by the Remove BG button added in Task 3 + const runBackgroundRemoval = async () => { + if (!onRemoveBackground || !projectSrc || removeBusy) return; + setRemoveBusy(true); + setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" }); + try { + const result = await onRemoveBackground(projectSrc, { + createBackgroundPlate: isVideo && createPlate, + quality, + onProgress: setRemoveProgress, + }); + await applyCutoutResult(result); + setRemoveProgress({ status: "complete", progress: 100, stage: "Applied cutout", ...result }); + } catch (error) { + setRemoveProgress({ + status: "failed", + progress: 0, + stage: "Failed", + error: error instanceof Error ? error.message : String(error), + }); + } finally { + setRemoveBusy(false); + } + }; + + return ( +
+
+ + + + {srcAttr} + + + +
+
+ ); +}