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
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} />;
|
|
}
|
|
|