I was building listener.app — bookmark a moment in a live Zoom meeting and get a shareable highlight, ready before the meeting ends. To pull that off I record the stream as many small chunks and cut them with ffmpeg -c copy from a Node Lambda, instead of re-encoding one giant file. Here's the whole system: the FFmpeg, the Node, and the AWS.
listener.app let you bookmark moments in a live Zoom meeting with a small companion tool. The promise was simple: the instant you bookmark 2:13 to 2:18, the system starts cutting that highlight, so by the time everyone says goodbye the clip is already sitting there — downloadable, shareable, done. A meeting runs 45 to 90 minutes. A highlight is 5 to 30 seconds. A host might bookmark a dozen of them, and none of them are allowed to wait for the recording to finish.
The obvious design is to capture one big file, then per bookmark run ffmpeg -ss … -t …over it and re-encode. It falls apart almost immediately:
A highlight from a live meeting in 2–4 seconds, for a fraction of a cent each, that never re-encodes the original stream — so the clip is ready before the host hangs up.
Instead of one big MP4 per recording, the recorder writes the live stream as a sequence of short, fixed-length chunks — here, 240 seconds each. This one decision is the entire trick. Everything else is bookkeeping.
Why 240 seconds?
// clip-lambda/config.ts
export default {
videoCodec: "libx264",
outputFormat: "mp4",
chunkSize: 240, // <- the magic number, in seconds
clipDimensions: ["512x288", "768x432"],
};The whole pipeline is a brain and a muscle. The API + MongoDB owns the chunk index and decides what work is even possible; a short-lived Node Lambda does the FFmpeg labour. Everything in between is S3 and a queue. Tap any stage to see what it's responsible for.
-c copy, optionally concat, scale to a couple of sizes, PUT the result back. It trusts nothing — it re-runs the chunk math itself before touching FFmpeg.The split matters: the API turns away impossible requests with a single MongoDB lookup, so the expensive Lambda only ever runs when it can actually succeed. More on that in a moment.
Given a bookmark in seconds relative to the whole meeting, and the chunk-duration map from MongoDB, the API works out which chunks to fetch from S3 and at what offsets. Drag the handles — the plan and the real FFmpeg commands update live.
The hot path is the single-chunk case: most highlights are short enough to live inside one chunk, so they need exactly one S3 GET and one copy. Here's the core of that logic — the same math the Lambda re-runs before it trusts the payload it was handed:
// which chunk does a timestamp fall in, and where inside it?
function locate(t, chunks) {
let acc = 0;
for (let i = 0; i < chunks.length; i++) {
if (t < acc + chunks[i] || i === chunks.length - 1) {
return { chunk: i + 1, offset: t - acc, dur: chunks[i] };
}
acc += chunks[i];
}
}
// a clip is single-chunk when both ends land in the same one
function planClip(start, end, chunks) {
const s = locate(start, chunks);
const e = locate(end, chunks);
if (s.chunk === e.chunk) {
return { kind: "single", chunk: s.chunk, ss: s.offset, t: e.offset - s.offset };
}
return { kind: "span", from: s, to: e }; // pull two, then concat
}For the common single-chunk case, the entire FFmpeg command is this — and every flag is earning its place. Tap each card to see the version that looks almost identical but costs far more.
ffmpeg -ss 00:01:12 -i chunk_2.mp4 -t 6 -c copy clip.mp4ffmpeg -i chunk.mp4 -ss 00:01:12 -t 6 -c copy out.mp4
-ss after -i is an output seek: FFmpeg decodes and throws away every frame from 0 to 1:12 before it starts copying. On a big file that's seconds of wasted work.
ffmpeg -ss 00:01:12 -i chunk.mp4 -t 6 -c copy out.mp4
-ss before -i is an input seek: FFmpeg jumps to the nearest keyframe and starts almost instantly. Same result, a fraction of the time.
ffmpeg -ss 00:01:12 -i chunk.mp4 -t 6 -c:v libx264 out.mp4
Re-encoding a 6-second clip with libx264 burns real CPU and softens the picture. You only need it when you must cut between keyframes.
ffmpeg -ss 00:01:12 -i chunk.mp4 -t 6 -c copy out.mp4
-c copy copies the audio and video packets byte-for-byte. No decode, no encode — FFmpeg is basically doing a smart copy over the container. Milliseconds, and identical quality.
A note on precision: millisecond timestamps like 00:01:12.347 make cuts feelframe-accurate, but stream-copy still snaps to the nearest keyframe underneath. With short keyframe intervals the error stays under half a second — a trade I'll take every time over re-encoding.
When the selection spans two chunks, it's three fluent-ffmpeg calls — and none of them re-encode the video. Step through them; this is the same builder the Lambda runs.
Grab from the clip's start offset to the end of the chunk it falls in — still a pure stream-copy, driven through fluent-ffmpeg.
// pull the tail of the start chunk — still a pure stream-copy
ffmpeg("chunk_1.mp4")
.inputOptions(["-ss 00:03:50"]) // input-side seek
.outputOptions(["-t 10", "-c copy"]) // copy packets, no re-encode
.on("end", () => resolve("part_1.mp4"))
.on("error", reject)
.save("part_1.mp4");This only works because both files came out of the same encoder with the same parameters and start on a keyframe. If MP4 boxes ever refuse to join cleanly, there's a more forgiving route: convert each part to MPEG-TS first (still a copy) by passing .outputOptions(["-bsf:v h264_mp4toannexb", "-f mpegts"]), then concat the TS files. It's the textbook fix when the plain MP4 demuxer gets cranky.
Everything above is FFmpeg the command-line tool. But the cutter is a Node Lambda, so in practice every cut is FFmpeg-as-a-child-process. I drive it with fluent-ffmpeg — a chainable builder over child_process.spawn — and the static ffmpeg binary ships in a Lambda layer at /opt/bin/ffmpeg, because Lambda doesn't have FFmpeg by default.
// clip-lambda/ffmpeg.ts
// FFmpeg is a CLI. Inside a Node Lambda you drive it as a child process —
// fluent-ffmpeg is a chainable builder over child_process.spawn.
import ffmpeg from "fluent-ffmpeg";
ffmpeg.setFfmpegPath("/opt/bin/ffmpeg"); // static binary shipped in a Lambda layer
export function cut(input, ss, duration, output) {
return new Promise((resolve, reject) => {
ffmpeg(input)
.inputOptions([`-ss ${ss}`]) // input-side seek — BEFORE -i
.outputOptions([`-t ${duration}`, "-c copy"])
.on("end", () => resolve(output))
.on("error", reject)
.save(output);
});
}A few things bite you here that the docs never mention. .on("error") is not optional — a rejected promise that nobody catches will hang the whole invocation until Lambda times out. The order of .inputOptions() versus .outputOptions() maps directly to before/after -i, so the seek-placement rule from earlier is something you enforce in which builder method you call. And fluent-ffmpeg swallows stderr by default — pipe it somewhere, because when a cut fails, FFmpeg's stderr is the only thing that tells you why.
The highlight keeps its original resolution, but feeds and mobile need smaller variants — and you can't rescale and -c copy at once, because -vf scale forces a decode and re-encode. So after the lossless cut, I fan the variants out with Promise.all, since each one is independent:
// the ONE re-encode: smaller variants for feeds + mobile.
// each is independent, so fan them out with Promise.all.
const variants = ["512x288", "768x432"];
await Promise.all(
variants.map((dim) => {
const [w, h] = dim.split("x");
return new Promise((resolve, reject) =>
ffmpeg(clip)
.outputOptions([`-vf scale=${w}:${h}`]) // scale ⇒ must re-encode
.on("end", resolve)
.on("error", reject)
.save(`clip_${dim}.mp4`)
);
})
);This is fine because it runs on a 5–30 second highlight, not a 90-minute meeting. The expensive thing — re-encoding the source — never happens. The cheap thing — re-encoding a few seconds of already-cut output — happens once, in parallel, off the critical path.
Clip jobs are bursty, short, parallel, and stateless — a host bookmarks five moments in a minute, then nothing for ten. That's exactly the shape Lambda is good at: I pay for the couple of seconds each cut runs and nothing while no one's clipping. But bundling FFmpeg into a function means living with a few limits.
// clip-lambda/handler.ts
export const handler = async (event) => {
try {
const plan = planClip(event.start, event.end, event.chunks); // re-run the math, trust nobody
const clip = await produceClip(plan); // GET chunk(s) from S3 → cut → (concat) → scale → PUT
await sendToSQS({ highlightId: event.highlightId, clip }); // async completion, not a sync reply
return ok(clip);
} finally {
deleteAllLocalFiles(event.verificationKey); // wipe /tmp even on success — warm runs share it
}
};The cheapest optimisation in the whole system lives in the API, not the Lambda. While a meeting is live, hosts constantly bookmark a moment that's still "now" — before the final chunk has uploaded to S3. One MongoDB read catches that and skips the invocation entirely. A Lambda call that was going to fail is a Lambda call you didn't have to pay for:
// api/src/highlight.ts — refuse impossible work before paying for a Lambda
const { callLambda } = await shouldCallLambda(start, end, chunkMap, recordingId);
// the chunk isn't in S3 yet — e.g. the host bookmarked a moment that is still "now"
if (!callLambda) return;
await invokeClipLambda({ start, end, chunks: chunkMap, highlightId });And the Lambda never replies to the API directly. It pushes to SQS; a worker drains the queue, updates MongoDB, and fires a WebSocket event to the listener.app UI. That decoupling is what lets a host bookmark ten moments in thirty seconds without the UI ever blocking — each highlight just appears as its cut lands.
A handful of things I didn't expect going in. Tap to read each one.
-ss from after -i to before it. Input seek versus output seek — same flag, completely different cost model. I now write the seek first out of muscle memory.fluent-ffmpeg swallows FFmpeg's stderr by default. The first time a concat produced a corrupt file I was completely blind until I piped stderr into the logs. That stream is where FFmpeg tells you exactly what it didn't like.finally, never assuming a clean slate.Same clip, two designs. Flip the switch to compare the naive "download everything and re-encode" approach against the chunked stream-copy one.
Storage barely moves — the chunks plus a clip add up to about the same bytes as one big file plus a clip. The win is entirely in compute saved and, just as much, in latency. Making clips feel instant changed how people used the feature: it stopped feeling like a render job and started feeling like highlighting text.
Four things that look obvious now. Tap to expand.
Every flag this system leans on, in one place. Filter by keyword or category.
The two that matter most are the seek-placement rule[1] and the concat demuxer[2]. Get those right and most of this falls out naturally.
Don't record one big file. Record a stream of well-formed, identically-encoded chunks. Then every "edit" becomes download the one or two chunks I need, copy the bytes out, optionally glue them, and never re-encode. The savings compound across CPU, latency, bill, and the way the feature feels.
Get the recorder, the index, and the cutter to cooperate — and a real-time video editor runs on a coffee-tab bill.
Use FFmpeg's stream-copy mode: ffmpeg -ss START -i input.mp4 -t DURATION -c copy out.mp4. The -c copy flag copies the audio and video packets byte-for-byte instead of decoding and re-encoding them, so a cut takes a few hundred milliseconds instead of tens of seconds and the quality is identical to the source.
-c copy tells FFmpeg to copy every audio and video stream straight through to the output without decoding or re-encoding. There is no quality loss and almost no CPU cost — FFmpeg is essentially doing a smart copy over the container. The catch is that cuts can only land on keyframes.
Placing -ss before -i is an input seek: FFmpeg jumps to the nearest keyframe before decoding anything, so seeking into a long file is nearly instant. Placing -ss after -i is an output seek that decodes and discards every frame up to the cut point — the same flag, but seconds slower on large files.
Use FFmpeg's concat demuxer: write a text file listing each input, then run ffmpeg -f concat -safe 0 -i list.txt -c copy out.mp4. It stitches the packets together without re-encoding. It only works when both files share identical codec parameters and each starts on a keyframe.
Chunking a long recording into short, identically-encoded segments means any clip touches only one or two chunks. You download megabytes instead of gigabytes, cut with stream-copy, and start clipping while the recording is still in progress. One large file forces you to seek through everything and wait for the recording to finish.
Yes. Ship a static FFmpeg binary in a Lambda layer and invoke it as a child process. Raise the /tmp ephemeral storage for large inputs, give the function enough memory (memory scales CPU), and delete temp files between invocations since warm Lambdas reuse the process. Stream-copy cuts finish in under a second.
FFmpeg is a CLI, so from Node you run it as a child process. The fluent-ffmpeg library wraps child_process.spawn with a chainable builder: ffmpeg(input).inputOptions(['-ss 5']).outputOptions(['-c copy']).save(out). Always handle the 'error' event and capture FFmpeg's stderr — it's the only signal you get when a command fails.
Record the meeting stream as short, identically-encoded chunks and upload each one to storage as it finishes. When someone bookmarks a moment, fetch only the one or two chunks it touches and cut them with FFmpeg stream-copy. Because you never wait for the full recording, the highlight is ready within seconds — before the meeting is over.
Not exactly — stream-copy can only cut on keyframes, so the real cut snaps to the nearest keyframe before your requested timestamp. If you encode with short keyframe intervals, the error stays small (often under half a second) and feels frame-accurate to users. True frame accuracy requires re-encoding, which costs far more CPU.