<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: james will</title>
    <description>The latest articles on DEV Community by james will (@wei_hua_8433e995e99ee612c).</description>
    <link>https://dev.to/wei_hua_8433e995e99ee612c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4235221%2Fc7cf4eba-a4df-4588-b6b8-1d36c1f7c118.png</url>
      <title>DEV Community: james will</title>
      <link>https://dev.to/wei_hua_8433e995e99ee612c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wei_hua_8433e995e99ee612c"/>
    <language>en</language>
    <item>
      <title>How We Stitch Parallel AI Video Segments With Frame Accuracy</title>
      <dc:creator>james will</dc:creator>
      <pubDate>Mon, 09 Nov 2026 13:24:18 +0000</pubDate>
      <link>https://dev.to/wei_hua_8433e995e99ee612c/how-we-stitch-parallel-ai-video-segments-with-frame-accuracy-350c</link>
      <guid>https://dev.to/wei_hua_8433e995e99ee612c/how-we-stitch-parallel-ai-video-segments-with-frame-accuracy-350c</guid>
      <description>&lt;p&gt;AI video models have a length limit, and the one we use renders at most 15 seconds per clip. That is a problem when a template has several shots and each cut has to land exactly where it does in the reference video.&lt;/p&gt;

&lt;p&gt;So at &lt;a href="https://iamthememe.com/" rel="noopener noreferrer"&gt;IAmTheMeme&lt;/a&gt; we render multi-shot templates one shot at a time, in parallel, and stitch the results together afterwards. The rendering is the easy part to describe. The stitching is where a single wrong frame breaks everything after it.&lt;/p&gt;

&lt;p&gt;Here is how that stitching works, including the ffmpeg flags that turned out to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reference video, split at its hard cuts
        |
        v
shot 1   shot 2   shot 3 ...      rendered in parallel
        |
        v
trim each shot to an exact frame count
        |
        v
concat into one silent video
        |
        v
optional upscale
        |
        v
add the original audio, deliver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Audio goes on last because the upscaler works on the silent base video. That also means audio can never drift during the stitch, because it isn't there yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count frames, not seconds
&lt;/h2&gt;

&lt;p&gt;Our model only accepts whole seconds. A shot that lasts 65 frames at 30 fps is about 2.17 seconds, so we ask for 3 seconds and get roughly 90 frames back. We need the first 65.&lt;/p&gt;

&lt;p&gt;Seconds are the wrong unit for that cut. &lt;code&gt;-t 2.17&lt;/code&gt; depends on rounding and on the timestamps in the file, while the template's timing is really a list of frame counts. So each template stores its frame rate and one frame count per shot, and that list is the source of truth for the entire edit.&lt;/p&gt;

&lt;p&gt;An earlier version used a filter chain with &lt;code&gt;fps&lt;/code&gt; and &lt;code&gt;trim&lt;/code&gt;. On a real template it came out one frame short, which is the kind of bug you only notice as a slightly late cut. We replaced it with &lt;code&gt;-frames:v&lt;/code&gt; on the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; shot.mp4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"fps=30,scale=480:854,setsar=1,format=yuv420p"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-frames&lt;/span&gt;:v 65 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 16 &lt;span class="nt"&gt;-bf&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  shot-trimmed.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each flag has a job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fps&lt;/code&gt;, &lt;code&gt;scale&lt;/code&gt;, &lt;code&gt;setsar&lt;/code&gt;, &lt;code&gt;format&lt;/code&gt;&lt;/strong&gt; make every shot identical in frame rate, size, pixel aspect ratio and pixel format. The concat step depends on this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-frames:v 65&lt;/code&gt;&lt;/strong&gt; keeps exactly the first 65 frames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-crf 16&lt;/code&gt;&lt;/strong&gt; is close to lossless, because this file still goes to the upscaler. Quality lost here can't be recovered later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-bf 0&lt;/code&gt;&lt;/strong&gt; turns off B-frames, so each part's first frame starts at timestamp zero and the seams don't drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yes, this re-encodes every shot. The cut points rarely fall on keyframes, and stream copy can only cut cleanly on a keyframe, so you can end up with too many or too few frames. It costs some CPU, and it is the only way we found to get exact cuts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concat can be a stream copy
&lt;/h2&gt;

&lt;p&gt;Re-encoding pays off in the next step. All the parts now share the same parameters, so the concat demuxer can join them without encoding anything again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# list.txt&lt;/span&gt;
&lt;span class="c"&gt;# file 'part-0.mp4'&lt;/span&gt;
&lt;span class="c"&gt;# file 'part-1.mp4'&lt;/span&gt;
&lt;span class="c"&gt;# file 'part-2.mp4'&lt;/span&gt;

ffmpeg &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; concat &lt;span class="nt"&gt;-safe&lt;/span&gt; 0 &lt;span class="nt"&gt;-i&lt;/span&gt; list.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; copy &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart stitched.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verify before you deliver
&lt;/h2&gt;

&lt;p&gt;An exit code of 0 from ffmpeg doesn't mean you got the frames you asked for. If a model returns a shot shorter than expected, &lt;code&gt;-frames:v&lt;/code&gt; simply stops early and everything after it lands late.&lt;/p&gt;

&lt;p&gt;So we check the count after every part and again after the concat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-select_streams&lt;/span&gt; v:0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;width,height,nb_frames &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-of&lt;/span&gt; json stitched.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In MP4, &lt;code&gt;nb_frames&lt;/code&gt; comes from the container's sample table, so this is cheap and doesn't decode anything. The check in the service looks like this (simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseProbedVideo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ffprobe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PROBE_VIDEO_ARGS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partPath&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;part_too_short&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...after the concat, the same check against the total&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;frame_count_mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status code carries meaning here. A 4xx says the input is wrong and retrying won't help. A 5xx says the container failed and a retry might work. The job's state machine uses that distinction to decide what to do next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running ffmpeg next to Cloudflare Workers
&lt;/h2&gt;

&lt;p&gt;The app runs on Cloudflare Workers, and Workers can't execute native binaries. ffmpeg therefore runs in &lt;a href="https://developers.cloudflare.com/containers/" rel="noopener noreferrer"&gt;Cloudflare Containers&lt;/a&gt;, behind a small Worker that is only reachable through a service binding from the main app.&lt;/p&gt;

&lt;p&gt;Three decisions shaped that service:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The container has no credentials and no network access.&lt;/strong&gt; The Worker reads the parts from R2, posts them to the container as multipart form data, and writes the result back. ffmpeg parsing untrusted media is a real attack surface, and a compromised container here has no route to the bucket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Output keys are deterministic.&lt;/strong&gt; The stitched file's key is derived from the job's segments. If that object already exists, the Worker returns it without waking a container, so a retry after a timeout costs nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The container's arguments are pure functions.&lt;/strong&gt; Each ffmpeg command is built by a plain function that unit tests can assert on directly. When ffmpeg is available locally, an end-to-end test stitches solid-color clips at 24 and 30 fps, then checks that each cut lands on the expected frame and that a too-short part gets rejected. Using a different color per shot makes a misplaced cut easy to detect.&lt;/p&gt;

&lt;p&gt;Container size mattered too. Scaling an upscaled 720p video takes around 27 CPU seconds, which gets too close to the call timeout on a quarter vCPU, so we run on half a vCPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Charging for a job that can fail halfway
&lt;/h2&gt;

&lt;p&gt;Parallel shots add a billing question: what happens if shot 4 of 5 fails?&lt;/p&gt;

&lt;p&gt;We reserve the credits when the order is placed, before any rendering starts. If the job ends without a finished video, the reservation is released and the credits go back to the user. Every step is a conditional update on the job's state, so a retry or a concurrent worker can't render or charge twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat frame counts as the source of truth and derive durations from them, never the other way round.&lt;/li&gt;
&lt;li&gt;Normalize every part before concat, even when the parts all come from the same model.&lt;/li&gt;
&lt;li&gt;Verify output with ffprobe, because a successful exit code proves very little.&lt;/li&gt;
&lt;li&gt;Keep credentials out of whatever process touches untrusted media.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the output, our &lt;a href="https://iamthememe.com/templates/tiny-desk-ai" rel="noopener noreferrer"&gt;Tiny Desk AI video generator&lt;/a&gt; is built from five stitched shots at 24 fps.&lt;/p&gt;

&lt;p&gt;How are you handling frame-accurate cuts in your own pipelines? We'd especially like to hear from anyone who has found a reliable way to do it without re-encoding.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
