Completed Is Not the Same as Correct: Output Reports, Admission Probes and a Free HLS Checker
An encoder exits zero on plenty of files that are not what was asked for. Every finished output now carries a report of what the file was measured to be and a verdict on whether it matches the request; the worker refuses to deliver a file that does not; dead input URLs are refused before a machine boots; and there is a free HLS checker that reads your initialization segments, not just your playlist.
status: "completed" says the encode finished. It does not say the file is the one you asked for.
An encoder exits zero on an audio-only source that was named .mp4 and packaged as video. It exits zero on a source truncated at 60% of its length. It exits zero on an 8-bit file wearing HDR colour tags it cannot possibly carry. In every one of those cases the job goes green, the output is billed, and the first person to find out is a viewer.
Three things shipped in September to close that gap, at three different points in the pipeline: before a job is accepted, after a file is produced, and on any HLS stream you care to paste into a form.
Every output carries a report
Every completed output now carries a report: the facts read back from the produced file, and a verdict on whether those facts match the output spec the job was created with.
{
"id": "out_m1n2o3p4q5",
"status": "completed",
"report": {
"container": "mp4",
"video": {
"codec": "h264",
"profile": "high",
"level": "4.0",
"pix_fmt": "yuv420p",
"width": 1920,
"height": 1080,
"frame_rate": 29.97,
"bitrate_kbps": 4800,
"color": { "primaries": "bt709", "transfer": "bt709", "matrix": "bt709", "range": "tv" },
"hdr_format": "none"
},
"audio": [
{ "codec": "aac", "channels": 2, "sample_rate_hz": 48000, "bitrate_kbps": 128, "language": "eng" }
],
"duration_seconds": 763.1,
"verdict": { "matches_request": true, "mismatches": [] },
"checked_at": "2026-09-14T10:55:08Z"
}
}And a file that is not what was asked for:
{
"verdict": {
"matches_request": false,
"mismatches": [
{ "field": "video.codec", "expected": "h264", "actual": "hevc" },
{ "field": "video.resolution", "expected": "1080p", "actual": "720p" }
]
}
}Three design decisions are worth stating, because each one is the opposite of the easy choice.
The worker measures; the API decides. The encoder host is the only place that holds the produced bytes, so the facts can only come from there. But the verdict is computed by the API from the job’s own spec, and a verdict sent by a worker is discarded on ingest. One job yields one verdict no matter which worker build encoded it, and a worker cannot report itself correct.
The verdict asserts only what your request pinned. A property you left open cannot produce a mismatch, because telling you a correct file is broken is worse than saying nothing. Audio codec, channels and sample rate are reported as facts with nothing to compare against, since a request never names them. Absent audio on an output that did not disable it is not a mismatch; the source may have had no sound. A 4:3 source at 1080p is legitimately 1440 pixels wide, so a tier request is compared as a tier. An output that runs a little long is fine, because a clipped encode starts on a keyframe; only a shortfall is a truncation, and only beyond the larger of one second and 2% of the promised length.
Absence is not a pass. report is omitted when the output was never measured, which covers outputs produced before the field existed and outputs that never produced a file. Absent means not measured. It never means nothing wrong. Branch on presence before you read the verdict.
The report rides JobOutput, so it appears on GetJob, ListJobs, WatchJob, on the output.ready and output.failed webhooks, inside outputs[] on job.succeeded, job.failed and job.canceled, and as a panel on the job page in the dashboard. No new webhook event was added; an integration you already have picks it up without changing anything.
The worker refuses to deliver a file that does not match
The report is the customer-facing half. The other half is that the worker now runs the same measurements on every file it produces before uploading it, and fails the output instead of delivering something unusable. The output’s error code is output_mismatch when the file did not match what was promised (a missing stream, the wrong codec or container, colour signalling that contradicts the encode), or output_verification_failed when the verification itself could not run on that machine. The second one is a property of the machine, not your request, and it is retryable. A rejected output keeps its report, which is exactly where you need to see what the file was instead.
Two hostile inputs are also now decided at probe, before any encode and before anything is billed. An audio-only file submitted as video is refused with input_no_video_stream, where previously ffmpeg’s default stream selection encoded the audio alone and billed for an “mp4 720p” output with no picture in it. A still image submitted as video is refused with input_unsupported_codec, decided by codec name only for formats that can never be motion, so a real MJPEG capture or an animated GIF is not rejected for its name. And an input’s HDR claim is adjudicated rather than believed: eight bits per component cannot carry PQ or HLG, so a stream that is positively 8-bit while signalling smpte2084 or arib-std-b67 is classified SDR and its tags are stripped rather than propagated onto your output.
A failed output bills nothing, which was already the rule. What changed is that a wrong output no longer completes.
Dead URLs are refused before a machine boots
Nothing on the create path had ever touched the network. A URL that was already dead when you pasted it (a typo, a deleted object, a bucket whose permissions were never opened, a one-hour AI-generation link that expired first) was accepted, queued, dispatched, and failed minutes later after a VM had booted for it. Your first signal was a failed job. Ours was a paid-for boot.
Plain http(s) input URLs now get one HEAD through the SSRF-safe transport at admission, with a ranged GET of the first byte as a fallback and an eight-second budget for the whole thing. The fallback is not an optimisation: presigned URLs are signed per method, so a GET-presigned S3 or GCS link answers a HEAD with 403 while the object is right there. Any non-2xx HEAD is re-asked as a ranged GET before it is believed.
The rule is deliberately asymmetric. Only an answer that will never change refuses a create: a 404, a 410 or a host that does not exist, returned as input_not_found. A timeout, a 5xx, a 429, a 403, or a DNS server having a bad minute all admit the job, because none of those is evidence about the object, and an origin scoped by source IP or WAF policy can refuse the API and serve the worker. gs:// and s3:// inputs, origin-sourced and hosted-video jobs, and the upload path are never probed, since an unauthenticated HEAD at any of them is answered 403 by the provider.
The point is not to catch everything at the door. It is to catch the cases that are certain, at the moment when catching them costs nothing.
A free HLS checker that reads the bytes
The defect that actually breaks HLS playback is almost never visible in the playlist text. The usual cause is a CODECS attribute that disagrees with the file behind it: a packager set to H.264 in front of an HEVC encode, a hand-edited ladder, a profile string copied from another stream. Safari rules the variant out before it fetches a byte, and the player reports nothing more useful than a blank frame. Every free m3u8 linter in the search results stops at the playlist text.
transcodely.com/tools/hls-check is a free checker that does not stop there. Paste a master or media playlist URL and it walks the ladder, applies a rule catalogue transcribed from RFC 8216 and its low-latency successor, and for each variant issues one ranged GET of the first 4 KiB of the initialization segment. That is enough to walk ftyp → moov → trak → mdia → minf → stbl → stsd, read the codec configuration record, and derive the RFC 6381 string the file actually deserves. If it disagrees with what you declared, the finding is codecs-mismatch, with the line number and the fix.
The comparison is narrowed on purpose. It matches by codec family, not exact string, because a declared avc1.640028 against a measured avc1.64001f differs only in level, which is routinely conservative and does not break playback. Calling it a defect would train you to ignore the case where the family itself is wrong.
What it does not do is stated before the rule list, because a checker that overstates its reach is worse than none. It does not play your stream, does not decode, does not fetch keys or attempt a decrypt, and is not Apple’s mediastreamvalidator. One check reads up to twelve variant and rendition playlists and six initialization segments under a fifteen-second deadline, and says so when a budget runs out rather than reporting a partial walk as complete. No key, no signup, ten checks a minute per address, and a shareable permalink that lives for seven days with the query string redacted, so a presigned manifest does not publish its signing token when you paste the link into an issue.
The rule catalogue was itself reviewed against real output before shipping. An earlier draft had invented four EXT-X-VERSION requirements no specification states, two of them graded as errors, and between them they fired on every master this platform produces and on the specification’s own low-latency example. The guard against a repeat is a test that runs the whole catalogue over vendored packager output and hand-written masters in the shapes ffmpeg and Apple’s examples produce, and asserts zero findings of error or warning severity. A rule can only fire on that corpus by being wrong.
Why all three at once
These are one idea at three points in the pipeline: a green status should mean the thing you asked for exists and is playable, and where it does not, you should see the measurement rather than the adjective.
The same idea is why the conformance page exists, and why the validation post lists the cases that do not pass yet alongside the ones that do. The output report is that discipline applied to every job rather than to our test corpus. If you have an integration that treats completed as done, the change to make is small: check that report is present, then check verdict.matches_request, and route anything else to the same place you route a failed job.
Try it on your own footage
Upload a clip, pick a ladder, and read the manifest that comes back. Test encodes up to 30 seconds are free.