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