Field notes · Video engineering

Cutting video without re-encoding, in production.

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.

01 The product I was building

A highlight, ready before the meeting ends.

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:

  • The full file doesn't exist yet. The meeting is still going. Hosts bookmark a moment at minute five while Zoom is still on minute twenty-three — there is no finished recording to cut from.
  • Re-encoding is expensive. Trimming a 7-second highlight out of a multi-gigabyte MP4 by re-encoding burns tens of seconds of Lambda CPU. Multiply by every bookmark, by every meeting.
  • Seeking through a huge file is slow. Without a smart input seek, FFmpeg walks the file to find your timestamp — and on a 90-minute recording that's real time.
  • Pulling the whole file is wasteful. Dragging a gigabyte from S3 into a Lambda's /tmp just to keep 7 seconds of it is the definition of overkill.
The result I was after

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.


02 The core idea

Don't record one file. Record many.

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.

chunk_10:004:00
chunk_24:008:00
chunk_38:0012:00
chunk_412:0013:27
green bar = keyframe at the start of every chunk · dashed = still recording

Why 240 seconds?

  • Short enough that any normal clip touches at most two chunks.
  • Long enough that uploads are amortised — you're not paying per-second upload overhead.
  • Concat-friendly every chunk starts on a clean keyframe, so two chunks join with -c copy and no re-encode.
// clip-lambda/config.ts
export default {
  videoCodec: "libx264",
  outputFormat: "mp4",
  chunkSize: 240,                 // <- the magic number, in seconds
  clipDimensions: ["512x288", "768x432"],
};

03 The shape of it

From Zoom to highlight, end to end.

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.

05 · Muscle
Clip Lambda
A Node Lambda does the mechanical work: GET one or two chunks from S3, drive FFmpeg with -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.


04 The brain's job

Which chunks does a highlight actually need?

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.

chunk_1
chunk_2
chunk_3
chunk_4
Single chunk → 1 stream-copy, no concat
ffmpeg -ss 00:01:12 -i chunk_2.mp4 -t 6 -c copy clip.mp4

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
}

05 The muscle

The whole cut is three flags.

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.mp4
✕ The slow way

ffmpeg -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.

tap — same flags, wrong order →
✓ The fast way

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.

← -ss moved before -i
✕ Re-encode

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.

tap — decodes & re-encodes →
✓ Stream-copy

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.

← copies the packets through

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.


06 The two-chunk case

When a clip crosses a boundary.

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.


07 FFmpeg, but from JavaScript

FFmpeg in the Node.js world.

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 one place I do re-encode

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.


08 The serverless part

Why Lambda — and the limits it makes you respect.

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.

  • /tmp is small and shared. Lambda's scratch space defaults to 512 MB and is reused across warm invocations. Two 240s chunks blow through that, and leftovers from the last run poison the next. I raise the ephemeral storage and delete every file at the end of the handler — even on success.
  • Memory is CPU. Lambda scales vCPU with memory, so a 3 GB function cuts noticeably faster than a 512 MB one. For CPU-bound FFmpeg work, more memory is often cheaper overall because the job finishes sooner.
  • Ship FFmpeg as a layer. A static ffmpeg binary in a Lambda layer keeps the deploy package small and shared across functions, instead of fattening every bundle.
  • Cold starts hurt the tail. An FFmpeg-bundled function cold-starts in 2–3s, which dominates p99 latency for highlights requested right after a meeting ends. Provisioned concurrency smooths it out.
// 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
  }
};

Don't pay for doomed work

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.


09 The sharp edges

What surprised me along the way.

A handful of things I didn't expect going in. Tap to read each one.

01Flag order quietly cost me an afternoon
My cuts were taking eight seconds and I couldn't see why. The fix was moving -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.
Lesson → -ss before -i for any cut on a non-trivial file.
02The concat demuxer is fussy about codec params
If one chunk lands at 30fps and the next at 29.97 — which a flaky network can cause — concat will silently produce a file with broken timestamps that plays in one player and crashes another. The fix lives upstream, not at cut time.
Lesson → make the recorder deterministic so every chunk is byte-compatible.
03FFmpeg's stderr is your only debugger
When a cut fails inside a Lambda, there's no console to poke at — and 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.
Lesson → capture FFmpeg's stderr and log it; it's the whole error message.
04Warm Lambdas share more than /tmp
A warm invocation reuses the entire process — module-level state, leftover temp dirs, even dangling event listeners from the last run. I learned to treat every handler as if the process is already dirty: scope work under a per-request key and tear it down in finally, never assuming a clean slate.
Lesson → assume the process is reused; isolate and clean up per request.
05Keyframe snapping is a feature, not a bug
Stream-copy can only cut on keyframes, so the real cut snaps to the nearest one before your timestamp. I worried users would notice. With short keyframe intervals the error stays under half a second — and nobody has ever complained. Re-encoding to gain those milliseconds wasn't worth the cost.
Lesson → pick keyframe interval deliberately; it's your accuracy/cost dial.

10 The bill

Where the design pays for itself.

Same clip, two designs. Flip the switch to compare the naive "download everything and re-encode" approach against the chunked stream-copy one.

Naive re-encode
Chunked stream-copy
Function time / clip
2.5–4.5s
pull one chunk, copy, rescale
download chunk ~1s stream-copy ~0.3s rescale (parallel) ~2s
Cost / clip
$0.0001–0.0004
fractions of a cent
roughly an 8–15× cut in billed compute time
Time the user waits
2–4s
feels instant
people went from 2–3 clips per recording to 15–20

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.


11 Hindsight

What I'd change next time.

Four things that look obvious now. Tap to expand.

01Tie chunk size to clip length, not a hunch
I picked the chunk length early and never revisited it. If clips are mostly a few seconds long, shorter chunks would make almost every clip single-chunk — no concat at all. The trade-off is more uploads from the recorder, so it's worth measuring rather than guessing.
02Cache the chunk index in Redis, not MongoDB
Every highlight request reads the chunk map from MongoDB. For one busy meeting being clipped live, that's a flood of identical reads. A short Redis TTL cache in front of the index would absorb almost all of them.
03Keep the Lambda warm
Cold starts on an FFmpeg-bundled Lambda are a couple of seconds, and they dominate the tail latency for highlights made right after a meeting ends. A small floor of provisioned concurrency smooths that out.
04Make the most forgiving concat path the default
There's a fallback that routes the join through MPEG-TS, which tolerates small parameter drift better than the plain MP4 concat. It's barely more expensive. I'd promote it from fallback to default.

12 Reference

The FFmpeg flags behind all of this.

Every flag this system leans on, in one place. Filter by keyword or category.

16 / 16
AllSeekTrimCopyConcatScaleOutputInput
-ss <time> (before -i)Input seek — jump to the nearest keyframe before decoding. Fast.Seek
-ss <time> (after -i)Output seek — decode and discard every frame up to the point. Slow.Seek
-i <file>Declare an input file or the concat list file.Input
-t <duration>Read this many seconds from the seek point, then stop.Trim
-to <time>Stop at this absolute timestamp instead of a duration.Trim
-c copyCopy all streams byte-for-byte — no decode, no re-encode.Copy
-c:v copyCopy just the video stream (e.g. while filtering audio).Copy
-c:a copyCopy just the audio stream.Copy
-f concatUse the concat demuxer to join files listed in a text file.Concat
-safe 0Allow absolute paths in the concat list file.Concat
-bsf:v h264_mp4toannexbBitstream filter: MP4 → Annex-B, the prep step for TS concat.Concat
-f mpegtsWrap as MPEG-TS — the forgiving route when MP4 concat is cranky.Concat
-vf scale=W:HRescale the picture. Forces a re-encode — can't combine with -c copy.Scale
-movflags +faststartMove the moov atom to the front so MP4 plays before full download.Output
-avoid_negative_ts make_zeroReset timestamps after a cut so the clip starts at zero.Output
-map 0Keep every stream from the input, not just the defaults.Output

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.


The whole trick, in one breath

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.

FAQ Quick answers

Cutting video without re-encoding — common questions.

How do you cut a video clip without re-encoding it?

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.

What does ffmpeg -c copy do?

-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.

Why should -ss come before -i in FFmpeg?

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.

How can you concatenate two video files without re-encoding?

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.

Why split a recording into chunks instead of one big file?

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.

Can you run FFmpeg on AWS Lambda?

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.

How do you run FFmpeg from Node.js?

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.

How can you create highlights from a live Zoom meeting before it ends?

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.

Is stream-copy cutting frame-accurate?

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.


Notes & references
  1. FFmpeg — Seeking (the -ss before/after -i rule). https://trac.ffmpeg.org/wiki/Seeking
  2. FFmpeg — Concatenating media files (concat demuxer). https://trac.ffmpeg.org/wiki/Concatenate
  3. FFmpeg — ffmpeg(1) full option reference. https://ffmpeg.org/ffmpeg.html
  4. FFmpeg — Stream copy in the documentation. https://ffmpeg.org/ffmpeg.html#Stream-copy
  5. FFmpeg — Bitstream filters (h264_mp4toannexb). https://ffmpeg.org/ffmpeg-bitstream-filters.html
Copied to clipboard