The two-host video pipeline in this project generates YouTube Shorts and longform videos through a CI run: edge-tts synthesizes dialogue audio, Pillow renders slides, ffmpeg stitches them into a final .mp4. Most runs produce a valid video. "Most" is not good enough when a bad upload consumes the YouTube Data API quota for the day and leaves a malformed file in the channel's unpublished queue.
I've had three actual failure categories before I added QC: a Shorts video that came out 16:9 instead of 9:16, an audio gap at the end that triggered YouTube's low-quality flag, and a pixel format mismatch that caused the video to appear washed out on Android. All three were detectable before the API call.
scripts/qc-media.py runs ffprobe and two ffmpeg filter passes on every .mp4 before the publish step. Here are the four checks.
Check 1: Aspect ratio
YouTube Shorts require vertical video — roughly 9:16. Longform requires 16:9. YouTube's upload specifications list the accepted formats, but they don't tell you what happens when you upload the wrong one. The check reads width and height from the video stream via ffprobe:
def probe(path: Path) -> dict:
result = run(["ffprobe", "-v", "error", "-show_streams",
"-show_format", "-of", "json", str(path)])
return json.loads(result.stdout)
data = probe(path)
videos = [s for s in data["streams"] if s["codec_type"] == "video"]
width, height = int(videos[0]["width"]), int(videos[0]["height"])
if mode == "short":
if height <= width or width / max(height, 1) > 0.58:
errors.append(f"Short must be vertical 9:16-ish, got {width}x{height}")
The 0.58 threshold (roughly 9/16 with slack for near-vertical frames) catches videos that are technically taller than wide but not vertical enough for Shorts. I caught a 720×900 render this way — 4:5 aspect ratio, which YouTube rejects for Shorts classification. Without this check, the video uploads fine and silently fails to appear in the Shorts feed.
Check 2: Duration window
Shorts must be 15–180 seconds. Longform must be 4–60 minutes. The pipeline can produce out-of-range clips if a slide generation step silently produces fewer frames than expected, or the audio synthesis stage generates a truncated clip.
duration = float(data["format"]["duration"] or 0)
if mode == "short":
if not 15 <= duration <= 180:
errors.append(f"Short duration out of range: {duration:.1f}s")
The failure mode here is non-obvious: a missing audio segment doesn't crash ffmpeg — it just renders a shorter clip. I had a 12-second Short upload successfully, appear in YouTube Studio, and then get flagged as "too short to qualify as a Short" with no explanation. Duration check catches this in under 100ms.
Check 3: Pixel format
YouTube ingests yuv420p. Other formats — yuv422p, rgb24, some yuvj420p variants — cause processing delays or display problems on certain devices:
pix_fmt = videos[0].get("pix_fmt")
if pix_fmt not in {"yuv420p", "yuvj420p"}:
errors.append(f"unexpected pixel format: {pix_fmt}")
I include yuvj420p in the allowed set because some Pillow-rendered frames arrive in that format (full-range YUV) and YouTube handles it without issues in practice. The check catches everything else. The pixel format mismatch that produced the washed-out Android rendering was yuv444p — perfectly valid for local playback, wrong for YouTube's transcoder.
Check 4: Silence and black frame ratios
Long silence at the start or end of a video is a quality signal YouTube's pipeline picks up. Black frame sequences do the same. Both are artifacts of render bugs: a timing offset in the ffmpeg concatenation step, a frame rendered before the slide loaded, a gap in edge-tts output.
The check uses ffmpeg's silencedetect and blackdetect filters, parses the detected event durations from stderr, and computes each as a fraction of total video length:
def detect_ratio(path: Path, filter_name: str, pattern: str) -> float:
result = run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path),
"-vf", filter_name, "-an", "-f", "null", "-"])
# Fail closed: broken filter must not read as "0 seconds detected"
if result.returncode:
raise ValueError(f"{filter_name} failed: {result.stderr[-300:]}")
values = [float(x) for x in re.findall(pattern, result.stderr)]
return sum(values) if values else 0.0
If more than 10% of the video is silence, or more than 20% is black frames, the check fails. These thresholds came from empirical observation — a 60-second Short with 5 seconds of silence at the end (8%) passes YouTube's quality checks; one with 12 seconds of silence doesn't.
The raise ValueError on a non-zero return code matters. A broken ffmpeg filter or corrupted input file must not silently register as "no silence detected." Fail-closed beats fail-open for a pre-upload gate. The same principle shows up in the thumbnail brightness check: if the brightness analysis can't run, the check fails rather than approving.
What this doesn't catch
These four checks cover container shape and rendering artifacts. They don't catch content quality — a correctly-formatted video can still be a bad video. They also don't catch YouTube-specific rejection reasons that only surface post-upload: trademark disputes, copyrighted audio in the soundtrack, community guideline flags on thumbnail text.
For those, I keep a manual review step before the publish cron fires. Deterministic technical constraints are worth automating. Judgment calls are not.
The three-tier thumbnail fallback pipeline handles a related check on the thumbnail PNG separately from this script — both are required to pass before the YouTube API request goes out. Two gates, two types of output, one publish step blocked if either fails.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)