"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; 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 ( ); } return ( ); } 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(null); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); const buildUploadedVideoPatch = useCallback( (videoUrl: string): Partial => { 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) => { 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>>, 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 ( <> {selected && (
)} {isAssetPickerOpen && ( 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 (
{isUploading && (
)}
); } export function SmartVideoNode({ id, data, selected }: NodeProps) { 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 ; } return ; } if (mode === "neutral") { return ; } return ; }