From bf66a25936cb9bc1ebfcb871f042cc73f5dd36f8 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Tue, 26 May 2026 14:09:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(image):=20=E4=BC=98=E5=8C=96=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=94=9F=E6=88=90=E8=AE=B0=E5=BD=95=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E5=92=8C=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增生成记录配置持久化功能,保存提示词、参考图、模型、质量、尺寸和张数配置 - 结果图写入本地图片存储后只保存 storageKey,减少存储空间占用 - 点击历史记录可回填生成配置并预览结果 - 优化我的画布、我的素材导出功能 - 新增视频创作台页面 - 修复图片节点size参数传递问题 - 添加完整的生成日志数据结构和序列化处理 - 实现图片存储和删除的批量操作支持 --- CHANGELOG.md | 4 + docs/pending-test.md | 3 +- web/src/app/(user)/image/page.tsx | 133 ++++++++++++++++++++---------- web/src/app/(user)/video/page.tsx | 81 ++++++++++++++---- web/src/services/file-storage.ts | 11 +++ 5 files changed, 175 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c66e28f..d1e713c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased ++ [优化] 优化我的画布、我的素材导出功能 + +## v0.0.9 - 2026-05-26 + + [新增] 新增视频创作台页面。 + [修复] 修复图片节点size参数传递问题。 diff --git a/docs/pending-test.md b/docs/pending-test.md index dd0254a..00b34dd 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -6,6 +6,7 @@ - “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。 - 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。 - 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。 +- 生图工作台新增生成记录配置持久化:每次生成会保存提示词、参考图、模型、质量、尺寸和张数,结果图写入本地图片存储后记录只保存 `storageKey`;点击历史记录会回填本次生成配置并预览结果。 - 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。 - 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。 - 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。 @@ -16,5 +17,5 @@ - 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。 - 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。 - 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。 -- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入。 +- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数。 - 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。 diff --git a/web/src/app/(user)/image/page.tsx b/web/src/app/(user)/image/page.tsx index b5a3118..073898f 100644 --- a/web/src/app/(user)/image/page.tsx +++ b/web/src/app/(user)/image/page.tsx @@ -16,17 +16,19 @@ import { useThemeStore } from "@/stores/use-theme-store"; import { nanoid } from "nanoid"; import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; 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 type { ReferenceImage } from "@/types/image"; type GeneratedImage = { id: string; dataUrl: string; + storageKey?: string; durationMs: number; width: number; height: number; bytes: number; + mimeType?: string; }; type GenerationResult = { @@ -40,8 +42,11 @@ type GenerationLog = { id: string; createdAt: number; title: string; + prompt: string; time: string; model: string; + config: GenerationLogConfig; + references: ReferenceImage[]; durationMs: number; successCount: number; failCount: number; @@ -49,13 +54,15 @@ type GenerationLog = { size: string; quality: string; status: "成功" | "失败"; + images: GeneratedImage[]; thumbnails: string[]; }; +type GenerationLogConfig = Pick; + type UpdateAiConfig = (key: K, value: AiConfig[K]) => void; 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" }); export default function ImagePage() { @@ -159,16 +166,23 @@ export default function ImagePage() { const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected"); 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( buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, + references: snapshot.references, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", - thumbnails: successImages.map((image) => image.dataUrl), + images: logImages, }), ); successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败"); @@ -222,7 +236,8 @@ export default function ImagePage() { }; 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)) { setPreviewLog(null); setResults([]); @@ -232,7 +247,7 @@ export default function ImagePage() { }; 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()); @@ -240,13 +255,13 @@ export default function ImagePage() { const previewGenerationLog = async (log: GenerationLog) => { setPreviewLog(log); setLogsOpen(false); - const images = await Promise.all( - log.thumbnails.map(async (dataUrl, index) => { - const meta = await readImageMeta(dataUrl); - return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) }; - }), - ); - setResults(images.map((image) => ({ id: image.id, status: "success", image }))); + setPrompt(log.prompt); + setReferences(log.references || []); + if (log.config.imageModel || log.model) updateConfig("imageModel", log.config.imageModel || log.model); + 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(log.images.map((image) => ({ id: image.id, status: "success", image }))); }; const buildRequestSnapshot = () => { @@ -660,47 +675,68 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge async function readStoredLogs() { if (typeof window === "undefined") return []; try { - const legacyValue = window.localStorage.getItem(LOG_STORE_KEY); - 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[] = []; + const values: GenerationLog[] = []; await logStore.iterate((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)); } catch { return []; } } -function normalizeLog(log: Partial): GenerationLog { +async function normalizeLog(log: Partial): Promise { + 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 { id: log.id || nanoid(), createdAt: log.createdAt || Date.now(), title: log.title || log.model || "未命名", + prompt: log.prompt || log.title || "", time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }), - model: log.model || "", + model: log.model || config.imageModel || "", + config, + references, durationMs: log.durationMs || 0, successCount: log.successCount ?? log.imageCount ?? 0, failCount: log.failCount || 0, imageCount: log.imageCount || log.successCount || 0, - size: log.size || "", - quality: log.quality || "", + size: log.size || config.size || "", + quality: log.quality || config.quality || "", 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): 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, model, config, + references, durationMs, successCount, failCount, status, - thumbnails, + images, }: { prompt: string; model: string; - config: { size: string; quality: string; count?: string }; + config: GenerationLogConfig; + references: ReferenceImage[]; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; - thumbnails: string[]; + images: GeneratedImage[]; }): GenerationLog { + const logConfig = { + model: config.model, + imageModel: config.imageModel, + quality: config.quality, + size: config.size, + count: config.count, + }; 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, successCount, failCount, - imageCount: Number(config.count) || successCount, - size: config.size, - quality: config.quality, + imageCount: Number(logConfig.count) || successCount, + size: logConfig.size, + quality: logConfig.quality, status, - thumbnails, + images, + thumbnails: images.map((image) => image.dataUrl), }; } diff --git a/web/src/app/(user)/video/page.tsx b/web/src/app/(user)/video/page.tsx index 5252e4b..85f9c42 100644 --- a/web/src/app/(user)/video/page.tsx +++ b/web/src/app/(user)/video/page.tsx @@ -13,8 +13,8 @@ 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 { resolveMediaUrl, uploadMediaFile } from "@/services/file-storage"; -import { uploadImage } from "@/services/image-storage"; +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"; @@ -43,8 +43,11 @@ type GenerationLog = { id: string; createdAt: number; title: string; + prompt: string; time: string; model: string; + config: GenerationLogConfig; + references: ReferenceImage[]; durationMs: number; size: string; resolution: string; @@ -54,6 +57,8 @@ type GenerationLog = { error?: string; }; +type GenerationLogConfig = Pick; + type UpdateAiConfig = (key: K, value: AiConfig[K]) => void; const LOG_STORE_KEY = "infinite-canvas:video_generation_logs"; @@ -151,12 +156,12 @@ export default function VideoPage() { 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 })); + 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, 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); } finally { setRunning(false); @@ -219,7 +224,11 @@ export default function VideoPage() { }; 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)) { setPreviewLog(null); setResults([]); @@ -229,7 +238,7 @@ export default function VideoPage() { }; 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()); @@ -237,6 +246,12 @@ export default function VideoPage() { 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 || "生成失败" }]); }; @@ -527,33 +542,71 @@ async function readStoredLogs() { 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 || "", + model: log.model || config.videoModel || "", + config, + references, durationMs: log.durationMs || 0, - size: log.size || "", - resolution: normalizeResolution(log.resolution || ""), - seconds: log.seconds || "", + size: log.size || config.size || "", + resolution: normalizeResolution(log.resolution || config.vquality || ""), + seconds: log.seconds || config.videoSeconds || "", 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 { +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: config.size, - resolution: normalizeResolution(config.vquality), - seconds: config.videoSeconds, + size: logConfig.size, + resolution: logConfig.vquality, + seconds: logConfig.videoSeconds, status, video, error, diff --git a/web/src/services/file-storage.ts b/web/src/services/file-storage.ts index d4243c8..6a1eed6 100644 --- a/web/src/services/file-storage.ts +++ b/web/src/services/file-storage.ts @@ -40,6 +40,17 @@ export async function setMediaBlob(storageKey: string, blob: Blob) { return url; } +export async function deleteStoredMedia(keys: Iterable) { + 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) { const usedKeys = collectMediaStorageKeys(usedData); const unused: string[] = [];