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