Extract the Last Frame in FFmpeg to Chain Veo 3 Clips Cleanly

You generated six 8-second clips with Veo 3, and now clip two has to start exactly where clip one ended. Every chaining guide says the same thing: pull the last frame, feed it back as the seed image for the next generation. None of them mention that the obvious FFmpeg command hands you a frame from a second and a half earlier, or a black one.
Quick answer: To extract the last frame, FFmpeg has to seek from the end of the file and keep overwriting one image until the stream runs out:ffmpeg -sseof -0.1 -i clip01.mp4 -update 1 last_frame.png. Swapping-update 1for-vframes 1returns the first frame after the seek point instead, which is normally the preceding keyframe or a partially decoded black frame. If you'd rather not keep an FFmpeg build running next to your Veo 3 and Sora 2 calls, FFmpeg Micro runs the frame grab and the seamless join as two API calls with no servers to manage: https://www.ffmpeg-micro.com/ffmpeg-api
The seek lands on a keyframe, not on your final frame
Conventional wisdom says a one-frame extract is a one-line command, and for the first frame of a file that's true. For the last frame it isn't a syntax problem, it's a seek problem. Both -ss and -sseof placed before -i are input seeks, and an input seek jumps to the nearest keyframe at or before the target because that's the only place a decoder can start. Clips coming back from Veo 3 and Sora 2 typically carry keyframes every one to two seconds, so a fast seek plus -vframes 1 can hand you a frame up to 48 frames early. It looks close enough to pass review and wrong enough that the next generation starts with a visible jump.
The black frame is the same mechanism with worse luck. When FFmpeg seeks and then decodes forward toward your exact timestamp, it can emit a frame whose reference frames were never decoded, which shows up as gray mush, green blocks, or pure black. Same command, different file, different failure.
-update 1 sidesteps all of it. That flag tells the image muxer to write every incoming frame to the same filename instead of numbering them, so FFmpeg overwrites last_frame.png two or three times and the file left standing is the true final frame. No frame counting, no duration arithmetic, no guessing where the keyframes sit. It's also why you must never combine -update 1 with -frames:v 1: the frame cap stops the loop after the first write, which is exactly the wrong frame.
Three ways to grab the final frame, ranked by how much they decode
The right method depends on how much you trust the file's duration metadata. AI generation endpoints mux their output in ways that sometimes report a container duration a few milliseconds longer than the last frame's presentation timestamp, and when that happens -sseof -0.1 starts past the final frame and FFmpeg exits with output file is empty, nothing was encoded.
| Method | Command | Frames decoded | Fails when |
|---|---|---|---|
| Fast window | `ffmpeg -sseof -0.1 -i clip.mp4 -update 1 last.png` | ~3 at 24 fps | Container duration overshoots the last PTS |
| Safe window | `ffmpeg -sseof -1 -i clip.mp4 -update 1 last.png` | ~24 at 24 fps | Basically never on 8-second clips |
| Frame-exact | `ffprobe` count, then `select=eq(n,N-1)` | Every frame in the file | Slow on anything over a minute |
For an 8-second Veo or Sora clip, use -sseof -1. Decoding one extra second costs a few milliseconds and removes the fast version's only failure mode. Reserve the frame-exact path for when you need the index number itself:
frames=$(ffprobe -v error -select_streams v:0 -count_frames \
-show_entries stream=nb_read_frames -of csv=p=0 clip01.mp4)
ffmpeg -i clip01.mp4 -vf "select=eq(n\,$((frames-1)))" \
-fps_mode passthrough -frames:v 1 last_frame.png
There's a fourth trick worth knowing. ffmpeg -sseof -1 -i clip01.mp4 -vf reverse -frames:v 1 last_frame.png reverses the tail and takes the new first frame. The reverse filter buffers its entire input into RAM before emitting anything, which makes it a bad idea on a full movie and a fine idea on one second of 720p.
Save the seed as PNG, not JPEG. A JPEG at -q:v 2 looks identical on its own, but you're feeding that image back into a generator and pulling a frame out of the result to feed in again. Across six links in the chain, the recompression artifacts stack into visible texture on flat surfaces like skies and walls.
The seam is a duplicated frame, not a bad cut
Once the chain works, the joins still stutter, and the usual reaction is to reach for a crossfade. The stutter isn't a cut problem. Your seed frame is the last frame of clip one and the first frame of clip two, so after concatenation that image is held for two frame durations. At 24 fps that's an 83 ms freeze at every join, which reads as a hitch rather than as motion. Six clips means five hitches.
The fix is one filter on every clip after the first: trim=start_frame=1,setpts=PTS-STARTPTS. Drop the duplicate head frame, reset the timestamps, and the motion runs continuously through the join.
The second seam problem is parameter mismatch. Generation endpoints don't always stamp a sample aspect ratio, and the concat filter refuses to run when one input declares SAR 1:1 and another declares SAR 0:1, even at identical pixel dimensions:
[Parsed_concat_6 @ 0x55f0c1] Input link in1:v0 parameters (size 1280x720, SAR 0:1)
do not match the corresponding output link in0:v0 parameters (size 1280x720, SAR 1:1)
[fc#0 @ 0x55f0a3] Error reinitializing filters!
Force setsar=1 and format=yuv420p on every input before they reach concat. Trying to avoid the re-encode with the concat demuxer and -c copy moves the problem rather than solving it: mismatched timebases produce non-monotonic DTS warnings and players hang at the boundaries.
Join the segments in one filter_complex pass
Normalizing, de-duplicating, concatenating, and laying the audio bed all happen in a single command, which matters because every extra encode pass on AI-generated footage softens it further. Here's the whole join for three clips over one continuous ambient track:
ffmpeg \
-i clip01.mp4 -i clip02.mp4 -i clip03.mp4 -i ambient.m4a \
-filter_complex "\
[0:v]fps=24,scale=1280:720,setsar=1,format=yuv420p[v0]; \
[1:v]fps=24,scale=1280:720,setsar=1,format=yuv420p,\
trim=start_frame=1,setpts=PTS-STARTPTS[v1]; \
[2:v]fps=24,scale=1280:720,setsar=1,format=yuv420p,\
trim=start_frame=1,setpts=PTS-STARTPTS[v2]; \
[v0][v1][v2]concat=n=3:v=1:a=0[v]" \
-map "[v]" -map 3:a \
-c:v libx264 -crf 18 -preset medium -pix_fmt yuv420p \
-c:a aac -b:a 192k -shortest chained.mp4
Note a=0 on the concat filter and the separate -map 3:a. Each generated clip carries its own synthesized room tone, and those beds have different noise floors, so concatenating the model's audio makes the level audibly step at every join. Throwing all of it away and running one continuous track underneath is what actually hides the seams. If you're adding voiceover on top of the bed, duck the music against the voice rather than picking one fixed level for the whole piece.
Both halves of this are a good fit for an API call instead of a local binary, because the frame grab happens between two generation requests and the join happens once at the end. Sending the clip URL to FFmpeg Micro and getting the seed frame back keeps your n8n, Make, or Zapier workflow to HTTP nodes with no FFmpeg container to maintain, and the same applies to the final concat.
Pitfalls that show up around the third clip
Most of these stay invisible while you test two clips and become obvious once the chain is long enough to accumulate error.
- Color drift compounds. Each generation slightly shifts the white balance of its seed, so clip six can be noticeably warmer than clip one. Check the chain end to end before you render, and correct per-clip with
eq=saturation=1.0:gamma=1.0rather than grading the joined output. - Some generators return variable frame rate.
fps=24in the filter chain forces CFR and is cheap insurance; without it, concat produces timestamps that drift against the audio bed. - Aspect ratio is a post-generation job, always. The Gemini API exposes only 16:9 and 9:16 for every current Veo variant, and Sora's size options resolve to the same two shapes. If you need 1:1 or 4:5 for Meta placements, that's an FFmpeg step after the join, not a generation parameter.
- Don't hard-code one generator. OpenAI's deprecations page schedules the Sora API for removal on September 24, 2026, so keep the frame handoff and the join independent of whichever model produced the clips.
- Watch the seek behavior if you're also trimming clips. FFmpeg cuts on keyframes unless you re-encode, and a trim that silently moves your endpoint moves the frame you extract with it.
When frame chaining is the wrong tool
Frame chaining holds up for roughly three to five links, then drifts. The seed frame carries composition and color, but it doesn't carry identity, so a specific face or a specific product label degrades a little with every handoff. If your piece needs one character to stay recognizable across 60 seconds, use a model's native extension feature where one exists, or generate the whole shot in one request and cut it down afterward.
Chaining is also the wrong shape for template work. If what you actually want is the same layout with swapped text, logo, and product shot, a template-editor service handles that job with far less machinery than a generation chain plus a normalize-and-concat pass. Chaining earns its keep when the content of each segment genuinely differs and continuity across the cut is the whole point. The assembly step, not the generation step, is where these pipelines usually break.
FAQ
Why does ffmpeg -vframes 1 give me a black frame at the end of a video?
FFmpeg returns a black frame because a fast input seek starts decoding at the nearest keyframe and -vframes 1 grabs whatever comes out first, which can be a frame assembled from reference frames that were never decoded. Use -update 1 instead of -vframes 1 so FFmpeg decodes the whole tail and overwrites a single output file, leaving the real final frame.
What's the difference between -ss and -sseof in FFmpeg?
-ss seeks to an absolute position measured from the start of the file, while -sseof seeks to a position measured backward from the end, so -sseof -1 means one second before the file ends. -sseof is the right choice for last-frame extraction because it needs no duration lookup and no arithmetic.
How do I join Veo 3 and Sora 2 clips without a visible jump at the cut?
Drop the duplicated seed frame from every clip after the first with trim=start_frame=1,setpts=PTS-STARTPTS, normalize all clips to the same resolution, frame rate, SAR, and pixel format before the concat filter, and replace the per-clip generated audio with one continuous track across the whole sequence. The jump is almost always the duplicate frame plus the audio level step, not the cut itself.
Can I concatenate AI-generated clips with -c copy instead of re-encoding?
Stream copying works only when every clip shares the same codec, resolution, pixel format, SAR, and timebase, which generation endpoints do not reliably guarantee even within one model. A single filter_complex concat with an explicit normalize chain is more predictable than a stream copy that produces non-monotonic timestamps at the joins.
Should the extracted frame be a PNG or a JPEG?
Use PNG for a seed frame that's going back into a generator. JPEG re-compression is invisible on one image and cumulative across a chain of six, showing up as blocky texture in skies, walls, and other flat areas by the last clip.
You can run both steps of this, the tail-seek frame grab and the normalized join, as API calls from the same workflow that's already calling your generation model. The free tier is enough to chain a few clips end to end and see whether the seams actually disappear.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

Extract Subtitles from MKV and MP4 with FFmpeg, No Re-encoding
The extract subtitles FFmpeg one-liner is easy. Finding the right track with ffprobe, handling PGS bitmaps, and fixing mov_text in MP4 is where jobs break.

Stop Pre-Rendering Lower Third PNGs: FFmpeg's drawvg Filter
The ffmpeg drawvg filter draws animated lower thirds and progress bars from a script, with no pre-rendered PNGs. Working VGS code plus the 8.1 build catch.

Veo and Sora Lock AI Video Aspect Ratio. Crop 4:5 and 1:1.
Veo and Sora render only 16:9 and 9:16. Get the AI video aspect ratio math and FFmpeg crops that turn one 9:16 generation into 4:5, 1:1, and 16:9 files.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free