Skip to content

fix(rsky-video): remux non-MP4 video containers before PDS upload, and verify what the PDS stored - #210

Merged
afbase merged 2 commits into
mainfrom
fix/rsky-video-mov-remux
Jul 29, 2026
Merged

fix(rsky-video): remux non-MP4 video containers before PDS upload, and verify what the PDS stored#210
afbase merged 2 commits into
mainfrom
fix/rsky-video-mov-remux

Conversation

@afbase

@afbase afbase commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Every video shot on an iPhone fails to post, on both web and mobile, with:

POST /xrpc/com.atproto.repo.applyWrites 400 (Bad Request)
{"error":"InvalidRequest","message":"Invalid app.bsky.feed.post record: Expected \"video/mp4\" (got \"video/quicktime\") at $.writes[0].record.embed.video.mimeType"}

The PDS is right to reject it — app.bsky.embed.video accepts only video/mp4. The bug is in rsky-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_video sends those bytes with a hardcoded Content-Type: video/mp4 header, but that header is a no-op — every spec-compliant PDS tags blobs by sniffing the bytes:

PDS Behavior
TypeScript sniffedMime || userSuggestedMimepackages/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 comes back tagged video/quicktime, rsky-video returns that ref to the client verbatim, and applyWrites fails 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/mp4 and remux it with -c copy -movflags faststartstream 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_remux gates 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):

Container Brand file-type (TS PDS) infer 0.15 (rsky-pds) Handled Attempts Last seen
.mov qt video/quicktime video/quicktime remux 1,047 2026-07-28
.mov (no ftyp) leading moov/mdat/free/wide video/quicktime partial remux
.m4v M4V /M4VH/M4VP video/x-m4v video/x-m4v remux 2 2026-03-03
.3gp 3gp* video/3gpp (none) → passes remux 15 2026-07-18
.3g2 3g2* video/3gpp2 (none) → passes remux 0
audio M4A /M4B /F4A /F4B audio/* audio/m4a reject 0
image avif/mif1/msf1/heic/heix/hevc/hevx/crx image/* reject 0
.webm video/webm video/webm reject 21 2026-07-03
.mkv / .avi reject 0
.mp4 isom/mp42/… video/mp4 pass through 4,259 2026-07-28

M4V and 3g* are not hypothetical — 17 real attempts, the most recent ten days ago — and they stream-copy with the exact argv already used here (ffmpeg demuxes mov/m4v/3gp with the same demuxer, and the mp4 muxer writes an isom brand 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/mp4 blob that embeds as a broken video instead of failing. They surface as an explicit error instead.

isom/mp42/mp41/iso2/avc1/dash/M4P already sniff as video/mp4 and 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_token now rejects a returned mimeType that 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 a video/quicktime blob_ref that rsky-video never objected to. In the last 30 days alone: 243 QuickTime attempts from 58 distinct users, every one recorded as JOB_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/.avi uploads 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) created transcode.rs fresh with a GIF path only — so main has never had QuickTime handling, and the .mov case has been broken since 4ff11af (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:

Month video/quicktime video/mp4
2026-01 5 0
2026-02 14 0
2026-03 305 3
2026-04 458 6
2026-05 4 275 ← May-1 remux goes live
2026-06 0 261 ← fully working
2026-07 240 110 ← regression

May–June is a clean window where .mov stopped sniffing as QuickTime, bracketed exactly by 4e889b3 (May 1), ending when a redeploy from main dropped it. July's 110 are just the mislabeled-extension baseline.

This re-applies the fix in main's style (Error::Internal, &[u8] -> Vec<u8>, hardcoded ffmpeg) rather than cherry-picking the May commit's different conventions, and factors the shared tempfile/ffmpeg plumbing out of gif_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-508 brand switch and core.js:1025 mov block) and infer 0.15 (rsky-pds, matchers/video.rs:49 is_mov, :101 is_mp4, :2 is_m4v, order in map.rs:217-241):

in.mov  (ftypqt  ) -> video/quicktime   <- the 400
in.m4v  (ftypM4V ) -> video/x-m4v       <- also the 400
in.3gp  (ftyp3gp6) -> video/3gpp        <- also the 400
out.mp4 (ftypisom) -> video/mp4         <- after remux, all three

Streams verified preserved (h264/aac, no re-encode). The GIF path was re-verified end-to-end after the refactor and still emits video/mp4.

  • 18 unit tests: is_quicktime_container (the qt brand, the ftyp-less variants, rejecting mp42/isom/GIF/skip/short buffers with no panic) and needs_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).
  • 2 #[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 through mov_to_mp4, and assert the container brand flipped to one that sniffs as video/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 in signing/mod.rs are pre-existing). cargo fmt: clean.

✅ Deploy prerequisite: ffmpeg is present and working — confirmed

An earlier revision of this PR flagged ffmpeg provisioning as an open risk, since it is not provisioned anywhere in this repo (no rsky-video Dockerfile; rsky-pds/Dockerfile stubs rsky-video/src/main.rs and ships only the rsky-pds binary). That is now resolved: it is host-provided and working.

Evidence — .gif uploads by month, by sniffed mime:

2026-03  image/gif 341      2026-06  image/gif 210
2026-04  image/gif 229      2026-07  video/mp4 110   <- after the Jul-10 GIF fix
2026-05  image/gif 267               image/gif  91   <- before it

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 to ffmpeg successfully in production since Jul 10.

Still worth making the dependency explicit somewhere, but it does not block this deploy.

Note on the infer divergence

One infer 0.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 by mdat. It affects rsky-pds only, and only for brands outside infer's is_mp4 whitelist since that matcher runs first (map.rs:217); the free/moov clauses 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 the is_quicktime_container doc comment.

Separately: infer's is_mp4 brand whitelist omits several valid MP4 brands (iso8, av01, cmf1, …). Those return None and 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/.3g2 fail only against the TypeScript PDS.

Which PDS is in front

The 15 video/3gpp rows settle a question the incident write-up left open. infer 0.15 has no 3GPP matcher at all — a 3gp6 file falls through every matcher, returns None, and would be stored under the fallback video/mp4 we send. Only file-type's default: if brandMajor.startsWith('3g') branch can produce the string video/3gpp. So blacksky.app is fronted by the TypeScript PDS — the stricter of the two. Worth knowing, since it determines which containers fail.

Not addressed

.webm, .mkv and .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 ftyp brands are likewise rejected rather than converted, for the reason given above.

Related Issues

Reported internally: video posts from iOS rejected with a video/quicktime mimeType error. Supersedes #185.

Changes

  • Bug fix

Checklist

  • I have tested the changes (including writing unit tests).
  • I confirm that my implementation aligns with the canonical Typescript implementation and/or atproto spec
  • I have updated relevant documentation.
  • I have formatted my code correctly
  • I have provided examples for how this code works or will be used

🤖 Generated with Claude Code

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>
… 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>
@afbase afbase changed the title fix(rsky-video): remux QuickTime uploads to MP4 before PDS upload fix(rsky-video): remux non-MP4 video containers before PDS upload, and verify what the PDS stored Jul 28, 2026
@afbase
afbase requested a review from rishibalakrishnan July 28, 2026 22:21
@afbase
afbase merged commit 201e44c into main Jul 29, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants