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