feat(image): 优化图片生成记录持久化和导出功能
- 新增生成记录配置持久化功能,保存提示词、参考图、模型、质量、尺寸和张数配置 - 结果图写入本地图片存储后只保存 storageKey,减少存储空间占用 - 点击历史记录可回填生成配置并预览结果 - 优化我的画布、我的素材导出功能 - 新增视频创作台页面 - 修复图片节点size参数传递问题 - 添加完整的生成日志数据结构和序列化处理 - 实现图片存储和删除的批量操作支持
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [优化] 优化我的画布、我的素材导出功能
|
||||||
|
|
||||||
|
## v0.0.9 - 2026-05-26
|
||||||
|
|
||||||
+ [新增] 新增视频创作台页面。
|
+ [新增] 新增视频创作台页面。
|
||||||
+ [修复] 修复图片节点size参数传递问题。
|
+ [修复] 修复图片节点size参数传递问题。
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。
|
- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。
|
||||||
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
|
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
|
||||||
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
|
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
|
||||||
|
- 生图工作台新增生成记录配置持久化:每次生成会保存提示词、参考图、模型、质量、尺寸和张数,结果图写入本地图片存储后记录只保存 `storageKey`;点击历史记录会回填本次生成配置并预览结果。
|
||||||
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。
|
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。
|
||||||
- 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。
|
- 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。
|
||||||
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
|
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
|
||||||
@@ -16,5 +17,5 @@
|
|||||||
- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。
|
- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。
|
||||||
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
|
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
|
||||||
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。
|
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。
|
||||||
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入。
|
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数。
|
||||||
- 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。
|
- 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。
|
||||||
|
|||||||
@@ -16,17 +16,19 @@ import { useThemeStore } from "@/stores/use-theme-store";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
||||||
import { requestEdit, requestGeneration } from "@/services/api/image";
|
import { requestEdit, requestGeneration } from "@/services/api/image";
|
||||||
import { uploadImage } from "@/services/image-storage";
|
import { deleteStoredImages, resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||||
import { useAssetStore } from "@/stores/use-asset-store";
|
import { useAssetStore } from "@/stores/use-asset-store";
|
||||||
import type { ReferenceImage } from "@/types/image";
|
import type { ReferenceImage } from "@/types/image";
|
||||||
|
|
||||||
type GeneratedImage = {
|
type GeneratedImage = {
|
||||||
id: string;
|
id: string;
|
||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
|
storageKey?: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
|
mimeType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GenerationResult = {
|
type GenerationResult = {
|
||||||
@@ -40,8 +42,11 @@ type GenerationLog = {
|
|||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
prompt: string;
|
||||||
time: string;
|
time: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
config: GenerationLogConfig;
|
||||||
|
references: ReferenceImage[];
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failCount: number;
|
failCount: number;
|
||||||
@@ -49,13 +54,15 @@ type GenerationLog = {
|
|||||||
size: string;
|
size: string;
|
||||||
quality: string;
|
quality: string;
|
||||||
status: "成功" | "失败";
|
status: "成功" | "失败";
|
||||||
|
images: GeneratedImage[];
|
||||||
thumbnails: string[];
|
thumbnails: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GenerationLogConfig = Pick<AiConfig, "model" | "imageModel" | "quality" | "size" | "count">;
|
||||||
|
|
||||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||||
|
|
||||||
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
|
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
|
||||||
const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`;
|
|
||||||
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
|
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
|
||||||
|
|
||||||
export default function ImagePage() {
|
export default function ImagePage() {
|
||||||
@@ -159,16 +166,23 @@ export default function ImagePage() {
|
|||||||
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
|
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const logImages = await Promise.all(
|
||||||
|
successImages.map(async (image) => {
|
||||||
|
const stored = await uploadImage(image.dataUrl);
|
||||||
|
return { ...image, dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType };
|
||||||
|
}),
|
||||||
|
);
|
||||||
saveLog(
|
saveLog(
|
||||||
buildLog({
|
buildLog({
|
||||||
prompt: text,
|
prompt: text,
|
||||||
model,
|
model,
|
||||||
config: { ...snapshot.config, count: String(generationCount) },
|
config: { ...snapshot.config, count: String(generationCount) },
|
||||||
|
references: snapshot.references,
|
||||||
durationMs: performance.now() - batchStartedAt,
|
durationMs: performance.now() - batchStartedAt,
|
||||||
successCount,
|
successCount,
|
||||||
failCount,
|
failCount,
|
||||||
status: successCount ? "成功" : "失败",
|
status: successCount ? "成功" : "失败",
|
||||||
thumbnails: successImages.map((image) => image.dataUrl),
|
images: logImages,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
|
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
|
||||||
@@ -222,7 +236,8 @@ export default function ImagePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteSelectedLogs = () => {
|
const deleteSelectedLogs = () => {
|
||||||
void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs);
|
const imageKeys = logs.filter((log) => selectedLogIds.includes(log.id)).flatMap((log) => log.images.map((image) => image.storageKey).filter((key): key is string => Boolean(key)));
|
||||||
|
void Promise.all([deleteStoredImages(imageKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs);
|
||||||
if (previewLog && selectedLogIds.includes(previewLog.id)) {
|
if (previewLog && selectedLogIds.includes(previewLog.id)) {
|
||||||
setPreviewLog(null);
|
setPreviewLog(null);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
@@ -232,7 +247,7 @@ export default function ImagePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveLog = (log: GenerationLog) => {
|
const saveLog = (log: GenerationLog) => {
|
||||||
void logStore.setItem(log.id, log).then(refreshLogs);
|
void logStore.setItem(log.id, serializeLog(log)).then(refreshLogs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshLogs = async () => setLogs(await readStoredLogs());
|
const refreshLogs = async () => setLogs(await readStoredLogs());
|
||||||
@@ -240,13 +255,13 @@ export default function ImagePage() {
|
|||||||
const previewGenerationLog = async (log: GenerationLog) => {
|
const previewGenerationLog = async (log: GenerationLog) => {
|
||||||
setPreviewLog(log);
|
setPreviewLog(log);
|
||||||
setLogsOpen(false);
|
setLogsOpen(false);
|
||||||
const images = await Promise.all(
|
setPrompt(log.prompt);
|
||||||
log.thumbnails.map(async (dataUrl, index) => {
|
setReferences(log.references || []);
|
||||||
const meta = await readImageMeta(dataUrl);
|
if (log.config.imageModel || log.model) updateConfig("imageModel", log.config.imageModel || log.model);
|
||||||
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
|
if (log.config.quality) updateConfig("quality", log.config.quality);
|
||||||
}),
|
if (log.config.size) updateConfig("size", log.config.size);
|
||||||
);
|
if (log.config.count) updateConfig("count", log.config.count);
|
||||||
setResults(images.map((image) => ({ id: image.id, status: "success", image })));
|
setResults(log.images.map((image) => ({ id: image.id, status: "success", image })));
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildRequestSnapshot = () => {
|
const buildRequestSnapshot = () => {
|
||||||
@@ -660,47 +675,68 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
|
|||||||
async function readStoredLogs() {
|
async function readStoredLogs() {
|
||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
try {
|
try {
|
||||||
const legacyValue = window.localStorage.getItem(LOG_STORE_KEY);
|
const values: GenerationLog[] = [];
|
||||||
if (legacyValue) {
|
|
||||||
await Promise.all((JSON.parse(legacyValue) as GenerationLog[]).map((log) => logStore.setItem(log.id, normalizeLog(log))));
|
|
||||||
window.localStorage.removeItem(LOG_STORE_KEY);
|
|
||||||
}
|
|
||||||
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
|
|
||||||
await Promise.all(
|
|
||||||
legacyKeys.map(async (key) => {
|
|
||||||
try {
|
|
||||||
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
|
|
||||||
await logStore.setItem(log.id, log);
|
|
||||||
window.localStorage.removeItem(key);
|
|
||||||
} catch {}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const logs: GenerationLog[] = [];
|
|
||||||
await logStore.iterate<GenerationLog, void>((value) => {
|
await logStore.iterate<GenerationLog, void>((value) => {
|
||||||
logs.push(normalizeLog(value));
|
values.push(value);
|
||||||
});
|
});
|
||||||
|
const logs = await Promise.all(values.map(normalizeLog));
|
||||||
return logs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
return logs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLog(log: Partial<GenerationLog>): GenerationLog {
|
async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog> {
|
||||||
|
const references = await Promise.all(
|
||||||
|
(log.references || []).map(async (item) => ({
|
||||||
|
...item,
|
||||||
|
dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const images = await Promise.all(
|
||||||
|
(log.images || []).map(async (item) => ({
|
||||||
|
...item,
|
||||||
|
dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const config = normalizeLogConfig(log);
|
||||||
return {
|
return {
|
||||||
id: log.id || nanoid(),
|
id: log.id || nanoid(),
|
||||||
createdAt: log.createdAt || Date.now(),
|
createdAt: log.createdAt || Date.now(),
|
||||||
title: log.title || log.model || "未命名",
|
title: log.title || log.model || "未命名",
|
||||||
|
prompt: log.prompt || log.title || "",
|
||||||
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
|
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||||
model: log.model || "",
|
model: log.model || config.imageModel || "",
|
||||||
|
config,
|
||||||
|
references,
|
||||||
durationMs: log.durationMs || 0,
|
durationMs: log.durationMs || 0,
|
||||||
successCount: log.successCount ?? log.imageCount ?? 0,
|
successCount: log.successCount ?? log.imageCount ?? 0,
|
||||||
failCount: log.failCount || 0,
|
failCount: log.failCount || 0,
|
||||||
imageCount: log.imageCount || log.successCount || 0,
|
imageCount: log.imageCount || log.successCount || 0,
|
||||||
size: log.size || "",
|
size: log.size || config.size || "",
|
||||||
quality: log.quality || "",
|
quality: log.quality || config.quality || "",
|
||||||
status: log.status || "成功",
|
status: log.status || "成功",
|
||||||
thumbnails: log.thumbnails || [],
|
images,
|
||||||
|
thumbnails: images.map((image) => image.dataUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeLog(log: GenerationLog): GenerationLog {
|
||||||
|
return {
|
||||||
|
...log,
|
||||||
|
references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })),
|
||||||
|
images: log.images.map((image) => ({ ...image, dataUrl: image.storageKey ? "" : image.dataUrl })),
|
||||||
|
thumbnails: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLogConfig(log: Partial<GenerationLog>): GenerationLogConfig {
|
||||||
|
return {
|
||||||
|
model: log.config?.model || log.model || "",
|
||||||
|
imageModel: log.config?.imageModel || log.model || "",
|
||||||
|
quality: log.config?.quality || log.quality || "",
|
||||||
|
size: log.config?.size || log.size || "",
|
||||||
|
count: log.config?.count || String(log.imageCount || log.successCount || 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,34 +744,47 @@ function buildLog({
|
|||||||
prompt,
|
prompt,
|
||||||
model,
|
model,
|
||||||
config,
|
config,
|
||||||
|
references,
|
||||||
durationMs,
|
durationMs,
|
||||||
successCount,
|
successCount,
|
||||||
failCount,
|
failCount,
|
||||||
status,
|
status,
|
||||||
thumbnails,
|
images,
|
||||||
}: {
|
}: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
model: string;
|
model: string;
|
||||||
config: { size: string; quality: string; count?: string };
|
config: GenerationLogConfig;
|
||||||
|
references: ReferenceImage[];
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failCount: number;
|
failCount: number;
|
||||||
status: GenerationLog["status"];
|
status: GenerationLog["status"];
|
||||||
thumbnails: string[];
|
images: GeneratedImage[];
|
||||||
}): GenerationLog {
|
}): GenerationLog {
|
||||||
|
const logConfig = {
|
||||||
|
model: config.model,
|
||||||
|
imageModel: config.imageModel,
|
||||||
|
quality: config.quality,
|
||||||
|
size: config.size,
|
||||||
|
count: config.count,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
title: prompt.slice(0, 12) || "未命名",
|
title: prompt.slice(0, 12) || "未命名",
|
||||||
|
prompt,
|
||||||
time: new Date().toLocaleString("zh-CN", { hour12: false }),
|
time: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||||
model,
|
model,
|
||||||
|
config: logConfig,
|
||||||
|
references,
|
||||||
durationMs,
|
durationMs,
|
||||||
successCount,
|
successCount,
|
||||||
failCount,
|
failCount,
|
||||||
imageCount: Number(config.count) || successCount,
|
imageCount: Number(logConfig.count) || successCount,
|
||||||
size: config.size,
|
size: logConfig.size,
|
||||||
quality: config.quality,
|
quality: logConfig.quality,
|
||||||
status,
|
status,
|
||||||
thumbnails,
|
images,
|
||||||
|
thumbnails: images.map((image) => image.dataUrl),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
|||||||
import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel";
|
import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel";
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { formatBytes, formatDuration } from "@/lib/image-utils";
|
import { formatBytes, formatDuration } from "@/lib/image-utils";
|
||||||
import { resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
|
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
|
||||||
import { uploadImage } from "@/services/image-storage";
|
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||||
import { requestVideoGeneration } from "@/services/api/video";
|
import { requestVideoGeneration } from "@/services/api/video";
|
||||||
import { useAssetStore } from "@/stores/use-asset-store";
|
import { useAssetStore } from "@/stores/use-asset-store";
|
||||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||||
@@ -43,8 +43,11 @@ type GenerationLog = {
|
|||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
prompt: string;
|
||||||
time: string;
|
time: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
config: GenerationLogConfig;
|
||||||
|
references: ReferenceImage[];
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
size: string;
|
size: string;
|
||||||
resolution: string;
|
resolution: string;
|
||||||
@@ -54,6 +57,8 @@ type GenerationLog = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GenerationLogConfig = Pick<AiConfig, "model" | "videoModel" | "size" | "vquality" | "videoSeconds">;
|
||||||
|
|
||||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||||
|
|
||||||
const LOG_STORE_KEY = "infinite-canvas:video_generation_logs";
|
const LOG_STORE_KEY = "infinite-canvas:video_generation_logs";
|
||||||
@@ -151,12 +156,12 @@ export default function VideoPage() {
|
|||||||
mimeType: stored.mimeType,
|
mimeType: stored.mimeType,
|
||||||
};
|
};
|
||||||
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
|
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
|
||||||
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
|
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
|
||||||
message.success("视频已生成");
|
message.success("视频已生成");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "生成失败";
|
const errorMessage = error instanceof Error ? error.message : "生成失败";
|
||||||
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
|
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
|
||||||
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
|
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
|
||||||
message.error(errorMessage);
|
message.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
@@ -219,7 +224,11 @@ export default function VideoPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteSelectedLogs = () => {
|
const deleteSelectedLogs = () => {
|
||||||
void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs);
|
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)) {
|
if (previewLog && selectedLogIds.includes(previewLog.id)) {
|
||||||
setPreviewLog(null);
|
setPreviewLog(null);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
@@ -229,7 +238,7 @@ export default function VideoPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveLog = (log: GenerationLog) => {
|
const saveLog = (log: GenerationLog) => {
|
||||||
void logStore.setItem(log.id, log).then(refreshLogs);
|
void logStore.setItem(log.id, serializeLog(log)).then(refreshLogs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshLogs = async () => setLogs(await readStoredLogs());
|
const refreshLogs = async () => setLogs(await readStoredLogs());
|
||||||
@@ -237,6 +246,12 @@ export default function VideoPage() {
|
|||||||
const previewGenerationLog = (log: GenerationLog) => {
|
const previewGenerationLog = (log: GenerationLog) => {
|
||||||
setPreviewLog(log);
|
setPreviewLog(log);
|
||||||
setLogsOpen(false);
|
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 || "生成失败" }]);
|
setResults(log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -527,33 +542,71 @@ async function readStoredLogs() {
|
|||||||
|
|
||||||
async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog> {
|
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 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 {
|
return {
|
||||||
id: log.id || nanoid(),
|
id: log.id || nanoid(),
|
||||||
createdAt: log.createdAt || Date.now(),
|
createdAt: log.createdAt || Date.now(),
|
||||||
title: log.title || log.model || "未命名",
|
title: log.title || log.model || "未命名",
|
||||||
|
prompt: log.prompt || "",
|
||||||
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
|
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||||
model: log.model || "",
|
model: log.model || config.videoModel || "",
|
||||||
|
config,
|
||||||
|
references,
|
||||||
durationMs: log.durationMs || 0,
|
durationMs: log.durationMs || 0,
|
||||||
size: log.size || "",
|
size: log.size || config.size || "",
|
||||||
resolution: normalizeResolution(log.resolution || ""),
|
resolution: normalizeResolution(log.resolution || config.vquality || ""),
|
||||||
seconds: log.seconds || "",
|
seconds: log.seconds || config.videoSeconds || "",
|
||||||
status: log.status || "成功",
|
status: log.status || "成功",
|
||||||
video,
|
video,
|
||||||
error: log.error,
|
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 {
|
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 {
|
return {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
title: prompt.slice(0, 12) || "未命名",
|
title: prompt.slice(0, 12) || "未命名",
|
||||||
|
prompt,
|
||||||
time: new Date().toLocaleString("zh-CN", { hour12: false }),
|
time: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||||
model,
|
model,
|
||||||
|
config: logConfig,
|
||||||
|
references,
|
||||||
durationMs,
|
durationMs,
|
||||||
size: config.size,
|
size: logConfig.size,
|
||||||
resolution: normalizeResolution(config.vquality),
|
resolution: logConfig.vquality,
|
||||||
seconds: config.videoSeconds,
|
seconds: logConfig.videoSeconds,
|
||||||
status,
|
status,
|
||||||
video,
|
video,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ export async function setMediaBlob(storageKey: string, blob: Blob) {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteStoredMedia(keys: Iterable<string>) {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(new Set(keys)).map(async (key) => {
|
||||||
|
const url = objectUrls.get(key);
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
objectUrls.delete(key);
|
||||||
|
await store.removeItem(key);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function cleanupUnusedMedia(usedData: unknown) {
|
export async function cleanupUnusedMedia(usedData: unknown) {
|
||||||
const usedKeys = collectMediaStorageKeys(usedData);
|
const usedKeys = collectMediaStorageKeys(usedData);
|
||||||
const unused: string[] = [];
|
const unused: string[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user