{"slug": "streaming-mp4-validation-on-cloudflare-workers", "title": "Streaming MP4 Validation on Cloudflare Workers", "summary": "A developer building Deck the Halls Dance AI, a service that turns two photos into a 15-second dance clip, implemented streaming MP4 validation inside a Cloudflare Workers render pipeline. The Worker parses MP4 boxes (ftyp, moov, mdat) as bytes stream through, confirming each file has both a video and audio track running roughly 15 seconds, while simultaneously uploading 5 MiB parts to Cloudflare R2 via multipart upload under a 100 MiB cap. Failed checks abort the upload so the job row never receives an output key, and lease-aware job updates prevent a slow, expired attempt from overwriting a newer attempt's file.", "body_md": "The render API returns a `.mp4` URL, and the download comes back with HTTP 200. Can you mark the job done? In Deck the Halls Dance AI, two photos become a 15-second Christmas dance clip like this [sample MP4](https://deckthehallsdanceai.com/examples/deck-duo.mp4). Before the Worker hands out a download link, it checks that the file has both video and audio, that each track runs for about 15 seconds, and that the full file made it into storage.\n\nA valid-looking response can still carry a silent clip or end halfway through a box. Downloading the whole thing into an `ArrayBuffer` would make those checks straightforward, but memory would grow with every download. The Worker already has to read the response to save the video in Cloudflare R2, so it inspects the MP4 during that same pass.\n\nAn MP4 is organized into boxes. Each box has a type and a size. For this check the Worker cares about three top-level types: `ftyp` for file type information, `moov` for the movie metadata, and `mdat` for the actual media bytes. The parser buffers the `moov` payload, with an 8 MiB cap. It counts the `mdat` bytes and passes them through to storage.\n\nWithin `moov`, it walks the track metadata down to `trak → mdia`. The `hdlr` box identifies a video track (`vide`) or an audio track (` soun`). The `mdhd` box provides the track’s duration and timescale. A track with a timescale of 48,000 and a duration of 720,000 units runs for 15 seconds. The app accepts a half-second of drift in either direction for each video and audio track. Checking each track catches cases like 15 seconds of video paired with one second of audio.\n\nNetwork chunks can end anywhere, including in the middle of the size field. They can also carry the end of one box and the start of another. The parser holds up to 16 header bytes, including room for an extended size, and remembers how much payload of the active box remains. It can keep reading the other boxes when `moov` arrives after `mdat`. At end of stream, it checks that the stream ended on a valid boundary and that the required boxes and tracks were present.\n\nThe [R2 multipart upload API](https://developers.cloudflare.com/r2/objects/upload-objects/) lets the Worker upload parts before it calls `complete`. The Worker fills 5 MiB parts sequentially and counts incoming bytes against a 100 MiB file limit. It enforces that limit even if the response has no `Content-Length`.\n\nThis TypeScript sketch shows the ordering. `writeParts` handles buffering and part numbering, and `flushParts` uploads any bytes left in the buffer.\n\n``` js\nlet bytesRead = 0\n\ntry {\n  while (true) {\n    const { done, value } = await reader.read()\n    if (done) break\n\n    bytesRead += value.byteLength\n    checkByteLimit(bytesRead)\n    inspector.push(value)\n    await writeParts(value)\n  }\n\n  checkExpectedLength(bytesRead)\n  inspector.finish()\n  await flushParts()\n  await upload.complete(parts)\n} catch (error) {\n  await reader.cancel().catch(() => undefined)\n  await upload.abort().catch(() => undefined)\n  throw error\n} finally {\n  reader.releaseLock()\n}\n```\n\nSuppose the last few bytes disappear after several parts have already made it to R2. The final length or box-boundary check fails and the Worker aborts the upload. The download stays unavailable because the job row only gets an output key after the upload completes.\n\nThat job update also checks which attempt still owns the lease. Each attempt writes to a different object key, so a slow attempt that finishes after its lease expires can’t overwrite the newer attempt’s file. If R2 is temporarily unavailable, the job can retry the download from its stored source URL and reuse the existing render.\n\nThe test fixtures build tiny MP4-shaped byte arrays so they can change one field at a time. Useful cases include a missing audio track, a one-second audio track under a full-length video track, an oversized `moov` header, and a response shorter than its declared size. One fixture uses a payload over 5 MiB to check that the upload splits into two parts. For a rejected file, the important assertion is that `complete` was never called.\n\nThese are container checks. The Worker does not decode frames, listen for silence in a valid audio track, or check that the dancers land on the beat. Those would require media-level checks. For this download endpoint, the gate catches the structural failures it was designed for without buffering the full file or running a transcoder on every render.", "url": "https://wpnews.pro/news/streaming-mp4-validation-on-cloudflare-workers", "canonical_source": "https://dev.to/hex_builder/streaming-mp4-validation-on-cloudflare-workers-56ng", "published_at": "2026-11-15 11:16:46+00:00", "updated_at": "2026-11-15 11:21:16.760827+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "developer-tools"], "entities": ["Cloudflare Workers", "Cloudflare R2", "Deck the Halls Dance AI", "TypeScript"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/streaming-mp4-validation-on-cloudflare-workers", "markdown": "https://wpnews.pro/news/streaming-mp4-validation-on-cloudflare-workers.md", "text": "https://wpnews.pro/news/streaming-mp4-validation-on-cloudflare-workers.txt", "jsonld": "https://wpnews.pro/news/streaming-mp4-validation-on-cloudflare-workers.jsonld"}}