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