feat(video): 新增视频创作台页面

- 添加 /video 页面路由及完整视频生成界面
- 集成视频生成 API 调用及本地存储功能
- 实现提示词、参考图、视频参数设置界面
- 添加生成结果预览、下载和保存素材功能
- 支持本地生成记录管理及历史查看
- 在导航菜单中添加视频创作台入口
- 更新 CHANGELOG 包含新功能说明
This commit is contained in:
HouYunFei
2026-05-25 17:37:43 +08:00
parent 796882064b
commit 1be6b4fa29
4 changed files with 583 additions and 1 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
+ [新增] 新增视频创作台页面。
## v0.0.8 - 2026-05-24
+ [新增] 新增用户账号与算力点体系,支持账号密码注册登录、Linux.do OAuth。
+1
View File
@@ -8,3 +8,4 @@
- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入。
+574
View File
@@ -0,0 +1,574 @@
"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 = <K extends keyof AiConfig>(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<HTMLInputElement>(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<ReferenceImage[]>([]);
const [results, setResults] = useState<GenerationResult[]>([]);
const [logs, setLogs] = useState<GenerationLog[]>([]);
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<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(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: { ...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 (
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} />
</aside>
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
<div className="thin-scrollbar flex flex-col rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto">
<div className="flex items-start justify-between gap-3">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1>
<div className="flex shrink-0 gap-2 lg:hidden">
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>
</Button>
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
</Button>
</div>
</div>
<div className="mt-6 space-y-5">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span>
<div className="flex gap-2">
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>
</Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
</Button>
</div>
</div>
<Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder="描述镜头运动、主体动作、场景氛围和画面风格" />
</div>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span>
<div className="flex gap-2">
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>
</Button>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
</Button>
</div>
</div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700">
{references.map((item) => (
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图">
<Trash2 className="size-3.5" />
</button>
</div>
))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"> 7 </div> : null}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
<span className="truncate text-stone-500 dark:text-stone-400">
{model} · {effectiveConfig.vquality}p · {effectiveConfig.size} · {effectiveConfig.videoSeconds}s
</span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
</Button>
</div>
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
</div>
<div className="mt-auto pt-6">
<Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}>
</Button>
</div>
</div>
<div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold"></h2>
{running ? <Tag className="m-0 px-2 py-1"> {formatDuration(elapsedMs)}</Tag> : null}
</div>
{results.length ? (
<div className="grid gap-4">
{results.map((result) => (result.status === "success" && result.video ? <ResultVideoCard key={result.id} video={result.video} onDownload={downloadVideo} onSaveAsset={saveResultToAssets} /> : result.status === "failed" ? <FailedVideoCard key={result.id} error={result.error || "生成失败"} onRetry={retryResult} /> : <PendingVideoCard key={result.id} />))}
</div>
) : (
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
<VideoIcon className="mb-4 size-11 text-stone-400" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有生成视频" />
</div>
)}
</div>
</section>
</main>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void addReferences(event.target.files);
event.target.value = "";
}}
/>
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} />
</Drawer>
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3">
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
</Drawer>
<PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} />
<AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} />
<Modal title="删除生成记录" open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{selectedLogIds.length}
</Modal>
</div>
);
}
function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) {
return (
<>
<label className="col-span-2 block min-w-0 sm:col-span-1">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("videoModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<AutoComplete className="canvas-control-select w-full" value={config.videoSeconds} options={secondOptions} onChange={(value) => updateConfig("videoSeconds", value)} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<AutoComplete className="canvas-control-select w-full" value={config.size} options={sizeOptions} placeholder="例如 1280x720" onChange={(value) => updateConfig("size", value)} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<AutoComplete className="canvas-control-select w-full" value={normalizeResolution(config.vquality)} options={resolutionOptions} placeholder="例如 720" onChange={(value) => updateConfig("vquality", value)} />
</label>
</>
);
}
function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedVideo; onDownload: (video: GeneratedVideo) => void; onSaveAsset: (video: GeneratedVideo) => void }) {
return (
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
<video src={video.url} controls className="aspect-video w-full bg-black object-contain" />
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
<span>
{video.width}x{video.height}
</span>
<span>{formatBytes(video.bytes)}</span>
<span>{formatDuration(video.durationMs)}</span>
</div>
<div className="flex shrink-0 gap-1">
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => onSaveAsset(video)}>
</Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(video)}>
</Button>
</div>
</div>
</div>
);
}
function PendingVideoCard() {
return (
<div className="relative aspect-video overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
<LoaderCircle className="size-6 animate-spin" />
<span></span>
</div>
</div>
);
}
function FailedVideoCard({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20">
<div className="flex aspect-video flex-col items-center justify-center gap-3 p-5 text-center">
<div className="text-sm font-medium text-red-600 dark:text-red-300"></div>
<Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300">
{error}
</Typography.Paragraph>
</div>
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
<Button size="small" danger onClick={onRetry}>
</Button>
</div>
</div>
);
}
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 (
<>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-base font-semibold"></h2>
<Tag className="m-0">{logs.length}</Tag>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>
</Button>
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>
{allSelected ? "取消" : "全选"}
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>
</Button>
</div>
<div className="space-y-3">
{logs.map((log) => (
<LogCard key={log.id} log={log} selected={selectedLogIds.includes(log.id)} active={activeLogId === log.id} onSelectedChange={(checked) => onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))} onClick={() => onPreviewLog(log)} />
))}
{!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700"></div> : null}
</div>
</>
);
}
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
return (
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2">
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-5">{log.title}</div>
<div className="mt-2 flex flex-wrap gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.size}</Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.resolution}p</Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.seconds}s</Tag>
</div>
</div>
<div className="grid justify-items-end gap-2">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color={log.status === "成功" ? "blue" : "red"}>
{log.status}
</Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">
{formatDuration(log.durationMs)}
</Tag>
</div>
</div>
</button>
);
}
async function readStoredLogs() {
if (typeof window === "undefined") return [];
try {
const logs: GenerationLog[] = [];
await logStore.iterate<GenerationLog, void>((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<GenerationLog>): Promise<GenerationLog> {
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 normalizeResolution(value: string) {
return value.replace(/p$/i, "") || "720";
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { FileText, ImagePlus, Images, Maximize2 } from "lucide-react";
import { FileText, ImagePlus, Images, Maximize2, Video } from "lucide-react";
export const navigationTools = [
{
@@ -11,6 +11,11 @@ export const navigationTools = [
label: "生图工作台",
icon: ImagePlus,
},
{
slug: "video",
label: "视频创作台",
icon: Video,
},
{
slug: "prompts",
label: "提示词库",