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