Files
infinite-canvas/web/src/app/(user)/video/page.tsx
T
HouYunFei bf66a25936 feat(image): 优化图片生成记录持久化和导出功能
- 新增生成记录配置持久化功能,保存提示词、参考图、模型、质量、尺寸和张数配置
- 结果图写入本地图片存储后只保存 storageKey,减少存储空间占用
- 点击历史记录可回填生成配置并预览结果
- 优化我的画布、我的素材导出功能
- 新增视频创作台页面
- 修复图片节点size参数传递问题
- 添加完整的生成日志数据结构和序列化处理
- 实现图片存储和删除的批量操作支持
2026-05-26 14:09:10 +08:00

639 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<AiConfig, "model" | "videoModel" | "size" | "vquality" | "videoSeconds">;
type UpdateAiConfig = <K extends keyof AiConfig>(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<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, 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 (
<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} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(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" height="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3 pb-4">
<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 }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
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>
<div className="col-span-2">
<VideoSettingsPanel config={config} onConfigChange={(key, value) => updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" />
</div>
</>
);
}
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;
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<GenerationLog>): 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);
}