fix(canvas): 生成结果命名 - 保留复制与参数变体标识避免下载重名
This commit is contained in:
@@ -5,6 +5,13 @@ description: 影策已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 画布生成结果命名
|
||||
|
||||
- [x] 快捷键复制节点生成图片后保留 `_copyN`,参数变体生成图片后保留 `A/B/C/D` 版本标识;批量输出追加结果序号。
|
||||
- [x] 下载文件名继续使用节点标题,因此复制、参数变体和同批多图结果不会再生成相同文件名。
|
||||
- [ ] 分别使用快捷键复制和“创建参数变体”生成图片,确认生成中、生成完成和刷新画布后标题标识不丢失。
|
||||
- [ ] 下载原图、复制结果、多个参数变体和同批多图结果,确认浏览器不会再提示同名文件覆盖。
|
||||
|
||||
## 视频画面与片段提取
|
||||
|
||||
- [x] 视频节点“尾帧截取”已合并为“提取画面”,支持首帧、当前帧、尾帧和最多 30 个自选时间点;批量结果保留来源节点与时间点元数据。
|
||||
|
||||
23
web/src/lib/canvas/canvas-generation-title.ts
Normal file
23
web/src/lib/canvas/canvas-generation-title.ts
Normal file
@@ -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 : "";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user