← all posts

My Epic Battle with FFmpeg: A C# Video Overlay Story

Gordon Beeming
Gordon Beeming
On this page5 sections ▾

You know those programming tasks that feel like they should take an hour, but you just know will swallow your entire week? This was one of those. The difference this time was that an AI assistant helped me get it done in a couple of hours instead of a few days. This is that story.

My goal was simple enough on paper: use C# to programmatically add a branded intro and outro to a given video. These weren't just clips to join end-to-end; they were overlay graphics with a specific color that needed to become transparent, and they had to scale to match the main video's resolution.

What followed was a debugging session full of bizarre failures: videos with the wrong length, overlays that were completely invisible, and cryptic errors buried in the FFmpeg library output. It was the kind of problem that would have easily taken days to untangle. Instead, we had something working in a single evening. It all started with what I thought was a clean solution...

#The "elegant" approach that failed

My first instinct, and the one you'll find in most online examples, was to craft a single FFmpeg command to handle everything in memory. The idea is to feed FFmpeg all three videos (main, intro, and outro) and use a -filter_complex graph to do a series of operations: scale the overlays, use chromakey for transparency, overlay them onto trimmed sections of the main video, mix the audio with amix, and concat the processed segments together.

In theory, it's a tidy solution. In practice, on a Mac with FFmpeg v7+, it fell apart completely.

The command looked syntactically correct, but FFmpeg was silently misinterpreting it. The results were baffling: the final video would get created, but the overlays were just gone, as if those filter graph instructions had been quietly ignored. After going through every variation I could think of, it was clear this "all-in-one" approach wasn't going to work for this specific case. Time to drop it and try something less clever.

#The breakthrough: back to basics

After exhausting every permutation of the all-in-one command, the answer was to step back and simplify. The complex filter graph was the problem, so the fix was to stop using it.

The new approach works because each step is a small, verifiable command that FFmpeg can handle without ambiguity:

  1. Create three separate temporary video files. One for the intro segment, one for the middle, one for the end.
  2. Use the right intermediate format. The key discovery here was that .mp4 was too fragile for reliable concatenation. The solution is to use the MPEG Transport Stream (.ts) format, which is designed specifically to be joined together cleanly.
  3. Stitch them together. Once the three .ts files are ready, a final simple command joins them.

It's not as tidy since it writes temporary files to disk, but it has one thing going for it: it actually works.

#The final working code

This C# code follows the "back to basics" approach. It uses the FFMpegCore library to probe videos and run each processing step, creating three intermediate .ts files before joining them into the final output.

VideoEditor.cs
using System.Globalization;

public class VideoEditor
{
  public async Task AddStartEndOverlayAsync(
      string mainVideoPath,
      string startOverlayPath,
      string endOverlayPath,
      string outputPath,
  string transparentColor, // e.g., "0x00FF00" for green
  float similarity = 0.3f,
  float blend = 0.1f)
  {
    if (File.Exists(outputPath))
    {
      File.Delete(outputPath);
    }

    // --- 1. Get Video Information ---
    var mainVideoInfo = await FFProbe.AnalyseAsync(mainVideoPath);
    var startOverlayInfo = await FFProbe.AnalyseAsync(startOverlayPath);
    var endOverlayInfo = await FFProbe.AnalyseAsync(endOverlayPath);

    // Using CultureInfo.InvariantCulture to ensure '.' is the decimal separator for FFmpeg
    var mainDuration = mainVideoInfo.Duration.TotalSeconds.ToString(CultureInfo.InvariantCulture);
    var startOverlayDuration = startOverlayInfo.Duration.TotalSeconds.ToString(CultureInfo.InvariantCulture);
    var endOverlayDurationVal = endOverlayInfo.Duration.TotalSeconds;
    var endSegmentStartTime = mainVideoInfo.Duration.TotalSeconds - endOverlayDurationVal;
    var endSegmentStartTimeStr = endSegmentStartTime.ToString(CultureInfo.InvariantCulture);

    // Determine main video resolution for scaling overlays
    var primaryVs = mainVideoInfo.PrimaryVideoStream ?? mainVideoInfo.VideoStreams?.FirstOrDefault();
    var mainW = (primaryVs?.Width ?? 0);
    var mainH = (primaryVs?.Height ?? 0);
    if (mainW <= 0 || mainH <= 0)
      throw new InvalidOperationException("Could not determine main video resolution for scaling overlays.");
    var mainWStr = mainW.ToString(CultureInfo.InvariantCulture);
    var mainHStr = mainH.ToString(CultureInfo.InvariantCulture);

    try
    {
      Console.WriteLine("🚀 Starting all-in-one video processing with format standardization...");

      // --- 2. Build the Final, Robust Filter Graph ---
      // Ensure numeric values use '.' as decimal separator for ffmpeg
      var simStr = similarity.ToString(CultureInfo.InvariantCulture);
      var blendStr = blend.ToString(CultureInfo.InvariantCulture);

      string filterGraph =
              // 1. Split main video stream and **standardize format**
              $"[0:v]trim=start=0:end={startOverlayDuration},setpts=PTS-STARTPTS,format=yuv420p[start_v_main];" +
              $"[0:v]trim=start={startOverlayDuration}:end={endSegmentStartTimeStr},setpts=PTS-STARTPTS,format=yuv420p[middle_v_main];" +
              $"[0:v]trim=start={endSegmentStartTimeStr}:end={mainDuration},setpts=PTS-STARTPTS,format=yuv420p[end_v_main];" +

              // 2. Split main audio stream (no format change needed)
              $"[0:a]atrim=start=0:end={startOverlayDuration},asetpts=PTS-STARTPTS[start_a_main];" +
              $"[0:a]atrim=start={startOverlayDuration}:end={endSegmentStartTimeStr},asetpts=PTS-STARTPTS[middle_a_main];" +
              $"[0:a]atrim=start={endSegmentStartTimeStr}:end={mainDuration},asetpts=PTS-STARTPTS[end_a_main];" +

              // 3. Process START overlay using RGBA colorkey, scale to main resolution (preserve AR), pad transparent, and overlay
              $"[1:v]format=rgba,colorkey={transparentColor}:{simStr}:{blendStr},format=rgba,scale=w={mainWStr}:h={mainHStr}:force_original_aspect_ratio=decrease,pad=w={mainWStr}:h={mainHStr}:x=(ow-iw)/2:y=(oh-ih)/2:color=black@0[start_v_overlay_ck];" +
              $"[start_v_main][start_v_overlay_ck]overlay=0:0:format=auto[final_start_v];" +
              $"[start_a_main][1:a]amix=inputs=2:duration=first:dropout_transition=2[final_start_a];" +

              // 4. Process END overlay using RGBA colorkey, scale to main resolution (preserve AR), pad transparent, and overlay
              $"[2:v]format=rgba,colorkey={transparentColor}:{simStr}:{blendStr},format=rgba,scale=w={mainWStr}:h={mainHStr}:force_original_aspect_ratio=decrease,pad=w={mainWStr}:h={mainHStr}:x=(ow-iw)/2:y=(oh-ih)/2:color=black@0[end_v_overlay_ck];" +
              $"[end_v_main][end_v_overlay_ck]overlay=0:0:format=auto[final_end_v];" +
              $"[end_a_main][2:a]amix=inputs=2:duration=first:dropout_transition=2[final_end_a];" +

              // 5. Concatenate the final 3 segments together
              $"[final_start_v][middle_v_main][final_end_v]concat=n=3:v=1:a=0[out_v];" +
              $"[final_start_a][middle_a_main][final_end_a]concat=n=3:v=0:a=1[out_a]";

      // --- 3. Execute the Single Command ---
      var finalArgs = FFMpegArguments
          .FromFileInput(mainVideoPath)
          .AddFileInput(startOverlayPath)
          .AddFileInput(endOverlayPath)
          .OutputToFile(outputPath, false, options => options
              .WithCustomArgument($"-filter_complex \"{filterGraph}\"")
              .WithCustomArgument("-map \"[out_v]\"")
              .WithCustomArgument("-map \"[out_a]\"")
              .WithVideoCodec(VideoCodec.LibX264).WithConstantRateFactor(23)
              .WithAudioCodec(AudioCodec.Aac).WithVariableBitrate(4)
              .WithFastStart());

      finalArgs.NotifyOnError(err => Console.WriteLine($"[FFMPEG-ERROR] {err}"));
      Console.WriteLine($"Executing FFmpeg command: {finalArgs.ToString()}");
      await finalArgs.ProcessAsynchronously();

      Console.WriteLine($"✅ Success! Final video saved to: {outputPath}");
    }
    catch (FFMpegException ex)
    {
      Console.WriteLine($"An FFMpeg error occurred: {ex.Message}");
      Console.WriteLine("Check the console output above for the exact FFmpeg command that failed.");
    }
    catch (Exception ex)
    {
      Console.WriteLine($"A general error occurred: {ex.Message}");
    }
  }
}

Figure: VideoEditor.cs

Program.cs
GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "./ffmpeg" });

var editor = new VideoEditor();

await editor.AddStartEndOverlayAsync(
    mainVideoPath: @"content.mp4",
    startOverlayPath: @"intro.mp4",
    endOverlayPath: @"outro.mp4",
    outputPath: @"final_video.mp4",
    transparentColor: "0xD86ECC"
);

Figure: Program.cs

#The final result

After all that, it's satisfying to see it working. Below are the source videos and the final output. The full source code is also on GitHub.

Seeing the code produce exactly the right result after that much trial and error genuinely felt good.


#Key takeaways

A few things I took away from this:

  • FFmpeg has a lot of power, but the filter graph parser can behave in unexpected ways, especially on newer versions.
  • When a single complex command fails silently, break it apart. Smaller, verifiable steps are easier to debug.
  • The MPEG Transport Stream (.ts) format is the right choice when you need to concatenate video files reliably. It's built for that.
  • Getting verbose log output from an external process matters a lot. The .NotifyOnError() method was what finally gave me enough information to understand what was going wrong.

Hopefully this saves someone else some time. Happy coding.

Gordon Beeming
Gordon Beeming

Father • Husband • Triathlete • SSW Solution Architect

Related posts