Browse Source

Unify the testing branch with the verified canvas release

TEST-s had the Popi.TV and browserfs fixes as cherry-picked commits, while master also carried the connected-output media release commit. Merging master preserves TEST-s history and brings the testing deployment branch onto the same verified release baseline without rewriting the branch.

Constraint: Testing deployment appears to track TEST-s while master is the canonical release branch

Rejected: Force-update TEST-s to master | would discard the existing TEST-s branch history

Confidence: high

Scope-risk: moderate

Directive: Keep TEST-s merged from master for deploy parity unless intentionally testing divergent changes

Tested: npm run test:run

Tested: npm run build

Not-tested: Direct server restart blocked by missing SSH credentials
test-0523
jiajia 2 months ago
parent
commit
14b4b9105e
  1. 32
      src/components/__tests__/OutputNode.test.tsx
  2. 313
      src/components/nodes/OutputNode.tsx
  3. 8
      src/i18n/index.tsx
  4. 32
      src/store/execution/__tests__/simpleNodeExecutors.test.ts
  5. 177
      src/store/execution/simpleNodeExecutors.ts

32
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(
<TestWrapper>
<OutputNode {...createNodeProps({
image: "data:image/png;base64,image456",
video: "data:video/mp4;base64,video123",
audio: "data:audio/mpeg;base64,audio789",
contentType: "image",
})} />
</TestWrapper>
);
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", () => {

313
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<OutputNodeData, "output">;
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<OutputNodeType>) {
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<OutputNodeType>) {
const isRunning = useWorkflowStore((state) => state.isRunning);
const showLabels = useShowHandleLabels(selected);
const [showLightbox, setShowLightbox] = useState(false);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const previousEdgeCountRef = useRef<number | null>(null);
const prevFitKeyRef = useRef<string | null>(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<Record<OutputKind, string>>(() => ({
image: t("toolbar.image"),
video: t("toolbar.video"),
audio: t("modelSearch.audio"),
"3d": "3D",
}), [t]);
const outputItems = useMemo<OutputItem[]>(() => {
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<OutputNodeType>) {
}, [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<OutputNodeType>) {
data-handletype="image"
style={{ top: "28%", zIndex: 10 }}
/>
<HandleLabel label="Image" side="target" color="var(--handle-color-image)" top="calc(28% - 18px)" visible={showLabels} />
<HandleLabel label={typeLabels.image} side="target" color="var(--handle-color-image)" top="calc(28% - 18px)" visible={showLabels} />
<Handle
type="target"
position={Position.Left}
@ -171,7 +243,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
data-handletype="video"
style={{ top: "44%", zIndex: 10 }}
/>
<HandleLabel label="Video" side="target" color="var(--handle-color-video)" top="calc(44% - 18px)" visible={showLabels} />
<HandleLabel label={typeLabels.video} side="target" color="var(--handle-color-video)" top="calc(44% - 18px)" visible={showLabels} />
<Handle
type="target"
position={Position.Left}
@ -179,7 +251,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
data-handletype="audio"
style={{ top: "60%", background: "rgb(167, 139, 250)", zIndex: 10 }}
/>
<HandleLabel label="Audio" side="target" color="var(--handle-color-audio)" top="calc(60% - 18px)" visible={showLabels} />
<HandleLabel label={typeLabels.audio} side="target" color="var(--handle-color-audio)" top="calc(60% - 18px)" visible={showLabels} />
<Handle
type="target"
position={Position.Left}
@ -190,74 +262,109 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
<HandleLabel label="3D" side="target" color="var(--handle-color-3d)" top="calc(76% - 18px)" visible={showLabels} />
<div className="relative w-full h-full overflow-hidden rounded-lg">
{contentSrc ? (
<>
{isAudio ? (
<div className="w-full h-full flex items-center justify-center p-4">
<audio
src={contentSrc}
controls
className="w-full rounded"
/>
</div>
) : is3D ? (
<div className="w-full h-full flex flex-col items-center justify-center gap-2 p-4 text-center bg-neutral-900/60">
<svg className="w-9 h-9 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9L12 21.75m0-9L3 7.5m9 5.25v9M3 7.5v9l9 5.25" />
</svg>
<span className="text-xs font-medium text-neutral-200">3D model</span>
<span className="max-w-full truncate text-[10px] text-neutral-500">
{contentSrc}
</span>
</div>
) : (
<div
className="relative cursor-pointer group w-full h-full"
onClick={() => setShowLightbox(true)}
>
{isVideo ? (
<video
ref={videoAutoplayRef}
src={videoBlobUrl ?? undefined}
{outputItems.length > 1 && (
<div className="absolute left-2 top-2 z-20 flex max-w-[calc(100%-5.5rem)] gap-1 overflow-x-auto rounded bg-black/50 p-1 backdrop-blur-sm">
{outputItems.map((item) => {
const isActive = activeItem?.id === item.id;
return (
<button
key={item.id}
type="button"
data-testid={`output-tab-${item.kind}`}
onClick={() => setSelectedItemId(item.id)}
aria-pressed={isActive}
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors ${
isActive
? "bg-white text-neutral-900"
: "text-neutral-300 hover:bg-white/10 hover:text-white"
}`}
title={item.label}
>
{item.label} 1
</button>
);
})}
</div>
)}
{contentSrc ? (
<>
{isAudio ? (
<div className="w-full h-full flex items-center justify-center p-4">
<audio
src={contentSrc}
controls
loop
muted
playsInline
className="w-full h-full object-contain"
onClick={(e) => e.stopPropagation()}
className="w-full rounded"
/>
) : (
<img
src={adaptiveImage ?? contentSrc}
alt="Output"
className="w-full h-full object-contain"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center pointer-events-none">
<span className="text-[10px] font-medium text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 px-2 py-1 rounded">
View full size
</div>
) : is3D ? (
<div className="w-full h-full flex flex-col items-center justify-center gap-2 p-4 text-center bg-neutral-900/60">
<svg className="w-9 h-9 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9L12 21.75m0-9L3 7.5m9 5.25v9M3 7.5v9l9 5.25" />
</svg>
<span className="text-xs font-medium text-neutral-200">3D model</span>
<span className="max-w-full truncate text-[10px] text-neutral-500">
{contentSrc}
</span>
</div>
</div>
)}
<button
onClick={handleDownload}
className="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white text-xs rounded transition-colors flex items-center gap-1"
title="Download"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
) : (
<div
className="relative cursor-pointer group w-full h-full"
onClick={() => setShowLightbox(true)}
>
{isVideo ? (
<video
ref={videoAutoplayRef}
src={videoBlobUrl ?? undefined}
controls
loop
muted
playsInline
className="w-full h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
) : (
<img
src={adaptiveImage ?? contentSrc}
alt="Output"
className="w-full h-full object-contain"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center pointer-events-none">
<span className="text-[10px] font-medium text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 px-2 py-1 rounded">
View full size
</span>
</div>
</div>
)}
{outputItems.length > 1 && (
<button
onClick={handleDownloadAll}
className="absolute top-2 right-10 p-1.5 bg-black/60 hover:bg-black/80 text-white text-xs rounded transition-colors flex items-center gap-1"
title={downloadAllTitle}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11l5 5 5-5M12 16V4M5 20h14" />
</svg>
</button>
)}
<button
onClick={handleDownload}
className="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white text-xs rounded transition-colors flex items-center gap-1"
title={downloadCurrentTitle}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</>
) : (
<div className="w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center">
<svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</button>
</>
) : (
<div className="w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center">
<svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
<span className="text-xs text-neutral-500 mt-2">Connect input</span>
</div>
)}
<span className="text-xs text-neutral-500 mt-2">Connect input</span>
</div>
)}
</div>
</BaseNode>

8
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": "モデルを選択...",

32
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", () => {

177
src/store/execution/simpleNodeExecutors.ts

@ -185,141 +185,54 @@ export async function executePromptConstructor(ctx: NodeExecutionContext): Promi
export async function executeOutput(ctx: NodeExecutionContext): Promise<void> {
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<string, unknown>) => {
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 });
}
/**

Loading…
Cancel
Save