[{"data":1,"prerenderedAt":2091},["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,1440,1755],{"id":728,"title":729,"body":730,"column":710,"date":1428,"description":734,"extension":712,"hero_image":713,"meta":1429,"navigation":715,"path":1430,"seo":1431,"series_id":713,"severity":713,"stem":1432,"summary":1433,"tags":1434,"__hash__":1439},"posts\u002F2026-07-08-视频包装与数字人配音.md","配音时长不可控——那就让画面跟着它走",{"type":7,"value":731,"toc":1419},[732,735,739,745,748,751,785,788,802,805,808,815,995,1008,1011,1022,1025,1028,1035,1045,1088,1095,1098,1109,1115,1124,1127,1138,1141,1144,1150,1185,1191,1223,1229,1246,1249,1255,1292,1298,1317,1322,1351,1354,1357,1360,1363,1377,1380,1383,1386,1405,1408,1415],[10,733,734],{},"数字人口播的完整链路已能生成对口型视频原片，但从音频出发反向约束画面节奏、再分层合成包装成可发布的成片，这一环涉及三个工程难点，都不是生成问题，而是一致性与路径确定性问题。",[17,736,738],{"id":737},"tts-时长驱动的反向工作流","TTS 时长驱动的反向工作流",[10,740,741,742,27],{},"MiMo TTS 客户端同步调用返回 base64 音频与实际音长（单位秒），这个时长是后续所有操作的源头。关键约束在于",[24,743,744],{},"时长由上游决定，不能人为指定",[10,746,747],{},"拿一个具体例子：用户输入 60 字的口播文案，送给 MiMo preset 模式（冰糖音色、wav 格式）。MiMo 返回的不是\"这段话应该播 30 秒\"，而是\"我合成出来的音频实际是 28.5 秒\"。TTS 生成的时长由语速、音色、模型版本等因素共同决定，变量太多了。",[10,749,750],{},"在这个约束下，整个包装流程必须反向适配：",[655,752,753,763,776],{},[242,754,755,758,759,762],{},[24,756,757],{},"TTS 合成音频"," → 得到 ",[593,760,761],{},"audioDurationSec","（来自 ffprobe 或 MiMo 返回）",[242,764,765,768,769,771,772,775],{},[24,766,767],{},"飞天对口型"," → 输入 ",[593,770,761],{},"，输出\"这个长度的人物口播视频\"（飞天返回 ",[593,773,774],{},"duration","）",[242,777,778,781,782,784],{},[24,779,780],{},"HyperFrames 包装"," → 模板中的时间线也以这个 ",[593,783,774],{}," 为基准构建字幕、转场、片头片尾的时间点",[10,786,787],{},"如果反过来做——先定好画面框架是 60 秒，再想办法让音频往里塞——就陷入了\"剪音频、变速播放、或者音视不同步\"的泥潭。",[10,789,790,791,794,795,798,799,801],{},"实现上，MiMo 音频落地后先上传我方 S3，用 ffprobe 测长作为权威值（",[593,792,793],{},"probeVideoDurationSec"," 对音频也适用），这个测值再被传给飞天 ",[593,796,797],{},"create_by_audio"," 接口。飞天不是按输入时长生成固定时长的视频，而是真正根据音轨长度对口型——返回的 ",[593,800,774],{}," 几乎就等于输入的音频时长（考虑 AAC 编码器的 priming delay，实测偏差 0.64 帧即 21.33ms，可忽略）。",[17,803,804],{"id":804},"数字人作为独立图层而非重新生成",[10,806,807],{},"一个常见的工程误区：既然最终成片是\"数字人 + 包装\"，能不能让 HyperFrames 直接去生成数字人？不能。飞天数字人是外部供应商接口，响应慢（异步任务，等待 2-5 分钟常态），接口也不开放生成能力给外部工具（只提供对口型 API）。再者，用户可能想用同一份配音试多个数字人形象、或试多个包装模板，重新生成整条视频的成本太高。",[10,809,810,811,814],{},"设计决策是把飞天原片（已完成对口型的 MP4 文件）当作 HyperFrames composition 里的一个 ",[593,812,813],{},"\u003Cvideo>"," 图层。模板里声明这样的结构：",[586,816,820],{"className":817,"code":818,"language":819,"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,821,822,849,876,888,901,911,921,927,943,950,959,964,980,986],{"__ignoreMap":595},[823,824,827,831,835,839,842,846],"span",{"class":825,"line":826},"line",1,[823,828,830],{"class":829},"sVt8B","\u003C",[823,832,834],{"class":833},"s9eBZ","div",[823,836,838],{"class":837},"sScJk"," data-track-index",[823,840,841],{"class":829},"=",[823,843,845],{"class":844},"sZZnC","\"0\"",[823,847,848],{"class":829},">\n",[823,850,851,854,857,860,862,865,868,870,873],{"class":825,"line":695},[823,852,853],{"class":829},"  \u003C",[823,855,856],{"class":833},"video",[823,858,859],{"class":837}," data-start",[823,861,841],{"class":829},[823,863,864],{"class":844},"\"0s\"",[823,866,867],{"class":837}," data-duration",[823,869,841],{"class":829},[823,871,872],{"class":844},"\"$MAIN_VIDEO_DURATION\"",[823,874,875],{"class":829}," \n",[823,877,878,881,883,886],{"class":825,"line":703},[823,879,880],{"class":837},"          data-has-audio",[823,882,841],{"class":829},[823,884,885],{"class":844},"\"true\"",[823,887,875],{"class":829},[823,889,891,894,896,899],{"class":825,"line":890},4,[823,892,893],{"class":837},"          src",[823,895,841],{"class":829},[823,897,898],{"class":844},"\"$MAIN_VIDEO_URL\"",[823,900,848],{"class":829},[823,902,904,907,909],{"class":825,"line":903},5,[823,905,906],{"class":829},"  \u003C\u002F",[823,908,856],{"class":833},[823,910,848],{"class":829},[823,912,914,917,919],{"class":825,"line":913},6,[823,915,916],{"class":829},"\u003C\u002F",[823,918,834],{"class":833},[823,920,848],{"class":829},[823,922,924],{"class":825,"line":923},7,[823,925,926],{"emptyLinePlaceholder":715},"\n",[823,928,930,932,934,936,938,941],{"class":825,"line":929},8,[823,931,830],{"class":829},[823,933,834],{"class":833},[823,935,838],{"class":837},[823,937,841],{"class":829},[823,939,940],{"class":844},"\"1\"",[823,942,848],{"class":829},[823,944,946],{"class":825,"line":945},9,[823,947,949],{"class":948},"sJ8bj","  \u003C!-- 字幕、花字、角标等包装层 -->\n",[823,951,953,955,957],{"class":825,"line":952},10,[823,954,916],{"class":829},[823,956,834],{"class":833},[823,958,848],{"class":829},[823,960,962],{"class":825,"line":961},11,[823,963,926],{"emptyLinePlaceholder":715},[823,965,967,969,971,973,975,978],{"class":825,"line":966},12,[823,968,830],{"class":829},[823,970,834],{"class":833},[823,972,838],{"class":837},[823,974,841],{"class":829},[823,976,977],{"class":844},"\"2\"",[823,979,848],{"class":829},[823,981,983],{"class":825,"line":982},13,[823,984,985],{"class":948},"  \u003C!-- 片头片尾、转场 -->\n",[823,987,989,991,993],{"class":825,"line":988},14,[823,990,916],{"class":829},[823,992,834],{"class":833},[823,994,848],{"class":829},[10,996,997,1000,1001,1003,1004,1007],{},[593,998,999],{},"data-has-audio=\"true\""," 这一行是命门——没有它，渲染器会给 ",[593,1002,813],{}," 默认加 ",[593,1005,1006],{},"muted","，成片就没声音。",[10,1009,1010],{},"这样做的好处显而易见：",[239,1012,1013,1016,1019],{},[242,1014,1015],{},"数字人原片一旦生成就不动，支持单独重做配音（只需重新调用 TTS 和飞天，不影响包装渲染）",[242,1017,1018],{},"同一个配音可套多个模板，不需要重新对口型",[242,1020,1021],{},"包装层的 HTML\u002FCSS 改动不会触发数字人重生成，迭代快",[10,1023,1024],{},"缺点是需要镜像内内置 Chromium 和 FFmpeg（官方渲染镜像实测 3.72GB），与 API 镜像分离部署。但这换来的是确定性输出和可控的环境——生产机、开发机、CI 跑同一个 composition，出片应该帧级一致（除了字体、系统库这类版本差异导致的微调，都锁版本了）。",[17,1026,1027],{"id":1027},"成片路径的幂等性与状态机",[10,1029,1030,1031,1034],{},"用户选定模板、确认方案后，系统提交一个 ",[593,1032,1033],{},"VideoRenderJob","：输入模板 ID、原片 key、字幕 cues、音频 URL 等，输出成片 objectKey。同一份输入如果重试或重新提交，必须得到同样的输出（或者快速失败）。",[10,1036,1037,1038,1041,1042,237],{},"状态流转是 ",[593,1039,1040],{},"queued → preparing → rendering → uploading → completed"," 或 ",[593,1043,1044],{},"failed",[239,1046,1047,1057,1063,1077,1083],{},[242,1048,1049,1052,1053,1056],{},[24,1050,1051],{},"queued","：任务入队，等待 worker 消费。这一步是防并发上限的信号量检查——飞天有并发限制（错误码 1001），Redis 信号量 ",[593,1054,1055],{},"yunclaude:dub:sky:sem"," 兜底（触顶不直接失败，改为入队等待）。",[242,1058,1059,1062],{},[24,1060,1061],{},"preparing","：下载原片、组装模板（注入数据、渲染占位符）。这一步的幂等性来自 objectKey 的内容寻址——同一个原片 key、同一个模板版本，组装出的 composition.html 比特级相同。",[242,1064,1065,1068,1069,1072,1073,1076],{},[24,1066,1067],{},"rendering","：Chromium 逐帧捕获、FFmpeg 合成。这是确定性的关键——环境锁定（Node 24、Chromium 版本、FFmpeg 版本、字体版本都在镜像里写死），同一个 composition 渲染多次输出帧级一致。M1 POC 中用 seek 点验证：",[593,1070,1071],{},"t=4.5s→第 135 帧","、",[593,1074,1075],{},"t=30.0s→第 900 帧","（读原片内置计数器，nb_frames=1800），长时间点零漂移。",[242,1078,1079,1082],{},[24,1080,1081],{},"uploading","：上传 OSS，记录 objectKey。返回给前端时用现签（每次读时重新签，不存短时 URL）。",[242,1084,1085,1087],{},[24,1086,1044],{},"：任何一步异常，立即 rollback。计费侧已扣的视频点全额退款（resource operationId 幂等）。",[10,1089,1090,1091,1094],{},"失败的兜底是 reaper（BullMQ 的死信队列处理），轮询 ",[593,1092,1093],{},"status=running"," 超期（>10 分钟）的任务，标记为失败并补退款。",[17,1096,1097],{"id":1097},"音画同步与字幕对齐",[10,1099,1100,1101,1104,1105,1108],{},"成片里字幕何时出现、何时消失，这些时间点由分析步生成的 ",[593,1102,1103],{},"subtitleCues"," 定义（格式 WebVTT）。一个 cue 的结构是 ",[593,1106,1107],{},"start → end"," + 文本，例如：",[586,1110,1113],{"className":1111,"code":1112,"language":591},[589],"00:05.000 --> 00:08.500\n这是一段口播文案\n",[593,1114,1112],{"__ignoreMap":595},[10,1116,1117,1118,1120,1121,1123],{},"HyperFrames 的字幕图层根据这些 cues 生成动画：start 时刻淡入，end 时刻淡出。整个成片的时间线参考都来自主视频（",[593,1119,813],{}," 元素），而主视频的时长就是飞天返回的 ",[593,1122,774],{},"——由音频长度决定。",[10,1125,1126],{},"一个细节：AAC-LC 编码器有 priming delay（约 1024 samples@48kHz = 21.33ms），所以成片里音频起点和视频起点存在一个已知的 21ms 偏移。但这是编码器层的常数，不是渲染问题，接受即可。",[10,1128,1129,1130,1133,1134,1137],{},"关键词高亮（比如把卖点词着色为黄色）需要在分析时做标记，例如 HTML 标签：",[593,1131,1132],{},"\u003Cspan class=\"highlight\">关键词\u003C\u002Fspan>","，然后 CSS 定义颜色。模板编写规约里明确禁止动画 ",[593,1135,1136],{},"letterSpacing"," 等布局属性（会在逐帧捕获时 snap 到整数像素产生抖动），只允许 transform（x\u002Fy\u002Fscale\u002Fopacity）。",[17,1139,1140],{"id":1140},"成片路径的端到端烟测",[10,1142,1143],{},"发版前的验收分六个阶段：",[10,1145,1146,1149],{},[24,1147,1148],{},"A. 发版前置","（缺一项线上就炸）",[239,1151,1152,1163,1169,1179,1182],{},[242,1153,1154,1155,1158,1159,1162],{},"后台已配渲染单价（resourceKey ",[593,1156,1157],{},"video_render_sec","），且 ",[593,1160,1161],{},"enabled"," 勾选",[242,1164,1165,1166,775],{},"灰度名单已配置（环境变量 ",[593,1167,1168],{},"VIDEO_RENDER_HTML_USER_IDS",[242,1170,1171,1172,1175,1176,775],{},"发版机 ",[593,1173,1174],{},"release.sh"," 已改（支持第 6 个镜像 ",[593,1177,1178],{},"yc-video-render",[242,1180,1181],{},"K8s 集群配额已提升（requestQuota 新增 2C\u002F2Gi、limitsQuota 新增 4C\u002F4Gi）",[242,1183,1184],{},"构建机磁盘充足（≥10GB 空闲）",[10,1186,1187,1190],{},[24,1188,1189],{},"B. 发版后基础设施","（不通过立即 rollout undo）",[239,1192,1193,1199,1206,1213,1220],{},[242,1194,1195,1196,1198],{},"迁移已执行（",[593,1197,1033],{}," 表已建）",[242,1200,1201,1202,1205],{},"渲染 worker 已起（",[593,1203,1204],{},"READY 1\u002F1","，镜像 tag 与本次发版一致）",[242,1207,1208,1209,1212],{},"容器内 hyperframes CLI 可用（",[593,1210,1211],{},"hyperframes --version"," 返回 0.7.70）",[242,1214,1215,1216,1219],{},"容器内中文字体已装（",[593,1217,1218],{},"fc-list :lang=zh"," 非空）",[242,1221,1222],{},"worker 连上 Redis 队列（日志无 crash loop）",[10,1224,1225,1228],{},[24,1226,1227],{},"C. 回归防线：未放量用户零感知","（最高优先级）",[239,1230,1231,1234,1237,1240],{},[242,1232,1233],{},"不在灰度名单的用户进增强步看不到\"包装模板\"卡",[242,1235,1236],{},"旧链路（ffmpeg）完整出片，字幕\u002F转场\u002FBGM 都在",[242,1238,1239],{},"旧链路仍生成 AI 插片，计费时间线出现 seedance 扣费",[242,1241,1242,1243,1245],{},"未放量时 ",[593,1244,1033],{}," 表没有新行",[10,1247,1248],{},"这段最重要是因为旧链路服务着所有现存数字人用户。一个新功能开关不应该波及灰度外的用户。",[10,1250,1251,1254],{},[24,1252,1253],{},"D. 灰度用户正向流程","（核心价值）",[239,1256,1257,1260,1267,1274,1277,1280,1283,1286,1289],{},[242,1258,1259],{},"模板列表可见：增强步看到\"不加包装\"+\"美食探店\"两张卡",[242,1261,1262,1263,1266],{},"选模板后出片最终 ",[593,1264,1265],{},"completed","，可播放",[242,1268,1269,1270,1273],{},"成片有口播声音（这是 ",[593,1271,1272],{},"data-has-audio"," 命门检验）",[242,1275,1276],{},"片头\u002F片尾\u002F角标都在：0-3s 品牌片头、右上角全程角标、最后 3s CTA",[242,1278,1279],{},"中文不乱码：片头标题与字幕汉字正常",[242,1281,1282],{},"字幕关键词高亮：关键词黄色，其余白色，标签没被打碎",[242,1284,1285],{},"进度实时可见：出片过程中进度条推进，不死在一个数字",[242,1287,1288],{},"成片链接可下载且长期有效：15 分钟后刷新页面仍能播放",[242,1290,1291],{},"渲染时长符合预期：60s 成片≤5 分钟（M1 POC 实测 39.5s）",[10,1293,1294,1297],{},[24,1295,1296],{},"E. 资金正确性","（看后台账单，不能只看页面）",[239,1299,1300,1305,1308,1311,1314],{},[242,1301,1302,1303,775],{},"扣的是视频点不是算力点（计费时间线条目 title 首段是 ",[593,1304,856],{},[242,1306,1307],{},"不再扣 seedance 插片钱（灰度用户的时间线里没有插片扣费）",[242,1309,1310],{},"结算按实际秒数（units ≈ 成片时长整秒向上取整）",[242,1312,1313],{},"余额不足时不产生任务、不扣费",[242,1315,1316],{},"重复提交不重复扣费（operationId 幂等）",[10,1318,1319],{},[24,1320,1321],{},"F. 异常路径",[239,1323,1324,1330,1336,1339,1342,1345],{},[242,1325,1326,1327,1329],{},"渲染失败会退款：任务置 ",[593,1328,1044],{},"，计费时间线出现退款条目",[242,1331,1332,1333,1335],{},"失败同步反映到增强任务：增强任务也变 ",[593,1334,1044],{},"，前端不会永远停在\"渲染中\"",[242,1337,1338],{},"排队中可取消并全额退：返回成功，退款到账",[242,1340,1341],{},"已在渲染中不可取消：返回 409，不退款（CPU 已烧）",[242,1343,1344],{},"worker 重启不丢任务：任务被重新捞起或置失败退款",[242,1346,1347,1348,1350],{},"超时判失败：>10 分钟置 ",[593,1349,1044],{}," 并退款",[10,1352,1353],{},"每一条都写了具体的检查命令和判据。比如验证成片有声音，就是直接播放、耳朵听；验证字幕关键词高亮，就是肉眼看颜色；验证退款，就是对比出片前后的计费时间线。",[17,1355,1356],{"id":1356},"设计的权衡",[10,1358,1359],{},"这套方案的成本是什么？",[10,1361,1362],{},"首先是镜像大小：官方渲染镜像装了 280 多个 apt 包、node_modules、Chromium 和 FFmpeg，构建出的镜像实测 3.72GB，单独占用一个 K8s node pool，构建时间约 10 分钟，推拉镜像耗时显著上升。但这是\"要么装进 API 镜像肥到 6GB+、拖累三个部署，要么单独镜像\"的取舍——选了单独。",[10,1364,1365,1366,1368,1369,1372,1373,1376],{},"其次是模板编写规约的学习成本。一个看起来\"普通的网页动画\"可能在逐帧 seek 渲染时炸：GSAP 退场动画必须挂在 clip 内层并补 hard kill，禁止 ",[593,1367,1136],{}," 等布局属性，中文字体必须显式 ",[593,1370,1371],{},"@font-face","（容器内用 Noto Sans CJK）。",[593,1374,1375],{},"hyperframes check"," 作为模板上架的强制 gate，能一次性拦截这类问题。",[10,1378,1379],{},"获得的是什么？確定性输出。同一个模板、同一份配音，渲染 100 次得到 100 个帧级一致的成片（前提是输入稳定，不变数字人形象、不变字体库版本）。这对 CI 自动回归、成片质量审核都很有意义。还有灵活性：配音、数字人、包装模板可以独立迭代，不互相阻塞。",[17,1381,1382],{"id":1382},"线上部署的最后两步",[10,1384,1385],{},"发版后的两个关键项，缺一项都会导致灰度用户无法下单：",[655,1387,1388,1397],{},[242,1389,1390,237,1393,1396],{},[24,1391,1392],{},"后台配价",[593,1394,1395],{},"resourceKey=video_render_sec"," 的单价必须填（单位元\u002F秒），且勾选 enabled。没配的话 chargeResource 会返回 404，用户点开始出片就直接报错。",[242,1398,1399,237,1402,1404],{},[24,1400,1401],{},"灰度名单",[593,1403,1168],{}," 环境变量决定了谁能看到模板卡。留空 = 全员走旧 ffmpeg 链路（可用于紧急回滚），指定用户 ID = 该用户进入新链路。发版初期应只填 1-2 个测试账号。",[10,1406,1407],{},"这两个配置是代码之外的硬依赖，容易遗漏。烟测清单里放在最前面，作为\"不做后面全走不通\"的前置项。",[10,1409,1410,1411,1414],{},"整个方案的核心原则是",[24,1412,1413],{},"分层隔离与路径确定性","：TTS 决定时长、飞天决定人像、HyperFrames 决定包装，各层独立演进，成片路径从输入到输出一条流水线，无分支、无条件、无随机。这对一个产生可发布物料的流水线来说，是底线。",[1416,1417,1418],"style",{},"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":1420},[1421,1422,1423,1424,1425,1426,1427],{"id":737,"depth":695,"text":738},{"id":804,"depth":695,"text":804},{"id":1027,"depth":695,"text":1027},{"id":1097,"depth":695,"text":1097},{"id":1140,"depth":695,"text":1140},{"id":1356,"depth":695,"text":1356},{"id":1382,"depth":695,"text":1382},"2026-07-08",{},"\u002F2026-07-08",{"title":729,"description":734},"2026-07-08-视频包装与数字人配音","数字人口播成片的最后一环：配音时长不可控，如何反向驱动画面？原片如何作为独立图层无损合成？成片路径如何保证确定性？",[1435,723,1436,1437,1438],"TTS","HyperFrames","音视同步","流水线设计","ISg-n2hvjyCKUW_WMTk0Ais8ji7YhOK5rXPmNyUBWQk",{"id":1441,"title":1442,"body":1443,"column":710,"date":1742,"description":1447,"extension":712,"hero_image":713,"meta":1743,"navigation":715,"path":1744,"seo":1745,"series_id":713,"severity":713,"stem":1746,"summary":1747,"tags":1748,"__hash__":1754},"posts\u002F2026-07-02-工作流迁移与复用.md","搬一条生产线，难的不是业务逻辑",{"type":7,"value":1444,"toc":1731},[1445,1448,1451,1454,1457,1464,1467,1473,1476,1479,1486,1501,1504,1507,1524,1527,1546,1549,1552,1565,1568,1574,1600,1611,1614,1617,1620,1627,1630,1633,1636,1639,1646,1649,1678,1693,1696,1699,1725,1728],[10,1446,1447],{},"旧平台上已经跑通了漫剧和小说两条完整的创作工作流。新平台已具备图片生成、异步任务、用户鉴权和资源计费的基础设施。现在需要把这两条链路搬过来，但不是照搬代码，而是复用领域逻辑重新适配。",[10,1449,1450],{},"迁移看似是一个代码挪动的问题，实际上是一个架构解耦的问题。两个平台在三个维度上产生了紧密耦合，直接挪动代码会导致新平台继承旧平台的设计包袱。",[17,1452,1453],{"id":1453},"三处不兼容的耦合",[525,1455,1456],{"id":1456},"任务队列与调度模型",[10,1458,1459,1460,1463],{},"旧平台的工作流采用进程内任务 Map 和命令式 API 网关。漫剧工作流从剧本拆分镜头、生成资产图、生成视频、拼接整集——这一系列操作都是通过 ",[593,1461,1462],{},"__api\u002Fcomic_*"," 这样的命令端点来驱动的，任务状态存在内存 Map 里，重启进程就丢了。",[10,1465,1466],{},"新平台设计了一套资源计费底座，任务需要经历预留（reserve）→ 执行 → 结算（settle）→ 失败时退款（refund）的流程。以小说工作流为例，每个阶段（设定、世界观、角色、分卷、大纲、章节）都是独立任务，生成前要冻结估算的算力点，生成后按实际输出字符数结算，多退少补。这套计费流程嵌入到新平台的 Billing 服务里，不能绕过。",[10,1468,1469,1470,27],{},"如果我直接把旧平台的任务调度逻辑搬过来，新平台的任务会跳过 reserve 这一步，造成\"已生成但余额不足无法扣费\"的问题。同样，旧平台的视频任务如果失败只是标记 failed，没有退款逻辑，新平台则需要原子地调用 ",[593,1471,1472],{},"RefundCharge",[10,1474,1475],{},"两边的任务模型在原语层面就不兼容。",[525,1477,1478],{"id":1478},"存储路径与对象约定",[10,1480,1481,1482,1485],{},"旧平台用本地 JSON 文件存储小说项目数据。小说作品、设定、世界观、角色这些结构化阶段的原始输出被序列化到磁盘上，依赖一套约定好的目录结构。漫剧项目也类似，源项目中通过 ",[593,1483,1484],{},"featureDir(userId, 'comic')"," 这样的函数来确定资产文件的存储根路径。",[10,1487,1488,1489,1492,1493,1496,1497,1500],{},"新平台统一使用 S3 \u002F OOS 对象存储加 Prisma 数据库。项目的元数据（作品标题、阶段状态、版本历史）存 Prisma 表，生成的内容（图片、视频、结构化文本）存对象存储并记录 key。例如小说的 ",[593,1490,1491],{},"NovelSection"," 表存储化后的 JSON 结构和展示文本，章节正文存在 ",[593,1494,1495],{},"NovelChapterVersion"," 表的 ",[593,1498,1499],{},"content"," 字段。",[10,1502,1503],{},"如果我从旧平台直接挪过来一套\"读本地 JSON\"的逻辑，新平台就要维护两套存储系统。更重要的是，新平台的计费系统依赖持久化的结构化数据——只有把规范化后的展示文本存进表里，后台才能追溯某次扣费对应的具体内容。",[525,1505,1506],{"id":1506},"计费模型与资源定价",[10,1508,1509,1510,1072,1513,1516,1517,1041,1520,1523],{},"旧平台对小说的计费可能是按生成 token 次数、按模型调用、或者干脆不计费。新平台设计了一套通用资源计费框架：每个资源有一个 key（例如 ",[593,1511,1512],{},"image_generation",[593,1514,1515],{},"novel_text_output","），配套一个定价模型（",[593,1518,1519],{},"PER_CALL",[593,1521,1522],{},"PER_UNIT","），后台可以随时调价。",[10,1525,1526],{},"对小说工作流，新平台的选择是按可见字符数计费，不按 token。这意味着：",[239,1528,1529,1532,1539],{},[242,1530,1531],{},"用户最终看到的文本去掉空白后的字符数才会被计入。",[242,1533,1534,1535,1538],{},"模型原始输出如果是 JSON 格式，那么 ",[593,1536,1537],{},"{}"," 括号、字段名、引号、逗号、缩进都不算——只计算最终展示的内容。",[242,1540,1541,1542,1545],{},"每个阶段的计费单位是 ",[593,1543,1544],{},"算力点 \u002F 千字","，后台可配置，用户无感知。",[10,1547,1548],{},"从旧平台的某种计费方式切换到这套新模型，需要重新梳理每个环节的计费触发点。如果只是把旧的生成函数搬过来调用一遍，新平台的 Billing 根本没有机会介入。",[17,1550,1551],{"id":1551},"迁移方案的抽象层次",[10,1553,1554,1555,1072,1558,1072,1561,1564],{},"面对这三处耦合，如果采取\"完全抽象\"的思路——把工作流定义成一个通用的步骤编排引擎，每个平台只需要实现 ",[593,1556,1557],{},"execute_step",[593,1559,1560],{},"store_result",[593,1562,1563],{},"charge_resource"," 这样的抽象接口——理论上很优雅，但代价是引入了一层不必要的复杂性。通用引擎要支持各种平台的差异，势必要留下很多可配置项，导致理解和维护难度上升。",[10,1566,1567],{},"实际采取的方案是有选择的复用：",[10,1569,1570,1573],{},[24,1571,1572],{},"复用稳定部分","：工作流的步骤编排和领域逻辑。小说工作流的七个阶段顺序（设定 → 宏观 → 世界观 → 角色 → 分卷 → 拆章 → 正文）是领域知识，与平台无关。这套提示词组装、JSON 解析、规范化、展示文本提取的逻辑，从旧平台完整迁移到新平台，TypeScript 化但不改核心算法。漫剧工作流的四阶段（剧本 → 资产 → 分镜 → 成片）也是如此。",[10,1575,1576,1579,1580,1072,1583,1072,1585,1072,1588,1591,1592,1595,1596,1599],{},[24,1577,1578],{},"重新实现易变部分","：任务调度、存储、计费。小说模块新增 ",[593,1581,1582],{},"NovelProject",[593,1584,1491],{},[593,1586,1587],{},"NovelChapter",[593,1589,1590],{},"NovelTask"," 等 Prisma 模型，完全按新平台的设计来。生成任务在开始前调用 ",[593,1593,1594],{},"billing.reserveResource()","，成功后提取展示文本、调用 ",[593,1597,1598],{},"billing.settleResource()","，失败时退款。这套接口与 Image Generation 任务的流程完全一致，在平台已有的基础上建造。",[10,1601,1602,1603,1606,1607,1610],{},"具体的模型调用、LLM 的参数、生成的超时策略这些\"具体模型调用\"的细节，留在各自的适配层。例如小说模块的 ",[593,1604,1605],{},"novel-generation.ts"," 只负责组装提示词、调用 LLM、解析结果，不涉及数据库操作；数据库操作交给 ",[593,1608,1609],{},"novel-service.ts","。这样领域逻辑与平台逻辑的边界清晰，后续不同平台可以并行维护。",[17,1612,1613],{"id":1613},"关键的取舍决定",[10,1615,1616],{},"完全抽象的诱惑在于\"一套代码支持多平台\"的承诺。但这需要引入足够的可配置性和接口设计，而代价是灵活性反而下降——当某个平台需要特殊处理某个步骤时，通用引擎不得不打补丁。这在两个平台差异较大的情况下尤其成立。",[10,1618,1619],{},"选择有针对性的复用，意味着承认重复：数据模型要各写一份，任务调度逻辑要各写一份。但这个重复是可控的，因为它们在各自平台内部是一致的。小说模块的任务预留\u002F结算逻辑完全复用 Image Generation 已有的那套，不需要新增抽象层。",[10,1621,1622,1623,1626],{},"另一个关键的取舍是用户模型的配置。旧平台可能让用户选择小说生成用哪个模型，新平台则完全隐藏模型选择，由后台配置或环境变量决定。这简化了前端和 API 的设计——用户请求体里根本不接受 ",[593,1624,1625],{},"model"," 字段，Billing 也不需要按模型定价。代价是失去了\"用户自行选择成本与质量的平衡\"的灵活性，但换来了计费模型的清晰和后台的可控。",[10,1628,1629],{},"这类决定需要在迁移前明确：哪些能力是目标平台\"必须有\"的，哪些是\"很有但可以先不做\"的。漫剧工作流迁移时明确排除了白模（3D 白模式）视频相关的所有功能，不复制 Blender 依赖、不暴露白模提示词输入、成片阶段只处理普通视频。这个决定减少了迁移的复杂性，也避免了在新平台上重新部署 Blender 和白模渲染的基础设施。",[17,1631,1632],{"id":1632},"稳定部分与易变部分的边界",[10,1634,1635],{},"这个分法的关键在于，稳定部分要确实稳定。领域逻辑（各阶段的提示词、章节的上下文组装、长篇的记忆管理）在两个平台上是一样的，因为它反映的是小说创作或漫剧创作的规律。但是，一旦涉及\"这个阶段的输入从哪里读、输出存到哪里、失败后怎么处理\"，就已经是平台相关的。",[10,1637,1638],{},"以小说的世界观生成为例：",[10,1640,1641,1642,1645],{},"稳定部分是 ",[593,1643,1644],{},"worldPrompt(previousSections, projectSettings)"," 这个函数，它组装 LLM 提示词，描述\"根据设定和前序阶段的输出，生成世界观\"。这个函数与平台无关。",[10,1647,1648],{},"易变部分是：",[239,1650,1651,1658,1661,1671],{},[242,1652,1653,1654,1657],{},"生成前如何预留算力点（新平台调 ",[593,1655,1656],{},"billing.reserveResource","，旧平台可能不调）",[242,1659,1660],{},"生成结果是一个 JSON 对象，如何从中提取展示文本（两个平台的数据模型可能不同，但提取逻辑应该是一样的——属于稳定部分）",[242,1662,1663,1664,1667,1668,775],{},"提取后的展示文本存到哪里（新平台的 ",[593,1665,1666],{},"NovelSection.displayText"," 字段，旧平台可能是本地文件 ",[593,1669,1670],{},"sections\u002Fworld.json",[242,1672,1673,1674,1677],{},"失败时如何处理（新平台调 ",[593,1675,1676],{},"billing.refundResource","，旧平台可能是清除临时文件）",[10,1679,1680,1681,1072,1684,1686,1687,1072,1689,1692],{},"这样划分后，新平台只需要在适配层（",[593,1682,1683],{},"novel-routes.ts",[593,1685,1609],{},"）重新实现存储和计费部分，核心生成逻辑（",[593,1688,1605],{},[593,1690,1691],{},"novel-prompts.ts","）从旧平台迁移过来。",[17,1694,1695],{"id":1695},"实际的迁移清单",[10,1697,1698],{},"从这个分析，迁移的具体工作清单变成：",[655,1700,1701,1707,1713,1719],{},[242,1702,1703,1706],{},[24,1704,1705],{},"评估可复用的代码"," — 在旧平台找出真正与平台无关的部分。对小说工作流，这包括提示词、JSON 解析、规范化逻辑。对漫剧工作流，这包括分镜拆分的逻辑、资产提取的规则。",[242,1708,1709,1712],{},[24,1710,1711],{},"定义新平台的合同"," — 每个工作流步骤的输入输出是什么，资源消耗如何计量。小说阶段的输出是规范化 JSON 加展示文本，消耗的资源是可见字符数。漫剧镜头的输出是镜头配置和首帧图片，消耗的资源是视频秒数（按模型和分辨率估算）。",[242,1714,1715,1718],{},[24,1716,1717],{},"实现平台适配层"," — 数据模型（Prisma 表）、任务调度（与 Billing 集成）、错误处理和重试。这部分代码是新平台特有的，不能也不应该复用。",[242,1720,1721,1724],{},[24,1722,1723],{},"测试边界"," — 在适配层写测试，验证计费逻辑、任务状态机、错误恢复。在稳定部分写测试，验证生成质量、规范化正确性。",[10,1726,1727],{},"不采用这样的分层，而是直接把旧平台的 Next.js 路由、本地文件操作、命令式 API 端点搬到新平台，结果是新平台沦为旧平台代码的容器。后续需要升级某个依赖、调整计费模型、或对接新的生成模型时，都会发现改一个地方影响到另一个地方。",[10,1729,1730],{},"设置清晰的边界，允许有选择的重复，是避免这个问题的方法。",{"title":595,"searchDepth":695,"depth":695,"links":1732},[1733,1738,1739,1740,1741],{"id":1453,"depth":695,"text":1453,"children":1734},[1735,1736,1737],{"id":1456,"depth":703,"text":1456},{"id":1478,"depth":703,"text":1478},{"id":1506,"depth":703,"text":1506},{"id":1551,"depth":695,"text":1551},{"id":1613,"depth":695,"text":1613},{"id":1632,"depth":695,"text":1632},{"id":1695,"depth":695,"text":1695},"2026-07-02",{},"\u002F2026-07-02",{"title":1442,"description":1447},"2026-07-02-工作流迁移与复用","两条生产工作流从旧平台迁移到新平台，难点不在业务逻辑重写，而在解耦三处平台依赖——任务调度、存储约定、计费接口。",[1749,724,1750,1751,1752,1753],"架构","微服务","平台化","任务队列","计费系统","zEJUEqZgsQkHJOxHWOoc4WL1iYD_NaAli4VZYCye-ZY",{"id":1756,"title":1757,"body":1758,"column":710,"date":2077,"description":1762,"extension":712,"hero_image":713,"meta":2078,"navigation":715,"path":2079,"seo":2080,"series_id":713,"severity":713,"stem":2081,"summary":2082,"tags":2083,"__hash__":2090},"posts\u002F2026-06-25-长文写作的记忆分层.md","三十万字之后，AI 该记住什么？",{"type":7,"value":1759,"toc":2068},[1760,1763,1766,1769,1772,1775,1778,1781,1785,1788,1794,1802,1808,1813,1819,1824,1830,1835,1846,1849,1853,1856,1862,1867,1873,1878,1883,1888,1894,1899,1905,1910,1913,1916,1920,1923,1926,1929,1932,1935,1942,1980,1991,2002,2005,2008,2017,2026,2039,2052,2055,2062,2065],[10,1761,1762],{},"长篇小说写到三十万字后，\"记住前面写了什么\"变成最大的瓶颈。角色在第五章确立的设定，到第二十章突然对不上；伏笔埋了二十章没回收；世界观里的时间线出现逻辑洞；前面说过的势力关系后面又改了。全量注入上一章或全书到上下文里根本不可能，纯向量检索又容易因为相似度不够而漏掉关键的硬约束。",[10,1764,1765],{},"这个问题需要分层的记忆系统。根据信息的稳定性和用途把记忆分成三层：设定层、事实层、文本层。每层的存储方式和检索策略完全不同。",[17,1767,1768],{"id":1768},"为什么不能统一用向量库",[10,1770,1771],{},"一个很自然的想法是\"把所有信息存到向量库里，需要时向量检索\"。但设定信息不行。",[10,1773,1774],{},"世界规则、人物档案这类信息是硬约束，一旦因为向量相似度不够高而没被召回，就会产生直接的设定冲突。模型可能生成\"主角这章掌握了某个禁忌知识\"，但系统没召回\"世界规则里明确说这个知识是自杀性的\"，结果后续剧情完全跑偏。向量检索是概率性的，概率性检索不能承载硬约束。",[10,1776,1777],{},"事实信息（已发生的事件、角色状态变化、伏笔）可以用向量检索，因为\"不完美的匹配\"还能通过更多文本从模型推理出来。如果系统检索到\"角色在某章失去了一条胳膊\"这个事实后又检索到\"这个事件在剧情中的含义是……\"，模型有足够的上下文修复漏掉的细节。",[10,1779,1780],{},"原文（正文片段）是纯量级的数据，全量注入的成本太高，用摘要替代是更经济的做法。",[17,1782,1784],{"id":1783},"设定层结构化存储全量或定向注入","设定层：结构化存储、全量或定向注入",[10,1786,1787],{},"设定层装的是世界级的硬规则和静态档案，包括：",[10,1789,1790,1793],{},[24,1791,1792],{},"世界规则","（约 600 字压缩）",[239,1795,1796,1799],{},[242,1797,1798],{},"现实是否稳定、超常能力是否公开、死亡是否可逆、信息获取是否受限",[242,1800,1801],{},"这个世界\"允许什么、不允许什么\"",[10,1803,1804,1807],{},[24,1805,1806],{},"系统性规则","（通常 4-6 条）",[239,1809,1810],{},[242,1811,1812],{},"力量体系的上限、交易与代价的原理、禁忌知识的危害方式",[10,1814,1815,1818],{},[24,1816,1817],{},"主要势力","（通常 3-5 个）",[239,1820,1821],{},[242,1822,1823],{},"每个势力的名称、目标、常用手段、与其他势力的关系",[10,1825,1826,1829],{},[24,1827,1828],{},"核心人物档案","（通常 4-8 个）",[239,1831,1832],{},[242,1833,1834],{},"每个人物的身份、核心目标、掌握的信息、心理底线",[10,1836,1837,1838,1841,1842,1845],{},"这些东西在整部小说中是不变的（或变化极缓），写作的任何环节都需要遵循它们。设定层必须在调用 AI 生成章节时",[24,1839,1840],{},"全量注入","或",[24,1843,1844],{},"按需定向注入","。全量注入适合小说世界相对简洁的情况；如果世界复杂设定众多，可以做定向注入——比如这一章涉及势力 A，就只注入 A 的档案和相关规则。",[10,1847,1848],{},"关键是：设定层的记忆片段永远不能因为上下文窗口压力而被省略。这是整个系统的天花板。",[17,1850,1852],{"id":1851},"事实层向量检索-时间过滤","事实层：向量检索 + 时间过滤",[10,1854,1855],{},"事实层装的是已经发生的、会影响后续剧情的信息。",[10,1857,1858,1861],{},[24,1859,1860],{},"章节摘要","（每章一句话）",[239,1863,1864],{},[242,1865,1866],{},"\"主角从势力 B 得到了关键物品 X，但同时被势力 A 注意到了\"",[10,1868,1869,1872],{},[24,1870,1871],{},"角色动态状态","（追加式、非覆盖）",[239,1874,1875],{},[242,1876,1877],{},"某角色掌握了什么新信息、失去了什么能力、和某人的关系发生了什么变化",[10,1879,1880],{},[24,1881,1882],{},"伏笔记录",[239,1884,1885],{},[242,1886,1887],{},"埋下的伏笔及其状态（待回收 \u002F 已回收）、涉及的章号",[10,1889,1890,1893],{},[24,1891,1892],{},"时间线","（关键事件及其时间距离）",[239,1895,1896],{},[242,1897,1898],{},"\"第五章后的第三天，X 事件发生\"",[10,1900,1901,1904],{},[24,1902,1903],{},"世界新设定","（剧情中确立、原设定没有的）",[239,1906,1907],{},[242,1908,1909],{},"\"这个世界原本不知道 X，但第十二章中 Y 揭露了 X\"",[10,1911,1912],{},"这一层用向量检索的原因是数据量大（几十万字的小说可能有几百条事实）且不需要完美精确。模型在知道\"五章前主角失去了左腿\"和\"十章前主角加入了某组织\"的基础上，能够合理推理出后续事件。即使系统漏掉了某条非关键事实，模型也不太会产生硬冲突。",[10,1914,1915],{},"时间过滤很重要。检索结果应该默认优先最近的事件，因为离当前章节越近的事件通常影响力越大。\"三章前角色的转折\"比\"五十章前的背景\"更应该被注入。",[17,1917,1919],{"id":1918},"文本层只取最近段落","文本层：只取最近段落",[10,1921,1922],{},"文本层就是原始的正文片段，用于衔接语气和细节。一个几十万字的小说，全部原文根本放不进上下文。",[10,1924,1925],{},"解决方案是简单的：只注入前一章的结尾（约 800 字），这足以让模型维持住文笔连贯性和情感线的延续。对于需要回顾很久之前的情节细节的场景，用事实层的摘要替代——\"第五章中，X 因为 Y 而死亡\"这一条摘要比翻出整个第五章的原文高效得多。",[10,1927,1928],{},"当然也存在\"某章需要直接引用或高度呼应某个很久以前的场景细节\"的情况。这时候文本层可以临时扩大范围，但这是特例，不是常态。",[17,1930,1931],{"id":1931},"具体的实现策略",[10,1933,1934],{},"设定层在每次生成章节前直接注入——要么全部、要么按这章涉及的范围选择。不需要任何检索逻辑，就是结构化的数据块。",[10,1936,1937,1938,1941],{},"事实层维护一个 ",[593,1939,1940],{},"memory.json"," 文件，存储：",[239,1943,1944,1950,1956,1962,1968,1974],{},[242,1945,1946,1949],{},[593,1947,1948],{},"synopsis","：全书概要，每章更新后重写，封顶 1000 字",[242,1951,1952,1955],{},[593,1953,1954],{},"chapterDigests","：逐章一句话摘要",[242,1957,1958,1961],{},[593,1959,1960],{},"characters","：角色的动态状态（与静态档案分开）",[242,1963,1964,1967],{},[593,1965,1966],{},"foreshadow","：伏笔列表，标记待回收或已回收",[242,1969,1970,1973],{},[593,1971,1972],{},"timeline","：关键事件及章号和相对时间",[242,1975,1976,1979],{},[593,1977,1978],{},"worldFacts","：剧情中新确立的设定事实",[10,1981,1982,1983,1986,1987,1990],{},"每章生成完成后，系统调用一次 AI，输入\"旧记忆 + 本章正文\"，要求输出",[24,1984,1985],{},"增量","——这一章新增了什么伏笔、角色状态如何变化、有没有新设定。然后用纯函数 ",[593,1988,1989],{},"normalizeMemory()"," 合并增量到旧记忆里：新伏笔追加、已回收伏笔置状态、角色按 name upsert（同 ID 的覆盖）、synopsis 重写、timeline 追加。",[10,1992,1993,1994,1997,1998,2001],{},"生成下一章时，",[593,1995,1996],{},"buildChapterMessages()"," 的流程变成：注入设定块 → 注入记忆块（由 ",[593,1999,2000],{},"buildMemoryContext()"," 生成） → 注入上一章结尾 → 生成新章。记忆块里包含：最近 6 章摘要、全部角色现状、最后 8 条未回收伏笔、最后 5 条时间线、最后 10 条世界新设定。这些都是硬上限，防止记忆块因为章数增多而无限膨胀。",[17,2003,2004],{"id":2004},"一致性与失败处理",[10,2006,2007],{},"这套流水线的核心难点不是单点生成质量，而是一致性和失败恢复。",[10,2009,2010,2013,2014,2016],{},[24,2011,2012],{},"一致性来自增量而不是合并","。每章记忆更新要求 AI 只输出增量，而不是整个记忆的重写。这样设计有两个好处：一是防止 AI 每次都改掉前面的内容（一种无意义的膨胀），二是增量小得多，解析 JSON 时出错的概率降低。合并逻辑交给纯函数 ",[593,2015,1989],{},"，这个函数可以单测，保证逻辑稳定。",[10,2018,2019,2022,2023,2025],{},[24,2020,2021],{},"同一章重建幂等","。由于 ",[593,2024,1954],{}," 按章号存储，相同章号的摘要会覆盖而不是追加，所以即使记忆重建时对同一章调用多次，也不会出现重复记录。角色状态的 upsert 机制也是同理。",[10,2027,2028,2031,2032,2034,2035,2038],{},[24,2029,2030],{},"记忆更新失败不影响正文","。每章正文先落盘、记忆更新在后。如果记忆更新失败（比如 AI 返回格式错误、JSON 解析异常），降级处理：用这章的 title 或 summary 作为摘要追加到 ",[593,2033,1954],{},"，",[593,2036,2037],{},"lastChapterNo"," 照常推进，其余记忆保持不变。这样下一章的生成可以继续进行，用户不会察觉到记忆层的故障。",[10,2040,2041,2044,2045,2048,2049,2051],{},[24,2042,2043],{},"重建任务的断点续跑","。存量书如果想补全记忆，后台可以跑 ",[593,2046,2047],{},"runMemoryRebuild()"," 任务，按章序遍历，跳过 ",[593,2050,2037],{}," 以内的章（已纳入过的）。这个任务可停、可继续，防止重复扣费。",[17,2053,2054],{"id":2054},"记忆分层的边界",[10,2056,2057,2058,2061],{},"这个设计对应的问题空间是：",[24,2059,2060],{},"长篇写作中保持内容一致性和连贯性","。它解决的是系统层面的记忆管理，不覆盖人工的内容审校和设定调整。",[10,2063,2064],{},"如果作者在某个时刻决定\"我要改前面某角色的设定\"，那是人为修改设定层、然后手动落盘的过程。系统的记忆不会自动同步这种改动，因为改动涉及主观判断。类似的，如果某章生成出来的内容和记忆产生冲突（比如模型莫名其妙加了个新势力），那也需要人工介入编辑，而不是靠记忆系统自动修正。",[10,2066,2067],{},"记忆系统的职责是：提供足够的上下文约束，让模型生成时减少无意义的冲突；一旦冲突出现，系统快速降级，不中断工作流。",{"title":595,"searchDepth":695,"depth":695,"links":2069},[2070,2071,2072,2073,2074,2075,2076],{"id":1768,"depth":695,"text":1768},{"id":1783,"depth":695,"text":1784},{"id":1851,"depth":695,"text":1852},{"id":1918,"depth":695,"text":1919},{"id":1931,"depth":695,"text":1931},{"id":2004,"depth":695,"text":2004},{"id":2054,"depth":695,"text":2054},"2026-06-25",{},"\u002F2026-06-25",{"title":1757,"description":1762},"2026-06-25-长文写作的记忆分层","长篇到几十万字后，用分层记忆解决剧情断裂和设定崩坏——设定层硬约束、事实层概率检索、文本层按需取用，层级的存储与检索策略完全不同。",[2084,2085,2086,2087,2088,2089],"长篇写作","记忆管理","内容生成","AI辅助创作","向量检索","一致性维护","IK_-YIn1bPi-9X-l1KlQn3a-D0cTe45drq3MhS7m-AA",1785406912440]