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