[{"data":1,"prerenderedAt":2742},["ShallowReactive",2],{"\u002F2026-08-30-canvas-ref":3,"\u002F2026-08-30-canvas-ref-rel":821},{"id":4,"title":5,"body":6,"column":803,"date":804,"description":12,"extension":805,"hero_image":806,"meta":807,"navigation":808,"path":809,"seo":810,"series_id":806,"severity":806,"stem":811,"summary":812,"tags":813,"__hash__":820},"posts\u002F2026-08-30-canvas-ref-提示词里的图片引用从哪来.md","提示词里的 @图片1 是从哪来的",{"type":7,"value":8,"toc":794},"minimark",[9,13,21,24,29,36,43,62,67,74,77,81,84,87,247,253,256,276,287,290,298,308,312,315,323,326,335,338,342,348,354,357,419,422,429,432,437,443,446,449,481,486,506,511,520,525,531,534,571,574,577,600,605,647,650,655,662,694,697,701,704,710,717,780,783,790],[10,11,12],"p",{},"脚本节点做的事，是把一段梗概或剧本变成结构化的镜头表，再按镜头批量出分镜图、批量生视频。",[10,14,15,16,20],{},"用户反馈：资产没成组，也没连脚本节点；分镜节点之间没有连线；",[17,18,19],"code",{},"@"," 功能不工作。",[10,22,23],{},"三句话指向同一件事——脚本节点在画布上像一个外来户。",[25,26,28],"h2",{"id":27},"一自造了一套画布不认识的格式","一、自造了一套画布不认识的格式",[10,30,31,32,35],{},"根因在这里：脚本节点用 ",[17,33,34],{},"{{Image 1}}"," 这种写法引用参考图。",[10,37,38,39,42],{},"这是它自己发明的格式。画布的原生引用是 ",[17,40,41],{},"@图片1","：",[44,45,46,53,59],"ul",{},[47,48,49,50,52],"li",{},"前端会把 ",[17,51,41],{}," 渲染成 chip 模块，用户看得出那是一个整体。",[47,54,55,56,58],{},"chip 与连线顺序一一对应——",[17,57,41],{}," 就是接进来的第一张图。",[47,60,61],{},"节点卡片上会根据编号显示出对应素材的缩略图。",[10,63,64,66],{},[17,65,34],{}," 哪一个都不占。它在提示词框里只是一串字面量，模型看到也是一串字面量。",[10,68,69,70,73],{},"而连线这一步也被绕开了：脚本节点原先用 ",[17,71,72],{},"refMaterialIds"," 参数把参考图传给下游节点，不走连线。参数传递能跑通生成，但画布上看不到连线，用户无从知道哪张图进了哪个节点。",[10,75,76],{},"于是「资产没成组、分镜节点没连线、@ 不工作」这三件事一起出现——它们本来就是一件事的三个侧面。",[25,78,80],{"id":79},"二编号只有一份真源","二、编号只有一份真源",[10,82,83],{},"改动的核心是把引用交还给画布原生体系。顺序很关键。",[10,85,86],{},"第一步是确定「这个镜头会用哪几张参考图」：",[88,89,94],"pre",{"className":90,"code":91,"language":92,"meta":93,"style":93},"language-ts shiki shiki-themes github-light github-dark","\u002F**\n * 该镜会被「连线接入」的参考图资产，顺序即连线顺序、即 @图片N 的编号顺序。\n *\n * **这是编号的唯一真源**：spawn 分镜节点时按它连线，拼提示词时按它编号。\n * 两处若各自计算，编号与实际连线就会错位——@图片2 指到另一张图上，\n * 而画面「看起来只是不太对」，极难发现。\n *\n * 只收已生成出图的资产：没有 materialId 的连不了线，占了编号会让后面全体错位。\n *\u002F\nexport function shotReferenceImages(shot, assets) {\n  return shotReferencedAssets(shot, assets).filter((asset) => asset.status === \"done\" && !!asset.materialId);\n}\n","ts","",[17,95,96,105,118,124,130,142,148,153,159,165,195,241],{"__ignoreMap":93},[97,98,101],"span",{"class":99,"line":100},"line",1,[97,102,104],{"class":103},"sJ8bj","\u002F**\n",[97,106,108,111,115],{"class":99,"line":107},2,[97,109,110],{"class":103}," * 该镜会被「连线接入」的参考图资产，顺序即连线顺序、即 ",[97,112,114],{"class":113},"szBVR","@图片N",[97,116,117],{"class":103}," 的编号顺序。\n",[97,119,121],{"class":99,"line":120},3,[97,122,123],{"class":103}," *\n",[97,125,127],{"class":99,"line":126},4,[97,128,129],{"class":103}," * **这是编号的唯一真源**：spawn 分镜节点时按它连线，拼提示词时按它编号。\n",[97,131,133,136,139],{"class":99,"line":132},5,[97,134,135],{"class":103}," * 两处若各自计算，编号与实际连线就会错位——",[97,137,138],{"class":113},"@图片2",[97,140,141],{"class":103}," 指到另一张图上，\n",[97,143,145],{"class":99,"line":144},6,[97,146,147],{"class":103}," * 而画面「看起来只是不太对」，极难发现。\n",[97,149,151],{"class":99,"line":150},7,[97,152,123],{"class":103},[97,154,156],{"class":99,"line":155},8,[97,157,158],{"class":103}," * 只收已生成出图的资产：没有 materialId 的连不了线，占了编号会让后面全体错位。\n",[97,160,162],{"class":99,"line":161},9,[97,163,164],{"class":103}," *\u002F\n",[97,166,168,171,174,178,182,186,189,192],{"class":99,"line":167},10,[97,169,170],{"class":113},"export",[97,172,173],{"class":113}," function",[97,175,177],{"class":176},"sScJk"," shotReferenceImages",[97,179,181],{"class":180},"sVt8B","(",[97,183,185],{"class":184},"s4XuR","shot",[97,187,188],{"class":180},", ",[97,190,191],{"class":184},"assets",[97,193,194],{"class":180},") {\n",[97,196,198,201,204,207,210,213,216,219,222,225,228,232,235,238],{"class":99,"line":197},11,[97,199,200],{"class":113},"  return",[97,202,203],{"class":176}," shotReferencedAssets",[97,205,206],{"class":180},"(shot, assets).",[97,208,209],{"class":176},"filter",[97,211,212],{"class":180},"((",[97,214,215],{"class":184},"asset",[97,217,218],{"class":180},") ",[97,220,221],{"class":113},"=>",[97,223,224],{"class":180}," asset.status ",[97,226,227],{"class":113},"===",[97,229,231],{"class":230},"sZZnC"," \"done\"",[97,233,234],{"class":113}," &&",[97,236,237],{"class":113}," !!",[97,239,240],{"class":180},"asset.materialId);\n",[97,242,244],{"class":99,"line":243},12,[97,245,246],{"class":180},"}\n",[10,248,249,250,252],{},"最后一句是这个函数里最容易忽略的约束。生成中的资产还连不了线，如果它先占了一个编号，后面所有素材的编号会整体前移一位，提示词里的 ",[17,251,138],{}," 就指到了另一张图上。",[10,254,255],{},"第二步是连线。spawn 分镜图节点时，参考图靠连线接入，顺序与编号一致：",[88,257,259],{"className":90,"code":258,"language":92,"meta":93,"style":93},"\u002F\u002F 参考图靠**连线**接入（画布原生机制），顺序必须与提示词里的 @图片N 一致：\n\u002F\u002F 两边都以 shotReferenceImages 为真源，先连的就是 @图片1。\n\u002F\u002F 用 refMaterialIds 参数传参会绕开 @ 引用体系，chip 不渲染、编号也对不上（线上踩过）。\n",[17,260,261,266,271],{"__ignoreMap":93},[97,262,263],{"class":99,"line":100},[97,264,265],{"class":103},"\u002F\u002F 参考图靠**连线**接入（画布原生机制），顺序必须与提示词里的 @图片N 一致：\n",[97,267,268],{"class":99,"line":107},[97,269,270],{"class":103},"\u002F\u002F 两边都以 shotReferenceImages 为真源，先连的就是 @图片1。\n",[97,272,273],{"class":99,"line":120},[97,274,275],{"class":103},"\u002F\u002F 用 refMaterialIds 参数传参会绕开 @ 引用体系，chip 不渲染、编号也对不上（线上踩过）。\n",[10,277,278,279,282,283,286],{},"第三步是资产落节点。资产出图之后自动落 ",[17,280,281],{},"asset.input"," 节点、编组、连到脚本节点上，按 ",[17,284,285],{},"scriptAssetId"," 查重保证幂等。",[10,288,289],{},"第四步是让模型别自己发明引用。智能合成的提示词里，正文只写资产的名字：",[88,291,296],{"className":292,"code":294,"language":295},[293],"language-text","资产图锚定：\n角色 林芽 的参考图是 @图片1\n","text",[17,297,294],{"__ignoreMap":93},[10,299,300,301,303,304,307],{},"生成提示词时明确禁止模型自造 ",[17,302,19],{}," 与 ",[17,305,306],{},"{{Image}}"," 记号——模型写出来的编号不受控制，写错一个，整段引用就错位。",[25,309,311],{"id":310},"三还有一个反向的坑","三、还有一个反向的坑",[10,313,314],{},"同一轮里发现一个反方向的问题：脚本节点连到下游时，它的文本产出会被当成提示词的一部分。",[10,316,317,318,322],{},"脚本节点的输出是",[319,320,321],"strong",{},"整张镜头表","。接进生图节点，等于把整篇镜头表灌进生图提示词。",[10,324,325],{},"所以后端的上游输入收集里显式排除了它：",[88,327,329],{"className":90,"code":328,"language":92,"meta":93,"style":93},"\u002F\u002F script.storyboard 的文本产出是整张镜头表，接进取参考图会把整篇灌进生图提示词\n",[17,330,331],{"__ignoreMap":93},[97,332,333],{"class":99,"line":100},[97,334,328],{"class":103},[10,336,337],{},"脚本节点连过来只做溯源，不作为文本输入。",[25,339,341],{"id":340},"四节点要有名字","四、节点要有名字",[10,343,344,345,347],{},"引用体系要能用，前提是用户能认出「",[17,346,41],{}," 到底是画布上哪一个」。",[10,349,350,351,353],{},"原先节点标题只有类型名。一排「图片」摆在那儿，",[17,352,19],{}," 菜单里也是一排「图片」。",[10,355,356],{},"改成按类型各自递增编号：",[88,358,360],{"className":90,"code":359,"language":92,"meta":93,"style":93},"\u002F**\n * 新节点的默认标题：「图片节点 1」「视频节点 2」这样按类型各自递增。\n *\n * 为什么要编号：@ 菜单里一排「图片」根本分不出是画布上哪一个，\n * 有了编号才认得出。编号一次性写进 params.nodeTitle 后就**不再变**——\n * 跟着当前节点数实时算的话，删掉中间一个会让后面的全部改名，\n * 用户提示词里已经写下的 @图片节点3 就指向了别的东西。\n *\n * 取「同类已用过的最大编号 + 1」而不是「同类个数 + 1」：后者在删掉中间节点后\n * 会复用旧编号，画布上于是出现两个「图片节点 2」。\n *\u002F\n",[17,361,362,366,371,375,380,385,390,401,405,410,415],{"__ignoreMap":93},[97,363,364],{"class":99,"line":100},[97,365,104],{"class":103},[97,367,368],{"class":99,"line":107},[97,369,370],{"class":103}," * 新节点的默认标题：「图片节点 1」「视频节点 2」这样按类型各自递增。\n",[97,372,373],{"class":99,"line":120},[97,374,123],{"class":103},[97,376,377],{"class":99,"line":126},[97,378,379],{"class":103}," * 为什么要编号：@ 菜单里一排「图片」根本分不出是画布上哪一个，\n",[97,381,382],{"class":99,"line":132},[97,383,384],{"class":103}," * 有了编号才认得出。编号一次性写进 params.nodeTitle 后就**不再变**——\n",[97,386,387],{"class":99,"line":144},[97,388,389],{"class":103}," * 跟着当前节点数实时算的话，删掉中间一个会让后面的全部改名，\n",[97,391,392,395,398],{"class":99,"line":150},[97,393,394],{"class":103}," * 用户提示词里已经写下的 ",[97,396,397],{"class":113},"@图片节点3",[97,399,400],{"class":103}," 就指向了别的东西。\n",[97,402,403],{"class":99,"line":155},[97,404,123],{"class":103},[97,406,407],{"class":99,"line":161},[97,408,409],{"class":103}," * 取「同类已用过的最大编号 + 1」而不是「同类个数 + 1」：后者在删掉中间节点后\n",[97,411,412],{"class":99,"line":167},[97,413,414],{"class":103}," * 会复用旧编号，画布上于是出现两个「图片节点 2」。\n",[97,416,417],{"class":99,"line":197},[97,418,164],{"class":103},[10,420,421],{},"两条规则都来自同一个要求：编号一旦被用户写进提示词，它就不能再变。实时计算看起来更「准确」，实际会让已经写下的引用指向别处。",[25,423,425,426,428],{"id":424},"五-菜单与富文本输入框","五、",[17,427,19],{}," 菜单与富文本输入框",[10,430,431],{},"引用记号定义好了，接下来是让用户能舒服地打出来。",[10,433,434],{},[319,435,436],{},"菜单要分组。",[88,438,441],{"className":439,"code":440,"language":295},[293],"已引用           （已经连线的上游）\n素材引用 · 图片\n素材引用 · 视频\n素材引用 · 音频\n素材引用 · 文本\n",[17,442,440],{"__ignoreMap":93},[10,444,445],{},"一长条平铺的列表里全是「图片」，认不出是画布上哪一个。分组之后，已经连上来的排在最前。",[10,447,448],{},"分组渲染有一个具体的坑：",[88,450,452],{"className":90,"code":451,"language":92,"meta":93,"style":93},"\u002F**\n * 菜单分组：已引用（已连线的上游）在前，其余按类型分节。\n *\n * 每项带上它在扁平列表中的下标——键盘上下键走的是扁平序，\n * 分组渲染时若各组自己从 0 数，高亮会跳到别的组去。\n *\u002F\n",[17,453,454,458,463,467,472,477],{"__ignoreMap":93},[97,455,456],{"class":99,"line":100},[97,457,104],{"class":103},[97,459,460],{"class":99,"line":107},[97,461,462],{"class":103}," * 菜单分组：已引用（已连线的上游）在前，其余按类型分节。\n",[97,464,465],{"class":99,"line":120},[97,466,123],{"class":103},[97,468,469],{"class":99,"line":126},[97,470,471],{"class":103}," * 每项带上它在扁平列表中的下标——键盘上下键走的是扁平序，\n",[97,473,474],{"class":99,"line":132},[97,475,476],{"class":103}," * 分组渲染时若各组自己从 0 数，高亮会跳到别的组去。\n",[97,478,479],{"class":99,"line":144},[97,480,164],{"class":103},[10,482,483],{},[319,484,485],{},"滚轮不能缩放画布。",[88,487,489],{"className":90,"code":488,"language":92,"meta":93,"style":93},"{\u002F* nowheel 不能少：xyflow 默认把滚轮当画布缩放，\n    没有它在菜单里滚一下就把整个画布缩放了，菜单本身纹丝不动 *\u002F}\n",[17,490,491,499],{"__ignoreMap":93},[97,492,493,496],{"class":99,"line":100},[97,494,495],{"class":180},"{",[97,497,498],{"class":103},"\u002F* nowheel 不能少：xyflow 默认把滚轮当画布缩放，\n",[97,500,501,504],{"class":99,"line":107},[97,502,503],{"class":103},"    没有它在菜单里滚一下就把整个画布缩放了，菜单本身纹丝不动 *\u002F",[97,505,246],{"class":180},[10,507,508],{},[319,509,510],{},"图标不用 emoji。",[88,512,514],{"className":90,"code":513,"language":92,"meta":93,"style":93},"\u002F** 线性图标，不用 emoji：emoji 在各系统字体下大小\u002F基线都不一样，一排下来参差不齐 *\u002F\n",[17,515,516],{"__ignoreMap":93},[97,517,518],{"class":99,"line":100},[97,519,513],{"class":103},[10,521,522],{},[319,523,524],{},"输入框要做成富文本。",[10,526,527,528,530],{},"原先 ",[17,529,41],{}," 就是七个字。用户看不出那是一个整体，删的时候要一个字一个字退。",[10,532,533],{},"改成 chip 之后，数据层仍然是纯文本：",[88,535,537],{"className":90,"code":536,"language":92,"meta":93,"style":93},"\u002F**\n * 画布提示词里的「模块」（chip）：素材引用与运镜。\n *\n * 提示词在数据层始终是**一段纯文本**（`一只猫@图片1（镜头左移）跑开`），\n * chip 只是这段文本在输入框里的渲染形态。这样存库\u002F发上游\u002FAI 助写回填全都不用改，\n * 而且用户手打 `@图片1` 也一样会显示成 chip。\n *\u002F\n",[17,538,539,543,548,552,557,562,567],{"__ignoreMap":93},[97,540,541],{"class":99,"line":100},[97,542,104],{"class":103},[97,544,545],{"class":99,"line":107},[97,546,547],{"class":103}," * 画布提示词里的「模块」（chip）：素材引用与运镜。\n",[97,549,550],{"class":99,"line":120},[97,551,123],{"class":103},[97,553,554],{"class":99,"line":126},[97,555,556],{"class":103}," * 提示词在数据层始终是**一段纯文本**（`一只猫@图片1（镜头左移）跑开`），\n",[97,558,559],{"class":99,"line":132},[97,560,561],{"class":103}," * chip 只是这段文本在输入框里的渲染形态。这样存库\u002F发上游\u002FAI 助写回填全都不用改，\n",[97,563,564],{"class":99,"line":144},[97,565,566],{"class":103}," * 而且用户手打 `@图片1` 也一样会显示成 chip。\n",[97,568,569],{"class":99,"line":150},[97,570,164],{"class":103},[10,572,573],{},"这条设计决定了实现的自由度：存库、发上游、AI 助写回填三处都不用动，因为它们看到的还是一样的字符串。",[10,575,576],{},"但解析必须是无损的：",[88,578,580],{"className":90,"code":579,"language":92,"meta":93,"style":93},"\u002F**\n * 解析必须是**无损**的：text → 段落 → text 必须还原成原串，\n * 否则用户每敲一个字都可能被悄悄改写。parse\u002Fserialize 的往返有测试钉死。\n *\u002F\n",[17,581,582,586,591,596],{"__ignoreMap":93},[97,583,584],{"class":99,"line":100},[97,585,104],{"class":103},[97,587,588],{"class":99,"line":107},[97,589,590],{"class":103}," * 解析必须是**无损**的：text → 段落 → text 必须还原成原串，\n",[97,592,593],{"class":99,"line":120},[97,594,595],{"class":103}," * 否则用户每敲一个字都可能被悄悄改写。parse\u002Fserialize 的往返有测试钉死。\n",[97,597,598],{"class":99,"line":126},[97,599,164],{"class":103},[10,601,602],{},[319,603,604],{},"chip 必须用原生 DOM 建。",[88,606,608],{"className":90,"code":607,"language":92,"meta":93,"style":93},"\u002F**\n * 为什么不是 textarea：textarea 只能存纯文本，`@图片1` 就是七个字，\n * 用户看不出那是一个整体，删的时候还得一个字一个字退。\n *\n * **chip 必须用原生 DOM 建**：\n * contenteditable 内部交给 React 渲染的话，每次 re-render 重建节点会打掉\n * 光标位置与输入法组字状态，中文根本没法连续输入。\n *\u002F\n",[17,609,610,614,619,624,628,633,638,643],{"__ignoreMap":93},[97,611,612],{"class":99,"line":100},[97,613,104],{"class":103},[97,615,616],{"class":99,"line":107},[97,617,618],{"class":103}," * 为什么不是 textarea：textarea 只能存纯文本，`@图片1` 就是七个字，\n",[97,620,621],{"class":99,"line":120},[97,622,623],{"class":103}," * 用户看不出那是一个整体，删的时候还得一个字一个字退。\n",[97,625,626],{"class":99,"line":126},[97,627,123],{"class":103},[97,629,630],{"class":99,"line":132},[97,631,632],{"class":103}," * **chip 必须用原生 DOM 建**：\n",[97,634,635],{"class":99,"line":144},[97,636,637],{"class":103}," * contenteditable 内部交给 React 渲染的话，每次 re-render 重建节点会打掉\n",[97,639,640],{"class":99,"line":150},[97,641,642],{"class":103}," * 光标位置与输入法组字状态，中文根本没法连续输入。\n",[97,644,645],{"class":99,"line":155},[97,646,164],{"class":103},[10,648,649],{},"最后一句是这个功能能不能用的分界线。中文输入有组字过程，React 重建 DOM 节点会打断它，用户打一个词会看到候选框消失。",[10,651,652],{},[319,653,654],{},"运镜也用同一种 chip。",[10,656,657,658,661],{},"运镜的记号是全角括号：",[17,659,660],{},"（镜头左移）","。",[88,663,665],{"className":90,"code":664,"language":92,"meta":93,"style":93},"\u002F**\n * 运镜在提示词里的写法：`（镜头左移）`。\n *\n * 全角括号是给模型看的分隔提示——正文写成「她回头（镜头左移）看向窗外」，\n * 镜头指令就落在它该发生的那一刻。用参数存整段后缀的话这个位置信息就没了。\n *\u002F\n",[17,666,667,671,676,680,685,690],{"__ignoreMap":93},[97,668,669],{"class":99,"line":100},[97,670,104],{"class":103},[97,672,673],{"class":99,"line":107},[97,674,675],{"class":103}," * 运镜在提示词里的写法：`（镜头左移）`。\n",[97,677,678],{"class":99,"line":120},[97,679,123],{"class":103},[97,681,682],{"class":99,"line":126},[97,683,684],{"class":103}," * 全角括号是给模型看的分隔提示——正文写成「她回头（镜头左移）看向窗外」，\n",[97,686,687],{"class":99,"line":132},[97,688,689],{"class":103}," * 镜头指令就落在它该发生的那一刻。用参数存整段后缀的话这个位置信息就没了。\n",[97,691,692],{"class":99,"line":144},[97,693,164],{"class":103},[10,695,696],{},"起初运镜是一个独立参数，拼提示词时把后缀接在末尾。但镜头运动发生在语句里的某个时刻——「她回头」之后、还是「看向窗外」之前，结果不一样。改成内联记号之后，位置由用户决定。",[25,698,700],{"id":699},"六两种记号的取舍","六、两种记号的取舍",[10,702,703],{},"引用体系最后收敛到两种记号：",[88,705,708],{"className":706,"code":707,"language":295},[293],"素材  @图片1        编号与连线顺序一一对应\n运镜  （镜头左移）   全角括号，落在它该发生的那一刻\n",[17,709,707],{"__ignoreMap":93},[10,711,712,713,716],{},"两者的共同点是",[319,714,715],{},"都可以被无损地解析回纯文本","，也都可以被用户手打出来。正则只认这两类：",[88,718,720],{"className":90,"code":719,"language":92,"meta":93,"style":93},"\u002F** `@图片1`：类型名 + 1~2 位编号。两位是为了万一素材超过 9 个。 *\u002F\nconst MATERIAL_REF = \u002F@(图片|视频|音频|文本)(\\d{1,2})\u002Fg;\n",[17,721,722,727],{"__ignoreMap":93},[97,723,724],{"class":99,"line":100},[97,725,726],{"class":103},"\u002F** `@图片1`：类型名 + 1~2 位编号。两位是为了万一素材超过 9 个。 *\u002F\n",[97,728,729,732,736,739,742,746,749,752,754,757,759,762,765,768,771,774,777],{"class":99,"line":107},[97,730,731],{"class":113},"const",[97,733,735],{"class":734},"sj4cs"," MATERIAL_REF",[97,737,738],{"class":113}," =",[97,740,741],{"class":230}," \u002F",[97,743,745],{"class":744},"sA_wV","@(图片",[97,747,748],{"class":113},"|",[97,750,751],{"class":744},"视频",[97,753,748],{"class":113},[97,755,756],{"class":744},"音频",[97,758,748],{"class":113},[97,760,761],{"class":744},"文本)(",[97,763,764],{"class":734},"\\d",[97,766,767],{"class":113},"{1,2}",[97,769,770],{"class":744},")",[97,772,773],{"class":230},"\u002F",[97,775,776],{"class":113},"g",[97,778,779],{"class":180},";\n",[10,781,782],{},"这一轮改动的实质，是把脚本节点从「自己发明一套引用方式」改回「用画布已有的引用方式」。前者看起来更自由——不用迁就画布的连线、编号、渲染规则。代价是它在画布上不可见：没有连线、没有 chip、没有缩略图，用户看不出数据是怎么流的。",[10,784,785,786,789],{},"可组合的系统里，",[319,787,788],{},"新功能要么复用既有的表达方式，要么就得把新方式补进所有既有环节","——渲染、解析、连线、菜单、缩略图。脚本节点选过前者，走了一段之后又回到了后者，中间那段的成本就是这篇文章。",[791,792,793],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}",{"title":93,"searchDepth":107,"depth":107,"links":795},[796,797,798,799,800,802],{"id":27,"depth":107,"text":28},{"id":79,"depth":107,"text":80},{"id":310,"depth":107,"text":311},{"id":340,"depth":107,"text":341},{"id":424,"depth":107,"text":801},"五、@ 菜单与富文本输入框",{"id":699,"depth":107,"text":700},"内容流水线","2026-08-30","md",null,{},true,"\u002F2026-08-30-canvas-ref",{"title":5,"description":12},"2026-08-30-canvas-ref-提示词里的图片引用从哪来","脚本节点批量出分镜图，资产却没成组、分镜节点没连线、@ 引用不生效。根因是自造了一套画布不认识的引用格式，整条链路脱离原生体系。",[814,815,816,817,818,819],"画布","引用体系","contenteditable","提示词","脚本节点","富文本","FCePXkKfWJEu7ut5v9W-crJUDCwiMVp6XIVPxFdayM8",[822,1399,2044],{"id":4,"title":5,"body":823,"column":803,"date":804,"description":12,"extension":805,"hero_image":806,"meta":1396,"navigation":808,"path":809,"seo":1397,"series_id":806,"severity":806,"stem":811,"summary":812,"tags":1398,"__hash__":820},{"type":7,"value":824,"toc":1388},[825,827,831,833,835,839,843,855,859,863,865,867,869,871,971,975,977,993,999,1001,1006,1012,1014,1016,1020,1022,1030,1032,1034,1038,1042,1044,1096,1098,1102,1104,1108,1113,1115,1117,1145,1149,1165,1169,1177,1181,1185,1187,1219,1221,1223,1243,1247,1283,1285,1289,1293,1321,1323,1325,1327,1332,1336,1380,1382,1386],[10,826,12],{},[10,828,15,829,20],{},[17,830,19],{},[10,832,23],{},[25,834,28],{"id":27},[10,836,31,837,35],{},[17,838,34],{},[10,840,38,841,42],{},[17,842,41],{},[44,844,845,849,853],{},[47,846,49,847,52],{},[17,848,41],{},[47,850,55,851,58],{},[17,852,41],{},[47,854,61],{},[10,856,857,66],{},[17,858,34],{},[10,860,69,861,73],{},[17,862,72],{},[10,864,76],{},[25,866,80],{"id":79},[10,868,83],{},[10,870,86],{},[88,872,873],{"className":90,"code":91,"language":92,"meta":93,"style":93},[17,874,875,879,887,891,895,903,907,911,915,919,937,967],{"__ignoreMap":93},[97,876,877],{"class":99,"line":100},[97,878,104],{"class":103},[97,880,881,883,885],{"class":99,"line":107},[97,882,110],{"class":103},[97,884,114],{"class":113},[97,886,117],{"class":103},[97,888,889],{"class":99,"line":120},[97,890,123],{"class":103},[97,892,893],{"class":99,"line":126},[97,894,129],{"class":103},[97,896,897,899,901],{"class":99,"line":132},[97,898,135],{"class":103},[97,900,138],{"class":113},[97,902,141],{"class":103},[97,904,905],{"class":99,"line":144},[97,906,147],{"class":103},[97,908,909],{"class":99,"line":150},[97,910,123],{"class":103},[97,912,913],{"class":99,"line":155},[97,914,158],{"class":103},[97,916,917],{"class":99,"line":161},[97,918,164],{"class":103},[97,920,921,923,925,927,929,931,933,935],{"class":99,"line":167},[97,922,170],{"class":113},[97,924,173],{"class":113},[97,926,177],{"class":176},[97,928,181],{"class":180},[97,930,185],{"class":184},[97,932,188],{"class":180},[97,934,191],{"class":184},[97,936,194],{"class":180},[97,938,939,941,943,945,947,949,951,953,955,957,959,961,963,965],{"class":99,"line":197},[97,940,200],{"class":113},[97,942,203],{"class":176},[97,944,206],{"class":180},[97,946,209],{"class":176},[97,948,212],{"class":180},[97,950,215],{"class":184},[97,952,218],{"class":180},[97,954,221],{"class":113},[97,956,224],{"class":180},[97,958,227],{"class":113},[97,960,231],{"class":230},[97,962,234],{"class":113},[97,964,237],{"class":113},[97,966,240],{"class":180},[97,968,969],{"class":99,"line":243},[97,970,246],{"class":180},[10,972,249,973,252],{},[17,974,138],{},[10,976,255],{},[88,978,979],{"className":90,"code":258,"language":92,"meta":93,"style":93},[17,980,981,985,989],{"__ignoreMap":93},[97,982,983],{"class":99,"line":100},[97,984,265],{"class":103},[97,986,987],{"class":99,"line":107},[97,988,270],{"class":103},[97,990,991],{"class":99,"line":120},[97,992,275],{"class":103},[10,994,278,995,282,997,286],{},[17,996,281],{},[17,998,285],{},[10,1000,289],{},[88,1002,1004],{"className":1003,"code":294,"language":295},[293],[17,1005,294],{"__ignoreMap":93},[10,1007,300,1008,303,1010,307],{},[17,1009,19],{},[17,1011,306],{},[25,1013,311],{"id":310},[10,1015,314],{},[10,1017,317,1018,322],{},[319,1019,321],{},[10,1021,325],{},[88,1023,1024],{"className":90,"code":328,"language":92,"meta":93,"style":93},[17,1025,1026],{"__ignoreMap":93},[97,1027,1028],{"class":99,"line":100},[97,1029,328],{"class":103},[10,1031,337],{},[25,1033,341],{"id":340},[10,1035,344,1036,347],{},[17,1037,41],{},[10,1039,350,1040,353],{},[17,1041,19],{},[10,1043,356],{},[88,1045,1046],{"className":90,"code":359,"language":92,"meta":93,"style":93},[17,1047,1048,1052,1056,1060,1064,1068,1072,1080,1084,1088,1092],{"__ignoreMap":93},[97,1049,1050],{"class":99,"line":100},[97,1051,104],{"class":103},[97,1053,1054],{"class":99,"line":107},[97,1055,370],{"class":103},[97,1057,1058],{"class":99,"line":120},[97,1059,123],{"class":103},[97,1061,1062],{"class":99,"line":126},[97,1063,379],{"class":103},[97,1065,1066],{"class":99,"line":132},[97,1067,384],{"class":103},[97,1069,1070],{"class":99,"line":144},[97,1071,389],{"class":103},[97,1073,1074,1076,1078],{"class":99,"line":150},[97,1075,394],{"class":103},[97,1077,397],{"class":113},[97,1079,400],{"class":103},[97,1081,1082],{"class":99,"line":155},[97,1083,123],{"class":103},[97,1085,1086],{"class":99,"line":161},[97,1087,409],{"class":103},[97,1089,1090],{"class":99,"line":167},[97,1091,414],{"class":103},[97,1093,1094],{"class":99,"line":197},[97,1095,164],{"class":103},[10,1097,421],{},[25,1099,425,1100,428],{"id":424},[17,1101,19],{},[10,1103,431],{},[10,1105,1106],{},[319,1107,436],{},[88,1109,1111],{"className":1110,"code":440,"language":295},[293],[17,1112,440],{"__ignoreMap":93},[10,1114,445],{},[10,1116,448],{},[88,1118,1119],{"className":90,"code":451,"language":92,"meta":93,"style":93},[17,1120,1121,1125,1129,1133,1137,1141],{"__ignoreMap":93},[97,1122,1123],{"class":99,"line":100},[97,1124,104],{"class":103},[97,1126,1127],{"class":99,"line":107},[97,1128,462],{"class":103},[97,1130,1131],{"class":99,"line":120},[97,1132,123],{"class":103},[97,1134,1135],{"class":99,"line":126},[97,1136,471],{"class":103},[97,1138,1139],{"class":99,"line":132},[97,1140,476],{"class":103},[97,1142,1143],{"class":99,"line":144},[97,1144,164],{"class":103},[10,1146,1147],{},[319,1148,485],{},[88,1150,1151],{"className":90,"code":488,"language":92,"meta":93,"style":93},[17,1152,1153,1159],{"__ignoreMap":93},[97,1154,1155,1157],{"class":99,"line":100},[97,1156,495],{"class":180},[97,1158,498],{"class":103},[97,1160,1161,1163],{"class":99,"line":107},[97,1162,503],{"class":103},[97,1164,246],{"class":180},[10,1166,1167],{},[319,1168,510],{},[88,1170,1171],{"className":90,"code":513,"language":92,"meta":93,"style":93},[17,1172,1173],{"__ignoreMap":93},[97,1174,1175],{"class":99,"line":100},[97,1176,513],{"class":103},[10,1178,1179],{},[319,1180,524],{},[10,1182,527,1183,530],{},[17,1184,41],{},[10,1186,533],{},[88,1188,1189],{"className":90,"code":536,"language":92,"meta":93,"style":93},[17,1190,1191,1195,1199,1203,1207,1211,1215],{"__ignoreMap":93},[97,1192,1193],{"class":99,"line":100},[97,1194,104],{"class":103},[97,1196,1197],{"class":99,"line":107},[97,1198,547],{"class":103},[97,1200,1201],{"class":99,"line":120},[97,1202,123],{"class":103},[97,1204,1205],{"class":99,"line":126},[97,1206,556],{"class":103},[97,1208,1209],{"class":99,"line":132},[97,1210,561],{"class":103},[97,1212,1213],{"class":99,"line":144},[97,1214,566],{"class":103},[97,1216,1217],{"class":99,"line":150},[97,1218,164],{"class":103},[10,1220,573],{},[10,1222,576],{},[88,1224,1225],{"className":90,"code":579,"language":92,"meta":93,"style":93},[17,1226,1227,1231,1235,1239],{"__ignoreMap":93},[97,1228,1229],{"class":99,"line":100},[97,1230,104],{"class":103},[97,1232,1233],{"class":99,"line":107},[97,1234,590],{"class":103},[97,1236,1237],{"class":99,"line":120},[97,1238,595],{"class":103},[97,1240,1241],{"class":99,"line":126},[97,1242,164],{"class":103},[10,1244,1245],{},[319,1246,604],{},[88,1248,1249],{"className":90,"code":607,"language":92,"meta":93,"style":93},[17,1250,1251,1255,1259,1263,1267,1271,1275,1279],{"__ignoreMap":93},[97,1252,1253],{"class":99,"line":100},[97,1254,104],{"class":103},[97,1256,1257],{"class":99,"line":107},[97,1258,618],{"class":103},[97,1260,1261],{"class":99,"line":120},[97,1262,623],{"class":103},[97,1264,1265],{"class":99,"line":126},[97,1266,123],{"class":103},[97,1268,1269],{"class":99,"line":132},[97,1270,632],{"class":103},[97,1272,1273],{"class":99,"line":144},[97,1274,637],{"class":103},[97,1276,1277],{"class":99,"line":150},[97,1278,642],{"class":103},[97,1280,1281],{"class":99,"line":155},[97,1282,164],{"class":103},[10,1284,649],{},[10,1286,1287],{},[319,1288,654],{},[10,1290,657,1291,661],{},[17,1292,660],{},[88,1294,1295],{"className":90,"code":664,"language":92,"meta":93,"style":93},[17,1296,1297,1301,1305,1309,1313,1317],{"__ignoreMap":93},[97,1298,1299],{"class":99,"line":100},[97,1300,104],{"class":103},[97,1302,1303],{"class":99,"line":107},[97,1304,675],{"class":103},[97,1306,1307],{"class":99,"line":120},[97,1308,123],{"class":103},[97,1310,1311],{"class":99,"line":126},[97,1312,684],{"class":103},[97,1314,1315],{"class":99,"line":132},[97,1316,689],{"class":103},[97,1318,1319],{"class":99,"line":144},[97,1320,164],{"class":103},[10,1322,696],{},[25,1324,700],{"id":699},[10,1326,703],{},[88,1328,1330],{"className":1329,"code":707,"language":295},[293],[17,1331,707],{"__ignoreMap":93},[10,1333,712,1334,716],{},[319,1335,715],{},[88,1337,1338],{"className":90,"code":719,"language":92,"meta":93,"style":93},[17,1339,1340,1344],{"__ignoreMap":93},[97,1341,1342],{"class":99,"line":100},[97,1343,726],{"class":103},[97,1345,1346,1348,1350,1352,1354,1356,1358,1360,1362,1364,1366,1368,1370,1372,1374,1376,1378],{"class":99,"line":107},[97,1347,731],{"class":113},[97,1349,735],{"class":734},[97,1351,738],{"class":113},[97,1353,741],{"class":230},[97,1355,745],{"class":744},[97,1357,748],{"class":113},[97,1359,751],{"class":744},[97,1361,748],{"class":113},[97,1363,756],{"class":744},[97,1365,748],{"class":113},[97,1367,761],{"class":744},[97,1369,764],{"class":734},[97,1371,767],{"class":113},[97,1373,770],{"class":744},[97,1375,773],{"class":230},[97,1377,776],{"class":113},[97,1379,779],{"class":180},[10,1381,782],{},[10,1383,785,1384,789],{},[319,1385,788],{},[791,1387,793],{},{"title":93,"searchDepth":107,"depth":107,"links":1389},[1390,1391,1392,1393,1394,1395],{"id":27,"depth":107,"text":28},{"id":79,"depth":107,"text":80},{"id":310,"depth":107,"text":311},{"id":340,"depth":107,"text":341},{"id":424,"depth":107,"text":801},{"id":699,"depth":107,"text":700},{},{"title":5,"description":12},[814,815,816,817,818,819],{"id":1400,"title":1401,"body":1402,"column":803,"date":2031,"description":1406,"extension":805,"hero_image":806,"meta":2032,"navigation":808,"path":2033,"seo":2034,"series_id":806,"severity":806,"stem":2035,"summary":2036,"tags":2037,"__hash__":2043},"posts\u002F2026-08-19-三十集的项目点了十几轮提取.md","三十集的项目，点了十几轮提取",{"type":7,"value":1403,"toc":2022},[1404,1407,1410,1414,1417,1420,1423,1426,1430,1433,1506,1512,1515,1597,1600,1603,1607,1610,1613,1646,1649,1652,1749,1753,1756,1759,1774,1777,1780,1808,1811,1851,1854,1858,1861,1867,1870,1873,1876,1880,1886,1889,1916,1919,1926,1933,1985,1989,1992,1995,2006,2009,2012,2019],[10,1405,1406],{},"一个 30 集的项目，用户点了十几轮资产提取。",[10,1408,1409],{},"账单上是 198 次模型调用，共 145.77 元。其中约 95% 是重复的。",[25,1411,1413],{"id":1412},"一每轮都从第-1-集开始","一、每轮都从第 1 集开始",[10,1415,1416],{},"提取按 3 集一批，一个 30 集的项目是 10 批。",[10,1418,1419],{},"原先的实现是：不管上次跑到哪，每次提取都从第 1 集重跑到第 30 集。去重只在入库那一步做——已经有的资产不重复写库，但模型的调用照发，费用照扣。",[10,1421,1422],{},"于是用户为了修 2 批失败，把已经成功的那 8 批又花了一遍钱。而界面上的失败提示写的是「可再点一次」。",[10,1424,1425],{},"这句话本身没错。错的是再点一次的代价没有被说清，也没有被限制。",[25,1427,1429],{"id":1428},"二记账的粒度是批次","二、记账的粒度是「批次」",[10,1431,1432],{},"修法是记断点。粒度取批次，不是集也不是任务：",[88,1434,1436],{"className":90,"code":1435,"language":92,"meta":93,"style":93},"export interface ExtractBatchRecord {\n  readonly episodeNos: readonly number[];\n  readonly scriptHash: string;\n  readonly extractedAt: string; \u002F\u002F ISO 8601\n}\n",[17,1437,1438,1451,1471,1485,1502],{"__ignoreMap":93},[97,1439,1440,1442,1445,1448],{"class":99,"line":100},[97,1441,170],{"class":113},[97,1443,1444],{"class":113}," interface",[97,1446,1447],{"class":176}," ExtractBatchRecord",[97,1449,1450],{"class":180}," {\n",[97,1452,1453,1456,1459,1462,1465,1468],{"class":99,"line":107},[97,1454,1455],{"class":113},"  readonly",[97,1457,1458],{"class":184}," episodeNos",[97,1460,1461],{"class":113},":",[97,1463,1464],{"class":113}," readonly",[97,1466,1467],{"class":734}," number",[97,1469,1470],{"class":180},"[];\n",[97,1472,1473,1475,1478,1480,1483],{"class":99,"line":120},[97,1474,1455],{"class":113},[97,1476,1477],{"class":184}," scriptHash",[97,1479,1461],{"class":113},[97,1481,1482],{"class":734}," string",[97,1484,779],{"class":180},[97,1486,1487,1489,1492,1494,1496,1499],{"class":99,"line":126},[97,1488,1455],{"class":113},[97,1490,1491],{"class":184}," extractedAt",[97,1493,1461],{"class":113},[97,1495,1482],{"class":734},[97,1497,1498],{"class":180},"; ",[97,1500,1501],{"class":103},"\u002F\u002F ISO 8601\n",[97,1503,1504],{"class":99,"line":132},[97,1505,246],{"class":180},[10,1507,1508,1509,661],{},"一个批次成功的条件是：跑完、解析成功、写进项目的 ",[17,1510,1511],{},"settings",[10,1513,1514],{},"判定键是「集号组合 + 正文哈希」：",[88,1516,1518],{"className":90,"code":1517,"language":92,"meta":93,"style":93},"export function buildBatchScriptHash(batchScriptText: string): string {\n  return createHash(\"sha256\").update(batchScriptText).digest(\"hex\").slice(0, 16);\n}\n",[17,1519,1520,1546,1593],{"__ignoreMap":93},[97,1521,1522,1524,1526,1529,1531,1534,1536,1538,1540,1542,1544],{"class":99,"line":100},[97,1523,170],{"class":113},[97,1525,173],{"class":113},[97,1527,1528],{"class":176}," buildBatchScriptHash",[97,1530,181],{"class":180},[97,1532,1533],{"class":184},"batchScriptText",[97,1535,1461],{"class":113},[97,1537,1482],{"class":734},[97,1539,770],{"class":180},[97,1541,1461],{"class":113},[97,1543,1482],{"class":734},[97,1545,1450],{"class":180},[97,1547,1548,1550,1553,1555,1558,1561,1564,1567,1570,1572,1575,1577,1580,1582,1585,1587,1590],{"class":99,"line":107},[97,1549,200],{"class":113},[97,1551,1552],{"class":176}," createHash",[97,1554,181],{"class":180},[97,1556,1557],{"class":230},"\"sha256\"",[97,1559,1560],{"class":180},").",[97,1562,1563],{"class":176},"update",[97,1565,1566],{"class":180},"(batchScriptText).",[97,1568,1569],{"class":176},"digest",[97,1571,181],{"class":180},[97,1573,1574],{"class":230},"\"hex\"",[97,1576,1560],{"class":180},[97,1578,1579],{"class":176},"slice",[97,1581,181],{"class":180},[97,1583,1584],{"class":734},"0",[97,1586,188],{"class":180},[97,1588,1589],{"class":734},"16",[97,1591,1592],{"class":180},");\n",[97,1594,1595],{"class":99,"line":120},[97,1596,246],{"class":180},[10,1598,1599],{},"带上哈希是为了处理剧本被改过的情况。集号组合相同但正文变了，哈希不同，这一批就要重跑。",[10,1601,1602],{},"解析失败的批次不记录，下次仍会重试。",[25,1604,1606],{"id":1605},"三一处必须只有一份实现","三、一处必须只有一份实现",[10,1608,1609],{},"这是这次改动里最容易埋雷的地方。",[10,1611,1612],{},"喂给模型的文本，和用来算哈希的文本，必须是同一份：",[88,1614,1616],{"className":90,"code":1615,"language":92,"meta":93,"style":93},"\u002F**\n * 这个函数是断点续传的地基，只能有这一份实现：\n * 喂给 LLM 的文本和算 hash 的文本必须逐字节相同，否则 hash 永远对不上，\n * 跳过判定永不命中——表现是「修了断点续传但还在全量重跑」，且测试用同一份\n * fixture 时两边照样一致、照样全绿，线上才发现。\n *\u002F\n",[17,1617,1618,1622,1627,1632,1637,1642],{"__ignoreMap":93},[97,1619,1620],{"class":99,"line":100},[97,1621,104],{"class":103},[97,1623,1624],{"class":99,"line":107},[97,1625,1626],{"class":103}," * 这个函数是断点续传的地基，只能有这一份实现：\n",[97,1628,1629],{"class":99,"line":120},[97,1630,1631],{"class":103}," * 喂给 LLM 的文本和算 hash 的文本必须逐字节相同，否则 hash 永远对不上，\n",[97,1633,1634],{"class":99,"line":126},[97,1635,1636],{"class":103}," * 跳过判定永不命中——表现是「修了断点续传但还在全量重跑」，且测试用同一份\n",[97,1638,1639],{"class":99,"line":132},[97,1640,1641],{"class":103}," * fixture 时两边照样一致、照样全绿，线上才发现。\n",[97,1643,1644],{"class":99,"line":144},[97,1645,164],{"class":103},[10,1647,1648],{},"最后半句是关键。如果两处各自拼文本，测试里两边都从同一个 fixture 拼，结果一定相同，用例全绿。只有线上真实的剧本内容才可能让两边出现差异——一个换行、一个前缀、一个空格。",[10,1650,1651],{},"所以文本构造被收口到一个函数：",[88,1653,1655],{"className":90,"code":1654,"language":92,"meta":93,"style":93},"export function buildBatchScriptText(episodes): string {\n  return episodes.map((ep) => `## 第 ${ep.episodeNo} 集\\n${ep.scriptText}`).join(\"\\n\\n\");\n}\n",[17,1656,1657,1679,1745],{"__ignoreMap":93},[97,1658,1659,1661,1663,1666,1668,1671,1673,1675,1677],{"class":99,"line":100},[97,1660,170],{"class":113},[97,1662,173],{"class":113},[97,1664,1665],{"class":176}," buildBatchScriptText",[97,1667,181],{"class":180},[97,1669,1670],{"class":184},"episodes",[97,1672,770],{"class":180},[97,1674,1461],{"class":113},[97,1676,1482],{"class":734},[97,1678,1450],{"class":180},[97,1680,1681,1683,1686,1689,1691,1694,1696,1698,1701,1703,1706,1709,1712,1715,1718,1720,1722,1725,1728,1730,1733,1735,1738,1741,1743],{"class":99,"line":107},[97,1682,200],{"class":113},[97,1684,1685],{"class":180}," episodes.",[97,1687,1688],{"class":176},"map",[97,1690,212],{"class":180},[97,1692,1693],{"class":184},"ep",[97,1695,218],{"class":180},[97,1697,221],{"class":113},[97,1699,1700],{"class":230}," `## 第 ${",[97,1702,1693],{"class":180},[97,1704,1705],{"class":230},".",[97,1707,1708],{"class":180},"episodeNo",[97,1710,1711],{"class":230},"} 集",[97,1713,1714],{"class":734},"\\n",[97,1716,1717],{"class":230},"${",[97,1719,1693],{"class":180},[97,1721,1705],{"class":230},[97,1723,1724],{"class":180},"scriptText",[97,1726,1727],{"class":230},"}`",[97,1729,1560],{"class":180},[97,1731,1732],{"class":176},"join",[97,1734,181],{"class":180},[97,1736,1737],{"class":230},"\"",[97,1739,1740],{"class":734},"\\n\\n",[97,1742,1737],{"class":230},[97,1744,1592],{"class":180},[97,1746,1747],{"class":99,"line":120},[97,1748,246],{"class":180},[25,1750,1752],{"id":1751},"四预估和实际必须走同一套规划","四、预估和实际必须走同一套规划",[10,1754,1755],{},"只告诉用户「这次会跑几批」不够，还要让他们知道要花多少钱。所以加了一个只读的预估端点。",[10,1757,1758],{},"这个端点不触发模型调用，但它必须和真正提取走同一套规划逻辑：",[88,1760,1762],{"className":90,"code":1761,"language":92,"meta":93,"style":93},"\u002F\u002F 收集正文、读断点、算批次都在 service 里，和真正提取共用同一套——\n\u002F\u002F 路由自己抄一份的话，预估数字迟早和实际跑的批次对不上\n",[17,1763,1764,1769],{"__ignoreMap":93},[97,1765,1766],{"class":99,"line":100},[97,1767,1768],{"class":103},"\u002F\u002F 收集正文、读断点、算批次都在 service 里，和真正提取共用同一套——\n",[97,1770,1771],{"class":99,"line":107},[97,1772,1773],{"class":103},"\u002F\u002F 路由自己抄一份的话，预估数字迟早和实际跑的批次对不上\n",[10,1775,1776],{},"返回的字段是六个：总集数、待处理集数、总批数、待跑批数、跳过批数、预计点数。",[10,1778,1779],{},"这里有一个不起眼但要紧的口径：",[88,1781,1783],{"className":90,"code":1782,"language":92,"meta":93,"style":93},"\u002F**\n * 待处理的「集数」= 各待跑批次的集数之和。不能取最大集号：\n * 只剩最后一批（28\u002F29\u002F30 集）要跑时，最大集号是 30，界面会写成「本次将处理 30 集」，\n * 而实际只跑 3 集——这个数字正是用户判断该不该花钱的依据，不能骗人。\n *\u002F\n",[17,1784,1785,1789,1794,1799,1804],{"__ignoreMap":93},[97,1786,1787],{"class":99,"line":100},[97,1788,104],{"class":103},[97,1790,1791],{"class":99,"line":107},[97,1792,1793],{"class":103}," * 待处理的「集数」= 各待跑批次的集数之和。不能取最大集号：\n",[97,1795,1796],{"class":99,"line":120},[97,1797,1798],{"class":103}," * 只剩最后一批（28\u002F29\u002F30 集）要跑时，最大集号是 30，界面会写成「本次将处理 30 集」，\n",[97,1800,1801],{"class":99,"line":126},[97,1802,1803],{"class":103}," * 而实际只跑 3 集——这个数字正是用户判断该不该花钱的依据，不能骗人。\n",[97,1805,1806],{"class":99,"line":132},[97,1807,164],{"class":103},[10,1809,1810],{},"预估点数的单价来自实测：",[88,1812,1814],{"className":90,"code":1813,"language":92,"meta":93,"style":93},"\u002F**\n * 每批 LLM 调用的经验点数（生产实测 198 次均值 73.6 点）\n * 仅用于给用户看的预估，不用于实际计费\n *\u002F\nexport const ESTIMATED_POINTS_PER_BATCH = 75;\n",[17,1815,1816,1820,1825,1830,1834],{"__ignoreMap":93},[97,1817,1818],{"class":99,"line":100},[97,1819,104],{"class":103},[97,1821,1822],{"class":99,"line":107},[97,1823,1824],{"class":103}," * 每批 LLM 调用的经验点数（生产实测 198 次均值 73.6 点）\n",[97,1826,1827],{"class":99,"line":120},[97,1828,1829],{"class":103}," * 仅用于给用户看的预估，不用于实际计费\n",[97,1831,1832],{"class":99,"line":126},[97,1833,164],{"class":103},[97,1835,1836,1838,1841,1844,1846,1849],{"class":99,"line":132},[97,1837,170],{"class":113},[97,1839,1840],{"class":113}," const",[97,1842,1843],{"class":734}," ESTIMATED_POINTS_PER_BATCH",[97,1845,738],{"class":113},[97,1847,1848],{"class":734}," 75",[97,1850,779],{"class":180},[10,1852,1853],{},"那 198 次正好就是这次事故的 198 次。",[25,1855,1857],{"id":1856},"五确认框要说的三件事","五、确认框要说的三件事",[10,1859,1860],{},"前端在提取前弹一个确认框。它要说清三件事：跑多少、花多少、以及重提的代价。",[88,1862,1865],{"className":1863,"code":1864,"language":295},[293],"本次将处理 X 集（共 Y 集）的 P 批剧本（共 Q 批）。\n预计消耗 N 点算力（约 ¥M）。\n剧本改过想重新提取的话，全量重提会把这 Q 批重跑一遍，按首次提取的量重新扣费。\n",[17,1866,1864],{"__ignoreMap":93},[10,1868,1869],{},"第三句是这个框存在的理由。全量重提是一个合法操作——用户改了剧本，就该重跑。但它的代价要写明，而不是藏在按钮背后。",[10,1871,1872],{},"按钮文案跟着状态变：待跑批数为 0 时，按钮从「确认提取」变成「仍要全量重提」。",[10,1874,1875],{},"失败提示也改了。原先写「可再点一次」，现在说明只重跑没成功的批次。",[25,1877,1879],{"id":1878},"六settings-是整列读改写","六、settings 是整列读改写",[10,1881,1882,1883,1885],{},"断点记录存在项目的 ",[17,1884,1511],{}," 字段里。这个字段是 JSON，整列读改写，不支持部分更新。",[10,1887,1888],{},"所以写入必须拿锁：",[88,1890,1892],{"className":90,"code":1891,"language":92,"meta":93,"style":93},"await prisma.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${projectId}, 0)::bigint)`;\n",[17,1893,1894],{"__ignoreMap":93},[97,1895,1896,1899,1902,1905,1908,1911,1914],{"class":99,"line":100},[97,1897,1898],{"class":113},"await",[97,1900,1901],{"class":180}," prisma.",[97,1903,1904],{"class":176},"$executeRaw",[97,1906,1907],{"class":230},"`SELECT pg_advisory_xact_lock(hashtextextended(${",[97,1909,1910],{"class":180},"projectId",[97,1912,1913],{"class":230},"}, 0)::bigint)`",[97,1915,779],{"class":180},[10,1917,1918],{},"同一把锁已经用在剧本和角色的写入上。三个地方写同一个 JSON 字段，共用一把锁。",[10,1920,1921,1922,1925],{},"用 JSON 字段存断点，代价是没有独立的表、没有索引，查询要走 ",[17,1923,1924],{},"jsonb"," 函数。收益是零迁移——这张表的其他部分已经在用同一个字段存生产状态，再加一块比新开一张表更贴合现有结构。",[10,1927,1928,1929,1932],{},"预估端点的候选项目查询写成了一条 SQL，按 ",[17,1930,1931],{},"LIMIT 200"," 限制单轮扫描量：",[88,1934,1938],{"className":1935,"code":1936,"language":1937,"meta":93,"style":93},"language-sql shiki shiki-themes github-light github-dark","SELECT id FROM \"ComicWorkflowProject\"\nWHERE EXISTS (\n  SELECT 1 FROM jsonb_array_elements(\n    COALESCE((settings->'scriptGeneration'->'tasks'), '[]'::jsonb)\n  ) AS task\n  WHERE task->>'status' IN ('queued', 'running')\n    AND task->>'updatedAt' \u003C ${beforeIso}\n)\nLIMIT 200\n","sql",[17,1939,1940,1945,1950,1955,1960,1965,1970,1975,1980],{"__ignoreMap":93},[97,1941,1942],{"class":99,"line":100},[97,1943,1944],{},"SELECT id FROM \"ComicWorkflowProject\"\n",[97,1946,1947],{"class":99,"line":107},[97,1948,1949],{},"WHERE EXISTS (\n",[97,1951,1952],{"class":99,"line":120},[97,1953,1954],{},"  SELECT 1 FROM jsonb_array_elements(\n",[97,1956,1957],{"class":99,"line":126},[97,1958,1959],{},"    COALESCE((settings->'scriptGeneration'->'tasks'), '[]'::jsonb)\n",[97,1961,1962],{"class":99,"line":132},[97,1963,1964],{},"  ) AS task\n",[97,1966,1967],{"class":99,"line":144},[97,1968,1969],{},"  WHERE task->>'status' IN ('queued', 'running')\n",[97,1971,1972],{"class":99,"line":150},[97,1973,1974],{},"    AND task->>'updatedAt' \u003C ${beforeIso}\n",[97,1976,1977],{"class":99,"line":155},[97,1978,1979],{},")\n",[97,1981,1982],{"class":99,"line":161},[97,1983,1984],{},"LIMIT 200\n",[25,1986,1988],{"id":1987},"七可恢复的前提是知道做到哪了","七、可恢复的前提是「知道做到哪了」",[10,1990,1991],{},"这类长流程任务的设计，难点从来不在「怎么继续」，而在「怎么知道该从哪继续」。",[10,1993,1994],{},"记进度的位置有三档可选：",[44,1996,1997,2000,2003],{},[47,1998,1999],{},"记任务：一个项目可能有几十个任务，任务之间的依赖关系要重建。",[47,2001,2002],{},"记集：粒度太细，集与集之间本来就要合并成批送模型。",[47,2004,2005],{},"记批次：粒度与实际的模型调用对齐。",[10,2007,2008],{},"选了批次。理由是它正好是一个「要么成功、要么没有发生过」的单位——批次里的 3 集要么一起被模型处理了，要么下次重新来。没有中间态。",[10,2010,2011],{},"哈希那一条也来自同一个思路：断点的有效性取决于输入有没有变。只记「第 1~3 集做过了」不够，还要记「当时那 3 集的正文是什么样」。哈希是这句话最省的表达。",[10,2013,2014,2015,2018],{},"这两条合起来，断点续传才成立：",[319,2016,2017],{},"进度记录必须和它依赖的输入绑定","。只记进度不记输入，改了内容之后要么错误跳过，要么完全失效——两种结果都不会报错。",[791,2020,2021],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":93,"searchDepth":107,"depth":107,"links":2023},[2024,2025,2026,2027,2028,2029,2030],{"id":1412,"depth":107,"text":1413},{"id":1428,"depth":107,"text":1429},{"id":1605,"depth":107,"text":1606},{"id":1751,"depth":107,"text":1752},{"id":1856,"depth":107,"text":1857},{"id":1878,"depth":107,"text":1879},{"id":1987,"depth":107,"text":1988},"2026-08-19",{},"\u002F2026-08-19",{"title":1401,"description":1406},"2026-08-19-三十集的项目点了十几轮提取","一个 30 集项目点了十几轮资产提取，198 次模型调用烧掉 145.77 元，其中 95% 是重复的。断点续传的实现方式与费用确认浮窗的设计取舍。",[2038,803,2039,2040,2041,2042],"断点续传","幂等","费用确认","sha256","漫剧","ba1tqdoZIfz3BDTQBT1JpEuUjCsRv2BQ1xH9gebPvkw",{"id":2045,"title":2046,"body":2047,"column":803,"date":2729,"description":2051,"extension":805,"hero_image":806,"meta":2730,"navigation":808,"path":2731,"seo":2732,"series_id":806,"severity":806,"stem":2733,"summary":2734,"tags":2735,"__hash__":2741},"posts\u002F2026-07-08-视频包装与数字人配音.md","配音时长不可控——那就让画面跟着它走",{"type":7,"value":2048,"toc":2720},[2049,2052,2056,2062,2065,2068,2103,2106,2120,2123,2126,2133,2297,2310,2313,2324,2327,2330,2337,2347,2390,2397,2400,2411,2417,2426,2429,2440,2443,2446,2452,2487,2493,2525,2531,2548,2551,2557,2594,2600,2619,2624,2653,2656,2659,2662,2665,2679,2682,2685,2688,2707,2710,2717],[10,2050,2051],{},"数字人口播的完整链路已能生成对口型视频原片，但从音频出发反向约束画面节奏、再分层合成包装成可发布的成片，这一环涉及三个工程难点，都不是生成问题，而是一致性与路径确定性问题。",[25,2053,2055],{"id":2054},"tts-时长驱动的反向工作流","TTS 时长驱动的反向工作流",[10,2057,2058,2059,661],{},"MiMo TTS 客户端同步调用返回 base64 音频与实际音长（单位秒），这个时长是后续所有操作的源头。关键约束在于",[319,2060,2061],{},"时长由上游决定，不能人为指定",[10,2063,2064],{},"拿一个具体例子：用户输入 60 字的口播文案，送给 MiMo preset 模式（冰糖音色、wav 格式）。MiMo 返回的不是\"这段话应该播 30 秒\"，而是\"我合成出来的音频实际是 28.5 秒\"。TTS 生成的时长由语速、音色、模型版本等因素共同决定，变量太多了。",[10,2066,2067],{},"在这个约束下，整个包装流程必须反向适配：",[2069,2070,2071,2081,2094],"ol",{},[47,2072,2073,2076,2077,2080],{},[319,2074,2075],{},"TTS 合成音频"," → 得到 ",[17,2078,2079],{},"audioDurationSec","（来自 ffprobe 或 MiMo 返回）",[47,2082,2083,2086,2087,2089,2090,2093],{},[319,2084,2085],{},"飞天对口型"," → 输入 ",[17,2088,2079],{},"，输出\"这个长度的人物口播视频\"（飞天返回 ",[17,2091,2092],{},"duration","）",[47,2095,2096,2099,2100,2102],{},[319,2097,2098],{},"HyperFrames 包装"," → 模板中的时间线也以这个 ",[17,2101,2092],{}," 为基准构建字幕、转场、片头片尾的时间点",[10,2104,2105],{},"如果反过来做——先定好画面框架是 60 秒，再想办法让音频往里塞——就陷入了\"剪音频、变速播放、或者音视不同步\"的泥潭。",[10,2107,2108,2109,2112,2113,2116,2117,2119],{},"实现上，MiMo 音频落地后先上传我方 S3，用 ffprobe 测长作为权威值（",[17,2110,2111],{},"probeVideoDurationSec"," 对音频也适用），这个测值再被传给飞天 ",[17,2114,2115],{},"create_by_audio"," 接口。飞天不是按输入时长生成固定时长的视频，而是真正根据音轨长度对口型——返回的 ",[17,2118,2092],{}," 几乎就等于输入的音频时长（考虑 AAC 编码器的 priming delay，实测偏差 0.64 帧即 21.33ms，可忽略）。",[25,2121,2122],{"id":2122},"数字人作为独立图层而非重新生成",[10,2124,2125],{},"一个常见的工程误区：既然最终成片是\"数字人 + 包装\"，能不能让 HyperFrames 直接去生成数字人？不能。飞天数字人是外部供应商接口，响应慢（异步任务，等待 2-5 分钟常态），接口也不开放生成能力给外部工具（只提供对口型 API）。再者，用户可能想用同一份配音试多个数字人形象、或试多个包装模板，重新生成整条视频的成本太高。",[10,2127,2128,2129,2132],{},"设计决策是把飞天原片（已完成对口型的 MP4 文件）当作 HyperFrames composition 里的一个 ",[17,2130,2131],{},"\u003Cvideo>"," 图层。模板里声明这样的结构：",[88,2134,2138],{"className":2135,"code":2136,"language":2137,"meta":93,"style":93},"language-html shiki shiki-themes github-light github-dark","\u003Cdiv data-track-index=\"0\">\n  \u003Cvideo data-start=\"0s\" data-duration=\"$MAIN_VIDEO_DURATION\" \n          data-has-audio=\"true\" \n          src=\"$MAIN_VIDEO_URL\">\n  \u003C\u002Fvideo>\n\u003C\u002Fdiv>\n\n\u003Cdiv data-track-index=\"1\">\n  \u003C!-- 字幕、花字、角标等包装层 -->\n\u003C\u002Fdiv>\n\n\u003Cdiv data-track-index=\"2\">\n  \u003C!-- 片头片尾、转场 -->\n\u003C\u002Fdiv>\n","html",[17,2139,2140,2161,2188,2200,2212,2221,2230,2235,2250,2255,2263,2267,2282,2288],{"__ignoreMap":93},[97,2141,2142,2145,2149,2152,2155,2158],{"class":99,"line":100},[97,2143,2144],{"class":180},"\u003C",[97,2146,2148],{"class":2147},"s9eBZ","div",[97,2150,2151],{"class":176}," data-track-index",[97,2153,2154],{"class":180},"=",[97,2156,2157],{"class":230},"\"0\"",[97,2159,2160],{"class":180},">\n",[97,2162,2163,2166,2169,2172,2174,2177,2180,2182,2185],{"class":99,"line":107},[97,2164,2165],{"class":180},"  \u003C",[97,2167,2168],{"class":2147},"video",[97,2170,2171],{"class":176}," data-start",[97,2173,2154],{"class":180},[97,2175,2176],{"class":230},"\"0s\"",[97,2178,2179],{"class":176}," data-duration",[97,2181,2154],{"class":180},[97,2183,2184],{"class":230},"\"$MAIN_VIDEO_DURATION\"",[97,2186,2187],{"class":180}," \n",[97,2189,2190,2193,2195,2198],{"class":99,"line":120},[97,2191,2192],{"class":176},"          data-has-audio",[97,2194,2154],{"class":180},[97,2196,2197],{"class":230},"\"true\"",[97,2199,2187],{"class":180},[97,2201,2202,2205,2207,2210],{"class":99,"line":126},[97,2203,2204],{"class":176},"          src",[97,2206,2154],{"class":180},[97,2208,2209],{"class":230},"\"$MAIN_VIDEO_URL\"",[97,2211,2160],{"class":180},[97,2213,2214,2217,2219],{"class":99,"line":132},[97,2215,2216],{"class":180},"  \u003C\u002F",[97,2218,2168],{"class":2147},[97,2220,2160],{"class":180},[97,2222,2223,2226,2228],{"class":99,"line":144},[97,2224,2225],{"class":180},"\u003C\u002F",[97,2227,2148],{"class":2147},[97,2229,2160],{"class":180},[97,2231,2232],{"class":99,"line":150},[97,2233,2234],{"emptyLinePlaceholder":808},"\n",[97,2236,2237,2239,2241,2243,2245,2248],{"class":99,"line":155},[97,2238,2144],{"class":180},[97,2240,2148],{"class":2147},[97,2242,2151],{"class":176},[97,2244,2154],{"class":180},[97,2246,2247],{"class":230},"\"1\"",[97,2249,2160],{"class":180},[97,2251,2252],{"class":99,"line":161},[97,2253,2254],{"class":103},"  \u003C!-- 字幕、花字、角标等包装层 -->\n",[97,2256,2257,2259,2261],{"class":99,"line":167},[97,2258,2225],{"class":180},[97,2260,2148],{"class":2147},[97,2262,2160],{"class":180},[97,2264,2265],{"class":99,"line":197},[97,2266,2234],{"emptyLinePlaceholder":808},[97,2268,2269,2271,2273,2275,2277,2280],{"class":99,"line":243},[97,2270,2144],{"class":180},[97,2272,2148],{"class":2147},[97,2274,2151],{"class":176},[97,2276,2154],{"class":180},[97,2278,2279],{"class":230},"\"2\"",[97,2281,2160],{"class":180},[97,2283,2285],{"class":99,"line":2284},13,[97,2286,2287],{"class":103},"  \u003C!-- 片头片尾、转场 -->\n",[97,2289,2291,2293,2295],{"class":99,"line":2290},14,[97,2292,2225],{"class":180},[97,2294,2148],{"class":2147},[97,2296,2160],{"class":180},[10,2298,2299,2302,2303,2305,2306,2309],{},[17,2300,2301],{},"data-has-audio=\"true\""," 这一行是命门——没有它，渲染器会给 ",[17,2304,2131],{}," 默认加 ",[17,2307,2308],{},"muted","，成片就没声音。",[10,2311,2312],{},"这样做的好处显而易见：",[44,2314,2315,2318,2321],{},[47,2316,2317],{},"数字人原片一旦生成就不动，支持单独重做配音（只需重新调用 TTS 和飞天，不影响包装渲染）",[47,2319,2320],{},"同一个配音可套多个模板，不需要重新对口型",[47,2322,2323],{},"包装层的 HTML\u002FCSS 改动不会触发数字人重生成，迭代快",[10,2325,2326],{},"缺点是需要镜像内内置 Chromium 和 FFmpeg（官方渲染镜像实测 3.72GB），与 API 镜像分离部署。但这换来的是确定性输出和可控的环境——生产机、开发机、CI 跑同一个 composition，出片应该帧级一致（除了字体、系统库这类版本差异导致的微调，都锁版本了）。",[25,2328,2329],{"id":2329},"成片路径的幂等性与状态机",[10,2331,2332,2333,2336],{},"用户选定模板、确认方案后，系统提交一个 ",[17,2334,2335],{},"VideoRenderJob","：输入模板 ID、原片 key、字幕 cues、音频 URL 等，输出成片 objectKey。同一份输入如果重试或重新提交，必须得到同样的输出（或者快速失败）。",[10,2338,2339,2340,2343,2344,42],{},"状态流转是 ",[17,2341,2342],{},"queued → preparing → rendering → uploading → completed"," 或 ",[17,2345,2346],{},"failed",[44,2348,2349,2359,2365,2379,2385],{},[47,2350,2351,2354,2355,2358],{},[319,2352,2353],{},"queued","：任务入队，等待 worker 消费。这一步是防并发上限的信号量检查——飞天有并发限制（错误码 1001），Redis 信号量 ",[17,2356,2357],{},"yunclaude:dub:sky:sem"," 兜底（触顶不直接失败，改为入队等待）。",[47,2360,2361,2364],{},[319,2362,2363],{},"preparing","：下载原片、组装模板（注入数据、渲染占位符）。这一步的幂等性来自 objectKey 的内容寻址——同一个原片 key、同一个模板版本，组装出的 composition.html 比特级相同。",[47,2366,2367,2370,2371,2374,2375,2378],{},[319,2368,2369],{},"rendering","：Chromium 逐帧捕获、FFmpeg 合成。这是确定性的关键——环境锁定（Node 24、Chromium 版本、FFmpeg 版本、字体版本都在镜像里写死），同一个 composition 渲染多次输出帧级一致。M1 POC 中用 seek 点验证：",[17,2372,2373],{},"t=4.5s→第 135 帧","、",[17,2376,2377],{},"t=30.0s→第 900 帧","（读原片内置计数器，nb_frames=1800），长时间点零漂移。",[47,2380,2381,2384],{},[319,2382,2383],{},"uploading","：上传 OSS，记录 objectKey。返回给前端时用现签（每次读时重新签，不存短时 URL）。",[47,2386,2387,2389],{},[319,2388,2346],{},"：任何一步异常，立即 rollback。计费侧已扣的视频点全额退款（resource operationId 幂等）。",[10,2391,2392,2393,2396],{},"失败的兜底是 reaper（BullMQ 的死信队列处理），轮询 ",[17,2394,2395],{},"status=running"," 超期（>10 分钟）的任务，标记为失败并补退款。",[25,2398,2399],{"id":2399},"音画同步与字幕对齐",[10,2401,2402,2403,2406,2407,2410],{},"成片里字幕何时出现、何时消失，这些时间点由分析步生成的 ",[17,2404,2405],{},"subtitleCues"," 定义（格式 WebVTT）。一个 cue 的结构是 ",[17,2408,2409],{},"start → end"," + 文本，例如：",[88,2412,2415],{"className":2413,"code":2414,"language":295},[293],"00:05.000 --> 00:08.500\n这是一段口播文案\n",[17,2416,2414],{"__ignoreMap":93},[10,2418,2419,2420,2422,2423,2425],{},"HyperFrames 的字幕图层根据这些 cues 生成动画：start 时刻淡入，end 时刻淡出。整个成片的时间线参考都来自主视频（",[17,2421,2131],{}," 元素），而主视频的时长就是飞天返回的 ",[17,2424,2092],{},"——由音频长度决定。",[10,2427,2428],{},"一个细节：AAC-LC 编码器有 priming delay（约 1024 samples@48kHz = 21.33ms），所以成片里音频起点和视频起点存在一个已知的 21ms 偏移。但这是编码器层的常数，不是渲染问题，接受即可。",[10,2430,2431,2432,2435,2436,2439],{},"关键词高亮（比如把卖点词着色为黄色）需要在分析时做标记，例如 HTML 标签：",[17,2433,2434],{},"\u003Cspan class=\"highlight\">关键词\u003C\u002Fspan>","，然后 CSS 定义颜色。模板编写规约里明确禁止动画 ",[17,2437,2438],{},"letterSpacing"," 等布局属性（会在逐帧捕获时 snap 到整数像素产生抖动），只允许 transform（x\u002Fy\u002Fscale\u002Fopacity）。",[25,2441,2442],{"id":2442},"成片路径的端到端烟测",[10,2444,2445],{},"发版前的验收分六个阶段：",[10,2447,2448,2451],{},[319,2449,2450],{},"A. 发版前置","（缺一项线上就炸）",[44,2453,2454,2465,2471,2481,2484],{},[47,2455,2456,2457,2460,2461,2464],{},"后台已配渲染单价（resourceKey ",[17,2458,2459],{},"video_render_sec","），且 ",[17,2462,2463],{},"enabled"," 勾选",[47,2466,2467,2468,2093],{},"灰度名单已配置（环境变量 ",[17,2469,2470],{},"VIDEO_RENDER_HTML_USER_IDS",[47,2472,2473,2474,2477,2478,2093],{},"发版机 ",[17,2475,2476],{},"release.sh"," 已改（支持第 6 个镜像 ",[17,2479,2480],{},"yc-video-render",[47,2482,2483],{},"K8s 集群配额已提升（requestQuota 新增 2C\u002F2Gi、limitsQuota 新增 4C\u002F4Gi）",[47,2485,2486],{},"构建机磁盘充足（≥10GB 空闲）",[10,2488,2489,2492],{},[319,2490,2491],{},"B. 发版后基础设施","（不通过立即 rollout undo）",[44,2494,2495,2501,2508,2515,2522],{},[47,2496,2497,2498,2500],{},"迁移已执行（",[17,2499,2335],{}," 表已建）",[47,2502,2503,2504,2507],{},"渲染 worker 已起（",[17,2505,2506],{},"READY 1\u002F1","，镜像 tag 与本次发版一致）",[47,2509,2510,2511,2514],{},"容器内 hyperframes CLI 可用（",[17,2512,2513],{},"hyperframes --version"," 返回 0.7.70）",[47,2516,2517,2518,2521],{},"容器内中文字体已装（",[17,2519,2520],{},"fc-list :lang=zh"," 非空）",[47,2523,2524],{},"worker 连上 Redis 队列（日志无 crash loop）",[10,2526,2527,2530],{},[319,2528,2529],{},"C. 回归防线：未放量用户零感知","（最高优先级）",[44,2532,2533,2536,2539,2542],{},[47,2534,2535],{},"不在灰度名单的用户进增强步看不到\"包装模板\"卡",[47,2537,2538],{},"旧链路（ffmpeg）完整出片，字幕\u002F转场\u002FBGM 都在",[47,2540,2541],{},"旧链路仍生成 AI 插片，计费时间线出现 seedance 扣费",[47,2543,2544,2545,2547],{},"未放量时 ",[17,2546,2335],{}," 表没有新行",[10,2549,2550],{},"这段最重要是因为旧链路服务着所有现存数字人用户。一个新功能开关不应该波及灰度外的用户。",[10,2552,2553,2556],{},[319,2554,2555],{},"D. 灰度用户正向流程","（核心价值）",[44,2558,2559,2562,2569,2576,2579,2582,2585,2588,2591],{},[47,2560,2561],{},"模板列表可见：增强步看到\"不加包装\"+\"美食探店\"两张卡",[47,2563,2564,2565,2568],{},"选模板后出片最终 ",[17,2566,2567],{},"completed","，可播放",[47,2570,2571,2572,2575],{},"成片有口播声音（这是 ",[17,2573,2574],{},"data-has-audio"," 命门检验）",[47,2577,2578],{},"片头\u002F片尾\u002F角标都在：0-3s 品牌片头、右上角全程角标、最后 3s CTA",[47,2580,2581],{},"中文不乱码：片头标题与字幕汉字正常",[47,2583,2584],{},"字幕关键词高亮：关键词黄色，其余白色，标签没被打碎",[47,2586,2587],{},"进度实时可见：出片过程中进度条推进，不死在一个数字",[47,2589,2590],{},"成片链接可下载且长期有效：15 分钟后刷新页面仍能播放",[47,2592,2593],{},"渲染时长符合预期：60s 成片≤5 分钟（M1 POC 实测 39.5s）",[10,2595,2596,2599],{},[319,2597,2598],{},"E. 资金正确性","（看后台账单，不能只看页面）",[44,2601,2602,2607,2610,2613,2616],{},[47,2603,2604,2605,2093],{},"扣的是视频点不是算力点（计费时间线条目 title 首段是 ",[17,2606,2168],{},[47,2608,2609],{},"不再扣 seedance 插片钱（灰度用户的时间线里没有插片扣费）",[47,2611,2612],{},"结算按实际秒数（units ≈ 成片时长整秒向上取整）",[47,2614,2615],{},"余额不足时不产生任务、不扣费",[47,2617,2618],{},"重复提交不重复扣费（operationId 幂等）",[10,2620,2621],{},[319,2622,2623],{},"F. 异常路径",[44,2625,2626,2632,2638,2641,2644,2647],{},[47,2627,2628,2629,2631],{},"渲染失败会退款：任务置 ",[17,2630,2346],{},"，计费时间线出现退款条目",[47,2633,2634,2635,2637],{},"失败同步反映到增强任务：增强任务也变 ",[17,2636,2346],{},"，前端不会永远停在\"渲染中\"",[47,2639,2640],{},"排队中可取消并全额退：返回成功，退款到账",[47,2642,2643],{},"已在渲染中不可取消：返回 409，不退款（CPU 已烧）",[47,2645,2646],{},"worker 重启不丢任务：任务被重新捞起或置失败退款",[47,2648,2649,2650,2652],{},"超时判失败：>10 分钟置 ",[17,2651,2346],{}," 并退款",[10,2654,2655],{},"每一条都写了具体的检查命令和判据。比如验证成片有声音，就是直接播放、耳朵听；验证字幕关键词高亮，就是肉眼看颜色；验证退款，就是对比出片前后的计费时间线。",[25,2657,2658],{"id":2658},"设计的权衡",[10,2660,2661],{},"这套方案的成本是什么？",[10,2663,2664],{},"首先是镜像大小：官方渲染镜像装了 280 多个 apt 包、node_modules、Chromium 和 FFmpeg，构建出的镜像实测 3.72GB，单独占用一个 K8s node pool，构建时间约 10 分钟，推拉镜像耗时显著上升。但这是\"要么装进 API 镜像肥到 6GB+、拖累三个部署，要么单独镜像\"的取舍——选了单独。",[10,2666,2667,2668,2670,2671,2674,2675,2678],{},"其次是模板编写规约的学习成本。一个看起来\"普通的网页动画\"可能在逐帧 seek 渲染时炸：GSAP 退场动画必须挂在 clip 内层并补 hard kill，禁止 ",[17,2669,2438],{}," 等布局属性，中文字体必须显式 ",[17,2672,2673],{},"@font-face","（容器内用 Noto Sans CJK）。",[17,2676,2677],{},"hyperframes check"," 作为模板上架的强制 gate，能一次性拦截这类问题。",[10,2680,2681],{},"获得的是什么？確定性输出。同一个模板、同一份配音，渲染 100 次得到 100 个帧级一致的成片（前提是输入稳定，不变数字人形象、不变字体库版本）。这对 CI 自动回归、成片质量审核都很有意义。还有灵活性：配音、数字人、包装模板可以独立迭代，不互相阻塞。",[25,2683,2684],{"id":2684},"线上部署的最后两步",[10,2686,2687],{},"发版后的两个关键项，缺一项都会导致灰度用户无法下单：",[2069,2689,2690,2699],{},[47,2691,2692,42,2695,2698],{},[319,2693,2694],{},"后台配价",[17,2696,2697],{},"resourceKey=video_render_sec"," 的单价必须填（单位元\u002F秒），且勾选 enabled。没配的话 chargeResource 会返回 404，用户点开始出片就直接报错。",[47,2700,2701,42,2704,2706],{},[319,2702,2703],{},"灰度名单",[17,2705,2470],{}," 环境变量决定了谁能看到模板卡。留空 = 全员走旧 ffmpeg 链路（可用于紧急回滚），指定用户 ID = 该用户进入新链路。发版初期应只填 1-2 个测试账号。",[10,2708,2709],{},"这两个配置是代码之外的硬依赖，容易遗漏。烟测清单里放在最前面，作为\"不做后面全走不通\"的前置项。",[10,2711,2712,2713,2716],{},"整个方案的核心原则是",[319,2714,2715],{},"分层隔离与路径确定性","：TTS 决定时长、飞天决定人像、HyperFrames 决定包装，各层独立演进，成片路径从输入到输出一条流水线，无分支、无条件、无随机。这对一个产生可发布物料的流水线来说，是底线。",[791,2718,2719],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":93,"searchDepth":107,"depth":107,"links":2721},[2722,2723,2724,2725,2726,2727,2728],{"id":2054,"depth":107,"text":2055},{"id":2122,"depth":107,"text":2122},{"id":2329,"depth":107,"text":2329},{"id":2399,"depth":107,"text":2399},{"id":2442,"depth":107,"text":2442},{"id":2658,"depth":107,"text":2658},{"id":2684,"depth":107,"text":2684},"2026-07-08",{},"\u002F2026-07-08",{"title":2046,"description":2051},"2026-07-08-视频包装与数字人配音","数字人口播成片的最后一环：配音时长不可控，如何反向驱动画面？原片如何作为独立图层无损合成？成片路径如何保证确定性？",[2736,2737,2738,2739,2740],"TTS","数字人","HyperFrames","音视同步","流水线设计","ISg-n2hvjyCKUW_WMTk0Ais8ji7YhOK5rXPmNyUBWQk",1789212571384]