We record interview answers in the browser with MediaRecorder, upload the blob, and process it on a worker: pull three frames for the visual analysis, convert the audio for transcription.
Step one of processing is "how long is this video". That sounds like the easy part.
const data = await runFfprobe([
'-v', 'error',
'-show_streams',
'-show_format',
'-of', 'json',
videoPath,
]);
const durationSeconds = parseFloat(data.format?.duration);
This returns NaN for a large share of real recordings, and the reason is structural rather than a bug anywhere.
Why the duration is missing
WebM is a Matroska container. The duration lives in a header element near the start of the file, and its value is not known until recording finishes. A normal encoder writes the whole file, then seeks back to the header and fills it in.
MediaRecorder is streaming. It emits chunks as they are produced, designed so you can upload while still recording, and it never goes back to patch the header. Firefox behaves differently from Chrome here, and both differ depending on whether you passed a timeslice to start(). So you get files that are completely valid, completely playable, and carry no duration at all.
Browsers handle this by decoding until they hit the end, which is why a WebM in a <video> element often shows a duration of Infinity until you seek to the end and back. That trick is available in the browser. It is not available to ffprobe -show_format.
Three levels, cheapest first
const formatDuration = parseFloat(String(data.format?.duration));
const streamDuration = parseFloat(String(videoStream.duration));
let durationSeconds = isNaN(formatDuration) ? streamDuration : formatDuration;
if (isNaN(durationSeconds) || durationSeconds <= 0) {
durationSeconds = await probeDurationFromPackets(videoPath);
}
if (isNaN(durationSeconds) || durationSeconds <= 0) {
throw new InterviewError(/* ... */, 'The uploaded video appears to be empty or corrupted.');
}
-
Container level
format.duration. Present for anything that went through a normal encoder. Free, already in the probe we were doing anyway. -
Stream level
stream.duration. Sometimes present when the container header is not. - Packet timestamp scan. The real answer for browser WebM.
The third one is the interesting one:
async function probeDurationFromPackets(videoPath: string): Promise<number> {
try {
const data = await runFfprobe([
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'packet=pts_time,duration_time',
'-of', 'json',
videoPath,
]);
let max = 0;
for (const pkt of data.packets ?? []) {
const pts = parseFloat(pkt.pts_time ?? 'NaN');
const dur = parseFloat(pkt.duration_time ?? '0');
const end = pts + (isFinite(dur) ? dur : 0);
if (isFinite(end) && end > max) max = end;
}
return max > 0 ? max : NaN;
} catch {
return NaN;
}
}
If the header will not tell you where the file ends, ask the packets. The last video packet's presentation timestamp plus its own duration is the end of the video.
Two things make this affordable. -show_entries packet=... reads the packet index without decoding a single frame, so it is fast even on a long recording. And -select_streams v:0 restricts it to the first video stream, so you are not walking audio packets to find a video duration.
The max accumulator rather than "read the last packet" is deliberate. Packets are in storage order, not necessarily presentation order, and with B-frames those differ. Taking the maximum is correct in both cases and costs nothing.
It returns NaN on failure rather than throwing, because it is the last of three fallbacks and the caller owns the error message. A helper that throws its own exception from inside a fallback chain makes the chain read backwards.
Once you have a duration, you can do arithmetic on it
Everything downstream depends on that number being real:
const timestamps = [durationSeconds * 0.25, durationSeconds * 0.5, durationSeconds * 0.75];
Three frames at quarter points, rather than the first frame and the last. The first frame of a webcam recording is frequently the person still reaching for the button, and the last is them reaching for it again. The quarter points are where somebody is actually answering the question.
If duration had silently come back as 0, all three timestamps would be 0 and we would extract the same frame three times. It would not error. It would just quietly produce a worse analysis, which is the failure mode to fear.
There is also a ceiling:
const MAX_VIDEO_DURATION_SECONDS = 180;
Recordings are capped at 60 seconds client side. The server ceiling is three times that, deliberately generous for clock skew and container overhead, and it exists because the client cap is a setTimeout in a page anybody can edit. It is not there to enforce the product rule, it is there so a malformed or hostile upload cannot hand us a duration that turns into an hour of processing.
Two ffmpeg flags that changed underneath us
await runFfmpeg([
'-ss', timestamps[i].toFixed(3),
'-i', videoPath,
'-frames:v', '1',
'-q:v', '2',
'-update', '1', // required by ffmpeg >= 7 for single-image JPEG output
'-y',
outputPath,
]);
-update 1 became required in ffmpeg 7 for writing a single image to a fixed filename. Without it, newer builds refuse rather than warn. If you have frame extraction code written a few years ago, this is the flag that broke it.
-frames:v 1 is the current spelling of -vframes 1. Both work today; only one is documented as preferred.
Putting -ss before -i is the other one that matters. Before the input, it seeks the container and starts decoding near the timestamp. After the input, it decodes from the beginning and discards frames until it arrives. For a 60 second clip the difference is small. It is the difference between a job that scales and one that does not.
And then the check nobody writes the first time:
try {
await fs.access(outputPath);
} catch {
throw new InterviewError(`Frame ${i + 1} output file not created at ${outputPath}`, /* ... */);
}
ffmpeg can exit 0 without producing the file. Seek past the end of a stream and you get a clean exit and no output. If you only check the exit code, you find out later when something tries to read a file that is not there, with a stack trace that points nowhere near the cause.
Why not fluent-ffmpeg
const execFileAsync = promisify(execFile);
Direct execFile, no wrapper. Two reasons, and neither is snobbery about dependencies.
The processing runs in a Trigger.dev ESM worker, and wrappers built around event emitters and stream plumbing behave unreliably in that environment in ways that are painful to debug through a layer you did not write. And fluent-ffmpeg is deprecated, so we would be inheriting its maintenance either way.
What the wrapper was buying us was a fluent API over a command line that is already well documented. What it cost was an indirection between our arguments and the arguments ffmpeg actually received. execFile with an explicit array is the version you can paste into a terminal to reproduce, which is worth more than the syntax.
The binaries themselves come from the environment:
function ffmpegBin(): string {
return process.env.FFMPEG_PATH ?? 'ffmpeg';
}
Set by the platform's ffmpeg extension in production, falling back to the system PATH in local dev. One line, and the same code runs in both places.
Where this ends up
All of that machinery exists to turn a browser recording into structured feedback on how somebody answered a question. The interview practice page describes what comes out the other end, which is the part users care about.
If you are recording video in a browser and doing anything with the file afterwards, the one thing to take from this: do not trust format.duration. Write the packet scan fallback before you need it, because the day you need it is the day a user's upload is failing and you cannot reproduce it locally.
Top comments (0)