From 8535658e5a371278f18f49d52d35879e1805f79e Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 29 Aug 2026 10:28:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E7=94=9F=E6=88=90=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=91=BD=E5=90=8D=20-=20=E4=BF=9D=E7=95=99=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E4=B8=8E=E5=8F=82=E6=95=B0=E5=8F=98=E4=BD=93=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E9=81=BF=E5=85=8D=E4=B8=8B=E8=BD=BD=E9=87=8D=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/progress/pending-test.mdx | 7 +++++ web/src/lib/canvas/canvas-generation-title.ts | 23 ++++++++++++++ .../canvas-image-generation-executor.ts | 5 ++-- web/test/canvas-media-download.test.ts | 30 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/canvas/canvas-generation-title.ts diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a490cc2e6..4d45ca22d 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,13 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布生成结果命名 + +- [x] 快捷键复制节点生成图片后保留 `_copyN`,参数变体生成图片后保留 `A/B/C/D` 版本标识;批量输出追加结果序号。 +- [x] 下载文件名继续使用节点标题,因此复制、参数变体和同批多图结果不会再生成相同文件名。 +- [ ] 分别使用快捷键复制和“创建参数变体”生成图片,确认生成中、生成完成和刷新画布后标题标识不丢失。 +- [ ] 下载原图、复制结果、多个参数变体和同批多图结果,确认浏览器不会再提示同名文件覆盖。 + ## 视频画面与片段提取 - [x] 视频节点“尾帧截取”已合并为“提取画面”,支持首帧、当前帧、尾帧和最多 30 个自选时间点;批量结果保留来源节点与时间点元数据。 diff --git a/web/src/lib/canvas/canvas-generation-title.ts b/web/src/lib/canvas/canvas-generation-title.ts new file mode 100644 index 000000000..0e49b7b03 --- /dev/null +++ b/web/src/lib/canvas/canvas-generation-title.ts @@ -0,0 +1,23 @@ +import type { CanvasNodeData } from "@/types/canvas"; + +const COPY_SUFFIX = /(_copy\d+|\s+Copy)$/i; +const VERSION_SUFFIX = /\s*·\s*([A-Z])\s*$/; + +export function buildImageGenerationNodeTitle(prompt: string, sourceNode?: CanvasNodeData, outputIndex?: number, outputCount = 1) { + let title = prompt.trim().slice(0, 32) || "Generated Image"; + if (sourceNode) { + const sourceTitleWithoutVersion = sourceNode.title.replace(VERSION_SUFFIX, ""); + const copySuffix = sourceTitleWithoutVersion.match(COPY_SUFFIX)?.[1] || ""; + const versionLabel = validVersionLabel(sourceNode.metadata?.versionLabel) || sourceNode.title.match(VERSION_SUFFIX)?.[1] || ""; + const titleWithoutVersion = title.replace(VERSION_SUFFIX, ""); + if (copySuffix && !titleWithoutVersion.toLowerCase().endsWith(copySuffix.toLowerCase())) title += copySuffix; + if (versionLabel && !title.endsWith(` · ${versionLabel}`)) title += ` · ${versionLabel}`; + } + if (outputCount > 1 && outputIndex !== undefined) title += ` · ${outputIndex + 1}`; + return title; +} + +function validVersionLabel(value?: string) { + const normalized = value?.trim().toUpperCase() || ""; + return /^[A-Z]$/.test(normalized) ? normalized : ""; +} diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index 962ed1c7d..2abc87127 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -2,6 +2,7 @@ import { nanoid } from "nanoid"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout"; +import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title"; import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size"; import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation"; import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error"; @@ -80,7 +81,7 @@ export async function executeImageGeneration({ const rootNode: CanvasNodeData = { id: rootId, type: CanvasNodeType.Image, - title: effectivePrompt.slice(0, 32) || "Generated Image", + title: buildImageGenerationNodeTitle(effectivePrompt, sourceNode), position: rootPosition, width: rootWidth, height: rootHeight, @@ -104,7 +105,7 @@ export async function executeImageGeneration({ const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ id, type: CanvasNodeType.Image, - title: effectivePrompt.slice(0, 32) || "Generated Image", + title: buildImageGenerationNodeTitle(effectivePrompt, sourceNode, index, count), position: imageGenerationChildPosition(rootNode.position, rootNode.width, outputNodeSize, index), width: outputNodeSize.width, height: outputNodeSize.height, diff --git a/web/test/canvas-media-download.test.ts b/web/test/canvas-media-download.test.ts index c919d2c1f..ed564a1d8 100644 --- a/web/test/canvas-media-download.test.ts +++ b/web/test/canvas-media-download.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title"; import { buildCanvasMediaDownloadFileName, canvasMediaFileExtension } from "@/lib/canvas/canvas-media-download"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; @@ -40,3 +41,32 @@ describe("canvas media download", () => { expect(canvasMediaFileExtension(mediaNode({ metadata: { content: "https://example.com/result.jpeg?token=hidden", mimeType: "image/*" } }))).toBe("jpg"); }); }); + +describe("generated image title", () => { + test("普通节点继续使用提示词摘要", () => { + expect(buildImageGenerationNodeTitle("一座云层中的未来城市", mediaNode({ title: "原图" }))).toBe("一座云层中的未来城市"); + }); + + test("快捷键复制节点生成后保留 copy 序号", () => { + const source = mediaNode({ title: "未来城市_copy2", metadata: { copiedFromNodeId: "source" } }); + expect(buildImageGenerationNodeTitle("未来城市", source)).toBe("未来城市_copy2"); + }); + + test("参数变体生成后保留版本字母", () => { + const source = mediaNode({ title: "未来城市 · C", metadata: { versionOfNodeId: "source", versionLabel: "C" } }); + expect(buildImageGenerationNodeTitle("未来城市", source)).toBe("未来城市 · C"); + }); + + test("复制变体和批量输出使用互不重复的标题", () => { + const source = mediaNode({ title: "未来城市_copy1 · B", metadata: { copiedFromNodeId: "source", versionOfNodeId: "source", versionLabel: "B" } }); + expect([0, 1].map((index) => buildImageGenerationNodeTitle("未来城市", source, index, 2))).toEqual(["未来城市_copy1 · B · 1", "未来城市_copy1 · B · 2"]); + }); + + test("下载文件名继承 copy 和版本标识,不再互相重复", () => { + const now = new Date(2026, 7, 29, 12); + const copy = mediaNode({ title: buildImageGenerationNodeTitle("未来城市", mediaNode({ title: "未来城市_copy1", metadata: { copiedFromNodeId: "source" } })) }); + const variant = mediaNode({ title: buildImageGenerationNodeTitle("未来城市", mediaNode({ title: "未来城市 · C", metadata: { versionOfNodeId: "source", versionLabel: "C" } })) }); + expect(buildCanvasMediaDownloadFileName("自由画布", copy, now)).toBe("自由画布_未来城市_copy1_20260829.png"); + expect(buildCanvasMediaDownloadFileName("自由画布", variant, now)).toBe("自由画布_未来城市 · C_20260829.png"); + }); +});