fix(canvas): 画布引用 - 统一角色与图片编号并保持生成资源顺序

This commit is contained in:
ddcat666
2026-10-08 14:20:45 +08:00
parent ef82ca1b62
commit 9ef10b7b2d
12 changed files with 368 additions and 130 deletions

View File

@@ -11,7 +11,6 @@ var cloudAgentMediaMention = regexp.MustCompile(`@(图片|视频|音频)[0-9]+`)
func cloudAgentMediaComposerPrompt(prompt string, refs map[string]any) string {
labels := map[string]string{}
counts := map[string]int{}
for _, kind := range []struct{ field, label string }{
{"referenceImages", "图片"}, {"referenceVideos", "视频"}, {"referenceAudios", "音频"},
} {
@@ -20,11 +19,11 @@ func cloudAgentMediaComposerPrompt(prompt string, refs map[string]any) string {
continue
}
label := kind.label
if stringValue(ref["canvasReferenceKind"]) == "character" {
if kind.field == "referenceImages" && stringValue(ref["canvasReferenceKind"]) == "character" {
label = "角色"
}
counts[label]++
labels[fmt.Sprintf("@%s%d", kind.label, index+1)] = fmt.Sprintf("@%s%d", label, counts[label])
// 显示时保留角色类型,编号仍等于实际图片数组的位置。
labels[fmt.Sprintf("@%s%d", kind.label, index+1)] = fmt.Sprintf("@%s%d", label, index+1)
}
}
return cloudAgentMediaMention.ReplaceAllStringFunc(prompt, func(token string) string {

View File

@@ -47,7 +47,7 @@ func TestCloudAgentMediaReferencePrompt(t *testing.T) {
}
}
func TestCloudAgentMediaComposerPromptSeparatesCharacterAndImageSlots(t *testing.T) {
func TestCloudAgentMediaComposerPromptUsesUnifiedImageSlots(t *testing.T) {
refs := map[string]any{
"referenceImages": []any{
map[string]any{"storageKey": "resource:character", "canvasReferenceKind": "character"},
@@ -57,7 +57,7 @@ func TestCloudAgentMediaComposerPromptSeparatesCharacterAndImageSlots(t *testing
"referenceAudios": []any{map[string]any{"storageKey": "resource:voice"}},
}
prompt := "@图片1、@图片2、@图片3 和 @音频1"
want := "@角色1、@图片1、@角色2 和 @音频1"
want := "@角色1、@图片2、@角色3 和 @音频1"
if got := cloudAgentMediaComposerPrompt(prompt, refs); got != want {
t.Fatalf("composer prompt = %q, want %q", got, want)
}

View File

@@ -253,7 +253,7 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考
- `generate_media` 支持图片 / 视频 / 音频:必填 `mode`、`prompt`、`nodeId`(可续用草稿,无草稿时才新建)、`title`、有序 `referenceNodeIds`(无参考传空数组),再加一种模型选择;`snapshotHash` 可省略,省略时服务端使用当前画布内容快照。图片 / 视频要求 `size`,视频要求正数 `durationSeconds`,图片 / 音频时长为 0。可传来源节点 `sourceNodeId`、`quality`、视频 `videoGenerateAudio`。`sourceNodeId` 仅接文本 / 镜头提示词节点;参考图片、视频、音频只放 `referenceNodeIds`,无文本来源时留空且不重复传入。引用从当前画布节点的账号资源解析,校验归属、类型、就绪状态和模型能力,不接受任意 URL。图片生成仅接图片参考;视频可按模型能力接图片、视频、音频参考;音频生成当前仅支持文本输入。
- 参数选择与收费授权分开:用户明确说“随便 / 你决定 / 按默认”时,Agent 可从当前目录选择能力及预算匹配的模型,优先用户最近明确使用的可用模型,其次价格清楚的低成本选项;支持时沿用参考图原始比例,否则选目录默认,时长用目录默认。未要求音频且模型不支持时用默片。用户接受已推荐方案后不再要求特定确认口令;仅真正缺失或冲突的阻塞项一次性询问。本轮已有目录结果应复用。以上是 Agent 的参数选择策略,服务端仍强校验具体规格,不提供绕过预算 / 能力校验的默认兜底,也不绕过独立收费审批。
- 媒体参数先进行无任务、无预留账单的准入校验,再在同一事务创建草稿节点、来源 / 资产连线及待审批检查点;`approval_requested` 和运行中的 `approval` 携带目录真实 `modelName`。用户批准后才在事务中绑定任务、创建预留账单并更新检查点;拒绝或取消保留未生成草稿,不提交媒体任务。`nodeId` 可续用同类型、未锁定、idle、无任务且无媒体结果的空白占位节点或当前运行草稿;跨轮 Agent 草稿还要求原运行属于同一用户和画布、已结束且清理完成。续用必须重新读取快照并重新审批,不能复用旧审批、覆盖已有任务或成品,不能通过循环新建节点绕过限制。画布读取的 `generationDraft` 提示未提交、原运行状态、是否仍在清理及仍需审批;写入仍重新强校验。已明确审批的规格不得被模型默认值静默替换。媒体终态回写原节点;已删除或已换任务的节点不自动复活,结果仍可从任务中心查看。
- `generate_media.prompt` 使用 `@图片1`、`@视频1`、`@音频1` 引用素材,各类型按 `referenceNodeIds` 的出现顺序独立编号,文本来源不占媒体编号。已有标签保留;遗漏的画布素材以“素材名称:@标签”补入 `【资产参考】`,不推断素材用途。节点入边同步排列,草稿、审批和实际任务保存同一份带标签的提示词。未绑定的媒体编号和补齐后超过16000字符的提示词会在提交前被拒绝,模型自身的提示词长度限制仍由任务准入校验。临时标注图没有持久画布节点,不自动添加无法解析的编辑器标签;历史节点不自动改写。
- `generate_media.prompt` 使用 `@图片1`、`@视频1`、`@音频1` 引用素材,各类型按 `referenceNodeIds` 的出现顺序独立编号,文本来源不占媒体编号。角色主图属于图片序列,与普通图片共享编号;编辑器显示时保留 `@角色N` 前缀,但 N 仍对应实际图片位置,例如角色、场景图、角色依次显示为 `@角色1`、`@图片2`、`@角色3`,任务使用 `@图片1`、`@图片2`、`@图片3`。已有标签保留;遗漏的画布素材以“素材名称:@标签”补入 `【资产参考】`,不推断素材用途。节点入边同步排列,草稿、审批和实际任务的标签对应同一组参考资源,编辑器与任务仅转换显示前缀。未绑定的媒体编号和补齐后超过16000字符的提示词会在提交前被拒绝,模型自身的提示词长度限制仍由任务准入校验。临时标注图没有持久画布节点,不自动添加无法解析的编辑器标签;历史节点不自动改写。
- Agent 的 `snapshotHash` 忽略顶层 `viewport`、`updatedAt`。`canvas_get_state` 另返回 `mediaSnapshotHash`,供 `generate_media.snapshotHash` 使用:仅比较节点与连线,节点忽略 `position`、`width`、`height`、`createdAt`、`updatedAt`,保留全部业务字段及未知 metadata。生成草稿审批也固定该哈希,移动、调整显示尺寸、自动补时间戳和保存聊天记录不会使生成审批失效,执行保留最新展示信息;提示词、素材、锁定状态、任务绑定和连线变化仍需重新读取并审批。画布修改与撤销继续使用完整内容哈希,数据库写入保持完整快照 CAS。发布前已按旧算法生成的待审批内容可能要求重新读取和审批,不自动替换旧快照重提任务。
- `canvas_get_state.nodes[].outputReference` 的 `ready` / `issue` 仅表示该节点产物能否作为其他生成的参考,不诊断本节点的生成输入。`generation` 返回当前画布、当前用户所属任务的 `taskId`、`taskStatus` 和安全的失败 / 取消 `error`;无任务绑定为 `not_submitted`,无法验证归属或读取失败为 `unavailable`。节点无法作为草稿提交时返回 `submitBlockedReason` / `submitBlockedIssue`,与写路径共用节点准入规则;未被阻止不等于已获授权,草稿所属运行和审批仍在提交时校验。`task_get` 同样返回安全错误详情。`generate_media` 准入失败可返回 `snapshot_conflict`、`task_bound`、`target_locked` 等结构化 `reason` 和 `nodeId`,不自动选择新节点或重新生成。
- `task_get` / `generation` 的执行状态与回写状态分开:`succeeded` 不返回执行 `error`;回写失败仍通过 `writebackOutcome` / `writebackError` 暴露,不能因生成成功而隐藏。失败 / 取消任务保留脱敏错误,缺少详情不能伪装成成功。Agent 文本步骤的持久诊断为 `upstream_dns_failed` 时,`run_failed.reason` 同步该分类并明确提示检查渠道域名及后端 DNS,不自动重跑。

View File

@@ -2065,7 +2065,7 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束
- [x] 29 项提示词引用专项测试、TypeScript 检查与生产构建通过。
- [x] 旧 `@[node:节点ID]` 仅做读取迁移;新写入、配置组装、故事板物化、Agent 建图和快捷生成均只生成位置型引用。
- [x] 图片、视频、音频、文本和角色分别独立计数;生成时按当前连线顺序解析槽位,删除连线不再删除提示词。
- [x] 普通图片、角色主图和绘图共享图片编号,保留各自显示前缀;视频、音频和文本独立计数。生成时按当前连线顺序解析槽位,删除连线不再删除提示词。
- [x] 只有 `storageKey` 的图片/视频引用会异步解析预览地址;生成中按钮使用节点强调色与可见旋转动画。
- [ ] 在画布中连接图片 A 并写入 `@图片1`,删除 A 的连线后连接图片 B,确认提示词不变、缩略图切换到 B、生成请求使用 B。
- [ ] 混合连接两张图片、一段音频和一段文本,确认标签依次为 `@图片1`、`@音频1`、`@图片2`、`@文本1`,调整不同类型的连线不会串号。
@@ -2937,3 +2937,16 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客
SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。
一级分类还需 PostgreSQL 验证 46→47 与并发改归/删除,以及浏览器图示、根停用默认桶与空根表回落。handler 全套涉及 socket,受限环境中未完成。
## 画布角色与媒体引用顺序一致性
- 已实现:普通图片、角色主图、绘图共享图片编号,编辑器保留 `@图片N`、`@角色N`、`@绘图N` 类型前缀,提交模型时统一映射为实际图片数组的 `@图片N`。引用先后按连接槽位确定,逆序提及不会重排资源;拖动引用后按节点身份同步编号,中文紧邻及连续引用可正常解析,多位数字不会被部分替换。
- 已实现:角色主图占据原槽位,素材库图片保留自身资源身份,去重后的补充视角追加到主图之后。关闭角色设定注入时仍保留角色与参考图、声音样本的对应关系;多角色共用声音样本只发送一份。
- 已实现:前端解析到的角色版本与固定版本不一致、图片/视频角色缺少主参考图、模型参考容量不足、角色授权或资源读取失败时阻止提交。文本/音频允许没有主图的角色,并将其提及改为角色名、重新对应后续图片编号。
- 自动验证:前端 `bun run test` 全量 2203 项通过,类型检查、lint、冻结锁文件安装检查通过;`bun --bun run build` 通过,仍有大分块提示。普通构建命令首次因本机 Node 子进程缺少 TypeScript Darwin x64 包失败,显式使用 Bun 运行时后通过,未修改依赖版本或构建脚本。后端 `go test ./internal/app -run 'TestCloudAgent(Media|Character)' -count=1` 通过,修改的 Go 文件符合 gofmt。
- Web checks 回归:更新时间轴容器、小屏抽屉、生命周期依赖及访客创作路由的过期源码断言,相关 45 项测试通过;真实 Chrome 预演台 E2E 65 项断言通过,覆盖模式时间轴切换、模型加载/重试/加载中删除、WebGL 恢复和保存失败关闭保护。
仍待真实环境验收:
- [ ] 在登录态画布混合连接两张角色卡、普通图片、绘图及素材库图片,调整顺序、编辑提示词、保存重载并重试任务,核对编辑器标签与任务参考数组一一对应。
- [ ] 在支持声音参考的视频模型上核对多角色共用样本、已有音频与角色声音样本的编号;通过真实模型验证外观与音色效果。本轮未调用付费模型,未执行整套后端及 PostgreSQL/Redis 集成测试。

View File

@@ -12,6 +12,7 @@ import { mediaConversionSourceFingerprint } from "@/lib/media-conversion/contrac
import { audioFileExtension } from "@/lib/character-voice-formats";
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
import { compileCharacterReferencePrompt, normalizeCharacterImageMentions } from "@/lib/canvas/canvas-character-reference";
import { createCanvasReferenceLabeler } from "@/lib/canvas/canvas-reference-slots";
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
@@ -42,6 +43,8 @@ export type NodeGenerationContext = {
referenceImages: ReferenceImage[];
referenceVideos: ReferenceVideo[];
referenceAudios: ReferenceAudio[];
/** 图片和角色卡主图在最终 image URL 列表中的槽位顺序。 */
referenceImageSlots: Array<{ nodeId: string; kind: "image" | "character"; imageId?: string }>;
characterReferences: CharacterGenerationReference[];
resolvedCharacterVersions: Array<{ assetId: string; versionId: string }>;
resolvedCharacterVoices: ResolvedCharacterVoice[];
@@ -97,7 +100,6 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
prompt,
// 工作流节点由字段映射接收全部连线媒体;视频节点的历史首尾帧字段不能再额外追加参考图。
autoIncludeWorkflowMedia || (promptOnly && hasConnectedMedia) ? [] : [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)),
promptOnly,
autoIncludeWorkflowMedia || (promptOnly && hasConnectedMedia),
connectedInputs,
);
@@ -114,12 +116,14 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
const referenceImages = connectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const referenceVideos = connectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
const referenceAudios = connectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
const referenceImageSlots = generationImageSlots(connectedInputs);
return {
prompt: promptOnly ? prompt : upstreamText ? `${basePrompt}\n\n${upstreamText}` : basePrompt,
referenceImages,
referenceVideos,
referenceAudios,
referenceImageSlots,
characterReferences,
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
@@ -186,7 +190,6 @@ function buildComposerGenerationContext(
inputs: NodeGenerationInput[],
prompt: string,
videoFrameNodeIds: string[] = [],
promptOnly = false,
autoIncludeWorkflowMedia = false,
workflowMediaInputs: NodeGenerationInput[] = [],
): NodeGenerationContext {
@@ -194,102 +197,50 @@ function buildComposerGenerationContext(
const slotInputByToken = new Map(generationSlotEntries(inputs).map(({ token, input }) => [token, input]));
const assetInputById = new Map(inputs.filter((input) => input.nodeId.startsWith("asset:")).map((input) => [input.nodeId.slice("asset:".length), input]));
const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input]));
const selectedInputs: NodeGenerationInput[] = [];
const labelByNodeId = new Map<string, string>();
const textBlocks: string[] = [];
const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 };
let hasToken = false;
const mentions = [...normalizedPrompt.matchAll(GENERATION_MENTION_PATTERN)].map((match) => ({
match,
input: resolveGenerationMention(normalizedPrompt, match, slotInputByToken, nodeInputById, assetInputById),
}));
const selectedIds = new Set(mentions.flatMap(({ input }) => input ? [input.nodeId] : []));
if (autoIncludeWorkflowMedia) workflowMediaInputs.forEach((input) => {
if (input.type !== "text") selectedIds.add(input.nodeId);
});
videoFrameNodeIds.forEach((id) => {
if (nodeInputById.get(id)?.image) selectedIds.add(id);
});
// 始终先按槽位选定资源,再一次性编译提示词;提及先后不能改变 URL 顺序。
const selected = inputs.filter((input) => selectedIds.has(input.nodeId));
const selectedInputs = selected.filter((input) => input.type !== "text");
const nextLabel = createCanvasReferenceLabeler();
const providerLabels = new Map(selected.map((input) => [input.nodeId, nextLabel(input.type === "character" ? "image" : input.type)]));
const textBlocks = selected.filter((input) => input.type === "text").map((input) => `【${providerLabels.get(input.nodeId)}】\n${input.text || ""}`);
let lastIndex = 0;
let nextPrompt = "";
if (autoIncludeWorkflowMedia) {
// 先固定“图片1/视频1”等提示词标签的顺序,和工作流槽位保持一致;
// 用户先 @ 第二张图时,提示词也不会把它误标成第一张。
workflowMediaInputs.forEach((input) => {
if (input.type === "text" || labelByNodeId.has(input.nodeId)) return;
const labelKind = input.sourceKind === "drawing" ? "drawing" : input.type;
labelByNodeId.set(input.nodeId, generationLabel(labelKind, counts[labelKind]++));
});
for (const { match, input } of mentions) {
const index = match.index!;
nextPrompt += normalizedPrompt.slice(lastIndex, index);
const label = input ? providerLabels.get(input.nodeId) : undefined;
nextPrompt += label ? input?.type === "text" ? `【${label}】` : `@${label}` : match[0];
lastIndex = index + match[0].length;
}
for (const match of normalizedPrompt.matchAll(GENERATION_MENTION_PATTERN)) {
if (match.index === undefined) continue;
nextPrompt += normalizedPrompt.slice(lastIndex, match.index);
const input = resolveGenerationMention(normalizedPrompt, match, slotInputByToken, nodeInputById, assetInputById);
if (input) {
hasToken = true;
let label = labelByNodeId.get(input.nodeId);
if (!label) {
const labelKind = input.sourceKind === "drawing" ? "drawing" : input.type;
label = generationLabel(labelKind, counts[labelKind]++);
labelByNodeId.set(input.nodeId, label);
if (input.type === "text") textBlocks.push(`【${label}】\n${input.text || ""}`);
else selectedInputs.push(input);
}
nextPrompt += input.type === "text" ? `【${label}】` : `@${label}`;
} else nextPrompt += match[0];
lastIndex = match.index + match[0].length;
}
nextPrompt += normalizedPrompt.slice(lastIndex);
// 显式 @文本 引用是用户写进输入框的内容,必须内联真实文本;promptOnly 只拦自动上游文本。
if (textBlocks.length) nextPrompt = `${nextPrompt.trim()}\n\n${textBlocks.join("\n\n")}`;
if (autoIncludeWorkflowMedia) {
// RunningHub/ComfyUI 工作流按保存的字段槽位接收图片、视频和音频;
// 配置节点不能因为提示词里没有逐个 @ 就丢失已连接媒体。
// 先按连线顺序放入媒体,避免用户在提示词里 @图片2 后改变工作流槽位的索引;
// 素材库中的显式 @ 引用仍保留在后面,不会被自动模式吞掉。
const explicitInputs = selectedInputs.splice(0);
const selectedNodeIds = new Set<string>();
workflowMediaInputs.forEach((input) => {
if (input.type === "text" || selectedNodeIds.has(input.nodeId)) return;
selectedInputs.push(input);
selectedNodeIds.add(input.nodeId);
});
explicitInputs.forEach((input) => {
if (selectedNodeIds.has(input.nodeId)) return;
selectedInputs.push(input);
selectedNodeIds.add(input.nodeId);
});
}
// 首尾帧是结构化生成参数,不受提示词中的 @ 引用筛选影响。
const selectedNodeIds = new Set(selectedInputs.map((input) => input.nodeId));
videoFrameNodeIds.forEach((nodeId) => {
const input = nodeInputById.get(nodeId);
if (!input?.image || selectedNodeIds.has(nodeId)) return;
selectedInputs.push(input);
selectedNodeIds.add(nodeId);
});
const referenceImages = selectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const referenceVideos = selectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
const referenceAudios = selectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
const characterReferences = selectedInputs.map((input) => input.character).filter((item): item is CharacterGenerationReference => Boolean(item));
if (!hasToken && !textBlocks.length && !selectedInputs.length) {
return {
prompt,
referenceImages: [],
referenceVideos: [],
referenceAudios: [],
characterReferences: [],
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
textCount: 0,
imageCount: 0,
videoCount: 0,
audioCount: 0,
};
}
const referenceImageSlots = generationImageSlots(selectedInputs);
return {
prompt: nextPrompt,
referenceImages,
referenceVideos,
referenceAudios,
referenceImageSlots,
characterReferences,
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
textCount: counts.text,
textCount: textBlocks.length,
imageCount: referenceImages.length,
videoCount: referenceVideos.length,
audioCount: referenceAudios.length,
@@ -310,6 +261,7 @@ export function normalizeGenerationNodeMentionTokens(prompt: string, inputs: Nod
const label = labelByNodeId.get(nodeId);
return label ? `@${label}` : token;
});
// 仅保留无普通图片时的既有别名;混合引用不能猜测旧的独立编号。
return normalizeCharacterImageMentions(normalized, slots.filter(({ input }) => input.type === "image").length, slots.filter(({ input }) => input.type === "character").map(({ label }) => label));
}
@@ -342,15 +294,20 @@ function inspectGenerationMentions(prompt: string, inputs: NodeGenerationInput[]
}
function generationSlotEntries(inputs: NodeGenerationInput[]) {
const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 };
const nextLabel = createCanvasReferenceLabeler();
return inputs.flatMap((input) => {
if (input.nodeId.startsWith("asset:")) return [];
const kind = input.sourceKind === "drawing" ? "drawing" : input.type;
const label = generationLabel(kind, counts[kind]++);
const label = nextLabel(input.sourceKind === "drawing" ? "drawing" : input.type);
return [{ input, label, token: `@${label}` }];
});
}
function generationImageSlots(inputs: NodeGenerationInput[]): NodeGenerationContext["referenceImageSlots"] {
return inputs.flatMap((input): NodeGenerationContext["referenceImageSlots"] => input.character
? [{ nodeId: input.nodeId, kind: "character" }]
: input.image ? [{ nodeId: input.nodeId, kind: "image", imageId: input.image.id }] : []);
}
function resolveGenerationMention(
prompt: string,
match: RegExpMatchArray,
@@ -369,7 +326,8 @@ function resolveGenerationMention(
function hasMentionBoundary(value: string, index: number) {
const char = value[index];
return !char || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(char);
// 与编辑器一致:编号可紧邻中文或下一个引用,不能截断更长的数字。
return !char || !/[0-9]/.test(char);
}
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
@@ -500,27 +458,71 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
const details = await Promise.all(context.characterReferences.map((reference) => getCharacter(reference.assetId)));
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
const selected = details.flatMap((detail) => {
const selectedEntries = details.flatMap((detail, index) => {
const reference = context.characterReferences[index];
if (reference.requestedVersionId && reference.requestedVersionId !== detail.character.versionId) throw new Error(`角色「${detail.asset.title}」的固定版本已变化,请重新选择角色后生成`);
const representation = preferredCharacterRepresentation(detail.character.representations);
return representation ? [representation] : [];
if (!representation && mode !== "audio" && mode !== "text") throw new Error(`角色「${detail.asset.title}」缺少可用的主参考图,请补充形象后再生成`);
return representation ? [{ nodeId: reference.nodeId, representation }] : [];
});
if (selected.length > remainingBudget) throw new Error(`当前模型参考图容量不足:角色至少需要 ${selected.length} 张主参考图`);
const usedResourceIds = new Set(selected.map((item) => item.resourceId));
const supplements = details.flatMap((detail) => detail.character.representations.filter((item) => {
if (!["front", "side", "back", "turnaround_sheet"].includes(item.role) || usedResourceIds.has(item.resourceId)) return false;
usedResourceIds.add(item.resourceId);
return true;
}));
const characterImages = [...selected, ...supplements].slice(0, Math.max(0, remainingBudget)).map((representation, index) => ({
if (selectedEntries.length > remainingBudget) throw new Error(`当前模型参考图容量不足:角色至少需要 ${selectedEntries.length} 张主参考图`);
const usedResourceIds = new Set(selectedEntries.map((item) => item.representation.resourceId));
const supplementEntries = details.flatMap((detail, index) => {
const reference = context.characterReferences[index];
if (!reference) return [];
return detail.character.representations.flatMap((representation) => {
if (!["front", "side", "back", "turnaround_sheet"].includes(representation.role) || !representation.resourceId || usedResourceIds.has(representation.resourceId)) return [];
usedResourceIds.add(representation.resourceId);
return [{ nodeId: reference.nodeId, representation }];
});
});
const imageEntries = [...selectedEntries, ...supplementEntries].slice(0, Math.max(0, remainingBudget));
const characterImages = imageEntries.map((entry, index) => ({
id: `character-reference-${index + 1}`,
name: `character-reference-${index + 1}.png`,
type: "image/png",
dataUrl: "",
storageKey: resourceStorageKey(representation.resourceId),
storageKey: resourceStorageKey(entry.representation.resourceId),
} satisfies ReferenceImage));
const hydratedCharacterImages = await Promise.all(characterImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) })));
referenceImages = [...referenceImages, ...hydratedCharacterImages];
const characterBlocks = details.map((detail) => compileCharacterReferencePrompt(detail.asset.title, detail.character.definition));
const primaryByNodeId = new Map<string, ReferenceImage>();
selectedEntries.forEach((entry, index) => {
const image = hydratedCharacterImages[index];
if (image) primaryByNodeId.set(entry.nodeId, image);
});
const regularById = new Map(referenceImages.map((image) => [image.id, image]));
const orderedIndexByNodeId = new Map<string, number>();
const providerMentionBySlot = new Map<string, string>();
const orderedImages: ReferenceImage[] = [];
context.referenceImageSlots.forEach((slot, index) => {
const image = slot.kind === "character" ? primaryByNodeId.get(slot.nodeId) : regularById.get(slot.imageId || slot.nodeId);
if (!image) {
const characterIndex = context.characterReferences.findIndex((reference) => reference.nodeId === slot.nodeId);
const detail = details[characterIndex];
if (slot.kind !== "character" || !detail || (mode !== "text" && mode !== "audio")) throw new Error("参考图片槽位无法解析,请重新选择引用后生成");
providerMentionBySlot.set(`@图片${index + 1}`, detail.asset.title.replaceAll("@", "@"));
return;
}
orderedImages.push(image);
orderedIndexByNodeId.set(slot.nodeId, orderedImages.length);
providerMentionBySlot.set(`@图片${index + 1}`, `@图片${orderedImages.length}`);
});
const compiledPrompt = context.prompt.replace(/@图片[1-9]\d*/g, (token, offset: number) => hasMentionBoundary(context.prompt, offset + token.length) ? providerMentionBySlot.get(token) || token : token);
const supplementLabelsByNodeId = new Map<string, string[]>();
supplementEntries.slice(0, Math.max(0, remainingBudget - selectedEntries.length)).forEach((entry, index) => {
const label = imageReferenceLabel(orderedImages.length + index);
supplementLabelsByNodeId.set(entry.nodeId, [...(supplementLabelsByNodeId.get(entry.nodeId) || []), `@${label}`]);
});
const supplementImages = hydratedCharacterImages.slice(selectedEntries.length);
referenceImages = [...orderedImages, ...supplementImages];
const characterBlocks = details.map((detail, index) => {
const nodeId = context.characterReferences[index]?.nodeId;
const primaryLabel = nodeId ? orderedIndexByNodeId.get(nodeId) : undefined;
const supplementLabels = nodeId ? supplementLabelsByNodeId.get(nodeId) || [] : [];
const referenceLine = primaryLabel ? `参考图片:@${imageReferenceLabel(primaryLabel - 1)}${supplementLabels.length ? `;补充视角:${supplementLabels.join("、")}` : ""}` : "";
const heading = includeCharacterPrompt ? compileCharacterReferencePrompt(detail.asset.title, detail.character.definition) : `【角色卡:${detail.asset.title}】`;
return [heading, referenceLine].filter(Boolean).join("\n");
});
const resolvedCharacterVersions = details.map((detail) => ({ assetId: detail.asset.id, versionId: detail.character.versionId }));
const resolvedCharacterVoices = details.flatMap((detail): ResolvedCharacterVoice[] => {
const voice = detail.character.voice;
@@ -567,10 +569,17 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
} satisfies ReferenceAudio;
}));
const referenceAudios = [...context.referenceAudios, ...characterVoiceAudios];
const voiceBlocks = mode === "video" ? resolvedCharacterVoices.map(compileResolvedVoicePrompt) : [];
const voiceBlocks = mode === "video" ? resolvedCharacterVoices.flatMap((voice) => {
const sampleIndex = voice.sampleResourceId ? referenceAudios.findIndex((audio) => resourceIdFromStorageKey(audio.storageKey) === voice.sampleResourceId) : -1;
if (sampleIndex < 0 && !includeCharacterPrompt) return [];
return [[
includeCharacterPrompt ? compileResolvedVoicePrompt(voice) : `【角色声音:${voice.characterName}】`,
sampleIndex >= 0 ? `声音参考:@音频${sampleIndex + 1}` : "",
].filter(Boolean).join("\n")];
}) : [];
return {
...context,
prompt: includeCharacterPrompt ? [context.prompt.trim(), ...characterBlocks, ...voiceBlocks].filter(Boolean).join("\n\n") : context.prompt,
prompt: [compiledPrompt.trim(), ...characterBlocks, ...voiceBlocks].filter(Boolean).join("\n\n"),
referenceImages,
referenceAudios,
resolvedCharacterVersions,
@@ -592,7 +601,7 @@ function readCharacterReference(node: CanvasNodeData): CharacterGenerationRefere
}
function preferredCharacterRepresentation(representations: Array<{ id: string; resourceId: string; role: string }>) {
return ["turnaround_sheet", "primary", "front", "side", "back"].map((role) => representations.find((item) => item.role === role)).find(Boolean);
return ["turnaround_sheet", "primary", "front", "side", "back"].map((role) => representations.find((item) => item.role === role && item.resourceId.trim())).find(Boolean);
}
function compileResolvedVoicePrompt(voice: ResolvedCharacterVoice) {

View File

@@ -20,7 +20,7 @@ export type CharacterBreakdown = {
export function normalizeCharacterImageMentions(prompt: string, imageCount: number, characterLabels: string[]) {
if (imageCount > 0 || !characterLabels.length) return prompt;
return prompt.replace(/@图片([1-9]\d*)(?=$|\s|[,.!?;:,。!?;:、)\]}】)])/g, (token, index: string) => {
return prompt.replace(/@图片([1-9]\d*)(?![0-9])/g, (token, index: string) => {
const label = characterLabels[Number(index) - 1];
return label ? "@" + label : token;
});

View File

@@ -0,0 +1,11 @@
export type CanvasReferenceLabelKind = "image" | "character" | "drawing" | "video" | "audio" | "text" | "skill" | "tool";
/** 图片、角色主图和绘图共享图片数组的位置;保留类型前缀供编辑器识别。 */
export function createCanvasReferenceLabeler() {
const counts: Record<CanvasReferenceLabelKind, number> = { image: 0, character: 0, drawing: 0, video: 0, audio: 0, text: 0, skill: 0, tool: 0 };
const prefixes: Record<CanvasReferenceLabelKind, string> = { image: "图片", character: "角色", drawing: "绘图", video: "视频", audio: "音频", text: "文本", skill: "技能", tool: "工具" };
return (kind: CanvasReferenceLabelKind) => {
const counter = kind === "character" || kind === "drawing" ? "image" : kind;
return `${prefixes[kind]}${++counts[counter]}`;
};
}

View File

@@ -1,10 +1,9 @@
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { createCanvasReferenceLabeler } from "@/lib/canvas/canvas-reference-slots";
import { normalizeCharacterImageMentions } from "@/lib/canvas/canvas-character-reference";
import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { Skill } from "@/services/api/skills";
import type { Asset, AssetCategory } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
@@ -292,7 +291,7 @@ export function replaceCanvasMentionToken(value: string, token: string, replacem
if (!token) return value;
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Numbered media mentions can touch Chinese prose or another mention, but not a longer number.
const boundary = /^@(图片|视频|音频|文本)\d+$/.test(token)
const boundary = /^@(图片|角色|绘图|视频|音频|文本)\d+$/.test(token)
? "(?![0-9])"
: token.startsWith("@[node:") ? "" : `(?=${CANVAS_RESOURCE_MENTION_BOUNDARY.source})`;
return value.replace(new RegExp(`${escapedToken}${boundary}`, "gu"), replacement);
@@ -525,13 +524,11 @@ export function reorderCanvasResourceConnections(targetNodeId: string, orderedNo
}
function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0, skill: 0, character: 0, tool: 0 };
let drawingCount = 0;
const nextLabel = createCanvasReferenceLabeler();
return nodes.flatMap((node): CanvasResourceReference[] => {
const kind = resourceKind(node);
if (!kind) return [];
const index = node.type === CanvasNodeType.Drawing ? drawingCount++ : counts[kind]++;
const label = node.type === CanvasNodeType.Drawing ? `绘图${index + 1}` : labelForKind(kind, index);
const label = nextLabel(node.type === CanvasNodeType.Drawing ? "drawing" : kind);
return [
{
id: node.id,
@@ -558,16 +555,6 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
});
}
function labelForKind(kind: CanvasResourceKind, index: number) {
if (kind === "character") return `角色${index + 1}`;
if (kind === "image") return imageReferenceLabel(index);
if (kind === "video") return seedanceReferenceLabel("video", index);
if (kind === "audio") return seedanceReferenceLabel("audio", index);
if (kind === "skill") return `技能${index + 1}`;
if (kind === "tool") return `工具${index + 1}`;
return `文本${index + 1}`;
}
function isResourceNode(node: CanvasNodeData) {
return Boolean(resourceKind(node));
}

View File

@@ -0,0 +1,185 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "../src/components/canvas/canvas-node-generation";
import { prepareBackendGenerationTask } from "../src/services/api/generation-task";
import { http } from "../src/services/api/request";
import type { ProjectCharacterDetail } from "../src/services/api/projects";
import * as imageStorage from "../src/services/image-storage";
import { createModelChannel, defaultConfig, encodeChannelModel } from "../src/stores/use-config-store";
import type { Asset } from "../src/stores/use-asset-store";
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
const restores: Array<() => void> = [];
afterEach(() =>
restores
.splice(0)
.reverse()
.forEach((restore) => restore()),
);
function node(id: string, type: CanvasNodeType, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {
return { id, type, title: id, metadata, position: { x: 0, y: 0 }, width: 100, height: 100 };
}
function characterNode(id: string) {
return node(id, CanvasNodeType.Text, { workflowKind: "character", characterAssetId: id });
}
function detail(id: string, representations: Array<[string, string]> = [["primary", `${id}-main`]]): ProjectCharacterDetail {
return {
asset: { id, title: id, category: "character", mediaType: "entity", status: "ready", versionCount: 1, usages: [], position: 0, updatedAt: "" },
character: {
versionId: `${id}-version`,
version: 1,
definition: { appearance: "角色外观设定" },
visualStatus: "ready",
voiceStatus: "missing",
representations: representations.map(([role, resourceId], index) => ({ id: `${id}-${index}`, role, resourceId, mediaType: "image/png" })),
},
};
}
function mockResources(details: ProjectCharacterDetail[]) {
const get = spyOn(http, "get").mockImplementation(async (url) => {
const character = details.find((item) => url === `/characters/${item.asset.id}`);
if (character) return character as never;
if (url === "/resources/shared-voice") return { resource: { id: "shared-voice", mimeType: "audio/wav", objectKey: "voice.wav" } } as never;
throw new Error(`Unexpected request: ${url}`);
});
const images = spyOn(imageStorage, "imageToDataUrl").mockImplementation(async (image) => {
// Resource I/O is stubbed; ordering, character resolution and task serialization run unchanged.
return `data:image/png;base64,${btoa(image.storageKey || image.dataUrl || "image")}`;
});
restores.push(
() => get.mockRestore(),
() => images.mockRestore(),
);
return { get, images };
}
function contextFor(sources: CanvasNodeData[], prompt: string, assets: Asset[] = [], mode = CanvasNodeType.Video) {
const target = node("target", mode);
const connections = sources.map((source) => ({ id: `edge-${source.id}`, fromNodeId: source.id, toNodeId: target.id }));
return buildNodeGenerationContext(target.id, [...sources, target], connections, prompt, assets, true);
}
function videoConfig() {
const channel = createModelChannel({
id: "video-test",
name: "Video",
baseUrl: "https://example.com",
apiKey: "test-key",
interfaceType: "volcengine-ark-video",
models: ["video-model"],
modelCosts: [{ model: "video-model", capability: "video", protocol: "volcengine-ark-video", billingMode: "fixed_request", unitPriceMicrocredits: 1 }],
});
const model = encodeChannelModel(channel.id, "video-model");
return { ...defaultConfig, channels: [channel], model, videoModel: model };
}
describe("canvas character reference delivery", () => {
test("mixed references preserve primary slots through hydration and backend task preparation", async () => {
const a = detail("a", [
["primary", "a-main"],
["front", "a-front"],
["side", "a-front"],
["back", ""],
]);
const b = detail("b", [
["primary", "b-main"],
["front", "b-front"],
]);
const voice = {
profile: { id: "voice", name: "声音", provider: "sample", voiceKey: "voice-key", language: "中文", timbre: "低沉", sampleResourceId: "shared-voice", compatibleModels: [], status: "ready" },
instructions: "轻声朗读",
};
a.character.voice = voice;
b.character.voice = voice;
mockResources([a, b]);
const asset: Asset = {
id: "library-image",
kind: "image",
title: "素材图",
tags: [],
createdAt: "",
updatedAt: "",
data: { dataUrl: "", storageKey: "resource:library", mimeType: "image/png", bytes: 1, width: 1, height: 1 },
};
const context = contextFor(
[characterNode("a"), node("scene", CanvasNodeType.Image, { storageKey: "resource:scene" }), characterNode("b"), node("audio", CanvasNodeType.Audio, { storageKey: "resource:music" })],
"@图片2场景里@角色3看向@角色1,参考@[asset:library-image],配合@音频1",
[asset],
);
const hydrated = await hydrateNodeGenerationContext(context, "canvas", undefined, "video", true, false, { maxImages: 6, maxVideos: 1, maxAudios: 2 });
const expectedKeys = ["resource:a-main", "resource:scene", "resource:b-main", "resource:library", "resource:a-front", "resource:b-front"];
expect(hydrated.referenceImages.map((image) => image.storageKey)).toEqual(expectedKeys);
expect(hydrated.prompt).toStartWith("@图片2场景里@图片3看向@图片1,参考@图片4,配合@音频1");
expect(hydrated.prompt).toContain("【角色卡:a】\n参考图片:@图片1;补充视角:@图片5");
expect(hydrated.prompt).toContain("【角色卡:b】\n参考图片:@图片3;补充视角:@图片6");
expect(hydrated.prompt).toContain("【角色声音:a】\n声音参考:@音频2");
expect(hydrated.prompt).toContain("【角色声音:b】\n声音参考:@音频2");
expect(hydrated.prompt).not.toContain("角色外观设定");
expect(hydrated.prompt).not.toContain("轻声朗读");
expect(hydrated.referenceAudios.map((audio) => audio.storageKey)).toEqual(["resource:music", "resource:shared-voice"]);
expect(hydrated.resolvedCharacterVersions).toEqual([
{ assetId: "a", versionId: "a-version" },
{ assetId: "b", versionId: "b-version" },
]);
const task = await prepareBackendGenerationTask({ mode: "video", config: videoConfig(), prompt: hydrated.prompt, referenceImages: hydrated.referenceImages, referenceAudios: hydrated.referenceAudios });
const input = task.input as { prompt: string; referenceImages: Array<{ storageKey: string }>; referenceAudios: Array<{ storageKey: string }> };
expect(input.prompt).toBe(hydrated.prompt);
expect(input.referenceImages.map((image) => image.storageKey)).toEqual(expectedKeys);
expect(input.referenceAudios.map((audio) => audio.storageKey)).toEqual(["resource:music", "resource:shared-voice"]);
});
test("reserved primary images cannot be displaced by supplements", async () => {
mockResources([
detail("a", [
["primary", "a-main"],
["front", "a-front"],
]),
detail("b"),
]);
const context = contextFor([characterNode("a"), node("scene", CanvasNodeType.Image, { storageKey: "resource:scene" }), characterNode("b")], "@角色3与@角色1");
const hydrated = await hydrateNodeGenerationContext(context, "canvas", undefined, "video", false, true, { maxImages: 3, maxVideos: 0, maxAudios: 0 });
expect(hydrated.referenceImages.map((image) => image.storageKey)).toEqual(["resource:a-main", "resource:scene", "resource:b-main"]);
expect(hydrated.prompt).not.toContain("补充视角");
await expect(hydrateNodeGenerationContext(context, "canvas", undefined, "video", false, true, { maxImages: 2, maxVideos: 0, maxAudios: 0 })).rejects.toThrow("参考图容量不足");
});
test("pinned version changes and missing image identities stop submission", async () => {
mockResources([detail("a")]);
const source = characterNode("a");
source.metadata = { ...source.metadata, characterVersionPolicy: "pinned", characterVersionId: "old-version" };
await expect(hydrateNodeGenerationContext(contextFor([source], "@角色1"), "canvas", undefined, "video")).rejects.toThrow("固定版本已变化");
});
test("image and video generation reject character cards without a usable main image", async () => {
mockResources([detail("a", [["primary", ""]])]);
const context = contextFor([characterNode("a")], "@角色1");
for (const mode of ["image", "video"] as const) {
await expect(hydrateNodeGenerationContext(context, "canvas", undefined, mode)).rejects.toThrow("缺少可用的主参考图");
}
});
test("text and audio may use an imageless character without shifting another image to the wrong mention", async () => {
mockResources([detail("a", []), detail("b")]);
const context = contextFor([characterNode("a"), node("scene", CanvasNodeType.Image, { storageKey: "resource:scene" }), characterNode("b")], "@角色1对@角色3说话,位于@图片2");
for (const mode of ["text", "audio"] as const) {
const hydrated = await hydrateNodeGenerationContext(context, "canvas", undefined, mode);
expect(hydrated.prompt).toStartWith("a对@图片2说话,位于@图片1");
expect(hydrated.referenceImages.map((image) => image.storageKey)).toEqual(["resource:scene", "resource:b-main"]);
expect(hydrated.prompt).toContain("【角色卡:b】\n角色外观设定\n参考图片:@图片2");
}
});
test("character authorization and image delivery failures remain errors", async () => {
const { get, images } = mockResources([detail("a")]);
const context = contextFor([characterNode("a")], "@角色1");
get.mockRejectedValueOnce(new Error("无权读取角色"));
await expect(hydrateNodeGenerationContext(context, "canvas", undefined, "video")).rejects.toThrow("无权读取角色");
images.mockRejectedValueOnce(new Error("图片下载失败"));
await expect(hydrateNodeGenerationContext(context, "canvas", undefined, "video")).rejects.toThrow("图片下载失败");
});
});

View File

@@ -43,24 +43,44 @@ describe("canvas node generation position mentions", () => {
const prompt = "严格参考 @图片1,保持服装一致。";
expect(normalizeCanvasNodeMentionTokens(prompt, references)).toBe("严格参考 @角色1,保持服装一致。");
const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, []);
expect(context.prompt).toBe("严格参考 @角色1,保持服装一致。");
expect(context.prompt).toBe("严格参考 @图片1,保持服装一致。");
expect(context.characterReferences).toEqual([{ nodeId: character.id, assetId: "character-asset", requestedVersionId: undefined }]);
expect(() => buildNodeGenerationContext(target.id, nodes, connections, "参考 @图片2", [])).toThrow("@图片2");
});
test("角色卡与普通图片混合时,图片编号不指向角色卡", () => {
test("角色卡与普通图片混合时共享图片序号,旧独立编号拒绝猜测迁移", () => {
const target = targetNode();
const character = node("character", CanvasNodeType.Text, "");
character.metadata = { workflowKind: "character", characterAssetId: "character-asset" };
const image = node("scene", CanvasNodeType.Image, "data:image/png;base64,AA==");
const nodes = [target, character, image];
const connections = [connection(character.id), connection(image.id)];
const prompt = "@角色1 站在 @图片1 场景内。";
const prompt = "@角色1站在@图片2场景内。";
const references = buildCanvasResourceReferences(nodes, connections, target.id);
expect(normalizeCanvasNodeMentionTokens(prompt, references)).toBe(prompt);
const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, []);
expect(context.prompt).toBe("@图片1站在@图片2场景内。");
expect(context.referenceImages.map((item) => item.id)).toEqual([image.id]);
expect(context.characterReferences.map((item) => item.nodeId)).toEqual([character.id]);
expect(() => buildNodeGenerationContext(target.id, nodes, connections, "@角色1站在@图片1场景内", [])).toThrow("@图片1");
});
test("混合角色、绘图和图片按连接位置编译,逆序提及不会重排资源", () => {
const character = node("character", CanvasNodeType.Text, "");
character.metadata = { workflowKind: "character", characterAssetId: "character-asset" };
const drawing = node("drawing", CanvasNodeType.Drawing, "");
drawing.metadata = { drawingId: "drawing-document" };
const image = node("scene", CanvasNodeType.Image, "data:image/png;base64,AA==");
const target = targetNode();
const nodes = [target, image, drawing, character];
const connections = [character, drawing, image].map((source) => connection(source.id));
const references = buildCanvasResourceReferences(nodes, connections, target.id).filter((reference) => reference.active);
expect(Object.fromEntries(references.map((reference) => [reference.nodeId, reference.label]))).toEqual({ character: "角色1", drawing: "绘图2", scene: "图片3" });
const context = buildNodeGenerationContext(target.id, nodes, connections, "@图片3背景,@绘图2@角色1保持一致", [], true);
expect(context.prompt).toBe("@图片3背景,@图片2@图片1保持一致");
expect(context.referenceImageSlots.map((slot) => slot.nodeId)).toEqual([character.id, drawing.id, image.id]);
expect(context.referenceImages.map((item) => item.id)).toEqual([drawing.id, image.id]);
expect(() => buildNodeGenerationContext(target.id, nodes, connections, "@角色10保持一致", [], true)).toThrow("@角色10");
});
test("父图无预览时仍可继承输入,显式子图输入优先且去重", () => {

View File

@@ -356,6 +356,20 @@ describe("image generation reference connections", () => {
});
describe("reorder canvas resource connections", () => {
test("混合角色、绘图和图片拖动后按节点身份同步所有提及", () => {
const character = { ...textNode("character"), metadata: { workflowKind: "character" as const, characterAssetId: "character-asset" } };
const drawing: CanvasNodeData = { ...imageNode("drawing"), type: CanvasNodeType.Drawing, metadata: { drawingId: "drawing-document" } };
const image = imageNode("scene");
const target = { ...videoNode("target"), metadata: { composerContent: "@角色1参考@绘图2,背景@图片3,@角色1@绘图2" } };
const nodes = [character, drawing, image, target];
const before = [character, drawing, image].map((source) => connection(source.id, target.id));
const after = reorderCanvasResourceConnections(target.id, [image.id, character.id, drawing.id], nodes, before);
const synced = applyCanvasConnectionPromptSync(nodes, before, nodes, after);
expect(synced.find((item) => item.id === target.id)?.metadata?.composerContent).toBe("@角色2参考@绘图3,背景@图片1,@角色2@绘图3");
expect(replaceCanvasMentionToken("@角色1@角色10@绘图2@绘图20", "@角色1", "@角色2")).toBe("@角色2@角色10@绘图2@绘图20");
expect(replaceCanvasMentionToken("@绘图2@绘图20", "@绘图2", "@绘图3")).toBe("@绘图3@绘图20");
});
test("直接引用按指定顺序换位并同步编号提示词", () => {
const imageA = imageNode("image-a");
const imageB = imageNode("image-b");

View File

@@ -70,7 +70,7 @@ describe("storyboard target materializer", () => {
it("combines stable bindings and produces position mention tokens", () => {
const references = storyboardRowReferenceNodeIds(script, row, nodes, connections, true);
expect(references).toEqual(["project-style", "prop", "character", "manual", "first-frame"]);
expect(storyboardComposerContent("快速跟拍", references, nodes)).toBe("参考资产:@图片1 @图片2 @角色1 @视频1 @图片3\n快速跟拍");
expect(storyboardComposerContent("快速跟拍", references, nodes)).toBe("参考资产:@图片1 @图片2 @角色3 @视频1 @图片4\n快速跟拍");
const withDirectManualInput = storyboardRowReferenceNodeIds(script, row, nodes, [...connections, { id: "direct", fromNodeId: "direct-manual", toNodeId: "target" }], false, "target");
expect(withDirectManualInput).toContain("direct-manual");