"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, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd"; import localforage from "localforage"; import { nanoid } from "nanoid"; import { saveAs } from "file-saver"; 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 { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel"; import { canvasThemes } from "@/lib/canvas-theme"; import { formatBytes, formatDuration } from "@/lib/image-utils"; import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage"; import { resolveImageUrl, 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 { useThemeStore } from "@/stores/use-theme-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; prompt: string; time: string; model: string; config: GenerationLogConfig; references: ReferenceImage[]; durationMs: number; size: string; resolution: string; seconds: string; status: "成功" | "失败"; video?: GeneratedVideo; error?: string; }; type GenerationLogConfig = Pick; type UpdateAiConfig = (key: K, value: AiConfig[K]) => void; 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, references: snapshot.references, 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, references: snapshot.references, 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) => { saveAs(video.url, "video.mp4"); }; 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 = () => { const mediaKeys = logs .filter((log) => selectedLogIds.includes(log.id)) .map((log) => log.video?.storageKey) .filter((key): key is string => Boolean(key)); void Promise.all([deleteStoredMedia(mediaKeys), ...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, serializeLog(log)).then(refreshLogs); }; const refreshLogs = async () => setLogs(await readStoredLogs()); const previewGenerationLog = (log: GenerationLog) => { setPreviewLog(log); setLogsOpen(false); setPrompt(log.prompt); setReferences(log.references || []); if (log.config.videoModel || log.model) updateConfig("videoModel", log.config.videoModel || log.model); if (log.config.size) updateConfig("size", log.config.size); if (log.config.vquality) updateConfig("vquality", log.config.vquality); if (log.config.videoSeconds) updateConfig("videoSeconds", log.config.videoSeconds); 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 · {videoSizeLabel(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 }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return ( <>
updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" />
); } 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; const references = await Promise.all( (log.references || []).map(async (item) => ({ ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl), })), ); const config = normalizeLogConfig(log); return { id: log.id || nanoid(), createdAt: log.createdAt || Date.now(), title: log.title || log.model || "未命名", prompt: log.prompt || "", time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }), model: log.model || config.videoModel || "", config, references, durationMs: log.durationMs || 0, size: log.size || config.size || "", resolution: normalizeResolution(log.resolution || config.vquality || ""), seconds: log.seconds || config.videoSeconds || "", status: log.status || "成功", video, error: log.error, }; } function serializeLog(log: GenerationLog): GenerationLog { return { ...log, references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })), video: log.video?.storageKey ? { ...log.video, url: "" } : log.video, }; } function normalizeLogConfig(log: Partial): GenerationLogConfig { return { model: log.config?.model || log.model || "", videoModel: log.config?.videoModel || log.model || "", size: log.config?.size || log.size || "", vquality: normalizeResolution(log.config?.vquality || log.resolution || ""), videoSeconds: log.config?.videoSeconds || log.seconds || "", }; } function buildLog({ prompt, model, config, references, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog { const logConfig = { model: config.model, videoModel: config.videoModel, size: config.size, vquality: normalizeResolution(config.vquality), videoSeconds: config.videoSeconds, }; return { id: nanoid(), createdAt: Date.now(), title: prompt.slice(0, 12) || "未命名", prompt, time: new Date().toLocaleString("zh-CN", { hour12: false }), model, config: logConfig, references, durationMs, size: logConfig.size, resolution: logConfig.vquality, seconds: logConfig.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(Math.max(1, Math.min(20, seconds))); } function normalizeVideoSize(value: string) { return normalizeVideoSizeValue(value); } function normalizeResolution(value: string) { return normalizeVideoResolutionValue(value); }