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