Handling Corrupt MP4 Files in Automated Pipelines
Within the media validation and error routing stage of the broader Media Ingestion & Format Architecture pipeline, corrupt MP4 ingestion is the failure that most often slips past a naive gate and detonates downstream — this page covers the exact production scenario where a truncated upload reaches a GPU transcoder and starves the queue. Resolving it requires a strict pre-flight validation gate, precise probe threshold tuning, and explicit routing logic that quarantines recoverable media instead of retrying it into an infinite loop.
Problem Framing
Corrupt MP4 ingestion is a deterministic pipeline breaker that manifests as silent truncation, malformed container atoms, or bitstream discontinuities — and it rarely presents as a single failure vector. The most frequent diagnostic signatures are a missing or displaced moov atom, which prevents stream indexing and forces a linear mdat scan; a truncated ftyp header, which causes immediate parser rejection; and fragmented MP4 structures with misaligned sidx boxes, which trigger seek failures during random access. Audio streams frequently exhibit sample-rate discontinuities or dropped AAC/Opus frames that desynchronize during codec normalization, while video bitstreams contain invalid H.264/HEVC NAL unit boundaries — particularly when a network transfer is interrupted mid-write or camera firmware writes an incomplete SEI message. All of these conform to (or violate) the ISO/IEC 14496-12 base media file format.
The symptom that brings engineers to this page is an ingestion worker that hangs rather than fails. Default ffprobe behavior aggressively scans an entire file to reconstruct stream metadata, so a truncated input produces a worker that blocks for minutes and a queue that backs up behind it:
ERROR: worker-07 probe stalled 184s on s3://ingest/raw/ep-4412.mp4
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x55f1] moov atom not found
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x55f1] error reading header
batch_queue_depth{stage="ingest"} 5120 (rising)
Once the probe is unbounded, one bad asset is enough to saturate a worker pool. The fix is to treat container validation as a threshold-bounded checkpoint, not a reactive error handler.
Solution Architecture
The gate is a single CPU-only step that probes the asset under a hard timeout and emits a deterministic routing verdict. Its design follows three rules: cap probe cost so corrupt input fails fast, classify each defect into a distinct routing class, and never hand a malformed container to a downstream worker. ffprobe is constrained to a deterministic byte window with explicit error tolerance — -probesize 50M and -analyzeduration 30000000 (30 seconds, in microseconds) cap memory and CPU, while -err_detect ignore_err suppresses non-fatal bitstream warnings that would otherwise abort the probe. Layering an OS-level timeout on top of these bounds guarantees the worker returns within ~200 ms for a clean file and within a couple of seconds for a severely truncated one, establishing a hard ceiling for the ingestion worker. The parameter precedence and stream-mapping behavior are documented in the upstream FFprobe reference.
Each verdict maps to a queue. Probe-level failures (timeout, non-zero exit, unparseable JSON) go to dead_letter for alerting and backoff; structural defects (non-ISO container, zero duration) go to quarantine for forensic analysis; soft anomalies such as audio sample-rate drift go to manual_review; and only assets that clear every invariant reach transcode_queue. Files that pass here are exactly the inputs that the fallback routing for failed transcodes layer can then safely run through GPU encoders, because container integrity is already guaranteed.
Implementation
The validation routine parses the ffprobe JSON payload and cross-references container metadata against structural invariants — recognizable container format, consistent stream duration, and bounded audio sample-rate drift — before returning a routing decision. Every threshold is explicit so staging and production behave identically.
# Python 3.10+
# ffprobe from FFmpeg >= 6.0 must be on PATH
import subprocess
import json
import os
import logging
from typing import Dict, Any
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
@dataclass
class ValidationResult:
is_valid: bool
routing_queue: str # dead_letter | quarantine | manual_review | transcode_queue
diagnostics: Dict[str, Any]
def probe_mp4(filepath: str, timeout: int = 5) -> Dict[str, Any]:
"""Execute ffprobe with constrained thresholds and return parsed JSON.
-probesize / -analyzeduration cap how much the probe reads before it gives
up reconstructing metadata; the subprocess `timeout` is the hard wall that
stops a truncated moov-less file from hanging the worker.
"""
cmd = [
"ffprobe",
"-v", "error", # structured errors only, no info noise
"-show_format",
"-show_streams",
"-print_format", "json",
"-probesize", "50M", # cap bytes scanned for stream discovery
"-analyzeduration", "30000000", # 30s in microseconds
"-err_detect", "ignore_err", # tolerate non-fatal bitstream warnings
filepath,
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, check=True, timeout=timeout
)
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
raise RuntimeError(f"Probe timed out after {timeout}s: {filepath}")
except subprocess.CalledProcessError as e:
raise RuntimeError(f"ffprobe exited {e.returncode}: {e.stderr.strip()}")
except json.JSONDecodeError as e:
raise RuntimeError(f"Invalid JSON payload from ffprobe: {e}")
def validate_container(filepath: str) -> ValidationResult:
"""Parse probe output, enforce structural invariants, and route accordingly."""
try:
probe_data = probe_mp4(filepath)
except RuntimeError as e:
# Probe could not complete at all -> fatal, alert + backoff.
logging.error("Probe failure for %s: %s", filepath, e)
return ValidationResult(
is_valid=False,
routing_queue="dead_letter",
diagnostics={"error": str(e), "file": os.path.basename(filepath)},
)
fmt = probe_data.get("format", {})
streams = probe_data.get("streams", [])
diagnostics = {"file": os.path.basename(filepath), "format_tags": fmt.get("tags", {})}
# 1. Confirm an ISO Base Media container. A successful return of format
# metadata implies ffprobe located the moov atom; major_brand carries
# values such as "isom", "mp42", "M4V ", "qt ".
format_name = fmt.get("format_name", "")
major_brand = fmt.get("tags", {}).get("major_brand", "").strip().lower()
iso_brands = {"isom", "mp41", "mp42", "m4v", "m4a", "qt",
"iso2", "iso4", "iso5", "iso6"}
if (not any(b in format_name for b in ("mp4", "mov", "m4a"))
or (major_brand and major_brand not in iso_brands)):
diagnostics["failure_reason"] = "Missing or malformed ISO Base Media container"
return ValidationResult(False, "quarantine", diagnostics)
# 2. Validate duration consistency. A <=0.1s duration means the container
# was truncated before the mdat carried real samples.
try:
duration_sec = float(fmt.get("duration", 0))
if duration_sec <= 0.1:
diagnostics["failure_reason"] = "Truncated or zero-duration container"
return ValidationResult(False, "quarantine", diagnostics)
except (ValueError, TypeError):
diagnostics["failure_reason"] = "Unparseable duration metadata"
return ValidationResult(False, "quarantine", diagnostics)
# 3. Detect audio sample-rate drift across streams. >50 Hz delta between
# audio streams desyncs during codec normalization -> human review.
audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
if audio_streams:
base_sr = int(audio_streams[0].get("sample_rate", 0))
drift = any(
abs(int(s.get("sample_rate", 0)) - base_sr) > 50 for s in audio_streams
)
if drift:
diagnostics["failure_reason"] = "Audio sample rate drift detected"
return ValidationResult(False, "manual_review", diagnostics)
diagnostics["status"] = "passed"
return ValidationResult(True, "transcode_queue", diagnostics)
if __name__ == "__main__":
import sys
target_file = sys.argv[1] if len(sys.argv) > 1 else "input.mp4"
result = validate_container(target_file)
print(json.dumps(result.__dict__, indent=2))
Wire this routine in at the S3/GCS event trigger or the message-queue consumer, so no raw upload can bypass it. The quarantine and manual_review classes share the dead-letter machinery — visibility timeouts, poison-message detection, and replay tooling — that the retry logic and dead-letter queue patterns define once for the whole platform.
Verification
Confirm the gate behaves on three classes of input: a clean file, a header-truncated file, and a moov-less file. Synthesize the corrupt cases directly so the test corpus is reproducible:
# Clean reference asset
ffmpeg -f lavfi -i testsrc=duration=5:size=320x240:rate=30 \
-f lavfi -i sine=frequency=440:duration=5 \
-c:v libx264 -c:a aac clean.mp4
# Header-truncated asset (drops the ftyp/moov region)
tail -c +4096 clean.mp4 > truncated.mp4
# Run the gate
python validate_mp4.py truncated.mp4
A truncated input must route to quarantine (or dead_letter if the probe cannot return at all), never to transcode_queue:
{
"is_valid": false,
"routing_queue": "quarantine",
"diagnostics": {
"file": "truncated.mp4",
"failure_reason": "Missing or malformed ISO Base Media container"
}
}
Independently confirm what ffprobe itself reports, and assert the gate honors its time budget:
# moov-less files print this on stderr and exit non-zero
ffprobe -v error -show_format truncated.mp4
# -> [mov,mp4,m4a,...] moov atom not found
# The probe must finish well under its wall-clock ceiling, even on bad input
/usr/bin/time -v python validate_mp4.py truncated.mp4 2>&1 | grep "Elapsed"
In production, assert the routing invariant as a metric rather than a log scrape: quarantine_total and dead_letter_total should rise on bad batches while transcode_jobs_total only ever counts assets that passed, and ingest_probe_seconds (a histogram) should stay flat regardless of how corrupt the input cohort is.
Failure Modes & Edge Cases
| Edge case | Probe signature | Correct route | Note |
|---|---|---|---|
Fragmented MP4 with moov at EOF |
probe slow but succeeds within budget | transcode_queue |
streaming-optimized; do not quarantine valid fMP4 |
moov atom never written |
moov atom not found, non-zero exit |
quarantine |
classic interrupted upload |
| Header bytes truncated | format_name empty / parse error |
quarantine |
ftyp lost; container unrecognizable |
| Zero / sub-0.1s duration | format.duration <= 0.1 |
quarantine |
mdat carries no real samples |
| 44.1 kHz + 48 kHz audio streams | sample-rate delta > 50 Hz | manual_review |
desyncs during normalization, not fatal |
| Valid container, broken NAL units | probe passes, decode later fails | transcode_queue then DLQ |
demux-clean defects surface at decode |
| Probe exceeds hard timeout | TimeoutExpired raised |
dead_letter |
alert + backoff; never silently retry |
| Variable frame rate source | probe succeeds, vfr flagged | transcode_queue |
downstream must force CFR, not a corruption |
Two of these deserve emphasis. A broken NAL boundary inside an otherwise valid container will pass a metadata-only probe and only fail at decode time — the fallback routing for failed transcodes layer is what catches these, classifying the FFmpeg Invalid data found when processing input as deterministic and routing it straight to the dead-letter queue. And fragmented MP4 with a trailing moov is valid: keep probesize/analyzeduration generous enough that streaming-optimized assets are not misclassified as corrupt.
FAQ
Why route a corrupt MP4 to quarantine instead of retrying it?
Structural container corruption is deterministic, not transient — a missing moov atom is missing on every attempt, so retrying just burns the same cycles three times and can wedge the queue. Quarantine isolates the asset for forensic replay while keeping the worker pool moving; only probe-level transient faults (broker timeout, scratch-disk full) belong on a retry path.
How do I keep the probe from hanging on a truncated file?
Bound it twice. Set -probesize 50M and -analyzeduration 30000000 so ffprobe stops trying to reconstruct metadata after a fixed budget, and wrap the subprocess in a hard timeout (5s here) so even a pathological input cannot block the worker. The hard timeout is the guarantee; the probe flags just make the common case fast.
Does a successful probe mean the file will transcode cleanly?
No. The gate validates container structure and stream metadata, which proves the moov atom was located and the duration is sane, but it does not decode every frame. Defects like broken H.264 NAL boundaries surface only at decode time, which is why the downstream transcode worker still needs deterministic dead-letter handling for Invalid data found when processing input errors.
Related
- Up to the parent overview: Media Validation & Error Routing
- Where decode-time failures degrade safely: setting up fallback routing for failed transcodes
- Structural pre-flight that feeds this gate: video container parsing with Python
- Shared quarantine and replay machinery: retry logic and dead-letter queues
- Grandparent overview: Media Ingestion & Format Architecture