脚本节点做的事,是把一段梗概或剧本变成结构化的镜头表,再按镜头批量出分镜图、批量生视频。
用户反馈:资产没成组,也没连脚本节点;分镜节点之间没有连线;@ 功能不工作。
三句话指向同一件事——脚本节点在画布上像一个外来户。
一、自造了一套画布不认识的格式
根因在这里:脚本节点用 {{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、没有缩略图,用户看不出数据是怎么流的。
可组合的系统里,新功能要么复用既有的表达方式,要么就得把新方式补进所有既有环节——渲染、解析、连线、菜单、缩略图。脚本节点选过前者,走了一段之后又回到了后者,中间那段的成本就是这篇文章。
■