"use client"; import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { App, AutoComplete, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd"; import localforage from "localforage"; import { nanoid } from "nanoid"; import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal"; import { ModelPicker } from "@/components/model-picker"; import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog"; import { formatBytes, formatDuration } from "@/lib/image-utils"; import { resolveMediaUrl, uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { requestVideoGeneration } from "@/services/api/video"; import { useAssetStore } from "@/stores/use-asset-store"; import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; type GeneratedVideo = { id: string; url: string; storageKey: string; durationMs: number; width: number; height: number; bytes: number; mimeType: string; }; type GenerationResult = { id: string; status: "pending" | "success" | "failed"; video?: GeneratedVideo; error?: string; }; type GenerationLog = { id: string; createdAt: number; title: string; time: string; model: string; durationMs: number; size: string; resolution: string; seconds: string; status: "成功" | "失败"; video?: GeneratedVideo; error?: string; }; type UpdateAiConfig = (key: K, value: AiConfig[K]) => void; const sizeOptions = ["1280x720", "720x1280", "1024x1024", "1792x1024", "1024x1792"].map((value) => ({ label: value, value })); const resolutionOptions = ["720", "480"].map((value) => ({ label: `${value}p`, value })); const secondOptions = ["6", "10", "12", "16", "20"].map((value) => ({ label: `${value}s`, value })); const LOG_STORE_KEY = "infinite-canvas:video_generation_logs"; const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "video_generation_logs" }); export default function VideoPage() { const { message } = App.useApp(); const fileInputRef = useRef(null); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const addAsset = useAssetStore((state) => state.addAsset); const [prompt, setPrompt] = useState(""); const [references, setReferences] = useState([]); const [results, setResults] = useState([]); const [logs, setLogs] = useState([]); const [running, setRunning] = useState(false); const [logsOpen, setLogsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [promptDialogOpen, setPromptDialogOpen] = useState(false); const [assetPickerOpen, setAssetPickerOpen] = useState(false); const [startedAt, setStartedAt] = useState(0); const [elapsedMs, setElapsedMs] = useState(0); const [selectedLogIds, setSelectedLogIds] = useState([]); const [previewLog, setPreviewLog] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const model = effectiveConfig.videoModel || effectiveConfig.model; const canGenerate = Boolean(prompt.trim()); useEffect(() => { if (!running || !startedAt) return; const timer = window.setInterval(() => setElapsedMs(performance.now() - startedAt), 1000); return () => window.clearInterval(timer); }, [running, startedAt]); useEffect(() => { void refreshLogs(); }, []); const addReferences = async (files?: FileList | null) => { const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/")).slice(0, 7 - references.length); const nextReferences = await Promise.all( imageFiles.map(async (file) => { const image = await uploadImage(file); return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; }), ); setReferences((value) => [...value, ...nextReferences].slice(0, 7)); }; const addReferencesFromClipboard = async () => { try { const items = await navigator.clipboard.read(); const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type)))); if (!blobs.length) { message.error("剪切板里没有可读取的图片"); return; } const nextReferences = await Promise.all( blobs.slice(0, 7 - references.length).map(async (blob, index) => { const image = await uploadImage(blob); return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; }), ); setReferences((value) => [...value, ...nextReferences].slice(0, 7)); message.success(`已读取 ${nextReferences.length} 张参考图`); } catch { message.error("剪切板里没有可读取的图片"); } }; const generate = async () => { const snapshot = buildRequestSnapshot(); if (!snapshot) return; setElapsedMs(0); setRunning(true); setPreviewLog(null); setResults([{ id: nanoid(), status: "pending" }]); const batchStartedAt = performance.now(); setStartedAt(batchStartedAt); try { const blob = await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references); const stored = await uploadMediaFile(blob, "video"); const nextVideo: GeneratedVideo = { id: nanoid(), url: stored.url, storageKey: stored.storageKey, durationMs: performance.now() - batchStartedAt, width: stored.width || 1280, height: stored.height || 720, bytes: stored.bytes, mimeType: stored.mimeType, }; setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]); saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo })); message.success("视频已生成"); } catch (error) { const errorMessage = error instanceof Error ? error.message : "生成失败"; setResults([{ id: nanoid(), status: "failed", error: errorMessage }]); saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage })); message.error(errorMessage); } finally { setRunning(false); } }; const buildRequestSnapshot = () => { const text = prompt.trim(); if (!text) { message.error("请输入视频提示词"); return null; } if (!isAiConfigReady(effectiveConfig, model)) { message.warning("请先完成配置"); openConfigDialog(true); return null; } return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references] }; }; const retryResult = () => { void generate(); }; const downloadVideo = (video: GeneratedVideo) => { const link = document.createElement("a"); link.href = video.url; link.download = "video.mp4"; link.click(); }; const saveResultToAssets = (video: GeneratedVideo) => { addAsset({ kind: "video", title: "生成视频", coverUrl: "", tags: [], source: "视频创作台", data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType }, metadata: { source: "video-page", prompt }, }); message.success("已加入我的素材"); }; const insertPickedAsset = async (payload: InsertAssetPayload) => { if (payload.kind === "text") { setPrompt(payload.content); } else if (payload.kind === "image") { const stored = await uploadImage(payload.dataUrl); setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }].slice(0, 7)); } setAssetPickerOpen(false); }; const createSession = () => { setPrompt(""); setReferences([]); setResults([]); setElapsedMs(0); setStartedAt(0); setSelectedLogIds([]); setPreviewLog(null); }; const deleteSelectedLogs = () => { void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs); if (previewLog && selectedLogIds.includes(previewLog.id)) { setPreviewLog(null); setResults([]); } setSelectedLogIds([]); setDeleteConfirmOpen(false); }; const saveLog = (log: GenerationLog) => { void logStore.setItem(log.id, log).then(refreshLogs); }; const refreshLogs = async () => setLogs(await readStoredLogs()); const previewGenerationLog = (log: GenerationLog) => { setPreviewLog(log); setLogsOpen(false); setResults(log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]); }; return (

视频创作台

提示词
setPrompt(event.target.value)} rows={7} placeholder="描述镜头运动、主体动作、场景氛围和画面风格" />
参考图
{references.map((item) => (
{item.name}
))} {!references.length ?
暂无参考图,最多 7 张
: null}
{model} · {normalizeResolution(effectiveConfig.vquality)}p · {normalizeVideoSize(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s

生成结果

{running ? 等待 {formatDuration(elapsedMs)} : null}
{results.length ? (
{results.map((result) => (result.status === "success" && result.video ? : result.status === "failed" ? : ))}
) : (
)}
{ void addReferences(event.target.files); event.target.value = ""; }} /> setLogsOpen(false)}> setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} /> setSettingsOpen(false)}>
void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} /> setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> 确定删除选中的 {selectedLogIds.length} 条生成记录吗?
); } function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) { return ( <> ); } function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedVideo; onDownload: (video: GeneratedVideo) => void; onSaveAsset: (video: GeneratedVideo) => void }) { return (
); } function PendingVideoCard() { return (
生成中
); } function FailedVideoCard({ error, onRetry }: { error: string; onRetry: () => void }) { return (
生成失败
{error}
); } function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, onCreateSession, onDeleteSelected, onPreviewLog, }: { logs: GenerationLog[]; selectedLogIds: string[]; activeLogId?: string; onSelectedLogIdsChange: (ids: string[]) => void; onCreateSession: () => void; onDeleteSelected: () => void; onPreviewLog: (log: GenerationLog) => void; }) { const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length; const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id)); return ( <>

生成记录

{logs.length}
{logs.map((log) => ( onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))} onClick={() => onPreviewLog(log)} /> ))} {!logs.length ?
暂无生成记录
: null}
); } function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) { return ( ); } async function readStoredLogs() { if (typeof window === "undefined") return []; try { const logs: GenerationLog[] = []; await logStore.iterate((value) => { logs.push(value); }); return (await Promise.all(logs.map(normalizeLog))).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); } catch { return []; } } async function normalizeLog(log: Partial): Promise { const video = log.video?.storageKey ? { ...log.video, url: await resolveMediaUrl(log.video.storageKey, log.video.url) } : log.video; return { id: log.id || nanoid(), createdAt: log.createdAt || Date.now(), title: log.title || log.model || "未命名", time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }), model: log.model || "", durationMs: log.durationMs || 0, size: log.size || "", resolution: normalizeResolution(log.resolution || ""), seconds: log.seconds || "", status: log.status || "成功", video, error: log.error, }; } function buildLog({ prompt, model, config, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog { return { id: nanoid(), createdAt: Date.now(), title: prompt.slice(0, 12) || "未命名", time: new Date().toLocaleString("zh-CN", { hour12: false }), model, durationMs, size: config.size, resolution: normalizeResolution(config.vquality), seconds: config.videoSeconds, status, video, error, }; } function buildVideoConfig(config: AiConfig, model: string): AiConfig { return { ...config, model, videoModel: model, size: normalizeVideoSize(config.size), videoSeconds: normalizeVideoSeconds(config.videoSeconds), vquality: normalizeResolution(config.vquality), }; } function normalizeVideoSeconds(value: string) { const seconds = Math.floor(Number(value) || 6); return String([6, 10, 12, 16, 20].includes(seconds) ? seconds : 6); } function normalizeVideoSize(value: string) { return /^\d+x\d+$/.test(value || "") ? value : "1280x720"; } function normalizeResolution(value: string) { return value.replace(/p$/i, "") || "720"; }