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