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