The Editing Bay: Assembling Footage Into One Vertical Cut With ffmpeg
Assemble shot footage, voiceover, subtitles, and background audio along a timeline into one vertical finished cut, add transitions and a cover, and have the program generate the command from a structured timeline rather than letting the model improvise it.
Today's Goals
- Use ffmpeg to splice multiple shots along a timeline into one vertical finished cut
- Add subtitles, an audio track, transitions, and a cover to the finished cut, and output encoding parameters the target platform accepts
- Have the program generate the ffmpeg command from a structured timeline, rather than handing the command to the model to improvise
You already have a picture for every shot, a voice for every line, and a timeline table. Today does exactly one thing: turn those scattered files into an mp4 you could publish. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
The editing bay has only one table
An editor's screen is split in half: the preview window on top, a horizontal track laid out below. Clips sit in cells along that track, each labeled from second X to second Y. The editor's job, stripped down, is arranging those cells in order so picture and sound line up at the same instant.
We do exactly that, except our track is not dragged into place, it is computed. D5 already defined it: an array where each record says which shot, from which millisecond to which, which video file, which audio, which subtitle line. That is the timeline, and everything today starts from it.
It looks like this — one episode, four shots, twenty-one seconds:
[
{
"shotId": "s01",
"startMs": 0,
"endMs": 6000,
"clipPath": "work/runs/run-20260907-001/shots/s01/clip.mp4",
"voicePath": "work/runs/run-20260907-001/shots/s01/voice.mp3",
"subtitle": "Why did the phone light up by itself"
},
{ "shotId": "s02", "startMs": 6000, "endMs": 11000, "...": "..." }
]Remember one rule: this table is the single truth. How long the cut runs, when a subtitle appears, which second the cover is grabbed from — all of it is computed from this table, and no hand-typed time value is allowed anywhere in the code. When a reader reports "the third shot's subtitle is half a second late," you do not go digging through a dozen ffmpeg commands for that 0.5; you look at how the third record's startMs was computed. A bug has exactly one place to hide.
The first thing computed from the table is the subtitle file. SRT's format is almost austere: an index, a line of start and end timecode, one or two lines of text, entries separated by a blank line.
// The timecode is HH:MM:SS,mmm - a comma before the milliseconds, not a period.
// Write a period and most players simply behave as if there were no subtitles, silently.
function srtTime(ms) {
const p = (n, w = 2) => String(n).padStart(w, '0')
const h = Math.floor(ms / 3600000)
const m = Math.floor(ms / 60000) % 60
const s = Math.floor(ms / 1000) % 60
return `${p(h)}:${p(m)}:${p(s)},${p(ms % 1000, 3)}`
}
export function renderSrt(entries, leadInMs = 200) {
let index = 0
return entries
.filter((e) => e.subtitle)
.map((e) => {
index += 1
// A little breathing room at both ends: no text on the frame the cut lands on,
// and the cue clears a beat before the shot ends.
const from = e.startMs + leadInMs
const to = Math.max(from + 500, e.endMs - leadInMs)
return `${index}\n${srtTime(from)} --> ${srtTime(to)}\n${e.subtitle}\n`
})
.join('\n')
}# The timecode is HH:MM:SS,mmm - a comma before the milliseconds, not a period.
# Write a period and most players simply behave as if there were no subtitles, silently.
def srt_time(ms: int) -> str:
h, m, s = ms // 3_600_000, ms // 60_000 % 60, ms // 1000 % 60
return f"{h:02d}:{m:02d}:{s:02d},{ms % 1000:03d}"
def render_srt(entries: list[dict], lead_in_ms: int = 200) -> str:
blocks = []
for index, e in enumerate((x for x in entries if x.get("subtitle")), start=1):
# A little breathing room at both ends: no text on the frame the cut lands on,
# and the cue clears a beat before the shot ends.
start = e["startMs"] + lead_in_ms
end = max(start + 500, e["endMs"] - lead_in_ms)
blocks.append(f"{index}\n{srt_time(start)} --> {srt_time(end)}\n{e['subtitle']}\n")
return "\n".join(blocks)Not one number in that code was invented on the spot; even the 200 breathing value is a parameter, changed once and applied everywhere. Its value is not cleverness, it is that "where does time come from" is now permanently settled.
So how do those pieces actually become one video? There are two completely different paths, and choosing wrong costs you either a tenfold slowdown or a cut that tears from the second shot onward.
Two paths for joining: one absurdly fast, one painfully slow
The first is the concat demuxer. You write a manifest, one segment per line, and let ffmpeg read straight through:
# concat.txt contents: one line per segment, file '/absolute/path'
ffmpeg -f concat -safe 0 -i concat.txt -c copy -movflags +faststart joined.mp4-c copy is the whole point: no decoding, no re-encoding, just moving packets in order into a new container. A twenty-one-second cut appears in a fraction of a second with not one bit of quality lost.
But it has an unforgiving precondition: every segment's encoding parameters must be identical. Resolution, frame rate, pixel format, sample rate, channel count — one mismatch and at best the second segment tears and loses sound, at worst you get Non-monotonous DTS and a file with a scrambled duration. And AI-generated material is exactly the kind most likely to be inconsistent: different models from one vendor can output different sizes, and image-to-video output frequently has no audio track at all.
The second path is the filter graph: feed all inputs into a directed graph, let ffmpeg decode and compute, and re-encode the output. It can do anything — scaling, padding, mixing, transitions — at the cost of re-encoding the whole piece: a tenfold slowdown and one generation of quality loss.
The beginner's instinct is "the filter graph can do everything, so always use it." That is wrong. The right move is to chain both paths:
The normalization filter chain looks like this. You do not need to read it word for word — just see that a program assembled it:
ffmpeg -i clip.mp4 -i voice.mp3 -filter_complex \
"[0:v]scale=1080:1920:force_original_aspect_ratio=decrease,\
pad=1080:1920:-1:-1:color=black,fps=24,trim=duration=6.000,setpts=PTS-STARTPTS[v];\
[0:a]volume=0.25,aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,\
apad,atrim=duration=6.000,asetpts=PTS-STARTPTS[amb];\
[1:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,\
adelay=200|200,apad,atrim=duration=6.000,asetpts=PTS-STARTPTS[vo];\
[amb][vo]amix=inputs=2:duration=longest:normalize=0[a]" \
-map "[v]" -map "[a]" -c:v libx264 -crf 20 -pix_fmt yuv420p -r 24 \
-c:a aac -b:a 128k -ar 44100 -ac 2 -t 6.000 seg-s01.mp4Transitions are a special case of this choice. The frames of a cross-fade between two shots are newly computed, so there is no way to have a transition without re-encoding. Wanting transitions means the filter graph, stacking segments pairwise with xfade. And there is one very easy miscalculation: each segment's start offset must subtract the transition time already consumed before it. Four segments with three 300-millisecond transitions make the cut 900 milliseconds shorter than the timeline — forget to correct the subtitle timecodes and they drift further the later you go, evenly, looking like "everything is slightly late," which is the drift most often blamed on the player.
// The choice between the two paths happens in exactly one place, and it is a pure function:
// give it segments and whether a transition is wanted, get back an argument list.
// The arguments are computed, not string-concatenated, and certainly not model-generated.
export function buildConcatArgs(segments, durationsMs, { fadeMs = 0 } = {}) {
if (fadeMs === 0) {
// Path one: identical parameters, straight stream copy. The caller writes the manifest first.
return ['-f', 'concat', '-safe', '0', '-i', 'concat.txt', '-c', 'copy', 'joined.mp4']
}
// Path two: an xfade chain. The offset subtracts all transition time consumed before it.
const fade = (fadeMs / 1000).toFixed(3)
const chains = []
let v = '0:v'
let offsetMs = 0
for (let i = 1; i < segments.length; i += 1) {
offsetMs += durationsMs[i - 1] - fadeMs
chains.push(`[${v}][${i}:v]xfade=transition=fade:duration=${fade}:offset=${(offsetMs / 1000).toFixed(3)}[v${i}]`)
v = `v${i}`
}
return [...segments.flatMap((p) => ['-i', p]), '-filter_complex', chains.join(';'), '-map', `[${v}]`, 'joined.mp4']
}# The choice between the two paths happens in exactly one place, and it is a pure function:
# give it segments and whether a transition is wanted, get back an argument list.
# The arguments are computed, not string-concatenated, and certainly not model-generated.
def build_concat_args(segments: list[str], durations_ms: list[int], fade_ms: int = 0) -> list[str]:
if fade_ms == 0:
# Path one: identical parameters, straight stream copy. The caller writes the manifest.
return ["-f", "concat", "-safe", "0", "-i", "concat.txt", "-c", "copy", "joined.mp4"]
# Path two: an xfade chain. The offset subtracts all transition time consumed before it.
fade = f"{fade_ms / 1000:.3f}"
chains, v, offset_ms = [], "0:v", 0
for i in range(1, len(segments)):
offset_ms += durations_ms[i - 1] - fade_ms
chains.append(f"[{v}][{i}:v]xfade=transition=fade:duration={fade}:offset={offset_ms / 1000:.3f}[v{i}]")
v = f"v{i}"
inputs = [arg for p in segments for arg in ("-i", p)]
return [*inputs, "-filter_complex", ";".join(chains), "-map", f"[{v}]", "joined.mp4"]The vertical canvas: 1080 by 1920
Short drama is watched upright, on a fixed 1080x1920 canvas at 9:16. The number itself is unremarkable; what is worth discussing is what to do when the material does not match the canvas, because that is close to inevitable.
Two options: scale up to fill and crop the excess, or scale down to fit and pad the edges with black. The force_original_aspect_ratio=decrease plus pad in the command above is the second — preserve the content first, then pad. Why is that the default? Because cropping cuts off the top of a head or a chin, and in AI-generated frames the face's position is uncontrollable to begin with, so cropping goes wrong easily. Black bars are at least predictably ugly rather than randomly ruinous.
One thing gets overlooked even more than resolution: the safe area. On a phone, the very top and bottom of a vertical video are not fully visible — notches, rounded corners, the scrubber, and the platform's overlaid username and hashtags all eat into it. So subtitles must not hug the bottom edge, and the subject must not touch the top. Placing subtitles in the band roughly one-tenth to one-sixth up from the bottom, with ten percent margins on each side, is a safe default.
How many characters fit on a subtitle line also needs pinning down. A 1080-wide frame holds about thirteen or fourteen CJK characters per line before the font shrinks past comfortable phone reading. Long lines must be broken when the subtitles are generated, not left to the player.
Which brings us to today's most important engineering habit.
Probe the capability, then degrade
Faced with "this feature may not exist," the beginner writes it into the docs: "please make sure your ffmpeg supports the subtitles filter." That sentence achieves roughly nothing — readers do not read it, and if they do, they do not know how to check, and they give up on an error message they cannot parse.
The right approach is for the program to probe, take another route if the probe fails, and tell the user which route it took. ffmpeg lists every supported filter under ffmpeg -filters, so the probe is one command and one match:
ffmpeg -hide_banner -filters | grep -w subtitlesFound: burn in. Not found: fall back to soft subtitles — muxing the SRT into the mp4 container as a separate subtitle track using mov_text, the subtitle codec mp4 understands:
ffmpeg -i with-bgm.mp4 -i episode-1.srt -c copy -c:s mov_text \
-metadata:s:s:0 language=eng episode-1.mp4The two produce different things. Burned-in subtitles are pixels no player can turn off, which is usually what short-drama distribution wants; soft subtitles are a toggleable track, keeping the master clean and letting you swap in another translation, at the price that many platform players do not surface the toggle. So the default policy is "burn if you can, mux if you cannot, and say clearly which one happened", not a hard-coded choice of one.
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const run = promisify(execFile)
async function hasFilter(name) {
try {
const { stdout } = await run('ffmpeg', ['-hide_banner', '-filters'])
return new RegExp(`(^|\\s)${name}\\s`, 'm').test(stdout)
} catch {
return false // no ffmpeg at all, so certainly no such filter
}
}
export async function attachSubtitles(video, srt, out) {
if (await hasFilter('subtitles')) {
const escaped = srt.replace(/:/g, '\\:').replace(/'/g, `\\'`)
await run('ffmpeg', ['-y', '-i', video, '-vf', `subtitles='${escaped}'`, '-c:a', 'copy', out])
return 'burned'
}
// Fallback: a soft subtitle track. The cut still comes out; the viewer toggles it on.
await run('ffmpeg', ['-y', '-i', video, '-i', srt, '-c', 'copy', '-c:s', 'mov_text', out])
return 'soft'
}import re
import subprocess
def has_filter(name: str) -> bool:
try:
out = subprocess.run(["ffmpeg", "-hide_banner", "-filters"], capture_output=True, text=True).stdout
except FileNotFoundError:
return False # no ffmpeg at all, so certainly no such filter
return re.search(rf"(^|\s){name}\s", out, re.MULTILINE) is not None
def attach_subtitles(video: str, srt: str, out: str) -> str:
if has_filter("subtitles"):
escaped = srt.replace(":", r"\:").replace("'", r"\'")
subprocess.run(["ffmpeg", "-y", "-i", video, "-vf", f"subtitles='{escaped}'", "-c:a", "copy", out], check=True)
return "burned"
# Fallback: a soft subtitle track. The cut still comes out; the viewer toggles it on.
subprocess.run(["ffmpeg", "-y", "-i", video, "-i", srt, "-c", "copy", "-c:s", "mov_text", out], check=True)
return "soft"This pattern reaches far beyond subtitles. Whether a hardware encoder exists, whether a decoder for some format is present, which system fonts are installed — all follow the same shape: probe once, cache the result, degrade, announce. It works far better than a line of "please make sure" in a README.
Out of sync: check these three places
The first thing to do with a finished cut is listen. Audio-video desync is the most frequent problem at this stage, and every instance looks the same — mouth and sound half a beat apart — while the causes differ completely. Check in this order and three steps locate it almost every time.
First, check the timeline table. Subtract each record's startMs from its endMs and compare with the real duration of that shot's video file. If the table says six seconds and the file is 5.8, every sound from that shot onward arrives 0.2 seconds early, and the error accumulates. This is the most common one. The root cause is usually upstream: the video API's returned duration is not exactly what you requested.
Second, check whether the voice was truncated. A twelve-character line at roughly 0.22 seconds per CJK character needs 2.64 seconds, plus 0.2 seconds of breathing at each end, so just over 3 seconds; if the shot is only 2 seconds long, the last few characters are chopped off. That is not desync, but it sounds almost identical. So the conflict should be detected and warned about while generating the timeline, not discovered by ear in the finished cut. Today's BREAK_SHOT switch lets you cause one deliberately.
Third, check the join. If the first two are clean, the join introduced it. Under stream copy, misaligned audio and video start timestamps across segments produce offsets after joining; with transitions the cut gets shorter overall, and subtitles that were not recomputed show up as "worse the further in you go."
The cover: do not use the first frame
Once an episode is out, the overwhelming majority of people see exactly one thing: the cover. It decides the click rate, and the click rate decides whether the episode gets watched at all.
Beginners grab frame one, which is almost always wrong. The opening frame is usually black, or the start of a push-in, or a wide shot that has not settled. Worse, the first frame of an AI-generated video is often its least stable one, with a face that has not fully formed.
A few directly usable rules: prefer close-ups, because a thumbnail is thumbnail-sized on a phone and nothing in a wide shot reads; take the midpoint of that shot, not its start; if the episode has a clear emotional peak, take it there. In code that is a pure function: find the first shot whose size is close and take the midpoint of its start and end.
The grab itself is simple, but one detail is worth stating:
ffmpeg -ss 8.500 -i episode-1.mp4 -frames:v 1 -q:v 2 cover-1.jpgPutting -ss before -i is keyframe-level fast seeking, nearly instant; after -i it is frame-accurate, at the cost of decoding from the start. A cover needs no such accuracy, so put it before. This is one of the few places in ffmpeg where argument position changes behavior, and it is worth remembering.
What size the cover should be exported at, whether to add title text, and what ratio each platform requires belongs to distribution, and D13 covers it. Today, one 1080x1920 image matching the cut is enough.
Let the model fill parameters, not write commands
One design question last, and it matters more than every ffmpeg trick above.
The thought will occur to you: ffmpeg parameters are so complicated, why not just have the model generate the command? Hand it the timeline and the requirements, get back a full command line, execute it. Very Agent-like.
Do not. Three reasons, in order of severity.
First, it is a command injection surface. You are handing a string to a shell, and part of that string came from a sentence a user typed and a script a model wrote. One unescaped quote and arbitrary commands run on your server.
Second, it is not reproducible. Given the same timeline, the command generated today may differ from tomorrow's: changed encoding parameters, reordered filters, a dropped flag. Re-run one episode and the output differs, and you cannot even say what changed. A production line needs the same input to yield the same output always, and tomorrow that becomes a hard requirement.
Third, it is not debuggable. When the command is wrong you get one cryptic ffmpeg error and then have to guess why the model wrote it that way; when your own pure function generates the arguments, a wrong result is a wrong function, and an assertion tests it.
Is the model useless here, then? No — but its place is parameter filler, not command generator: is this shot cold or warm, hard cut or fade, which shot for the cover. It emits structured choices only, selected from an enumeration you fixed in advance. Validate the result against a whitelist, then feed it to your own argument-building function.
// The model only picks from an enumeration, and the pick still passes a whitelist.
// So even if the model is prompt-injected, the worst it can do is choose an ugly transition.
const TRANSITIONS = new Set(['none', 'fade'])
const CROPS = new Set(['pad', 'crop'])
export function sanitizePlan(raw) {
return {
transition: TRANSITIONS.has(raw?.transition) ? raw.transition : 'none',
crop: CROPS.has(raw?.crop) ? raw.crop : 'pad',
// The cover shot must actually exist in this episode's timeline, else fall back to default.
coverShotId: typeof raw?.coverShotId === 'string' ? raw.coverShotId : null,
}
}# The model only picks from an enumeration, and the pick still passes a whitelist.
# So even if the model is prompt-injected, the worst it can do is choose an ugly transition.
TRANSITIONS = {"none", "fade"}
CROPS = {"pad", "crop"}
def sanitize_plan(raw: dict) -> dict:
return {
"transition": raw.get("transition") if raw.get("transition") in TRANSITIONS else "none",
"crop": raw.get("crop") if raw.get("crop") in CROPS else "pad",
# The cover shot must actually exist in this episode's timeline, else fall back.
"coverShotId": raw["coverShotId"] if isinstance(raw.get("coverShotId"), str) else None,
}This boundary is worth reusing in any scenario where an Agent touches system resources: the model judges, the program executes; the model's output must land in a constrained structure before it becomes an action. Hold on to that sentence and you will not go far wrong designing any Agent that does real work.
Source Reading
Hands-On Lab
Confirm two things before starting: ffmpeg -version and ffprobe -version both run on this machine, and yesterday's dubbing output is still there. The lab needs no API key — MOCK=1 has ffmpeg generate placeholder material on the spot — but the business logic (timeline computation, SRT generation, argument assembly, capability probing) all runs for real. If you get stuck, look at the full ffmpeg command printed in the terminal and run it standalone; the error will be far clearer.
- Run the full flow once, open the cut, and confirm the four shots join in order, each has voice, and the subtitles can be turned on in a player.
- Inspect the cut with ffprobe: three streams, 1080x1920, duration matching the timeline, and reconcile those three numbers against the table printed in the terminal.
- Turn on
TRANSITION=fadeand run again, compare the elapsed time, and confirm the subtitle timecodes shifted earlier by three transitions' worth. - Open the generated cover, confirm it is not a black frame, and find the line in the code that decided which second to grab.
- Use
BREAK_SHOTto shorten one shot, watch the warning and the change in the cut, then trace the warning back to that record in the timeline.
Interview Questions
Today's 3 questions are in the question bank below, focused on the division of labor between the timeline data structure and command generation, the order of desync diagnosis, and the risk of letting a model generate commands. Read the analysis before the key points — practicing the derivation beats memorizing the points. The cn / global labels let you pick by target market.
Checklist and Tomorrow
- I can use ffmpeg to splice multiple shots along a timeline into one vertical finished cut
- I can add subtitles, an audio track, transitions, and a cover to the finished cut, and output encoding parameters the target platform accepts
- I can have the program generate the ffmpeg command from a structured timeline, rather than handing the command to the model to improvise
- I can state the precondition and the cost of stream-copy joining versus filter-graph joining, and why normalization comes first
- I can recite the three-step desync diagnosis and say why step one is the likeliest hit
- All 5 lab acceptance criteria pass
- I can answer at least 2 of the 3 interview questions without looking at the key points
Tomorrow (D7) we connect all six stages end to end, running one command from a sentence to a finished cut, and measure how much time and money each stage consumed. Why wait until day seven to connect them? Because only once all six run individually are the problems that surface problems caused by the connecting itself — artifact paths, intermediate state, log flooding, and the most painful one: step four blows up and the money spent on the first three is gone. D7 does not fix that; it makes you see it clearly, because only then does D8's workflow engine mean anything.
Interview questions
What are the risks of letting an LLM generate ffmpeg command lines directly, and how would you redesign it?如果让大模型直接生成 ffmpeg 命令来合成视频,会有什么风险?你会怎么改造这个设计?
Common in ChinaCommon overseasDeep dive#prompt-injection#pipeline-design#reproducibilityHow to reason about it · think before answering
- This probes where you draw the line between model judgment and real execution. Saying 'injection risk' is the passing bar; missing reproducibility and debuggability signals you have not run a generative pipeline in production.
- The chain is short: model output is untrusted input, untrusted input into a shell is command injection, model output is also nondeterministic, nondeterministic commands mean the same input yields different files, and debugging then requires guessing what the model was thinking.
- The fix is not 'validate and forward'. Move the model: let it emit only structured choices drawn from an enum you fixed in advance (transition type, crop strategy, which shot the cover comes from), and compute the command yourself from the timeline with a pure function.
- Add the concrete detail: invoke external binaries with an argument array (execFile, not exec) so escaping stops being a class of bug, then whitelist-validate the model's choices and fall back to a default instead of erroring.
- Expect the follow-up 'so what is the model still good for here'. Answer: taste calls — tone, cover selection, whether to use a transition. Judgment to the model, execution to the program. That boundary generalizes to any agent with side effects.
分析过程 · 先想清楚再作答
- 这题考的是「Agent 到底能不能碰真实执行」这条边界,区分度在于你会不会主动说出安全之外的两条。只答「有注入风险」是及格线,答不出可复现与可调试就说明没在生产里跑过生成式流水线。
- 推导链很短:模型的输出是不可信输入 → 不可信输入进 shell 就是命令注入 → 而且模型输出天然不确定 → 不确定的命令意味着同样的输入产出不同的文件 → 排查时你还得先猜模型当时为什么那么写。三条风险分别对应安全、可复现、可调试。
- 改造的方向不是「加一层校验就放行」,而是把模型挪到另一个位置:让它只输出结构化的选择项,且每一项都从你定死的枚举里选(转场类型、裁切策略、封面取哪一镜),命令本身由你自己的纯函数从时间轴算出来。
- 补一句更硬的落地细节:调用外部程序不要走 shell 字符串,用参数数组(execFile 而不是 exec),从根上消掉转义问题;再加一层白名单校验,模型给出枚举外的值就退回默认值而不是报错。
- 可预期的追问是「那模型在这一环还有什么用」。答:用在需要审美判断的地方——情绪偏冷还是偏暖、封面选哪一镜、要不要转场。判断交给模型,执行留给程序,这是所有会产生副作用的 Agent 场景的通用分界。
Key points
- Three risks in order: command injection, non-reproducible output, undebuggable failures. Naming only the first is not enough.
- Turn the model into a parameter filler: structured choices constrained to a predefined enum.
- Generate the command from the timeline with a pure function, invoked via an argument array rather than a shell string.
- Whitelist-validate and fall back to defaults for out-of-enum values instead of surfacing an error.
- One-line boundary: the model decides, the program executes.
答题要点
- 三条风险按严重度排:命令注入、结果不可复现、报错不可调试;只说第一条不够。
- 改造成参数填充器:模型输出结构化选择项,取值必须落在预定义枚举里。
- 命令由程序的纯函数从时间轴生成,用参数数组调用而不是拼 shell 字符串。
- 白名单校验兜底,枚举外的值退回默认,而不是把错误抛给用户。
- 分界线一句话:模型负责判断,程序负责执行。
An auto-generated episode comes out with audio and video out of sync. What is your debugging order, and why that order?一集自动生成的短剧成片出现音画不同步,你的排查顺序是什么?为什么是这个顺序?
Common in ChinaCommon overseasIntermediate#debugging#av-sync#timelineHow to reason about it · think before answering
- The question is about ordering, not about listing causes. The interviewer wants to see you rank checks by hit rate divided by cost, not enumerate everything you can think of.
- Ask yourself first: where does time come from in this pipeline? If the answer is 'a structured timeline table', then step one is comparing planned durations in that table against the real durations of the media files. Highest hit rate, lowest cost, one ffprobe call.
- Step two is the upstream artifacts: when the voice track is longer than the shot, the line gets cut off. It sounds almost identical to drift but the root cause is different, and it should have been caught with a warning when the timeline was built.
- Step three is the compose stage: stream-copy concatenation requires identical parameters across segments, and misaligned timestamps shift things; adding crossfades shortens the final cut, so subtitles drift progressively unless their timecodes are recomputed.
- Also mention a general move: when all three fail, stop staring at the final cut and play the normalized per-shot segments to narrow the problem to one shot. Always shrink the search space before guessing.
- Expect the follow-up 'how do you stop relying on human ears'. Answer: assert at timeline-build time when planned and actual durations diverge beyond a threshold, and automatically verify that the final cut's duration matches the timeline total.
分析过程 · 先想清楚再作答
- 这题的题眼在「顺序」两个字,不在「有哪些原因」。面试官想看的是你会不会按「命中率乘以排查成本」来排,而不是把想到的原因罗列一遍。
- 先问自己一个问题:这条流水线上,时间是从哪里来的?如果答案是「一张结构化的时间轴表」,那么第一步必然是拿表里的计划时长和素材文件的真实时长去对——这一步命中率最高、成本最低,一条 ffprobe 就能查完。
- 第二步查上游的产物本身:配音时长超过镜头时长时,台词会被截断,听感和不同步几乎一样,但根因完全不同。这类冲突应该在生成时间轴时就打警告,而不是留到成片阶段靠耳朵发现。
- 第三步才查合成环节:流拷贝拼接要求各段参数一致,时间戳对不齐就会错位;加了转场则成片整体变短,字幕若没跟着重算,表现为越到后面偏得越多。
- 还有一条通用招式值得说出来:三步都查不出来时,不要在成片里死磕,去播归一化之后的单镜片段,把问题缩小到某一镜身上。排查多段合成的问题永远优先缩小范围。
- 可预期的追问是「怎么让这类问题不再靠人耳发现」。答:在时间轴生成阶段加断言(计划时长与素材真实时长的偏差超过阈值就失败),并把成片时长与时间轴总时长的一致性做成自动校验。
Key points
- Start with the timeline table: compare planned durations against the media files' real durations. Highest hit rate, cheapest check.
- Then check whether the voice track exceeds the shot duration and truncates the line. That should be warned about at timeline-build time.
- Only then look at compose: concat method, timestamp alignment, and crossfades shortening the cut without recomputed subtitle timecodes.
- General move: play the per-shot normalized segments to isolate one shot instead of guessing on the final cut.
- Long term, turn duration consistency into assertions and automated checks rather than relying on ears.
答题要点
- 先查时间轴表里的计划时长与素材真实时长是否一致,这一步命中率最高、成本最低。
- 再查配音是否超出镜头时长导致台词被截断,这类问题应在生成时间轴时就报警告。
- 最后查合成环节:拼接方式、时间戳对齐、转场是否让成片变短而字幕没重算。
- 三步之外的通用招式:播单镜片段把问题缩小到某一镜,不要盯着最终产物猜。
- 长期方案是把时长一致性做成断言与自动校验,不靠人耳兜底。
When can you concatenate video segments without re-encoding, and when must you re-encode?把多个视频片段拼成一条完整的视频,什么时候可以不重新编码,什么时候必须重编码?
Common in ChinaCommon overseasBasic#ffmpeg#encoding#media-pipelineHow to reason about it · think before answering
- This is a giveaway concept question, but it only gives points to people who state the precondition. 'Just use concat' and 'stream copy requires identical parameters' read as two different levels.
- There is exactly one criterion: does concatenation only need to move packets into a new container in order? If yes, stream copy works. If even one frame has to be newly computed, you must re-encode.
- Be able to recite the preconditions: resolution, frame rate, pixel format, codec, audio sample rate and channel layout must all match. Miss one and you get corruption, dropped audio, or a broken duration.
- Cases that force re-encoding: transitions (those frames are new), scaling and padding to a common canvas, mixing in a new audio track, or changing encoding parameters. AI-generated material varies in size and often lacks audio, so normalization is almost always required in practice.
- The conclusion is a combination: normalize each shot with its own filter graph pass, then stream-copy the now-identical segments together. Total re-encoding is still one pass, but you gain full control over each shot.
- Expect the follow-up 'how do you know whether the parameters match'. Answer: read the key fields of each segment with ffprobe and compare. That precheck belongs in any automated pipeline.
分析过程 · 先想清楚再作答
- 这是一道概念送分题,但送分的是「说出前提」的人。答「用 concat 就行」和答「参数一致才能流拷贝」,在面试官眼里是两个水平。
- 判据只有一条:拼接是不是只需要把数据包按顺序搬进新容器。只搬不算,就能流拷贝;只要有任何一帧画面是新算出来的,就必须重编码。
- 流拷贝的前提要能背出来:分辨率、帧率、像素格式、编码器、音频采样率、声道数全部一致。差一项,产物要么花屏掉音,要么时长错乱。
- 必须重编码的典型场景:转场(那几帧是新画面)、缩放补边到统一画布、混入新的音轨、改变编码参数。AI 生成的素材尺寸和音轨天然不一致,所以实际工程里几乎总要先归一化。
- 结论落在一个组合拳上:每一镜单独走一次滤镜图做归一化,然后用流拷贝把参数已经一致的片段拼起来。重编码的总量还是一遍,但换来了对每一镜的完全控制。
- 可预期的追问是「怎么判断素材参数一不一致」。答:用 ffprobe 把每段的关键字段读出来做一次比对,不一致就走归一化,这一步也是自动化流水线里必须有的前置检查。
Key points
- The criterion is whether concatenation only moves packets: if so, stream copy; if any frame is newly computed, re-encode.
- Stream-copy preconditions: identical resolution, frame rate, pixel format, codec, sample rate and channel layout.
- Transitions, scale-and-pad, mixing a new audio track, and changing encoding parameters all force re-encoding.
- The practical combination: normalize per shot first, then stream-copy concatenate. Total re-encoding stays at one pass.
- Use ffprobe to compare segment parameters as a pipeline precheck.
答题要点
- 判据是拼接是否只需要搬数据包:只搬就能流拷贝,有新算出来的帧就必须重编码。
- 流拷贝的前提:分辨率、帧率、像素格式、编码器、采样率、声道数全部一致。
- 转场、缩放补边、混入新音轨、改编码参数,这几类一定要重编码。
- 实践中的组合拳:先逐镜归一化,再流拷贝拼接,重编码总量仍是一遍。
- 用 ffprobe 比对各段参数,作为流水线里的前置检查。