From 7f0d6154449db1d913939e5f07f3624f38fec7dc Mon Sep 17 00:00:00 2001 From: jiajia Date: Wed, 20 May 2026 22:40:07 +0800 Subject: [PATCH 1/3] Make output nodes expose connected media results Output nodes were collapsing multiple connected media inputs into one visible result, which made image/video/audio/3D connections look ignored. The node now preserves each connected media kind and presents explicit result switching with current/all download actions. Constraint: Output node remains a result endpoint and does not transform media or create hidden downstream behavior Rejected: Auto-merge media into one combined preview | would hide workflow structure and violate the endpoint-only product model Confidence: high Scope-risk: moderate Directive: Keep output node behavior as explicit result display; route further processing through visible downstream nodes Tested: npm test -- --run src/components/__tests__/OutputNode.test.tsx src/store/execution/__tests__/simpleNodeExecutors.test.ts Tested: npm run build Tested: npm run test:run Tested: Playwright localhost smoke with no page or console errors --- src/components/__tests__/OutputNode.test.tsx | 32 ++ src/components/nodes/OutputNode.tsx | 313 ++++++++++++------ src/i18n/index.tsx | 8 + .../__tests__/simpleNodeExecutors.test.ts | 32 +- src/store/execution/simpleNodeExecutors.ts | 177 +++------- 5 files changed, 326 insertions(+), 236 deletions(-) diff --git a/src/components/__tests__/OutputNode.test.tsx b/src/components/__tests__/OutputNode.test.tsx index 1f6dc320..6a70a6b7 100644 --- a/src/components/__tests__/OutputNode.test.tsx +++ b/src/components/__tests__/OutputNode.test.tsx @@ -354,6 +354,38 @@ describe("OutputNode", () => { expect(video).toBeInTheDocument(); expect(video).toHaveAttribute("src", "data:video/mp4;base64,video123"); }); + + it("should expose tabs for multiple media types and switch the visible preview", () => { + render( + + + + ); + + expect(screen.getByTestId("output-tab-image")).toHaveTextContent("Image 1"); + expect(screen.getByTestId("output-tab-video")).toHaveTextContent("Video 1"); + expect(screen.getByTestId("output-tab-audio")).toHaveTextContent("Audio 1"); + expect(screen.getByAltText("Output")).toHaveAttribute("src", "data:image/png;base64,image456"); + expect(screen.getByTitle("Download current Image")).toBeInTheDocument(); + expect(screen.getByTitle("Download all outputs")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("output-tab-video")); + const video = document.querySelector("video"); + expect(video).toBeInTheDocument(); + expect(video).toHaveAttribute("src", "data:video/mp4;base64,video123"); + expect(screen.getByTitle("Download current Video")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("output-tab-audio")); + const audio = document.querySelector("audio"); + expect(audio).toBeInTheDocument(); + expect(audio).toHaveAttribute("src", "data:audio/mpeg;base64,audio789"); + expect(screen.getByTitle("Download current Audio")).toBeInTheDocument(); + }); }); describe("Video Controls Rendering", () => { diff --git a/src/components/nodes/OutputNode.tsx b/src/components/nodes/OutputNode.tsx index d094ca61..58bbcd07 100644 --- a/src/components/nodes/OutputNode.tsx +++ b/src/components/nodes/OutputNode.tsx @@ -13,11 +13,33 @@ import { downloadMedia, MediaType } from "@/utils/downloadMedia"; import { useShowHandleLabels } from "@/hooks/useShowHandleLabels"; import { HandleLabel } from "./HandleLabel"; import { calculateAspectFitSize, getImageDimensions, getVideoDimensions } from "@/utils/nodeDimensions"; +import { useI18n } from "@/i18n"; type OutputNodeType = Node; +type OutputKind = "image" | "video" | "audio" | "3d"; + +interface OutputItem { + id: string; + kind: OutputKind; + src: string; + label: string; +} + +function isVideoSource(src: string): boolean { + return src.startsWith("data:video/") || src.includes(".mp4") || src.includes(".webm"); +} + +function isAudioSource(src: string): boolean { + return src.startsWith("data:audio/"); +} + +function is3DSource(src: string): boolean { + return src.includes(".glb") || src.includes(".gltf"); +} export function OutputNode({ id, data, selected }: NodeProps) { const nodeData = data; + const { t } = useI18n(); const commentNavigation = useCommentNavigation(id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); @@ -28,44 +50,76 @@ export function OutputNode({ id, data, selected }: NodeProps) { const isRunning = useWorkflowStore((state) => state.isRunning); const showLabels = useShowHandleLabels(selected); const [showLightbox, setShowLightbox] = useState(false); + const [selectedItemId, setSelectedItemId] = useState(null); const previousEdgeCountRef = useRef(null); const prevFitKeyRef = useRef(null); const videoAutoplayRef = useVideoAutoplay(id, selected); - // Determine if content is audio - const isAudio = useMemo(() => { - if (nodeData.audio) return true; - if (nodeData.contentType === "audio") return true; - if (nodeData.image?.startsWith("data:audio/")) return true; - return false; - }, [nodeData.audio, nodeData.contentType, nodeData.image]); - - const is3D = useMemo(() => { - if (nodeData.model3d) return true; - if (nodeData.contentType === "3d") return true; - if (nodeData.image?.includes(".glb") || nodeData.image?.includes(".gltf")) return true; - return false; - }, [nodeData.model3d, nodeData.contentType, nodeData.image]); - - // Determine if content is video - const isVideo = useMemo(() => { - if (isAudio || is3D) return false; - if (nodeData.video) return true; - if (nodeData.contentType === "video") return true; - if (nodeData.image?.startsWith("data:video/")) return true; - if (nodeData.image?.includes(".mp4") || nodeData.image?.includes(".webm")) return true; - return false; - }, [isAudio, is3D, nodeData.video, nodeData.contentType, nodeData.image]); - - // Get the content source (audio, video, 3D model, or image) - const contentSrc = useMemo(() => { - if (nodeData.audio) return nodeData.audio; - if (nodeData.video) return nodeData.video; - if (nodeData.model3d) return nodeData.model3d; - return nodeData.image; - }, [nodeData.audio, nodeData.video, nodeData.model3d, nodeData.image]); - - const imageSrc = !isAudio && !isVideo && !is3D ? contentSrc : null; + const typeLabels = useMemo>(() => ({ + image: t("toolbar.image"), + video: t("toolbar.video"), + audio: t("modelSearch.audio"), + "3d": "3D", + }), [t]); + + const outputItems = useMemo(() => { + const items: OutputItem[] = []; + const addItem = (kind: OutputKind, src: string | null | undefined) => { + if (!src) return; + if (items.some((item) => item.kind === kind && item.src === src)) return; + items.push({ + id: `${kind}:${src}`, + kind, + src, + label: typeLabels[kind], + }); + }; + + const imageKind: OutputKind | null = nodeData.image + ? nodeData.contentType === "video" || isVideoSource(nodeData.image) + ? "video" + : nodeData.contentType === "audio" || isAudioSource(nodeData.image) + ? "audio" + : nodeData.contentType === "3d" || is3DSource(nodeData.image) + ? "3d" + : "image" + : null; + + if (imageKind === "image") addItem("image", nodeData.image); + addItem("video", nodeData.video ?? (imageKind === "video" ? nodeData.image : null)); + addItem("audio", nodeData.audio ?? (imageKind === "audio" ? nodeData.image : null)); + addItem("3d", nodeData.model3d ?? (imageKind === "3d" ? nodeData.image : null)); + + return items; + }, [nodeData.audio, nodeData.contentType, nodeData.image, nodeData.model3d, nodeData.video, typeLabels]); + + const preferredItemId = useMemo(() => { + if (outputItems.length === 0) return null; + + const explicitItem = nodeData.contentType + ? outputItems.find((item) => item.kind === nodeData.contentType) + : null; + if (explicitItem) return explicitItem.id; + + const legacyPriority: OutputKind[] = ["audio", "video", "3d", "image"]; + return legacyPriority + .map((kind) => outputItems.find((item) => item.kind === kind)) + .find(Boolean)?.id ?? outputItems[0].id; + }, [nodeData.contentType, outputItems]); + + const activeItem = useMemo(() => { + if (outputItems.length === 0) return null; + return outputItems.find((item) => item.id === selectedItemId) + ?? outputItems.find((item) => item.id === preferredItemId) + ?? outputItems[0]; + }, [outputItems, preferredItemId, selectedItemId]); + + const contentSrc = activeItem?.src ?? null; + const isAudio = activeItem?.kind === "audio"; + const isVideo = activeItem?.kind === "video"; + const is3D = activeItem?.kind === "3d"; + + const imageSrc = activeItem?.kind === "image" ? contentSrc : null; const adaptiveImage = useAdaptiveImageSrc(imageSrc, id); const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null); @@ -137,14 +191,32 @@ export function OutputNode({ id, data, selected }: NodeProps) { }, [id, regenerateNode]); const handleDownload = useCallback(async () => { - if (!contentSrc) return; - const type: MediaType = is3D ? "3d" : isAudio ? "audio" : isVideo ? "video" : "image"; + if (!contentSrc || !activeItem) return; + const type: MediaType = activeItem.kind; try { await downloadMedia(contentSrc, type, nodeData.outputFilename ?? undefined); } catch (err) { console.error("Download failed:", err); } - }, [contentSrc, is3D, isAudio, isVideo, nodeData.outputFilename]); + }, [activeItem, contentSrc, nodeData.outputFilename]); + + const handleDownloadAll = useCallback(async () => { + for (const item of outputItems) { + const filename = nodeData.outputFilename + ? `${nodeData.outputFilename}-${item.kind}` + : undefined; + try { + await downloadMedia(item.src, item.kind, filename); + } catch (err) { + console.error("Download failed:", err); + } + } + }, [nodeData.outputFilename, outputItems]); + + const downloadCurrentTitle = outputItems.length > 1 && activeItem + ? t("output.downloadCurrent", { type: activeItem.label }) + : t("common.download"); + const downloadAllTitle = t("output.downloadAll"); return ( <> @@ -163,7 +235,7 @@ export function OutputNode({ id, data, selected }: NodeProps) { data-handletype="image" style={{ top: "28%", zIndex: 10 }} /> - + ) { data-handletype="video" style={{ top: "44%", zIndex: 10 }} /> - + ) { data-handletype="audio" style={{ top: "60%", background: "rgb(167, 139, 250)", zIndex: 10 }} /> - + ) {
- {contentSrc ? ( - <> - {isAudio ? ( -
-
- ) : is3D ? ( -
- - - - 3D model - - {contentSrc} - -
- ) : ( -
setShowLightbox(true)} - > - {isVideo ? ( -
diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 1986d1d2..ef22cec0 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -387,6 +387,8 @@ const en = { "node.conditionalSwitch": "Conditional Switch", "node.output": "Output", "node.outputGallery": "Output Gallery", + "output.downloadCurrent": "Download current {type}", + "output.downloadAll": "Download all outputs", "node.settings": "Settings", "node.runToGenerate": "Run to generate", "node.selectModel": "Select model...", @@ -1035,6 +1037,8 @@ const zhCN: Dictionary = { "node.conditionalSwitch": "条件开关", "node.output": "输出", "node.outputGallery": "输出画廊", + "output.downloadCurrent": "下载当前{type}", + "output.downloadAll": "下载全部输出", "node.settings": "设置", "node.runToGenerate": "运行以生成", "node.selectModel": "选择模型...", @@ -1663,6 +1667,8 @@ const zhTW: Dictionary = { "splitGridNode.connectImageFirst": "請先連接圖片", "splitGridNode.splitGrid": "拆分網格", "splitGridNode.split": "拆分", + "output.downloadCurrent": "下載目前{type}", + "output.downloadAll": "下載全部輸出", "node.settings": "設定", "node.runToGenerate": "執行以生成", "node.selectModel": "選擇模型...", @@ -2195,6 +2201,8 @@ const ja: Dictionary = { "node.conditionalSwitch": "条件スイッチ", "node.output": "出力", "node.outputGallery": "出力ギャラリー", + "output.downloadCurrent": "現在の{type}をダウンロード", + "output.downloadAll": "すべての出力をダウンロード", "node.settings": "設定", "node.runToGenerate": "実行して生成", "node.selectModel": "モデルを選択...", diff --git a/src/store/execution/__tests__/simpleNodeExecutors.test.ts b/src/store/execution/__tests__/simpleNodeExecutors.test.ts index 209fd9ce..2eb1513a 100644 --- a/src/store/execution/__tests__/simpleNodeExecutors.test.ts +++ b/src/store/execution/__tests__/simpleNodeExecutors.test.ts @@ -288,8 +288,9 @@ describe("executeOutput", () => { await executeOutput(ctx); expect(ctx.updateNodeData).toHaveBeenCalledWith("out", { - image: "data:video/mp4;base64,abc", + image: null, video: "data:video/mp4;base64,abc", + audio: null, model3d: null, contentType: "video", }); @@ -341,6 +342,7 @@ describe("executeOutput", () => { expect(ctx.updateNodeData).toHaveBeenCalledWith("out", { image: "data:image/png;base64,img", video: null, + audio: null, model3d: null, contentType: "image", }); @@ -366,6 +368,7 @@ describe("executeOutput", () => { expect(ctx.updateNodeData).toHaveBeenCalledWith("out", { image: "data:video/mp4;base64,vid", video: "data:video/mp4;base64,vid", + audio: null, model3d: null, contentType: "video", }); @@ -391,10 +394,37 @@ describe("executeOutput", () => { expect(ctx.updateNodeData).toHaveBeenCalledWith("out", { image: "https://fal.media/files/abc123.mp4", video: "https://fal.media/files/abc123.mp4", + audio: null, model3d: null, contentType: "video", }); }); + + it("should keep image, video, audio, and 3D outputs together", async () => { + const node = makeNode("out", "output", {}); + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: ["data:image/png;base64,img"], + videos: ["data:video/mp4;base64,vid"], + audio: ["data:audio/mpeg;base64,aud"], + model3d: "https://example.com/model.glb", + text: null, + textItems: [], + dynamicInputs: {}, + easeCurve: null, + }), + }); + + await executeOutput(ctx); + + expect(ctx.updateNodeData).toHaveBeenCalledWith("out", { + image: "data:image/png;base64,img", + video: "data:video/mp4;base64,vid", + audio: "data:audio/mpeg;base64,aud", + model3d: "https://example.com/model.glb", + contentType: "image", + }); + }); }); describe("executeOutputGallery", () => { diff --git a/src/store/execution/simpleNodeExecutors.ts b/src/store/execution/simpleNodeExecutors.ts index 6c220b58..f499ad39 100644 --- a/src/store/execution/simpleNodeExecutors.ts +++ b/src/store/execution/simpleNodeExecutors.ts @@ -185,141 +185,54 @@ export async function executePromptConstructor(ctx: NodeExecutionContext): Promi export async function executeOutput(ctx: NodeExecutionContext): Promise { const { node, getConnectedInputs, updateNodeData, saveDirectoryPath } = ctx; const { images, videos, audio, model3d } = getConnectedInputs(node.id); - - if (model3d) { - updateNodeData(node.id, { - model3d, - image: null, - video: null, - audio: null, - contentType: "3d", - }); - - if (saveDirectoryPath) { - const outputNodeData = node.data as OutputNodeData; - const outputsPath = `${saveDirectoryPath}/outputs`; - - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: outputsPath, - model3d, - customFilename: outputNodeData.outputFilename || undefined, - createDirectory: true, - }), - }).catch((err) => { - console.error("Failed to save output:", err); - }); - } - return; - } - - // Check audio array first - if (audio.length > 0) { - const audioContent = audio[0]; - updateNodeData(node.id, { - audio: audioContent, - image: null, - video: null, - model3d: null, - contentType: "audio", - }); - - // Save to /outputs directory if we have a project path - if (saveDirectoryPath) { - const outputNodeData = node.data as OutputNodeData; - const outputsPath = `${saveDirectoryPath}/outputs`; - - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: outputsPath, - audio: audioContent, - customFilename: outputNodeData.outputFilename || undefined, - createDirectory: true, - }), - }).catch((err) => { - console.error("Failed to save output:", err); - }); - } - return; - } - - // Check videos array (typed data from source) - if (videos.length > 0) { - const videoContent = videos[0]; - updateNodeData(node.id, { - image: videoContent, - video: videoContent, - model3d: null, - contentType: "video", + const outputNodeData = node.data as OutputNodeData; + const outputsPath = saveDirectoryPath ? `${saveDirectoryPath}/outputs` : null; + const saveOutput = (payload: Record) => { + if (!outputsPath) return; + + fetch("/api/save-generation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: outputsPath, + ...payload, + customFilename: outputNodeData.outputFilename || undefined, + createDirectory: true, + }), + }).catch((err) => { + console.error("Failed to save output:", err); }); + }; + + const rawImage = images[0] ?? null; + const imageLooksLikeVideo = rawImage + ? rawImage.startsWith("data:video/") || + rawImage.includes(".mp4") || + rawImage.includes(".webm") || + rawImage.includes("fal.media") + : false; + const imageContent = rawImage; + const videoContent = videos[0] ?? (imageLooksLikeVideo ? rawImage : null); + const audioContent = audio[0] ?? null; + const contentType: OutputNodeData["contentType"] = + rawImage && !imageLooksLikeVideo ? "image" : + videoContent ? "video" : + audioContent ? "audio" : + model3d ? "3d" : + undefined; - // Save to /outputs directory if we have a project path - if (saveDirectoryPath) { - const outputNodeData = node.data as OutputNodeData; - const outputsPath = `${saveDirectoryPath}/outputs`; - - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: outputsPath, - video: videoContent, - customFilename: outputNodeData.outputFilename || undefined, - createDirectory: true, - }), - }).catch((err) => { - console.error("Failed to save output:", err); - }); - } - } else if (images.length > 0) { - const content = images[0]; - // Fallback pattern matching for edge cases (video data that ended up in images array) - const isVideoContent = - content.startsWith("data:video/") || - content.includes(".mp4") || - content.includes(".webm") || - content.includes("fal.media"); - - if (isVideoContent) { - updateNodeData(node.id, { - image: content, - video: content, - model3d: null, - contentType: "video", - }); - } else { - updateNodeData(node.id, { - image: content, - video: null, - model3d: null, - contentType: "image", - }); - } - - // Save to /outputs directory if we have a project path - if (saveDirectoryPath) { - const outputNodeData = node.data as OutputNodeData; - const outputsPath = `${saveDirectoryPath}/outputs`; + updateNodeData(node.id, { + image: imageContent, + video: videoContent, + audio: audioContent, + model3d: model3d ?? null, + contentType, + }); - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: outputsPath, - image: isVideoContent ? undefined : content, - video: isVideoContent ? content : undefined, - customFilename: outputNodeData.outputFilename || undefined, - createDirectory: true, - }), - }).catch((err) => { - console.error("Failed to save output:", err); - }); - } - } + if (rawImage && !imageLooksLikeVideo) saveOutput({ image: rawImage }); + if (videoContent) saveOutput({ video: videoContent }); + if (audioContent) saveOutput({ audio: audioContent }); + if (model3d) saveOutput({ model3d }); } /** From 444e4c62e73ab66cf112ba68045f9074d48ef604 Mon Sep 17 00:00:00 2001 From: jiajia Date: Fri, 22 May 2026 17:56:35 +0800 Subject: [PATCH 2/3] Show Popi.TV in the header brand The app header still exposed the internal popiart-node label while the requested product brand is Popi.TV. This keeps the existing icon asset but makes it decorative so the visible header label is the accessible brand signal. Constraint: Keep the change scoped to the top-left header logo area. Rejected: Rename all popiart-node strings globally | the request only targeted the top-left Logo. Confidence: high Scope-risk: narrow Directive: Do not broaden this into a product-wide rename without checking welcome and onboarding copy. Tested: npm run test:run -- src/components/__tests__/Header.test.tsx --- src/components/Header.tsx | 4 ++-- src/components/__tests__/Header.test.tsx | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 71f27eb1..b7462612 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -251,9 +251,9 @@ export function Header() { className="flex items-center gap-2 hover:opacity-80 transition-opacity" title={t("header.openWelcome")} > - PopiArt +

- popiart-node + Popi.TV

diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index 84a41691..ce618df4 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -75,14 +75,16 @@ describe("Header", () => { describe("Basic Rendering", () => { it("should render the app title", () => { render(
); - expect(screen.getByText("popiart-node")).toBeInTheDocument(); + expect(screen.getByText("Popi.TV")).toBeInTheDocument(); }); it("should render the brand icon", () => { - render(
); - const icon = screen.getByAltText("PopiArt"); + const { container } = render(
); + const icon = container.querySelector('img[src="/banana_icon.png"]'); expect(icon).toBeInTheDocument(); expect(icon).toHaveAttribute("src", "/banana_icon.png"); + expect(icon).toHaveAttribute("alt", ""); + expect(icon).toHaveAttribute("aria-hidden", "true"); }); it("should hide external author link", () => { From 109012cbbfa05e58a6fdf20a005bc7deae06f6d7 Mon Sep 17 00:00:00 2001 From: jiajia Date: Fri, 22 May 2026 18:17:47 +0800 Subject: [PATCH 3/3] Persist browserfs workflow media locally Browser-file-system workflow saves were still bypassing external media storage, so local projects could keep large image, video, and audio data embedded instead of writing refs into the selected browser-backed workspace. The storage path now uses the browser file helpers for image, video, and audio refs and hydrates them back without API calls. Constraint: Keep local browserfs storage on the existing external media storage path instead of adding a new persistence mode. Rejected: Keep browserfs excluded from externalization | it preserves the old large-payload behavior for local projects. Confidence: high Scope-risk: moderate Directive: Do not route browserfs media through server API endpoints; browserfs paths must stay client-local. Tested: npm run test:run Tested: npm run build Tested: git diff --check -- src/store/workflowStore.ts src/utils/browserFileSystem.ts src/utils/mediaStorage.ts src/utils/__tests__/mediaStorage.browserfs.test.ts Not-tested: npm run lint is blocked because next lint is no longer a valid Next 16 command in this repo Not-tested: npx tsc --noEmit reports existing test-file type debt outside this change while next build TypeScript passes --- src/store/workflowStore.ts | 6 +- .../__tests__/mediaStorage.browserfs.test.ts | 204 ++++++++++++++++++ src/utils/browserFileSystem.ts | 48 ++++- src/utils/mediaStorage.ts | 54 +++++ 4 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 src/utils/__tests__/mediaStorage.browserfs.test.ts diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 59d82505..df989e10 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -2689,7 +2689,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const isBrowserPath = isBrowserFileSystemPath(saveDirectoryPath); // If external media storage is enabled, externalize media before saving - if (useExternalImageStorage && !isBrowserPath) { + if (useExternalImageStorage) { workflow = await externalizeWorkflowMedia(workflow, saveDirectoryPath); } @@ -2710,7 +2710,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // If we externalized media, update store nodes with the refs // This prevents duplicate media on subsequent saves - if (useExternalImageStorage && !isBrowserPath && workflow.nodes !== currentNodes) { + if (useExternalImageStorage && workflow.nodes !== currentNodes) { // Merge refs from externalized nodes into current nodes (keeping media data) const nodesWithRefs = currentNodes.map((node, index) => { const externalizedNode = workflow.nodes[index]; @@ -2776,7 +2776,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ lastSavedAt: timestamp, hasUnsavedChanges: false, // Update imageRefBasePath to reflect save location - imageRefBasePath: useExternalImageStorage && !isBrowserPath ? saveDirectoryPath : null, + imageRefBasePath: useExternalImageStorage ? saveDirectoryPath : null, }); } diff --git a/src/utils/__tests__/mediaStorage.browserfs.test.ts b/src/utils/__tests__/mediaStorage.browserfs.test.ts new file mode 100644 index 00000000..2279bc03 --- /dev/null +++ b/src/utils/__tests__/mediaStorage.browserfs.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { externalizeWorkflowMedia, hydrateWorkflowMedia } from "../mediaStorage"; +import type { WorkflowFile } from "@/store/workflowStore"; + +const browserFsMocks = vi.hoisted(() => ({ + writeBrowserGenerationFile: vi.fn(), + loadBrowserGenerationFile: vi.fn(), +})); + +vi.mock("../browserFileSystem", () => ({ + isBrowserFileSystemPath: (path: string | null | undefined) => + typeof path === "string" && path.startsWith("browserfs://"), + joinBrowserFileSystemPath: (basePath: string, folderName: string) => + `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`, + writeBrowserGenerationFile: browserFsMocks.writeBrowserGenerationFile, + loadBrowserGenerationFile: browserFsMocks.loadBrowserGenerationFile, +})); + +const imageData = "data:image/png;base64,aW1hZ2U="; +const videoData = "data:video/mp4;base64,dmlkZW8="; +const audioData = "data:audio/mpeg;base64,YXVkaW8="; +const workflowPath = "browserfs://root/project"; + +function makeWorkflow(): WorkflowFile { + return { + version: 1, + id: "wf-browserfs", + name: "browserfs media", + nodes: [ + { + id: "imageInput-1", + type: "imageInput", + position: { x: 0, y: 0 }, + data: { + image: imageData, + filename: "input.png", + dimensions: null, + }, + }, + { + id: "generateVideo-1", + type: "generateVideo", + position: { x: 200, y: 0 }, + data: { + inputImages: [imageData], + inputPrompt: "move", + outputVideo: videoData, + outputVideoRef: undefined, + outputVideoRemoteUrl: undefined, + outputVideoStorageStatus: undefined, + selectedVideoHistoryIndex: 0, + videoHistory: [ + { + id: "vid-history-1", + video: videoData, + timestamp: 1, + prompt: "move", + }, + ], + status: "completed", + error: null, + }, + }, + { + id: "generateAudio-1", + type: "generateAudio", + position: { x: 400, y: 0 }, + data: { + inputPrompt: "sound", + outputAudio: audioData, + outputAudioRef: undefined, + selectedAudioHistoryIndex: 0, + audioHistory: [ + { + id: "aud-history-1", + audio: audioData, + timestamp: 1, + prompt: "sound", + }, + ], + status: "completed", + error: null, + }, + }, + ], + edges: [], + edgeStyle: "angular", + } as WorkflowFile; +} + +describe("mediaStorage browserfs support", () => { + beforeEach(() => { + browserFsMocks.writeBrowserGenerationFile.mockReset(); + browserFsMocks.loadBrowserGenerationFile.mockReset(); + browserFsMocks.writeBrowserGenerationFile.mockResolvedValue({ filePath: "browserfs://root/project/media.bin" }); + vi.stubGlobal("fetch", vi.fn(async () => ({ + json: async () => ({ success: true, imageId: "server-ref" }), + }))); + }); + + it("externalizes browserfs media through browser file helpers instead of API fetch", async () => { + const workflow = makeWorkflow(); + + const externalized = await externalizeWorkflowMedia(workflow, workflowPath); + + expect(fetch).not.toHaveBeenCalled(); + expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith( + "browserfs://root/project/inputs", + expect.any(String), + imageData + ); + expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith( + "browserfs://root/project/generations", + "vid-history-1", + videoData + ); + expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith( + "browserfs://root/project/generations", + "aud-history-1", + audioData + ); + + expect(externalized.nodes[0].data).toMatchObject({ + image: null, + imageRef: expect.any(String), + }); + expect(externalized.nodes[1].data).toMatchObject({ + inputImages: [], + inputImageRefs: [expect.any(String)], + outputVideo: null, + outputVideoRef: "vid-history-1", + outputVideoStorageStatus: "localized", + videoHistory: [ + { + id: "vid-history-1", + timestamp: 1, + prompt: "move", + }, + ], + }); + expect(externalized.nodes[2].data).toMatchObject({ + outputAudio: null, + outputAudioRef: "aud-history-1", + }); + }); + + it("hydrates browserfs media refs through browser file helpers instead of API fetch", async () => { + browserFsMocks.loadBrowserGenerationFile.mockImplementation(async (directoryPath: string, mediaId: string) => { + if (directoryPath.endsWith("/inputs") && mediaId === "input-ref") return imageData; + if (directoryPath.endsWith("/generations") && mediaId === "video-ref") return videoData; + if (directoryPath.endsWith("/generations") && mediaId === "audio-ref") return audioData; + return null; + }); + + const workflow: WorkflowFile = { + ...makeWorkflow(), + nodes: [ + { + id: "imageInput-1", + type: "imageInput", + position: { x: 0, y: 0 }, + data: { image: null, imageRef: "input-ref" }, + }, + { + id: "generateVideo-1", + type: "generateVideo", + position: { x: 200, y: 0 }, + data: { + inputImages: [], + inputImageRefs: ["input-ref"], + outputVideo: null, + outputVideoRef: "video-ref", + outputVideoStorageStatus: "localized", + status: "idle", + error: null, + }, + }, + { + id: "generateAudio-1", + type: "generateAudio", + position: { x: 400, y: 0 }, + data: { + inputPrompt: "sound", + outputAudio: null, + outputAudioRef: "audio-ref", + status: "idle", + error: null, + }, + }, + ], + } as WorkflowFile; + + const hydrated = await hydrateWorkflowMedia(workflow, workflowPath); + + expect(fetch).not.toHaveBeenCalled(); + expect(hydrated.nodes[0].data).toMatchObject({ image: imageData }); + expect(hydrated.nodes[1].data).toMatchObject({ + inputImages: [imageData], + outputVideo: videoData, + outputVideoStorageStatus: "localized", + }); + expect(hydrated.nodes[2].data).toMatchObject({ outputAudio: audioData }); + }); +}); diff --git a/src/utils/browserFileSystem.ts b/src/utils/browserFileSystem.ts index 334722e8..d3ce6837 100644 --- a/src/utils/browserFileSystem.ts +++ b/src/utils/browserFileSystem.ts @@ -52,15 +52,49 @@ export function joinBrowserFileSystemPath(basePath: string, folderName: string): return `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`; } -const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"] as const; +const MEDIA_EXTENSIONS = [ + "png", + "jpg", + "jpeg", + "webp", + "gif", + "mp4", + "webm", + "mov", + "mp3", + "wav", + "ogg", + "flac", + "aac", +] as const; function extensionFromMime(mime: string | null | undefined): string { if (mime === "image/jpeg") return "jpg"; if (mime === "image/webp") return "webp"; if (mime === "image/gif") return "gif"; + if (mime === "video/webm") return "webm"; + if (mime === "video/quicktime") return "mov"; + if (mime?.startsWith("video/")) return "mp4"; + if (mime === "audio/wav") return "wav"; + if (mime === "audio/ogg") return "ogg"; + if (mime === "audio/flac") return "flac"; + if (mime === "audio/aac") return "aac"; + if (mime?.startsWith("audio/")) return "mp3"; return "png"; } +function mimeFromExtension(extension: string): string { + if (extension === "jpg" || extension === "jpeg") return "image/jpeg"; + if (extension === "mov") return "video/quicktime"; + if (extension === "mp3") return "audio/mpeg"; + if (extension === "wav") return "audio/wav"; + if (extension === "ogg") return "audio/ogg"; + if (extension === "flac") return "audio/flac"; + if (extension === "aac") return "audio/aac"; + if (extension === "mp4" || extension === "webm") return `video/${extension}`; + return `image/${extension}`; +} + function extensionFromDataUrl(dataUrl: string): string { return extensionFromMime(dataUrl.match(/^data:([^;]+);base64,/)?.[1]); } @@ -75,12 +109,16 @@ async function imageSourceToBlob(src: string): Promise { return response.blob(); } -async function fileToDataUrl(file: File): Promise { +async function fileToDataUrl(file: File, fallbackMime?: string): Promise { + const blob = file.type || !fallbackMime + ? file + : file.slice(0, file.size, fallbackMime); + return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); - reader.readAsDataURL(file); + reader.readAsDataURL(blob); }); } @@ -154,10 +192,10 @@ export async function loadBrowserGenerationFile( const directoryHandle = await resolveDirectoryHandle(directoryPath, false); await ensurePermission(directoryHandle, "readwrite"); - for (const extension of IMAGE_EXTENSIONS) { + for (const extension of MEDIA_EXTENSIONS) { try { const fileHandle = await directoryHandle.getFileHandle(`${imageId}.${extension}`); - return fileToDataUrl(await fileHandle.getFile()); + return fileToDataUrl(await fileHandle.getFile(), mimeFromExtension(extension)); } catch { continue; } diff --git a/src/utils/mediaStorage.ts b/src/utils/mediaStorage.ts index ec1c96aa..40266610 100644 --- a/src/utils/mediaStorage.ts +++ b/src/utils/mediaStorage.ts @@ -1,6 +1,12 @@ import { WorkflowNode, WorkflowNodeData } from "@/types"; import { WorkflowFile } from "@/store/workflowStore"; import crypto from "crypto"; +import { + isBrowserFileSystemPath, + joinBrowserFileSystemPath, + loadBrowserGenerationFile, + writeBrowserGenerationFile, +} from "./browserFileSystem"; /** * Fetch with timeout support using AbortController @@ -672,6 +678,16 @@ async function saveImageAndGetId( const imageId = existingId || generateImageId(); const savePromise = (async () => { + if (isBrowserFileSystemPath(workflowPath)) { + await writeBrowserGenerationFile( + joinBrowserFileSystemPath(workflowPath, folder), + imageId, + imageData + ); + savedImageIds.set(hash, imageId); + return imageId; + } + const response = await fetchWithTimeout( "/api/workflow-images", { @@ -738,6 +754,16 @@ async function saveVideoAndGetRef( const videoId = existingId || generateMediaId("vid"); const savePromise = (async () => { + if (isBrowserFileSystemPath(workflowPath)) { + await writeBrowserGenerationFile( + joinBrowserFileSystemPath(workflowPath, "generations"), + videoId, + videoData + ); + savedMediaIds.set(hash, videoId); + return videoId; + } + const response = await fetchWithTimeout( "/api/save-generation", { @@ -806,6 +832,16 @@ async function saveAudioAndGetRef( const audioId = existingId || generateMediaId("aud"); const savePromise = (async () => { + if (isBrowserFileSystemPath(workflowPath)) { + await writeBrowserGenerationFile( + joinBrowserFileSystemPath(workflowPath, "generations"), + audioId, + audioData + ); + savedMediaIds.set(hash, audioId); + return audioId; + } + const response = await fetchWithTimeout( "/api/save-generation", { @@ -1242,6 +1278,24 @@ async function loadMediaById( return loadedMedia.get(mediaId)!; } + if (isBrowserFileSystemPath(workflowPath)) { + const folderNames = mediaType === "image" ? ["inputs", "generations"] : ["generations"]; + + for (const folderName of folderNames) { + const mediaData = await loadBrowserGenerationFile( + joinBrowserFileSystemPath(workflowPath, folderName), + mediaId + ); + if (mediaData) { + loadedMedia.set(mediaId, mediaData); + return mediaData; + } + } + + console.log(`${mediaType} not found: ${mediaId}`); + return ""; + } + let response: Response; if (mediaType === "image") {