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