diff --git a/backend/internal/app/cloud_agent_media_prompt.go b/backend/internal/app/cloud_agent_media_prompt.go index 0015ab94a..1a7f4b6ff 100644 --- a/backend/internal/app/cloud_agent_media_prompt.go +++ b/backend/internal/app/cloud_agent_media_prompt.go @@ -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 { diff --git a/backend/internal/app/cloud_agent_media_prompt_test.go b/backend/internal/app/cloud_agent_media_prompt_test.go index 4c5b6c4eb..266656f62 100644 --- a/backend/internal/app/cloud_agent_media_prompt_test.go +++ b/backend/internal/app/cloud_agent_media_prompt_test.go @@ -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) } diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index ea1397648..7b29608e2 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -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,不自动重跑。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 108a49a87..c51d95fab 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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 集成测试。 diff --git a/web/src/components/canvas/canvas-node-generation.ts b/web/src/components/canvas/canvas-node-generation.ts index 36ca5cf49..06e8d9c8b 100644 --- a/web/src/components/canvas/canvas-node-generation.ts +++ b/web/src/components/canvas/canvas-node-generation.ts @@ -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(); - 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(); - 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(); + 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(); + const providerMentionBySlot = new Map(); + 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(); + 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) { diff --git a/web/src/lib/canvas/canvas-character-reference.ts b/web/src/lib/canvas/canvas-character-reference.ts index 6eff414b4..1de5ac1b0 100644 --- a/web/src/lib/canvas/canvas-character-reference.ts +++ b/web/src/lib/canvas/canvas-character-reference.ts @@ -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; }); diff --git a/web/src/lib/canvas/canvas-reference-slots.ts b/web/src/lib/canvas/canvas-reference-slots.ts new file mode 100644 index 000000000..c4809a501 --- /dev/null +++ b/web/src/lib/canvas/canvas-reference-slots.ts @@ -0,0 +1,11 @@ +export type CanvasReferenceLabelKind = "image" | "character" | "drawing" | "video" | "audio" | "text" | "skill" | "tool"; + +/** 图片、角色主图和绘图共享图片数组的位置;保留类型前缀供编辑器识别。 */ +export function createCanvasReferenceLabeler() { + const counts: Record = { image: 0, character: 0, drawing: 0, video: 0, audio: 0, text: 0, skill: 0, tool: 0 }; + const prefixes: Record = { image: "图片", character: "角色", drawing: "绘图", video: "视频", audio: "音频", text: "文本", skill: "技能", tool: "工具" }; + return (kind: CanvasReferenceLabelKind) => { + const counter = kind === "character" || kind === "drawing" ? "image" : kind; + return `${prefixes[kind]}${++counts[counter]}`; + }; +} diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index d8f836891..f0703d549 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -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 = { 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)); } diff --git a/web/test/canvas-character-generation.test.ts b/web/test/canvas-character-generation.test.ts new file mode 100644 index 000000000..2ad7ba5e2 --- /dev/null +++ b/web/test/canvas-character-generation.test.ts @@ -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("图片下载失败"); + }); +}); diff --git a/web/test/canvas-node-generation-mentions.test.ts b/web/test/canvas-node-generation-mentions.test.ts index 66d696030..a479614f2 100644 --- a/web/test/canvas-node-generation-mentions.test.ts +++ b/web/test/canvas-node-generation-mentions.test.ts @@ -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("父图无预览时仍可继承输入,显式子图输入优先且去重", () => { diff --git a/web/test/canvas-resource-references.test.ts b/web/test/canvas-resource-references.test.ts index 97f99bf76..6ada41a5f 100644 --- a/web/test/canvas-resource-references.test.ts +++ b/web/test/canvas-resource-references.test.ts @@ -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"); diff --git a/web/test/canvas-storyboard-assets.test.ts b/web/test/canvas-storyboard-assets.test.ts index 3ba5a747b..f35ce665b 100644 --- a/web/test/canvas-storyboard-assets.test.ts +++ b/web/test/canvas-storyboard-assets.test.ts @@ -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");