Skip to content

Warn on, then fix, browser uploads that break phone playback - #34

Open
jlocala1 wants to merge 3 commits into
mieweb:mainfrom
jlocala1:feat/browser-upload-contract
Open

Warn on, then fix, browser uploads that break phone playback#34
jlocala1 wants to merge 3 commits into
mieweb:mainfrom
jlocala1:feat/browser-upload-contract

Conversation

@jlocala1

@jlocala1 jlocala1 commented Aug 4, 2026

Copy link
Copy Markdown

Laptop drag-and-drop is the one upload path no phone-side fix can reach. A 4K/HEVC/~25 Mbps file dropped into the dropzone stays unplayable on phones for the life of that pulse.

Two commits: detect and warn, then convert.

The contract

H.264 · long edge <= 1920 · <= 5 Mbps · AAC · faststart

Not arbitrary — it is what the PulseCam recorder already pins itself to, and what export.ts already produces.

1. Detection (client/src/lib/videoContract.ts)

Dependency-free inspection of the picked File:

  • resolution and duration from a detached <video>, falling back to the MP4 boxes when the browser cannot decode the file at all — which is exactly the Android + HEVC case being warned about
  • bitrate as size * 8 / duration
  • video/audio fourcc by walking the top-level box chain by size. These files are not faststart, so moov sits at the very end and scanning the first N bytes finds nothing. The walk handles the 64-bit largesize form (real 4K recordings use it for mdat), then descends moov > trak > mdia > minf > stbl > stsd
  • faststart from the same walk: moov before mdat

Reads are ranged, so a 344MB file costs a few hundred bytes plus the moov box. Nothing throws into the UI — an unparseable file reports unknown and produces no violation.

2. Conversion (client/src/lib/transcodeToContract.ts)

Re-encodes in the browser via WebCodecs before upload. Measured on a real 344MB 4K HEVC phone capture:

source output
codec hevc 3840x2160 (-90 matrix) h264 1080x1920
bitrate 24.8 Mbps 5.2 Mbps
index moov at 100% moov at 0.0%
size 344 MB 72 MB

22 seconds, duration preserved to 13ms, zero decode errors on a full decode. Verified in real Chrome and WebKit.

MediaRecorder over a canvas was not an option: realtime-bound, and WebM outside Safari. Nor was a <video>-element pipeline — Chrome decodes HEVC through WebCodecs but reports canPlayType('video/mp4; codecs="hvc1"') === '', so it would fail silently on precisely the captures this exists for.

Dependencies: mp4box (BSD-3) demuxes, mp4-muxer (MIT) muxes. Deliberate — a subtly wrong hand-rolled sample-table reader corrupts a user's upload, and the variations (co64 vs stco, ctts, edit lists, hvc1 vs hev1) are not something this repo can regression-test. mediabunny would have done more of the work but is MPL-2.0, which felt like a licensing decision rather than an implementation one. The transcoder is a dynamic import, so the ~220KB only loads for a file that actually needs converting; the main bundle is unchanged.

Three non-obvious things, all found by running it rather than reading it:

  • Rotation is baked in, not passed through as a display matrix, so the output needs no orientation metadata. That makes the long-edge cap apply to display dimensions — a 3840x2160 + 90deg capture displays as 2160x3840, and capping its coded width yields 1920x3414: taller than the source and still over the ceiling. Verified by extracting frames from source and output and comparing them.
  • Timestamps are zero-based at demux. A capture with B-frames starts its first composition time above zero, and the muxer requires the first chunk at 0. Without this every frame was rejected, and the failure surfaced far away as a null decoder config at finalize().
  • The engines disagree on decoderConfig.description for AAC. Chrome returns the bare AudioSpecificConfig; WebKit returns a whole ES_Descriptor. Written verbatim into an esds, WebKit's version produced a file whose video was perfect and whose audio read as object type 0 ... 0 channels — the dangerous kind of bug, because it looks like it worked.

3. Making it usable on a phone

Everything above was built for laptop drag-and-drop, then tested on an actual iPhone against prod, which surfaced four things:

  • The warning rendered while the file was converting. Explaining how to re-export a video by hand, with an ffmpeg command, while we are busy doing exactly that, is noise. It now appears only when conversion actually failed — which is the only time it is actionable.
  • It was far too long: a headline, four bullets with "should be" clauses, a paragraph, and an always-visible ffmpeg command. Now a headline, one line, and the recipe folded behind a toggle.
  • It could widen the page. The command is a nowrap <code>; without max-w-full/overflow-hidden on the alert a long line pushed the document wider than the viewport on a narrow screen.
  • Converting is now capped by device. Peak memory is a multiple of the file, which a laptop absorbs and a phone does not, and a 300MB+ capture runs for minutes on the device whose own camera app is the intended path anyway. Phones cap at 250MB and warn past it.

Separately — and this is a pre-existing bug, not introduced here — the editor could not be scrolled on a phone at all. .app--split is a fixed-viewport shell (100dvh, overflow: hidden, touch-action: none) whose panes scroll internally. That works where there is room for the panes; on a phone the video, controls and transcript do not fit, and the document lock made everything below the fold unreachable. Measured at 430px: scrollHeight === clientHeight, html and body both overflow: hidden, and a wheel event moved scrollY by zero.

The shell (and the html/body lock in index.scss) now applies only at >= 768px. Verified at both viewports: mobile scrolls (scrollY 0 -> 311), desktop behaviour is unchanged.

Behaviour

Conversion failure is never upload failure: on an unsupported browser, an undecodable source, or a file too large to convert on this device, the original is uploaded and the warning is shown then — and only then.

Verification

  • 50 tests (node --test, no new test deps), including the portrait cap, both description shapes, and two opt-in integration tests against real files via PULSECLIP_TEST_MP4.
  • Typecheck adds zero errors versus the same branch without these changes.
  • Exercised end to end against the real 344MB capture in Chrome and WebKit.
  • Exercised end to end through this UI on a deployed instance. A 4K HEVC clip (3840x2160, 23 Mbps) selected in the real dropzone rendered the progress label (0% -> 92%), uploaded, and landed on the server as:
h264  1080x1920  5.18 Mbps   aac 48kHz   ftyp@0.0% moov@0.0% mdat@0.1%
11.8 MB in, 2.76 MB out

Driven in real Chrome rather than a bundled Chromium build, since the bundled one has no HEVC decode — which is the exact case under test.

Running in production. All of the above is deployed on pulseclip.os.mieweb.org and pulseclip-dev2.os.mieweb.org and has been exercised by hand on an iPhone.

No server changes.

Laptop drag-and-drop is the one upload path no phone-side fix reaches, and
a 4K/HEVC/~25 Mbps file dropped there is unplayable on phones for the life
of the pulse. Detect it and say so. No encoding, no blocking.

The contract every capture path targets: H.264, long edge <= 1920,
<= 5 Mbps, AAC, faststart.

client/src/lib/videoContract.ts inspects the picked File with no
dependencies:

- resolution and duration from a detached <video> (object URL revoked
  either way), falling back to the MP4 boxes when the browser cannot
  decode the file at all -- which is exactly the Android + HEVC case we
  warn about
- bitrate as size * 8 / duration
- video/audio fourcc by walking the top-level box chain by size. These
  files are not faststart, so moov sits at the very end; scanning the
  first N bytes finds nothing. The walk handles the 64-bit largesize form
  because real 4K recordings use it for mdat, then descends
  moov > trak > mdia > minf > stbl > stsd
- faststart from the same walk: moov before mdat

Reads are ranged, so a 344 MB file costs a few hundred bytes plus the
moov box. Nothing throws into the UI -- an unparseable file reports
"unknown" and produces no violation.

The warning renders in the @mieweb/ui Alert, listing what is wrong with
real numbers plus a copyable ffmpeg fix. Inspection runs alongside the
upload rather than in front of it, so it adds no latency, and the report
is lifted into App so the warning survives the navigation to the new
artipod.

Note the ffmpeg suggestion fits the frame inside a 1920x1920 box instead
of capping width: phone recordings are usually portrait, and
scale='min(1920,iw)':-2 turns a 2160x3840 clip into 1920x3414.

38 tests on Node's built-in runner, wired into CI. Two are integration
tests against real files, opt-in via PULSECLIP_TEST_MP4 and
PULSECLIP_TEST_MP4_COMPLIANT.
Detecting a contract breach and warning about it leaves the user with an
unplayable pulse and some advice. This converts the file instead, in the
browser, before it uploads.

    H.264 · long edge <= 1920 · <= 5 Mbps · AAC · faststart

Measured on a real 344MB 4K HEVC phone capture: 344MB -> 72MB in 22s, h264
1080x1920, 48kHz AAC, moov at the front, zero decode errors, duration preserved
to 13ms. Verified in real Chrome and in WebKit.

Why WebCodecs and not the obvious alternatives: MediaRecorder over a canvas is
realtime-bound and emits WebM outside Safari, and a <video> element cannot read
the files that matter -- Chrome decodes HEVC through WebCodecs but reports
canPlayType('video/mp4; codecs="hvc1"') === '', so an element pipeline fails
silently on exactly the captures this exists for.

Container work is delegated to mp4box (BSD-3) and mp4-muxer (MIT). A subtly
wrong hand-rolled sample-table reader corrupts a user's upload, and the
variations involved are not something this repo can regression-test. mediabunny
would have done more of the work but is MPL-2.0, which is a licensing decision
rather than an implementation one.

Three things this cost that were not obvious:

- Rotation is BAKED IN, not passed through as a display matrix, so the output
  needs no orientation metadata. That makes the long-edge cap apply to DISPLAY
  dimensions -- a 3840x2160+90deg capture displays as 2160x3840, and capping its
  coded width yields 1920x3414: taller than the source and still over the
  ceiling. Verified by extracting frames from source and output.
- Presentation timestamps are zero-based at demux. A capture with B-frames
  starts its first composition time above zero, and the muxer requires the first
  chunk at 0; without this every frame was rejected and the failure surfaced
  later as a null decoder config at finalize().
- The engines disagree on what AAC decoderConfig.description means. Chrome
  returns the bare AudioSpecificConfig; WebKit returns a whole ES_Descriptor.
  Written verbatim into an esds, WebKit's version produced a file whose video
  was fine and whose audio read as "object type 0 ... 0 channels". The inner
  config is now extracted from either shape.

The transcoder is a dynamic import, so the ~220KB of demuxer only loads for a
file that actually needs converting. Conversion failure is not upload failure:
the original is sent and the warning stays up.

50 tests, including the portrait cap and both description shapes.
@jlocala1
jlocala1 marked this pull request as ready for review August 4, 2026 17:35
Four problems, all visible in one screenshot of prod on an iPhone.

The warning rendered WHILE the file was being converted. Telling someone how
to re-export a video by hand, with an ffmpeg command, while we are busy doing
exactly that for them, is noise. It now appears only when conversion actually
failed -- an unsupported browser, an undecodable source, or a file too large
for the device -- which is the only time it is actionable.

The warning was also far too long: a headline, four bullets with "should be"
clauses, a paragraph, and an always-visible ffmpeg command. It is now a
headline, one line, and the recipe folded behind a toggle. The command earns
its place again in that state, because in that state we did not fix it.

The alert could widen the page. The ffmpeg command is a nowrap <code>, and
without max-w-full/overflow-hidden on the alert itself a long line pushed the
document wider than the viewport on a narrow screen.

Converting is capped by device. The pipeline holds the source bytes, the
demuxed samples and the muxed output at once, so peak memory is a multiple of
the file -- a budget a laptop absorbs and a phone does not, and a 300MB+
capture runs for minutes on the device whose own camera app is the intended
path anyway. Phones now cap at 250MB and warn past it, instead of grinding.

Separately, the editor could not be scrolled on a phone at all. `.app--split`
is a fixed-viewport shell -- 100dvh, overflow hidden, touch-action none -- with
panes that scroll internally. That works where there is room for the panes;
on a phone the video, controls and transcript do not fit, and the document
lock meant everything below the fold was unreachable. Measured at 430px:
scrollHeight === clientHeight, html and body both overflow:hidden, and a wheel
event moved scrollY not at all. The shell (and the html/body lock in
index.scss) is now applied only at >= 768px; below that the layout grows to
its content and the page scrolls the way a phone expects.
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.

1 participant