fix(rsky-video): remux non-MP4 video containers before PDS upload, and verify what the PDS stored - #210
Merged
Merged
Conversation
app.bsky.embed.video accepts only mimeType video/mp4, but iPhone camera
captures and screen recordings ship H.264/AAC inside a QuickTime container.
upload_video sent those bytes to the PDS verbatim with a hardcoded
Content-Type: video/mp4 header -- and that header is a no-op, because every
spec-compliant PDS tags blobs by sniffing the bytes:
- TypeScript PDS: `sniffedMime || userSuggestedMime`
(packages/pds/src/actor-store/blob/transactor.ts)
- rsky-pds: `sniffed_mime.unwrap_or(user_suggested_mime)`
(rsky-pds/src/actor_store/blob/mod.rs)
So the blob came back tagged video/quicktime, rsky-video handed that ref to
the client as-is, and the client's applyWrites failed record validation with
a 400. Every video shot on an iPhone failed to post, on web and mobile; MP4
uploads passed coincidentally because their bytes already sniff as video/mp4.
Detect QuickTime on upload and remux to MP4 with `-c copy -movflags
faststart` -- stream copy, no re-encode, so it is lossless and costs only
the time to copy the bytes once. The conversion runs before both the PDS
upload and the Bunny transcode, so Bunny receives the MP4 too and streaming
is unaffected. MOVs carrying codecs MP4 cannot hold (ProRes) fail the remux
with ffmpeg's error rather than storing a blob that cannot be embedded.
The detector mirrors what the PDS sniffers themselves treat as QuickTime,
since anything they tag video/quicktime fails validation: an `ftyp` box with
the `qt ` brand, or a leading moov/mdat/free/wide box for older MOVs with no
ftyp at all. Verified against file-type 16.x and the infer crate. ISO BMFF
brands (isom, mp42, ...) already sniff as video/mp4 and are left alone.
A MOV remux was written for this in May (4e889b3, branch
fix/rsky-video-mov-to-mp4) but never merged; the July GIF-transcode PR
(d8a4e0d) then created transcode.rs fresh with a GIF path only, so main has
never had QuickTime handling. This re-applies the fix in main's style and
factors the shared tempfile/ffmpeg plumbing out of gif_to_mp4 -- the GIF
argv is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 tasks
… PDS stored
Three gaps in the QuickTime remux, found by checking the sniffer sources against
production upload data (videos.video_jobs, 6,660 attempts since 2026-01-21).
The failing condition is not "is QuickTime" but "the PDS sniffer will not report
video/mp4", so is_quicktime_container was too narrow a gate. Two other brands
fail identically and both occur in production:
- M4V /M4VH/M4VP -> video/x-m4v. Apple exports, Handbrake m4v presets,
ffmpeg's ipod muxer. Rejected by both sniffers (file-type core.js:480,
infer is_m4v at matchers/video.rs:2). 2 attempts.
- 3g* -> video/3gpp / video/3gpp2. Older Android capture. Rejected by the
TypeScript PDS (core.js:500); infer has no 3GPP matcher, so rsky-pds lets
these pass via the content-type fallback. 15 attempts, latest 2026-07-18.
Both stream-copy cleanly with the argv this already uses -- ffmpeg demuxes mov,
m4v and 3gp with the same demuxer and the mp4 muxer writes isom regardless -- so
needs_mp4_remux now gates on all three. Verified end to end against real ffmpeg
output in remux_normalizes_m4v_and_3gp_brands.
Audio brands (M4A /M4B /F4A /F4B ) and image brands (avif/mif1/msf1/heic/heix/
hevc/hevx/crx) also fail validation but are deliberately not remuxed: they carry
no video track, so converting them would mint a video/mp4 blob that embeds as a
broken video rather than failing. Never observed in production.
upload_blob_with_token now rejects a returned mimeType that disagrees with the
one it sent. This is the change that matters most. The PDS sniffs bytes and
ignores our header, so a disagreement means the blob can never be embedded --
but nothing checked it, so the job completed, the post failed client-side in
applyWrites, and the only trace was the mimeType in the job row. That is how
this stayed broken for six months across two independent fix attempts: 1,044
rows carry a video/quicktime blob_ref that rsky-video never objected to. Any
container we do not normalize is now a visible server-side error instead.
Behaviour change: .webm/.mkv/.avi uploads (21 attempts, all .webm) now fail at
the PDS upload step with an explicit mime mismatch rather than completing and
failing in the client. They need a real re-encode, still out of scope.
Also documents the one infer 0.15 divergence not mirrored here: is_mov's fourth
clause, bytes[12..16] == "mdat" (matchers/video.rs:55). It affects rsky-pds only,
and only for brands outside infer's is_mp4 whitelist since that matcher runs
first (map.rs:217); mirroring it would mean duplicating the whitelist, and the
mimeType check above catches it if it ever occurs.
Tests: 18 unit tests (4 new covering the added brands, the mp4-sniffing brands
that must pass through, and the audio/image brands that must not be remuxed),
plus a second ignored end-to-end test building real M4V and 3GP fixtures. 16
passed, 2 ignored; both ignored tests pass with ffmpeg present. No new clippy
warnings; cargo fmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rishibalakrishnan
approved these changes
Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every video shot on an iPhone fails to post, on both web and mobile, with:
The PDS is right to reject it —
app.bsky.embed.videoaccepts onlyvideo/mp4. The bug is inrsky-video: it uploads the original, un-transcoded bytes to the user's PDS, and iPhone captures/screen recordings are H.264/AAC inside a QuickTime container.upload_videosends those bytes with a hardcodedContent-Type: video/mp4header, but that header is a no-op — every spec-compliant PDS tags blobs by sniffing the bytes:sniffedMime || userSuggestedMime—packages/pds/src/actor-store/blob/transactor.tssniffed_mime.unwrap_or(user_suggested_mime)—rsky-pds/src/actor_store/blob/mod.rsSo the blob comes back tagged
video/quicktime,rsky-videoreturns that ref to the client verbatim, andapplyWritesfails record validation. Bunny transcoding happens afterward and its MP4 is never re-uploaded, so the webhook reuses the original QuickTime blob ref.MP4 uploads pass coincidentally — their bytes already sniff as
video/mp4— which is why MP4 test uploads never caught this.Fix
Detect any container the PDS will not report as
video/mp4and remux it with-c copy -movflags faststart— stream copy, no re-encode, so it's lossless and costs only the time to copy the bytes once. The conversion runs before both the PDS upload and the Bunny transcode, so Bunny receives the MP4 too and streaming is unaffected.Then verify what the PDS actually stored and fail loudly if it disagrees. See "The mimeType check" below — that is the part that keeps this class of bug from recurring.
Coverage
The failing condition is not "is QuickTime" but "the PDS sniffer will not report
video/mp4".needs_mp4_remuxgates on every video brand that trips it, checked against the sniffer sources and against six months of real upload attempts (videos.video_jobs, 6,660 rows since 2026-01-21):.movqtvideo/quicktime❌video/quicktime❌.mov(noftyp)moov/mdat/free/widevideo/quicktime❌.m4vM4V/M4VH/M4VPvideo/x-m4v❌video/x-m4v❌.3gp3gp*video/3gpp❌.3g23g2*video/3gpp2❌M4A/M4B/F4A/F4Baudio/*❌audio/m4a❌avif/mif1/msf1/heic/heix/hevc/hevx/crximage/*❌.webmvideo/webm❌video/webm❌.mkv/.avi.mp4isom/mp42/…video/mp4✓M4Vand3g*are not hypothetical — 17 real attempts, the most recent ten days ago — and they stream-copy with the exact argv already used here (ffmpeg demuxesmov/m4v/3gpwith the same demuxer, and the mp4 muxer writes anisombrand regardless of input). Verified end to end.Audio and image brands are deliberately not remuxed. They fail validation too, but they carry no video track, so converting them would mint a
video/mp4blob that embeds as a broken video instead of failing. They surface as an explicit error instead.isom/mp42/mp41/iso2/avc1/dash/M4Palready sniff asvideo/mp4and are left untouched — there is a test asserting exactly that, since remuxing them would be pure waste.The mimeType check (the part that matters most)
upload_blob_with_tokennow rejects a returnedmimeTypethat disagrees with the one it sent.Nothing checked this before, and that is why this bug survived six months and two independent fix attempts: the PDS sniffs bytes and ignores our header, so a disagreement means the blob can never be embedded — but the video job completed successfully, the failure surfaced only client-side in
applyWrites, and the sole trace was a mimeType sitting in a job row. 1,044 rows carry avideo/quicktimeblob_ref thatrsky-videonever objected to. In the last 30 days alone: 243 QuickTime attempts from 58 distinct users, every one recorded asJOB_STATE_COMPLETED.With the check, any container we do not normalize becomes a visible, greppable server-side error naming the actual mime — independent of how good the detector is.
Behaviour change:
.webm/.mkv/.aviuploads now fail at the PDS upload step with an explicit mime mismatch rather than completing and failing in the client. That is the intent. They need a real re-encode (see "Not addressed").Why this is a regression
A MOV remux was written for this in May (
4e889b3, #185) but never merged. In July the GIF-transcode PR (#205,d8a4e0d) createdtranscode.rsfresh with a GIF path only — somainhas never had QuickTime handling, and the.movcase has been broken since4ff11af(Jan 21) added the raw-bytes-to-PDS upload.The job data confirms this independently.
.mov-named uploads, by what the PDS sniffed them as:video/quicktimevideo/mp4May–June is a clean window where
.movstopped sniffing as QuickTime, bracketed exactly by4e889b3(May 1), ending when a redeploy frommaindropped it. July's 110 are just the mislabeled-extension baseline.This re-applies the fix in
main's style (Error::Internal,&[u8] -> Vec<u8>, hardcodedffmpeg) rather than cherry-picking the May commit's different conventions, and factors the shared tempfile/ffmpeg plumbing out ofgif_to_mp4. The GIF argv is unchanged.Byte sniffing is empirically necessary, not just spec-correct
From the same data, filename extension vs. what the bytes actually were:
.mov-named → 655 were really MP4 inside (28% of all.mov). Extension-based conversion would needlessly transcode all 655..mp4-named → 21 were really QuickTime, 14 were 3GP, 2 were M4V. Extension-based detection would miss every one.Verification
Reproduced and fixed at the byte level against the exact sniffer libraries both PDS implementations use —
file-type@16.5.4(TS PDS,core.js:457-508brand switch andcore.js:1025mov block) andinfer0.15 (rsky-pds,matchers/video.rs:49is_mov,:101is_mp4,:2is_m4v, order inmap.rs:217-241):Streams verified preserved (
h264/aac, no re-encode). The GIF path was re-verified end-to-end after the refactor and still emitsvideo/mp4.is_quicktime_container(theqtbrand, theftyp-less variants, rejectingmp42/isom/GIF/skip/short buffers with no panic) andneeds_mp4_remux(every added brand; the mp4-sniffing brands that must pass through; the audio and image brands that must not be remuxed; junk input).#[ignore]d end-to-end tests (cargo test -p rsky-video -- --ignored) that build real H.264/AAC QuickTime, M4V and 3GP files with ffmpeg, run them throughmov_to_mp4, and assert the container brand flipped to one that sniffs asvideo/mp4. Follows the crate's existing convention for tests needing external tooling.cargo test -p rsky-video: 16 passed, 2 ignored; both ignored tests pass with ffmpeg present.cargo clippy: no new warnings (the two insigning/mod.rsare pre-existing).cargo fmt: clean.✅ Deploy prerequisite:
ffmpegis present and working — confirmedAn earlier revision of this PR flagged
ffmpegprovisioning as an open risk, since it is not provisioned anywhere in this repo (norsky-videoDockerfile;rsky-pds/Dockerfilestubsrsky-video/src/main.rsand ships only thersky-pdsbinary). That is now resolved: it is host-provided and working.Evidence —
.gifuploads by month, by sniffed mime:The flip lands exactly on #205's deploy, and zero job rows have ever matched
error ~* 'ffmpeg|transcode|remux'. The GIF path has been shelling out toffmpegsuccessfully in production since Jul 10.Still worth making the dependency explicit somewhere, but it does not block this deploy.
Note on the
inferdivergenceOne
infer0.15 behaviour is deliberately not mirrored:is_mov's fourth clause,bytes[12..16] == "mdat"(matchers/video.rs:55), which fires for a 12-byte leading box followed bymdat. It affects rsky-pds only, and only for brands outsideinfer'sis_mp4whitelist since that matcher runs first (map.rs:217); thefree/moovclauses already catch the realistic shapes. Mirroring it faithfully would mean duplicating that whitelist here. The mimeType check catches it if it ever occurs in the wild. Documented in theis_quicktime_containerdoc comment.Separately:
infer'sis_mp4brand whitelist omits several valid MP4 brands (iso8,av01,cmf1, …). Those returnNoneand fall back to the client-suggested mime, so they pass — no bug, but it means rsky-pds is the more permissive of the two implementations, and.3gp/.3g2fail only against the TypeScript PDS.Which PDS is in front
The 15
video/3gpprows settle a question the incident write-up left open.infer0.15 has no 3GPP matcher at all — a3gp6file falls through every matcher, returnsNone, and would be stored under the fallbackvideo/mp4we send. Onlyfile-type'sdefault: if brandMajor.startsWith('3g')branch can produce the stringvideo/3gpp. Soblacksky.appis fronted by the TypeScript PDS — the stricter of the two. Worth knowing, since it determines which containers fail.Not addressed
.webm,.mkvand.avi(21 real attempts, all.webm) still fail, because their codecs generally cannot be stream-copied into MP4 — VP8/VP9 + Opus needs a real re-encode, which is a different cost profile and belongs behind the Bunny pipeline rather than inline in the upload path. With this PR they at least fail as an explicit server-side error instead of a silent client-side 400.Audio-only and image
ftypbrands are likewise rejected rather than converted, for the reason given above.Related Issues
Reported internally: video posts from iOS rejected with a
video/quicktimemimeType error. Supersedes #185.Changes
Checklist
🤖 Generated with Claude Code