DEV Community

Cover image for The file existed, so I assumed the check passed
Hammad Shams Uddin
Hammad Shams Uddin

Posted on

The file existed, so I assumed the check passed

A user sent me two clips and a screenshot. My video merge tool had run to 100%, said "Applying transitions…", and then thrown:

FS error: readFile 'merged.mp4'
Enter fullscreen mode Exit fullscreen mode

The error is honest and useless. merged.mp4 does not exist. It does not say why, because by the time you ask, the thing that should have made it has already finished and moved on.

The cause turned out to be four functions upstream, and the shape of the mistake is one I keep meeting: a check that tests whether something exists when the question was whether it has a particular property.

What the tool does

Merging clips with different sizes and formats means normalising them first — same dimensions, same frame rate, same codecs — and only then joining. If the user asked for a transition, the join is an xfade for the picture and an acrossfade for the sound.

That second one matters more than it looks.

The check

Normalising ran this per clip:

ffmpeg.run('-i', inN, '-vf', vf,
           '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23',
           '-c:a', 'libmp3lame', '-b:a', '128k', outN)
  .then(() => {
    try {
      ffmpeg.FS('readFile', outN);
    } catch (e) {
      // no audio in this clip — redo it with a silent track
      return ffmpeg.run(/* ...anullsrc version... */);
    }
  });
Enter fullscreen mode Exit fullscreen mode

Read the intent: if the encode failed because the source had no audio, redo it with generated silence, so every input to the transition has a sound track.

Read what it does: if the output file is missing, redo it.

Those are not the same sentence, and the gap between them is the bug.

Give ffmpeg a video-only file and ask it to encode audio with -c:a libmp3lame, and it does not fail. There is no audio stream, so it encodes the video, writes no audio stream, and exits successfully. The file exists. The catch never runs. The silence fallback — written specifically for this case — never fires.

Then the transition builds its filter graph:

[0:a][1:a]acrossfade=d=0.5[aout]
Enter fullscreen mode Exit fullscreen mode

[0:a] does not exist. The graph fails, no output is written, and one .then() later you get an error about merged.mp4.

Why it survived

Two reasons, and the second is the interesting one.

Clips with sound worked perfectly. Every file I had tested with was a screen recording with audio or a phone video. The failing case is a clip with no audio track at all — which describes every AI-generated clip, most screen recordings, and any video someone has already stripped the sound from. It is not an edge case. It is a whole category of ordinary file I did not happen to own.

And ffmpeg.wasm does not reject on ffmpeg failure. ffmpeg.run() resolves whether the command succeeded or not. A filter graph that fails produces no output and no rejection, so the await returns normally and execution carries on into code that assumes the file is there. Every error in this class arrives late and blames the wrong line.

The fix is to ask the actual question

You cannot infer "has an audio stream" from "a file appeared". So ask:

function hasAudio(name) {
  const probe = 'probe_' + name + '.mka';
  return ffmpeg.run('-i', name, '-vn', '-map', '0:a:0', '-c', 'copy', '-t', '0.05', probe)
    .then(() => {
      let found = false;
      try { found = ffmpeg.FS('readFile', probe).length > 0; } catch (e) { found = false; }
      try { ffmpeg.FS('unlink', probe); } catch (e) {}
      return found;
    })
    .catch(() => false);
}
Enter fullscreen mode Exit fullscreen mode

-map 0:a:0 names the first audio stream. If there is no audio stream, the mapping cannot be satisfied and nothing is written. It is a stream copy of 50 milliseconds, so it costs nothing beside the encode that follows, and the answer is about the thing I actually care about.

Then the command is chosen once, rather than attempted and repaired:

const args = audible
  ? ['-i', inN, '-vf', vf, /* ... */ '-c:a', 'libmp3lame', outN]
  : ['-i', inN, '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
     '-shortest', '-map', '0:v', '-map', '1:a', '-vf', vf, /* ... */ outN];
Enter fullscreen mode Exit fullscreen mode

I did consider reading ffmpeg's log output for Stream #0:1: Audio: instead. It works, and I did not use it, because 0.11 and the 0.12 adapter surface logging differently and I would have had two code paths to keep true. A probe behaves identically on both.

Testing something that only runs in a browser

There is no ffmpeg binary on my machine or my server — the whole point is that this runs in the user's browser via WebAssembly. And headless Chrome's virtual time does not advance a web worker, so the usual scripted approach hangs at the first run().

What worked: a page that builds its own fixtures with lavfi, runs the real checks, and POSTs its results back to a PHP file so the run can outlive any screenshot timing.

made sil.mp4=28362B snd.mp4=48141B
ok   snd.mp4 -> hasAudio=true    (expected true)
ok   sil.mp4 -> hasAudio=false   (expected false)
ok   acrossfade on silent clips produces no output
Enter fullscreen mode Exit fullscreen mode

That third line is the one worth writing. It is not a test of the fix — it is a test of the diagnosis. Before trusting the repair, prove the thing you blamed actually misbehaves the way you claim. I have shipped fixes for problems I had misdiagnosed, and they are worse than the original bug, because now the symptom is gone and the cause is still there.

The general version

The bug is not really about ffmpeg. It is this:

An operation completing is not evidence that it did what you wanted. It is evidence that it did not crash.

Some places the same shape shows up:

  • a file exists, so the write must have contained data
  • an HTTP request returned 200, so the response must be the resource you asked for
  • a command exited 0, so it must have produced output
  • a catch block did not run, so the happy path must have happened

Each of those is a check on the wrong noun. The fix is always the same and always feels like extra work: name the property you actually depend on, and ask about that — not about whatever is easiest to observe nearby.

And add the belt-and-braces, because you will get it wrong again somewhere. If the transition now fails for a reason I have not thought of, the tool joins the clips without one and says so, rather than handing anyone FS error: readFile 'merged.mp4'.


I build Utilorax, a set of free browser-based tools — this one came out of the video merger, which does now handle a silent clip.

Top comments (0)