Skip to content

15 长视频分段生成与一致性深挖问答 ​

这一册是"从你的实战经验直接接进代码"的一册。 你当年用 Seedance 2.0 的做法是:单场景 <15 秒并发生成,>15 秒顺序生成,把上一段的尾帧当下一段的首帧,并对服装/角色/音色做一致性处理。 这套思路在现在的代码里仍然成立,而且被工程化成了三个显式概念:Semaphore(并发闸)、needsBridging/dependsOn(依赖图)、Phase 1 并行 / Phase 2 串行(两阶段调度)。下面每一题都会先把"你当年的做法"与"现在的实现"对上,再补今天的完整链路。 代码主战场:backend/services/longVideo/generator.js(5,342 行)、characterAnchor.js(516 行)、tts.js(418 行)、segmentValidation.ts(140 行)、REFACTOR.md(真实待办清单)。


〇、一页总览:一条长视频是怎么被造出来的 ​

脚本(scenes → shots,每 shot 带 duration/copywriting/description)
   │
   ├─ ① 段规划 + 服务端硬校验 validateLongVideoSegmentPlan()
   │     单镜/单段上限来自 getModelMaxSegmentSeconds(模型) ← 能力表(不是写死的 15s)
   │     台词容量校验:字数 ≤ 时长 × 4 字/秒(dialogueMaxCharsPerSecond);超了直接拒绝
   │
   ├─ Phase 0  场景锚图:无场景图且开着 autoSceneAnchor 时,先生成一张统一空镜
   │            → 注入全段参考图,保证"AI 想象的场景"前后一致
   ├─ Phase 0a TTS 预合成(engineAudioOnly 模式下整段跳过)
   │            → 每段一条 reference_audio(统一音色)
   ├─ Phase 0b 共享资产 fan-out:每段独立 URL/文件名,绕过上游服务端的 URL 去重缓存
   │
   ├─ Phase 1  独立段并发生成(Semaphore,默认并发 3)
   │            → 每段:组装参考图 + 生成 + 抽尾帧 + 落库
   ├─ Phase 2  桥接段**串行**生成(needsBridging=true 的段按 dependsOn 链逐段跑)
   │            → 段 k 的首帧 = 段 k-1 的尾帧(extractLastFrame → 上传 → role:first_frame)
   │            → 前驱失败 → 本段直接报错,不白跑
   │
   ├─ Phase 3  拼接 concatVideos(逐段归一化 → concat;可选 master 单趟重编码换音轨+烧字幕)
   │
   └─ 结算:全成功 → 按全片秒数结算;有失败段 → 成片标 partial,
            对话链路按"交付段比例"退差价,脚本模式则"完全失败才退款"

三个必讲的点

  1. 你的"<15s 并发 / >15s 顺序"已经升级成依赖图:现在不是按"场景是否超上限"一刀切,而是只有同场景拆出来的段才需要桥接(needsBridging),不同场景之间是独立段,可以并行——比"全有桥接就全串行"吞吐高得多。
  2. 上限不再是硬编码 15 秒:段长上限从模型能力表来(2.0 是 15s、2.5 是 30s),因为写死常量会让新模型的能力白给。
  3. 音色一致性有两套世代方案:旧的"TTS 整片音轨 + 整轨替换"(物理保证 100% 一致,但链路长、要多次重编码)与现在的"引擎自带配音"(默认开、快得多,靠克隆与锚点缓解)。这是一个真实的一致性 vs 复杂度取舍,面试官问"你怎么选"你有话可讲。

一、把"你当年的做法"逐条对到现在的代码 ​

你当年的做法现在代码里的对应实现变化与理由
单场景 < 15s → 并发生成Semaphore(默认 concurrency = 3)+ Phase 1 并行独立段(generator.js:2534)并发上限从"场景数"变成显式信号量,且默认 3 是为了不打爆第三方限流
单场景 > 15s → 顺序生成needsBridging = true + dependsOn = 前一段 id → Phase 2 串行(generator.js:2486-2492、:2562-2570)只给同场景拆段打桥接标记:跨场景不桥接,所以能并行
上一段尾帧当下一段首帧extractLastFrame(prevSeg.videoUrl) → uploadImageToStorage → role: 'first_frame'(generator.js:3389-3395)抽帧失败降级为独立段并打 warn,不让整单失败
服装/角色一致性buildProductLock + buildCharacterLock + buildCharacterConsistencyGuard + 【一致性要求】 段(generator.js:571-584、:626)多角色脚本里单主角锁是空的,所以要一段独立的"谁是谁、什么时候才允许换装"守卫
人物音色一致性两套:① master 整片音轨(buildMasterAudioTrack/renderFinalMasterVideo);② 引擎按 reference_audio 逐段克隆 + 独立 URL 绕过缓存(fanOutAssetsForSegments)默认走 ②(LONGVIDEO_ENGINE_AUDIO !== '0'),②快但一致性弱、靠多处缓解

【备注讲解】这张表就是你面试时"我做过什么"的最短路径:先说当年的朴素方案 → 再说现在的工程化形态 → 最后说为什么这么演进。面试官会很清楚地看到你不是"用过某个 API",而是在解决一致性问题本身。


Q1. 长视频这一条链路的完整流程是什么? ​

【考点】 你能不能把一条复杂链路讲清楚,而不是只会讲自己写的那一段。

【口述答案】 长视频是"脚本驱动"的,不是"一段文案生成一条视频"。整条链路是: 第一步,脚本 → 分镜。用户给产品信息、参考素材和一段需求,我们用 LLM 生成结构化脚本:scenes → shots,每个 shot 带 duration、copywriting(台词)、description(画面)、camera(运镜)等。 第二步,段规划 + 服务端硬校验。把 shots 按场景和时长上限切成"段"(segment),然后过一个服务端校验器 validateLongVideoSegmentPlan:单镜不能超模型上限、单段总时长不能超上限、台词字数不能超过"时长 × 4 字/秒"的容量(这个数字来自前后端共用的 shared/shot-beat-allocation.json,时长估算另用 4.5 字/秒)——超了直接拒绝提交,并告诉用户"请缩短台词或延长/拆分镜头",绝不静默截断。 第三步,前置阶段。先生成一张"场景锚图"(保证 AI 想象的场景前后一致),再给每段预合成 TTS 参考音(统一音色),然后做共享素材的 fan-out——每段用独立的 URL 和文件名,绕过上游服务端按 URL 做的去重缓存。 第四步,段生成。按依赖图跑两阶段:独立段并行(信号量控并发),桥接段串行(后一段的首帧取前一段的尾帧)。 第五步,拼接。逐段做规格归一化后 concat;如果开着 master 音轨,会用一趟重编码同时完成"拼接 + 换音轨 + 烧字幕"。 第六步,结算。全段成功就按整片结算;有段失败就把成片标成 partial,保留已成功的段供用户单独重试,并按链路规则退差价。

【备注讲解】 讲这条链路时不要一上来就讲尾帧。先给骨架(脚本 → 校验 → 前置 → 段生成 → 拼接 → 结算),面试官想深挖哪一段会自己问。顺序本身就在证明你有全局观。

【代码依据】 backend/services/longVideo/scriptGenerator.js(550 行,脚本生成)、segmentValidation.ts:58-137、generator.js:3817-3870(Phase 0 / 0a / 0b)、:3817 起 executeScriptGeneration 全流程。


Q2. 为什么"独立段并行、桥接段串行"?这个划分是怎么定的? ​

【考点】 这是你自己的经验落地成代码的地方,是最应该讲透的一题。

【口述答案】 因为只有需要"接住上一段画面"的段才有真依赖,其它段彼此完全没有关系。 具体规则是:同一个场景被拆成多段时才需要桥接——段 2 的首帧必须等于段 1 的尾帧,所以它们必须串行;而跨场景的段之间不需要画面连续(场景都换了),所以它们是独立的,可以并行。 代码里的表达很直白:给需要桥接的段打上 needsBridging = true 和 dependsOn = 前一段 id,然后调度分成两阶段:

  • Phase 1:把所有"独立段"丢进信号量并发生成,并发默认 3;
  • Phase 2:按依赖顺序逐个跑桥接段,每段开始前先从前驱成片里抽尾帧、上传、当作本段的 first_frame 输入。 这个划分比"只要多段就全串行"吞吐高很多。我当年自己实现的时候是按"单场景是否超过模型上限"一刀切的:超了就整条顺序跑——那是能work但吞吐不高的做法;现在按场景边界决定依赖,同一批里能并行的部分全都并行了。 有一个细节我觉得挺重要:桥接段如果前驱失败了,会直接报错不跑("桥接段 seg_3 的前驱段 seg_2 未成功,无法继续"),而不是拿一个空首帧硬生成——否则出来的就是一条画面跳跃的废片,比直接失败更糟。

【备注讲解】 可以补一句边界,而且这里有个容易被追问的细节:桥接是按入口分别默认的——

  • 长视频脚本入口(routes/longVideo.ts)是 enableBridging === 'true',也就是默认关;
  • 元素替换/爆款复刻那条入口(routes/tools.ts)是 enableBridging !== false,默认开,但有参考视频时会跳过桥接(needsBridging = enableBridging && i > 0 && !referenceVideoUrl)——因为原片的首尾帧带着旧产品/旧人脸,跟替换目标冲突,硬接反而更差。 所以要回答"你们默认桥接吗",准确说法是:"看入口;而且有参考视频时一定不桥接"。这个细节说出来,面试官会知道你清楚每条链路的差异,而不是笼统地讲一个开关。

【代码依据】 generator.js:2469(config 默认 enableBridging = false)、:2486-2492(依赖标记 + "仅同场景拆段"注释)、:2528-2532(独立段/桥接段划分)、:2534(Phase 1 并行)、:2562-2570(Phase 2 串行 + 前驱失败直接报错)、backend/routes/longVideo.ts:267(默认关)、backend/routes/tools.ts:1573-1583(默认开 + 有参考视频不桥接)。


Q3. 段长上限是怎么定的?超上限了怎么办? ​

【考点】 考察"硬编码常量"的教训——你们在这方面踩过坑。

【口述答案】 段长上限不是写死的数字,而是从模型能力表取的:getModelMaxSegmentSeconds(model)。旧模型(Seedance 2.0 / Kling 3.0 Omni / MiniMax H3)是 15 秒,2.5 是 30 秒。 为什么要专门抽出来:写死的常量会让新模型的能力白给——2.5 接进来之后,如果代码里还写着 15 秒,用户选了 2.5 也只会被按 15 秒切段,而且不会报任何错。更严重的一次是 planForJob 用局部值判模型、而另一个守卫用全局值,导致"把短档上限配到 20 秒时,2.0 的任务会被静默切成两段而校验一声不吭"。 超上限的处理分两种,这是这条链路的硬红线:

  • 单镜或单段超过模型上限 → 永远拦("系统不会从镜头中间切开")。因为这是供应商的硬约束,放行只会换来一次 400。
  • 台词超出容量 → 默认拦,但用户可以点"强制生成"放行(allowDialogueOverflow)。放行时是明确告知过"模型可能会截断尾句"的,所以这个风险由用户承担;而超模型上限的风险由系统承担,永远不放行。

【备注讲解】 这里有个很好的抽象:"用户能承担的风险(台词可能被截断)可以放行,系统承担不起的风险(上游必然报错)不能放行。"把"谁能承担这个风险"作为是否放行的判据,比"重要不重要"这种模糊标准清晰得多。

【代码依据】 segmentValidation.ts:57-84(超上限 + 台词容量 + allowDialogueOverflow 注释)、:52-55("不会从镜头中间切开")、backend/services/video/modelCapabilities.ts:1-70。


Q4. 尾帧→首帧具体是怎么实现的?失败怎么办? ​

【考点】 你亲手做过的事,要讲到实现级。

【口述答案】 具体是三步:抽帧 → 上传 → 作为下一段的首帧参考图。

  • 抽帧:在前驱段成片生成完之后,用 ffmpeg 从它的 videoUrl 里抽最后一帧,落成一张本地 PNG(extractLastFrame);
  • 上传:把这张图传到我们的存储拿一个公网 URL——因为供应商要能访问到这个 URL 才能把它当输入;
  • 注入:以 role: 'first_frame' 的身份放进下一段的素材列表里,同时在提示词里加一段桥接约束:"起始画面必须与 first_frame 参考图完全一致(相同构图、人物姿势、产品位置、服装与主光方向),从此画面自然延续动作"。 失败处理有三层:
  1. 抽帧或上传失败 → 降级为独立段(打 warn,不让整单失败)。因为我们宁可少一段画面连续,也不能让一条长视频整体失败。
  2. 桥接段的前驱没成功 → 直接报错不跑(上面说过,硬生成出来是废片)。
  3. 单段重试时会重新抽前驱尾帧——重试路径专门处理了这个:如果前驱已经 done,就重新抽一次它的尾帧再上传,而不是用重试前缓存下来的旧首帧。

【备注讲解】 有个工程细节值得主动说:"抽首帧这个动作必须在前驱段完全落库之后才做。我们有个显式判断——只有 status === 'done' 的段才算数,generated(视频生成了但抽帧还没做完)的段它的 lastFrameUrl 恒为 null。如果只判断字段是否为空而不判断状态,会出现'拿一个还没写好的引用去生成下一段'的竞态。"

【代码依据】 generator.js:1181(extractLastFrame)、:3389-3395(重试路径重新抽尾帧 + 抽帧失败降级为独立段)、:600-604(桥接提示词,含"构图/姿势/产品位置/服装与主光方向")、backend/services/queue/productionWorker.ts:276-280(status === 'done' 才算数的显式判断)。


Q5. 服装、角色一致性你们是怎么处理的?(这是最容易被深挖的一题) ​

【考点】 一致性问题在并行生成下会放大——因为并行段彼此看不到对方的成片。这是个很好的思考题。

【口述答案】 我按"锁"来分层讲,一共五层,核心矛盾是并行段互相看不见对方的成片,所以只能靠"把同一套约束在每一段里说同一遍"。 第一层:产品锁(buildProductLock)。 把产品名、品类、外观要点在每段都写一遍,保证产品外观不跑。 第二层:角色锁(buildCharacterLock)。 单主角场景下用角色档案(性别/年龄/脸型/肤色/发型/体型/服装)锁住。 第三层:多角色一致性守卫(buildCharacterConsistencyGuard)。 这个是关键补丁——因为角色锁是单主角口径的,而多角色脚本里它多半是空串(角色档案只有一份)。所以额外加一段守卫,明确"谁是谁、什么时候才允许换装"。代码注释写得很直白:"各段并行生成、彼此看不到对方成片,只能靠这段把'谁是谁、什么时候才允许换装'在每段里说同一遍。" 第四层:一致性要求段。 每段提示词里固定写:"角色脸部/发型/肤色全段一致,产品外观与参考图一致;人物服装在本段同一场景内保持同一套,不得逐镜更换;只有脚本写明换装或切换到新场景时才更换;全段光照与色调统一。" 第五层:角色锚图(characterAnchor.js)。 这是我认为最有效的一层:用生图模型先给同一个角色生成多角度的定妆照,然后这些图作为身份参考注入到所有段。理由很实在——并行段各自会"想象"女主长什么样,尤其当用户只提供了文字描述、没有模特图时,几个并行段生出来的可能完全不是同一个人。有了多角度锚图,所有段都从同一张脸出发。 另外还有两件事是配合这个目标的:场景锚图(同理会各自想象背景,所以先生成一张统一空镜注入全段)和素材 fan-out(每段独立 URL/文件名,绕过上游按 URL 的去重缓存,同时保证每段的素材角色标注与实际下发顺序一致)。

【备注讲解】 这道题的杀手句是:"并行生成下,一致性的本质是"把共享上下文显式写进每一次独立调用",因为各段之间没有通信。"你可以再补一句对比:"如果用串行生成,理论上后一段能看到前一段的成片,一致性问题会小一些——但吞吐会掉一个数量级,所以我们选了'并行 + 显式约束注入'这条路。"

【代码依据】 generator.js:571-584(三层锁的组装顺序 + "并行段彼此看不到对方成片"注释)、:626(【一致性要求】原文)、generator.js:395(resolveLongVideoCharacterNames —— 从脚本里解析角色名,注释指出"女主/老板这类纯脚本角色恰恰是没有图的那批,名字拿不到各并行段就会各自想象")、characterAnchor.js:390(generateCharacterAnchor)、:192(splitAnglesIntoWaves 波次切分)、:1752(generateSceneAnchorImage)、:2047(fanOutAssetsForSegments 共享资产 fan-out)。


Q6. 音色一致性呢?这里你们换过方案? ​

【考点】 这一题的含金量在于你有一个真实的架构取舍可以讲。

【口述答案】 音色一致性我们做过两代方案,而且现在默认跑的是第二代。 第一代:整片音轨替换。 思路是"物理上保证一致"——先用 TTS(阿里云 CosyVoice 音色)一次性把整片的配音合成出来,同时产出每段的精确时间轴;然后视频段全部生成静音片,拼接之后用整轨替换掉成片音轨,再用 ffmpeg 把字幕烧进去。代码里为了省时间,把"拼接 + 换音轨 + 烧字幕"合并成一趟重编码完成(renderFinalMasterVideo)。 这个方案的一致性是最好的——因为每一段的音都来自同一次音色输出的直接产物,物理上不可能不一致。但代价是链路长:要测时长、要截断/补静音、要多一次全片编码,而且早期版本还依赖 adelay/amix 这些滤镜,很脆。 第二代(当前默认):引擎自带配音。 直接让模型按提示词里的台词生成带声视频(generate_audio),跳过 TTS 预合成、跳过整轨替换、拼接直接 -c copy 不重编码,速度明显快、链路明显短。代价是音色一致性回到"每段各自克隆"的问题上,我们用三个手段缓解:

  1. 共享素材 fan-out:每段用独立 URL 和文件名,强制上游走完整克隆流程、绕过它按 URL 做的结果缓存——因为实测发现并发克隆时首段质量最高、后面的段会回落到默认音色;
  2. 音色基线复用:从第一段成片里抽出音轨做 baseline,让后续段基于它再克隆,而不是每次都从原始参考音重来;
  3. 提示词层:承接块与一致性要求里要求音色统一。 顺便说,第一代的代码全部保留着,只是用环境变量关掉了入口(LONGVIDEO_ENGINE_AUDIO=0 就能切回去)。

【备注讲解】 这一题的价值不在"哪个更好",而在你能不能把取舍讲清楚:

"一致性最强和交付最快是矛盾的。我们的判断是:长视频的主要瓶颈是墙钟时间(用户等不起),而音色的段间差异比"画面跳帧"更容易被容忍,所以我们把默认切到了引擎配音,同时用三重手段把音色差异压到可接受范围;如果哪天业务对音色一致性的要求高过交付速度(比如品牌口播类),切回整轨方案只需要一个环境变量。"

这句话里有判断依据(瓶颈在哪)、有取舍(牺牲了什么)、有回退手段(一个开关),是完整工程决策的表达方式。

【代码依据】 generator.js:1640-1644("逐段克隆质量每段独立波动是长视频前后段音色不一致的根源" + master 音轨物理保证一致)、:1651(buildMasterAudioTrack)、:1693(renderFinalMasterVideo)、:3824-3827(engineAudioOnly 默认开 + 跳过 Phase 0a)、:1981-1983 与 :2148-2158(并发克隆退化 + 独立 URL/文件名绕过缓存)、:2160(extractAudioBaselineFromSegmentVideo)、:4522-4525(master 链路"代码全部保留,仅关闭入口")。


Q7. 台词、口型、字幕这块有哪些坑? ​

【考点】 中文电商场景的本地化细节,这类问题很能体现"你真的做过中文内容"。

【口述答案】 四个点。 第一,台词容量是硬约束。 中文口播的语速上限是每秒 4 个字(时长估算用 4.5 字/秒),所以我们校验"台词字数 ≤ 时长 × 上限",超了直接拒绝(并提示用户缩短台词或拆镜头)。理由是硬塞会导致模型截断或者漏掉尾句——用户看到的是"话没说完",但排查起来非常难。 第二,语音闭集规则。 我们要求模型"只念给定台词、念完必须静音、不许重复上一句"。这条规则来自一个真实缺口:分段链路在中段空档时模型会即兴补话,出来的往往是听不清的乱码人声。 第三,禁止模型在画面上渲染字幕,改成后期烧录。 因为 AI 渲染中文必然乱码。所以提示词里要求画面零文字,字幕由后期按脚本台词用 ASS 精确烧录。这里还有一个部署级坑:libass 找不到指定的中文字体时就渲染成方块,所以我们做了运行时字体探测,探测不到宁可不出字幕也不出乱码。 第四,多音字要"喂引擎前替换、但字幕保留原字"。 比如"东阿阿胶"喂给 TTS 之前替换成同音的"东婀婀胶",否则发音是错的;但只改喂引擎的那份文本,不动 copywriting 本体,这样字幕显示的还是正确的"东阿阿胶"。

【备注讲解】 "只改喂引擎的文本、不动展示文本"这个设计很值得单独点出来:它对应一个通用模式——同一份内容在"机器消费"和"人消费"两个通道上可以有不同的规范化形式,但必须明确哪个是真相源。同类的还有"包装文字 A/B 分类"(A 类噪声不得复述、B 类本体印刷必须忠实)以及"模型可能幻觉出价格叠层,所以生产链路直接禁止"。

【代码依据】 segmentValidation.ts:32-50(字数容量 + 与前端共用同一份 JSON 口径)、backend/services/prompts/videoPromptRules.ts:92-178(零字幕规则、语音闭集、多音字)、generator.js:630-640(台词只作声轨参考 + 多音字替换注释)、:1958-1965(writeSubtitleAss)、:1789-1791(字体探测,找不到宁可无字幕)、backend/services/longVideo/tts.js:52-53(多音字替换表)。


Q8. 分段拼起来会不会有问题?拼接是怎么做的? ​

【考点】 ffmpeg 工程细节,这类问题最能筛出"真调过"和"只见过"。

【口述答案】 拼接是我们自己在 Node 里编排 ffmpeg 做的,不用云剪辑。流程是:先把每段的规格归一化(分辨率、缩放/补边、像素比、帧率对齐到同一个规范),再走 concat;能 -c copy 就 copy,不行就降级重编码。 踩过的坑主要有三个: 坑一:音频帧粒度和帧率互质会导致拼接处卡帧。 AAC 的帧粒度是 23.2 毫秒,而 24fps 的帧是 41.7 毫秒,两者互质,音频会溢出写进 stts/mdhd,于是 concat 时段边界被插入一个畸长的音频包,表现就是"拼到那里卡一下"。根因其实是 -shortest 当时只加在了无音轨分支。 坑二:时基不同会让音频串味。 不同段可能来自不同的编码参数,所以我们归一化时会带 -video_track_timescale 统一时基,否则拼接后音画会整体漂移。 坑三:拼完必须校验时长。 我们对"各段时长之和"做了一次校验(容差 ±max(0.2 秒, 0.5%)),因为这个校验确实抓到过"某段 URL 是残缺的、对象还没写完就被下载了"的情况——我们当时的做法是先用 ffprobe 校验完整性再拼接,而不是相信 HTTP 的 content-length,因为对象没写完时服务器声明的长度就是当前的部分长度,光看长度是发现不了的。

【备注讲解】 可以主动交底一个现存状况:"仓库里现在有四套 concat 实现并存(多段生产、长视频、长视频 master 单趟、创意/标准复刻各一份),各自处理的细节不一样。这是真实的技术债,收敛方向是抽一个统一的'规格归一化 + 拼接'服务。"

【代码依据】 generator.js:1299(resolveConcatCanonicalSpec)、:1319(buildConcatNormalizeArgs,含 -video_track_timescale)、:1354(concatVideos + 时长校验)、backend/services/production/stitcher.ts:164-171(AAC 帧粒度与 stts 卡帧)、:297-313(抽帧用容器时长的问题)、:1120-1122("对象未写完时服务器声明的长度就是当前的部分长度" → 用 ffprobe 而不是 content-length)。


Q9. 有段失败了怎么办?会不会白花钱? ​

【考点】 部分失败的处理,是长视频链路最有工程含量的部分。

【口述答案】 分四层。 第一层,不自动拼一个缺镜头的片子。 我们有个显式判定 resolveConcatCompletionState:只有所有段都 done 才自动拼接;只要有段没完成,任务就标成 partial(部分完成),保留逐段视图让用户单独重试失败段,然后再拼。注释写得很清楚:"任务仍必须保持 partial,避免前端和历史记录把缺镜头的视频误报为完整成片。" 第二层,失败段可以单独重试。 单段重试时会重新组装那一段的输入(包括重新抽前驱尾帧),并且不会把任务误标成 done——重试期间会把任务切到"预编辑中",避免前端轮询看到 done 就停止。(前端轮询在 done/partial/failed 时会停,所以这是一个很容易踩的坑。) 第三层,钱按交付来算,而且不同链路规则不同:

  • 对话链路(首页/剧情模式):按交付段比例结算。公式是"应扣 = 总扣费 × 已交付秒数 / 总秒数",差额通过 settleCreditsWithAdjustment 原路退回(退款的赠送/充值拆分与冻结记录一致)。而且这个结算是幂等的——hold 已经结算或退款过时 adjustment 直接 no-op,重复保存历史不会重复退钱。
  • 脚本模式/爆款复刻:只有在没有任何一段成功时才整单退款,部分成功不退款。 第四层,完全失败才退款,而且必须 await。 退款函数会把实退金额写进任务配置,而历史快照要带上它给前端展示"已退回 X 积分"。早期有五个调用点是"即发即忘"(不 await),结果退款还没落库、历史快照就抢跑写完了,恢复出来的失败卡片永远显示"没退过钱"——注释里现在写着"别把 await 再删掉"。

【备注讲解】 🚩 这里要特别小心口径:两条链路的"部分失败"政策不一样(一个是按比例退、一个是完全不退),被追问时要说清是哪条链路。这也是个很好的主动交底点:"按比例结算只做在对话链路上;脚本模式目前是'部分成功不退',因为那些段本身已经交付给用户了。这两套口径不统一是历史原因,长期应该收敛。"

【代码依据】 generator.js:2391-2406(resolveConcatCompletionState + "缺镜头不能误报为完整成片")、:4965-4987(computeChatPartialSettlement 按交付比例)、:4989-5010(幂等结算)、:4703-4719("仅在没有任何段成功时退款" + await 警告)、:3250(retrySingleSegment)、:3090-3091(重试期间切状态避免前端停轮询)。


Q10. 长视频任务崩了怎么办?它是内存态吧? ​

【考点】 承接 04 那一册的"自管链路"话题——这是你自己写的恢复逻辑和这条链路的交汇点。

【口述答案】 对,长视频的状态主要在进程内存里(taskStore 是个内存 Map)。我们做了两层: 第一层,任务本身持久化到 Redis(TTL 24 小时),所以进程重启后还能把任务捞回来。 第二层,Redis 租约(lease)标志"这个进程还活着":活跃进程会持续给 shatang:longvideo:lease:<taskId> 续期。恢复巡检判定孤儿用的就是这个——lease 不见了(且超过宽限期)→ 说明那个进程已经不在了。 宽限期是 2 分钟,而且有一条安全规则:如果 Redis 查询本身抛异常,必须跳过不判——否则 Redis 抖一下就会把正在跑的任务误杀退款。原则是"不确定的时候什么都不做"。 判定成立之后的处置是:标失败 + 退款,因为内存态任务重启后既没有外部任务号可以查询、也没有中间状态可以续跑,重跑等于从头花钱。所以策略不是"恢复它",而是"安全地宣判它死亡并退款"。

【备注讲解】 这段可以和 04 分册的崩溃恢复连起来讲,形成完整闭环:"长视频是我们九条'内存自管'链路之一,它必须在通用恢复逻辑的最前面被拦掉——因为它在 video_tasks 里只有一行占位行,落到通用分支会被误标失败、清零积分,甚至拿空 prompt 去烧钱。"

【代码依据】 generator.js:2315-2330(runWithLongVideoLease + Redis key)、:2503-2509(任务持久化到 Redis TTL 24h)、backend/services/taskRecovery.ts:29-75(lease 判定 + 2 分钟宽限 + Redis 异常时跳过 + 双退款路径)。


Q11. 并发上做过哪些治理? ​

【考点】 上游限流意识。

【口述答案】 四处: 第一,段生成的信号量:默认并发 3——这个数字不是技术上限,而是"不打爆上游限流"的工程上限。 第二,TTS 预合成的独立信号量:并发 2,比视频段更保守,因为 TTS 接口更脆。 第三,前置阶段串行化:场景锚图生成和 TTS 预合成放在段生成之前,而不是跟段生成并发,就是因为"10 条任务同时进来,Phase 0 的突发会把第三方生图/TTS 限流打爆"——这条注释明确写了是"避免 10 条同时突发打爆第三方限流"。 第四,上游并发槽租约:到腾讯云这一层,并发额度是按账号的,我们用 Redis 分布式租约(跨进程、跨服务共享),并且槽的池名按账号 id 分片,因为不同账号的额度是硬隔离的。

第四层,段生成的等待方式有点特别:长视频的段不是在提交 Worker 里等结果,而是"提交完之后投一条 lv-segment 轮询 job 到共享的 video-poll 队列,然后段生成器 await 这个段的结果"(enqueueLvSegmentPollJob + awaitLvSegment,超时是"上游轮询超时 + 120 秒")。 这么设计的原因是:段生成器的并发是靠信号量控制的,而不是靠队列并发——如果让 BullMQ 的 Worker 直接跑整段生成,那"平均 5 分钟的段"会长期占住 Worker 槽位(和 04 那册里"提交与轮询分离"是同一个道理,只是这里的执行单元是"段"而不是"任务")。

【备注讲解】 主动交底一个已知缺口(来自项目自己的待办文档):候选静帧生成没有限并发——连点 N 个段会同时打 N 个生图请求,容易触发上游 429。修法是加前端信号量或改成后端批量端点控并发。说出这个会让"我做过并发治理"变得可信,因为你有边界感。

【代码依据】 generator.js:2451(concurrency 默认 3)、:3862(TTS 信号量 2)、:3549("避免 10 条同时突发打爆第三方生图/TTS 限流")、:2407-2438(Semaphore 实现)、:267-270 与 :376-380(enqueueLvSegmentPollJob + awaitLvSegment,超时 = 上游超时 + 120s)、REFACTOR.md:182-186(候选静帧无限并发的待办)、backend/services/tencent/slotAdmission.ts。


Q12. 这条链路现在还有什么问题? ​

【考点】 自省题。用项目自己的待办文档回答,可信度极高。

【口述答案】 我按项目里的待办文档说,这些都是明确记录的:

  1. 音色一致性依赖"引擎配音"这条较弱的路径(上一题讲过),一致性最强的整轨方案默认关着,需要时靠一个环境变量切回。
  2. 候选静帧生成没限并发,容易触发上游 429。
  3. 四套 concat 实现并存,是重复实现的技术债。
  4. 内存态任务不支持水平扩展:状态在进程里,多副本部署时对不上,只能靠 Redis lease 猜存活。
  5. 没有"跑全片之前先试播一段"的机制——长视频跑一次成本不低,本可以让用户先验证一段的拍摄风格再跑全片(这也写在待办里)。
  6. 前端入口有重复:早期还有一个独立的长视频页,跟主入口功能重复。

【备注讲解】 第 5 条特别值得说,因为它是一个产品视角的工程建议——"让用户先花小钱验证风格,再花大钱跑全片"。面试官会看到你不只在实现功能,也在想成本和用户体验。

【代码依据】 backend/services/longVideo/REFACTOR.md:124-198(B/C 系列待办:Step 2 可编辑、候选静帧并发限制、段视频试播、跨 tab 一致性补完、独立页修剪)。


Q13. 如果现在让你重做一遍,你会怎么设计? ​

【考点】 收尾题。

【口述答案】 三处改动。 第一,把段的依赖关系做成显式 DAG 而不是"两阶段"。 现在 Phase 1 并行、Phase 2 串行,只能表达"链式依赖";如果将来出现"多个段共享一个中间结果"或者"分支合并"的需求(比如一个场景拆段、另一个场景复用它的尾帧),两阶段就不够了。一个通用的 DAG 调度器能覆盖这些,代价只是实现复杂度。 第二,把段状态从内存搬到数据库,和短视频链路统一——这样恢复逻辑就不用为它单独写分支,也才能水平扩展。 第三,把"一致性约束"抽成一份可复用的资源。现在产品锁、角色锁、多角色守卫、一致性要求、场景锚图、角色锚图是六处各自为政的约束;更好的形态是"一个'一致性上下文'对象,由规划阶段生成,然后统一注入每一次段调用"。 但有一条我会保留:"并行段之间没有通信,所以共享上下文必须显式写进每一次调用" 这个判断——这是并行化一致性的本质,任何重构都不该把它当成可以省的样板代码。


Q14. 口述速记版(90 秒) ​

长视频是脚本驱动的:LLM 生成 scenes → shots,然后按场景和模型单段上限切成段,过一遍服务端硬校验(单镜/单段超上限永远拦,台词超容量可由用户强制放行),再过 Phase 0 生成场景锚图、Phase 0a 预合成 TTS 参考音、Phase 0b 做共享素材 fan-out(每段独立 URL 绕过上游缓存)。 段生成按依赖图跑两阶段:独立段并行(信号量默认 3)、桥接段串行——而"需不需要桥接"只看是不是同场景拆出来的段,跨场景不桥接,所以能并行。桥接时把上一段尾帧抽出来当下一段首帧,抽帧失败就降级为独立段,前驱失败则本段直接不跑。 一致性我按五层讲:产品锁、角色锁、多角色一致性守卫(因为角色锁是单主角口径,多角色脚本里是空的,而并行段彼此看不到对方成片,只能把"谁是谁、何时允许换装"在每段说同一遍)、每段固定的一致性要求(脸/发型/肤色一致、同场景服装不逐镜换)、以及角色锚图(先用生图模型出同一角色多角度定妆照,避免并行段各自想象)。 音色有两代方案:整片 TTS 音轨 + 整轨替换(物理保证一致但链路长)和引擎自带配音(默认开、快,靠素材 fan-out 绕过克隆缓存 + 音色基线复用缓解)。这是个明确的一致性 vs 交付速度取舍。 拼接是自己编排 ffmpeg:逐段归一化 → concat,踩过"AAC 帧粒度和 24fps 互质导致拼接卡帧"、"时基不同导致音画漂移",所以拼完必须校验时长,而且用 ffprobe 而不是 content-length 判完整性——对象没写完时服务器声明的长度就是当前长度。 失败处理:有段没完成就绝不自动拼接,标 partial 保留逐段视图让用户单独重试;钱按链路规则退——对话链路按交付段比例退,脚本模式只有全失败才整单退。

持续学习,持续构建。