内容流水线

提示词里的 @图片1 是从哪来的

脚本节点批量出分镜图,资产却没成组、分镜节点没连线、@ 引用不生效。根因是自造了一套画布不认识的引用格式,整条链路脱离原生体系。

STRUCTURE ── 篇幅剖面6 节 · 点击跳转

脚本节点做的事,是把一段梗概或剧本变成结构化的镜头表,再按镜头批量出分镜图、批量生视频。

用户反馈:资产没成组,也没连脚本节点;分镜节点之间没有连线;@ 功能不工作。

三句话指向同一件事——脚本节点在画布上像一个外来户。

一、自造了一套画布不认识的格式

根因在这里:脚本节点用 {{Image 1}} 这种写法引用参考图。

这是它自己发明的格式。画布的原生引用是 @图片1

  • 前端会把 @图片1 渲染成 chip 模块,用户看得出那是一个整体。
  • chip 与连线顺序一一对应——@图片1 就是接进来的第一张图。
  • 节点卡片上会根据编号显示出对应素材的缩略图。

{{Image 1}} 哪一个都不占。它在提示词框里只是一串字面量,模型看到也是一串字面量。

而连线这一步也被绕开了:脚本节点原先用 refMaterialIds 参数把参考图传给下游节点,不走连线。参数传递能跑通生成,但画布上看不到连线,用户无从知道哪张图进了哪个节点。

于是「资产没成组、分镜节点没连线、@ 不工作」这三件事一起出现——它们本来就是一件事的三个侧面。

二、编号只有一份真源

改动的核心是把引用交还给画布原生体系。顺序很关键。

第一步是确定「这个镜头会用哪几张参考图」:

/**
 * 该镜会被「连线接入」的参考图资产,顺序即连线顺序、即 @图片N 的编号顺序。
 *
 * **这是编号的唯一真源**:spawn 分镜节点时按它连线,拼提示词时按它编号。
 * 两处若各自计算,编号与实际连线就会错位——@图片2 指到另一张图上,
 * 而画面「看起来只是不太对」,极难发现。
 *
 * 只收已生成出图的资产:没有 materialId 的连不了线,占了编号会让后面全体错位。
 */
export function shotReferenceImages(shot, assets) {
  return shotReferencedAssets(shot, assets).filter((asset) => asset.status === "done" && !!asset.materialId);
}

最后一句是这个函数里最容易忽略的约束。生成中的资产还连不了线,如果它先占了一个编号,后面所有素材的编号会整体前移一位,提示词里的 @图片2 就指到了另一张图上。

第二步是连线。spawn 分镜图节点时,参考图靠连线接入,顺序与编号一致:

// 参考图靠**连线**接入(画布原生机制),顺序必须与提示词里的 @图片N 一致:
// 两边都以 shotReferenceImages 为真源,先连的就是 @图片1。
// 用 refMaterialIds 参数传参会绕开 @ 引用体系,chip 不渲染、编号也对不上(线上踩过)。

第三步是资产落节点。资产出图之后自动落 asset.input 节点、编组、连到脚本节点上,按 scriptAssetId 查重保证幂等。

第四步是让模型别自己发明引用。智能合成的提示词里,正文只写资产的名字:

资产图锚定:
角色 林芽 的参考图是 @图片1

生成提示词时明确禁止模型自造 @{{Image}} 记号——模型写出来的编号不受控制,写错一个,整段引用就错位。

三、还有一个反向的坑

同一轮里发现一个反方向的问题:脚本节点连到下游时,它的文本产出会被当成提示词的一部分。

脚本节点的输出是整张镜头表。接进生图节点,等于把整篇镜头表灌进生图提示词。

所以后端的上游输入收集里显式排除了它:

// script.storyboard 的文本产出是整张镜头表,接进取参考图会把整篇灌进生图提示词

脚本节点连过来只做溯源,不作为文本输入。

四、节点要有名字

引用体系要能用,前提是用户能认出「@图片1 到底是画布上哪一个」。

原先节点标题只有类型名。一排「图片」摆在那儿,@ 菜单里也是一排「图片」。

改成按类型各自递增编号:

/**
 * 新节点的默认标题:「图片节点 1」「视频节点 2」这样按类型各自递增。
 *
 * 为什么要编号:@ 菜单里一排「图片」根本分不出是画布上哪一个,
 * 有了编号才认得出。编号一次性写进 params.nodeTitle 后就**不再变**——
 * 跟着当前节点数实时算的话,删掉中间一个会让后面的全部改名,
 * 用户提示词里已经写下的 @图片节点3 就指向了别的东西。
 *
 * 取「同类已用过的最大编号 + 1」而不是「同类个数 + 1」:后者在删掉中间节点后
 * 会复用旧编号,画布上于是出现两个「图片节点 2」。
 */

两条规则都来自同一个要求:编号一旦被用户写进提示词,它就不能再变。实时计算看起来更「准确」,实际会让已经写下的引用指向别处。

五、@ 菜单与富文本输入框

引用记号定义好了,接下来是让用户能舒服地打出来。

菜单要分组。

已引用           (已经连线的上游)
素材引用 · 图片
素材引用 · 视频
素材引用 · 音频
素材引用 · 文本

一长条平铺的列表里全是「图片」,认不出是画布上哪一个。分组之后,已经连上来的排在最前。

分组渲染有一个具体的坑:

/**
 * 菜单分组:已引用(已连线的上游)在前,其余按类型分节。
 *
 * 每项带上它在扁平列表中的下标——键盘上下键走的是扁平序,
 * 分组渲染时若各组自己从 0 数,高亮会跳到别的组去。
 */

滚轮不能缩放画布。

{/* nowheel 不能少:xyflow 默认把滚轮当画布缩放,
    没有它在菜单里滚一下就把整个画布缩放了,菜单本身纹丝不动 */}

图标不用 emoji。

/** 线性图标,不用 emoji:emoji 在各系统字体下大小/基线都不一样,一排下来参差不齐 */

输入框要做成富文本。

原先 @图片1 就是七个字。用户看不出那是一个整体,删的时候要一个字一个字退。

改成 chip 之后,数据层仍然是纯文本:

/**
 * 画布提示词里的「模块」(chip):素材引用与运镜。
 *
 * 提示词在数据层始终是**一段纯文本**(`一只猫@图片1(镜头左移)跑开`),
 * chip 只是这段文本在输入框里的渲染形态。这样存库/发上游/AI 助写回填全都不用改,
 * 而且用户手打 `@图片1` 也一样会显示成 chip。
 */

这条设计决定了实现的自由度:存库、发上游、AI 助写回填三处都不用动,因为它们看到的还是一样的字符串。

但解析必须是无损的:

/**
 * 解析必须是**无损**的:text → 段落 → text 必须还原成原串,
 * 否则用户每敲一个字都可能被悄悄改写。parse/serialize 的往返有测试钉死。
 */

chip 必须用原生 DOM 建。

/**
 * 为什么不是 textarea:textarea 只能存纯文本,`@图片1` 就是七个字,
 * 用户看不出那是一个整体,删的时候还得一个字一个字退。
 *
 * **chip 必须用原生 DOM 建**:
 * contenteditable 内部交给 React 渲染的话,每次 re-render 重建节点会打掉
 * 光标位置与输入法组字状态,中文根本没法连续输入。
 */

最后一句是这个功能能不能用的分界线。中文输入有组字过程,React 重建 DOM 节点会打断它,用户打一个词会看到候选框消失。

运镜也用同一种 chip。

运镜的记号是全角括号:(镜头左移)

/**
 * 运镜在提示词里的写法:`(镜头左移)`。
 *
 * 全角括号是给模型看的分隔提示——正文写成「她回头(镜头左移)看向窗外」,
 * 镜头指令就落在它该发生的那一刻。用参数存整段后缀的话这个位置信息就没了。
 */

起初运镜是一个独立参数,拼提示词时把后缀接在末尾。但镜头运动发生在语句里的某个时刻——「她回头」之后、还是「看向窗外」之前,结果不一样。改成内联记号之后,位置由用户决定。

六、两种记号的取舍

引用体系最后收敛到两种记号:

素材  @图片1        编号与连线顺序一一对应
运镜  (镜头左移)   全角括号,落在它该发生的那一刻

两者的共同点是都可以被无损地解析回纯文本,也都可以被用户手打出来。正则只认这两类:

/** `@图片1`:类型名 + 1~2 位编号。两位是为了万一素材超过 9 个。 */
const MATERIAL_REF = /@(图片|视频|音频|文本)(\d{1,2})/g;

这一轮改动的实质,是把脚本节点从「自己发明一套引用方式」改回「用画布已有的引用方式」。前者看起来更自由——不用迁就画布的连线、编号、渲染规则。代价是它在画布上不可见:没有连线、没有 chip、没有缩略图,用户看不出数据是怎么流的。

可组合的系统里,新功能要么复用既有的表达方式,要么就得把新方式补进所有既有环节——渲染、解析、连线、菜单、缩略图。脚本节点选过前者,走了一段之后又回到了后者,中间那段的成本就是这篇文章。

星野的头像

星野 XINGYE

全栈工程师。这里记录 82 篇复盘:24 份故障档案、OTA、架构演进与工作流。