You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

356 lines
12 KiB

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Node, NodeProps, useUpdateNodeInternals } from "@xyflow/react";
import { Spin } from "antd";
import { AssetPickerModal, PopiAssetItem } from "@/components/AssetPickerModal";
import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { SmartVideoNodeData } from "@/types";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { isSmartGenerateUploadEnabled } from "@/utils/smartGenerateUpload";
import { deriveSmartVideoMode } from "@/utils/smartVideoMode";
import { getPopiVideoAssetPreview, resolvePopiVideoAsset } from "@/lib/popiAssetApi";
import { GenerateVideoNodeView } from "./GenerateVideoNode";
import { VideoInputNodeView } from "./VideoInputNode";
type SmartVideoNodeType = Node<SmartVideoNodeData, "smartVideo">;
const MAX_FILE_SIZE = 200 * 1024 * 1024;
const ACCEPTED_FORMATS = "video/mp4,video/webm,video/quicktime";
const ACCEPTED_MIME_TYPES = ACCEPTED_FORMATS.split(",");
function SmartVideoToolbarIcon({ kind }: { kind: "upload" | "assets" }) {
if (kind === "upload") {
return (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v11m0-11 4 4m-4-4-4 4M4 17v2h16v-2" />
</svg>
);
}
return (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 8h8M8 12h8M8 16h5" />
</svg>
);
}
function SmartVideoUploadToolbar({
id,
selected,
isUploading,
addToGenerationHistory = false,
}: {
id: string;
selected?: boolean;
isUploading: boolean;
addToGenerationHistory?: boolean;
}) {
const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const buildUploadedVideoPatch = useCallback(
(videoUrl: string): Partial<SmartVideoNodeData> => {
if (!addToGenerationHistory) return {};
const currentNode = useWorkflowStore.getState().getNodeById(id);
const currentData = (currentNode?.data ?? {}) as SmartVideoNodeData;
const timestamp = Date.now();
return {
outputVideo: videoUrl,
videoHistory: [
{
id: `${timestamp}`,
video: videoUrl,
timestamp,
prompt: currentData.inputPrompt || "",
model: currentData.selectedModel?.modelId || "",
modelDisplayName: currentData.selectedModel?.displayName,
modelProvider: currentData.selectedModel?.provider,
},
...(currentData.videoHistory || []),
].slice(0, 50),
selectedVideoHistoryIndex: 0,
};
},
[addToGenerationHistory, id]
);
const applyVideo = useCallback(
(videoSrc: string, filename: string, format: string, duration: number | null, dimensions: { width: number; height: number } | null) => {
updateNodeData(id, {
video: videoSrc,
videoRef: undefined,
assetId: undefined,
previewVideoPoster: undefined,
assetDetailLoading: false,
assetDetailError: null,
filename,
format,
duration,
dimensions,
...buildUploadedVideoPatch(videoSrc),
});
},
[buildUploadedVideoPatch, id, updateNodeData]
);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!ACCEPTED_MIME_TYPES.includes(file.type)) {
alert(t("upload.videoUnsupported"));
return;
}
if (file.size > MAX_FILE_SIZE) {
alert(t("upload.videoTooLarge"));
return;
}
updateNodeData(id, { uploadStatus: "loading", uploadError: null });
const metadataUrl = URL.createObjectURL(file);
const video = document.createElement("video");
video.preload = "metadata";
const applyUpload = (metadata: {
duration: number | null;
dimensions: { width: number; height: number } | null;
}) => {
URL.revokeObjectURL(metadataUrl);
uploadFileToGatewayMedia(file, "video")
.then((uploaded) => {
updateNodeData(id, { uploadStatus: "idle", uploadError: null });
applyVideo(
uploaded.url,
uploaded.filename,
uploaded.contentType,
metadata.duration,
metadata.dimensions
);
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Media upload failed";
updateNodeData(id, { uploadStatus: "error", uploadError: message });
alert(message);
});
};
video.onloadedmetadata = () => {
applyUpload({
duration: Number.isFinite(video.duration) ? video.duration : null,
dimensions: video.videoWidth && video.videoHeight
? { width: video.videoWidth, height: video.videoHeight }
: null,
});
};
video.onerror = () => {
applyUpload({ duration: null, dimensions: null });
};
video.src = metadataUrl;
},
[applyVideo, id, t, updateNodeData]
);
const handleSelectAsset = useCallback((asset: PopiAssetItem) => {
const preview = getPopiVideoAssetPreview(asset);
if (!preview) return;
updateNodeData(id, {
video: preview.videoUrl,
videoRef: undefined,
assetId: preview.assetId,
previewVideoPoster: preview.previewPosterUrl || undefined,
assetDetailLoading: true,
assetDetailError: null,
filename: preview.filename,
format: "video/asset",
duration: preview.duration,
dimensions: preview.dimensions,
});
setIsAssetPickerOpen(false);
const applyResolvedAsset = (
resolved: NonNullable<Awaited<ReturnType<typeof resolvePopiVideoAsset>>>,
metadata: { duration: number | null; dimensions: { width: number; height: number } | null }
) => {
const currentNode = useWorkflowStore.getState().getNodeById(id);
const currentData = currentNode?.data as SmartVideoNodeData | undefined;
if (currentData?.assetId !== preview.assetId) return;
const videoUrl = resolved.videoUrl;
if (!videoUrl) return;
updateNodeData(id, {
video: videoUrl,
videoRef: undefined,
assetId: resolved.assetId,
previewVideoPoster: resolved.previewPosterUrl || undefined,
assetDetailLoading: false,
assetDetailError: null,
filename: resolved.filename,
format: "video/asset",
duration: resolved.duration || metadata.duration,
dimensions: resolved.dimensions || metadata.dimensions,
...buildUploadedVideoPatch(videoUrl),
});
};
void resolvePopiVideoAsset(asset)
.then((resolved) => {
if (!resolved?.videoUrl) return;
if (resolved.dimensions && resolved.duration) {
applyResolvedAsset(resolved, { duration: resolved.duration, dimensions: resolved.dimensions });
return;
}
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
applyResolvedAsset(resolved, {
duration: Number.isFinite(video.duration) ? video.duration : null,
dimensions: video.videoWidth && video.videoHeight
? { width: video.videoWidth, height: video.videoHeight }
: null,
});
};
video.onerror = () => applyResolvedAsset(resolved, {
duration: preview.duration,
dimensions: preview.dimensions,
});
video.src = resolved.videoUrl;
})
.catch((error) => {
const currentNode = useWorkflowStore.getState().getNodeById(id);
const currentData = currentNode?.data as SmartVideoNodeData | undefined;
if (currentData?.assetId !== preview.assetId) return;
updateNodeData(id, {
assetDetailLoading: false,
assetDetailError: error instanceof Error ? error.message : "Failed to load asset detail",
});
});
}, [buildUploadedVideoPatch, id, updateNodeData]);
const handleOpenFileDialog = useCallback(() => {
if (isUploading) return;
selectSingleNode(id);
fileInputRef.current?.click();
}, [id, isUploading, selectSingleNode]);
return (
<>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FORMATS}
onChange={handleFileChange}
className="hidden"
/>
{selected && (
<div className="nodrag nopan absolute left-1/2 -top-[50px] z-[10002] flex -translate-x-1/2 items-center gap-2">
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleOpenFileDialog();
}}
aria-label={t("imageInput.localUpload")}
disabled={isUploading}
className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-2)] px-3 text-sm text-[var(--text-secondary)] shadow-[var(--shadow-panel)] transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)] disabled:cursor-wait disabled:opacity-60"
>
<SmartVideoToolbarIcon kind="upload" />
<span>{t("imageInput.localUpload")}</span>
</button>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (isUploading) return;
selectSingleNode(id);
setIsAssetPickerOpen(true);
}}
aria-label={t("imageInput.fromAssets")}
disabled={isUploading}
className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-2)] px-3 text-sm text-[var(--text-secondary)] shadow-[var(--shadow-panel)] transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)] disabled:cursor-wait disabled:opacity-60"
>
<SmartVideoToolbarIcon kind="assets" />
<span>{t("imageInput.fromAssets")}</span>
</button>
</div>
)}
{isAssetPickerOpen && (
<AssetPickerModal
kind="video"
onClose={() => setIsAssetPickerOpen(false)}
onSelect={handleSelectAsset}
/>
)}
</>
);
}
function SmartVideoNeutralView({
id,
data,
selected,
addToGenerationHistory = false,
}: {
id: string;
data: SmartVideoNodeData;
selected?: boolean;
addToGenerationHistory?: boolean;
}) {
const isUploading = data.uploadStatus === "loading";
return (
<div className="relative h-full w-full">
<GenerateVideoNodeView id={id} data={data} selected={selected} />
<SmartVideoUploadToolbar
id={id}
selected={selected}
isUploading={isUploading}
addToGenerationHistory={addToGenerationHistory}
/>
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
</div>
);
}
export function SmartVideoNode({ id, data, selected }: NodeProps<SmartVideoNodeType>) {
const edges = useWorkflowStore((state) => state.edges);
const providerEnvStatus = useWorkflowStore((state) => state.providerEnvStatus);
const updateNodeInternals = useUpdateNodeInternals();
const mode = deriveSmartVideoMode(id, data, edges);
useEffect(() => {
updateNodeInternals(id);
}, [id, mode, updateNodeInternals]);
if (mode === "generate") {
if (isSmartGenerateUploadEnabled(providerEnvStatus)) {
return <SmartVideoNeutralView id={id} data={data} selected={selected} addToGenerationHistory />;
}
return <GenerateVideoNodeView id={id} data={data} selected={selected} />;
}
if (mode === "neutral") {
return <SmartVideoNeutralView id={id} data={data} selected={selected} />;
}
return <VideoInputNodeView id={id} data={data} selected={selected} />;
}