Warn on, then fix, browser uploads that break phone playback - #34
Open
jlocala1 wants to merge 3 commits into
Open
Warn on, then fix, browser uploads that break phone playback#34jlocala1 wants to merge 3 commits into
jlocala1 wants to merge 3 commits into
Conversation
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
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.
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.
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
Not arbitrary — it is what the PulseCam recorder already pins itself to, and what
export.tsalready produces.1. Detection (
client/src/lib/videoContract.ts)Dependency-free inspection of the picked
File:<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 aboutsize * 8 / durationmoovsits at the very end and scanning the first N bytes finds nothing. The walk handles the 64-bitlargesizeform (real 4K recordings use it formdat), then descendsmoov > trak > mdia > minf > stbl > stsdmoovbeforemdatReads are ranged, so a 344MB file costs a few hundred bytes plus the
moovbox. Nothing throws into the UI — an unparseable file reportsunknownand 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:
moovat 100%moovat 0.0%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 reportscanPlayType('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.mediabunnywould 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:
3840x2160 + 90degcapture displays as2160x3840, and capping its coded width yields1920x3414: taller than the source and still over the ceiling. Verified by extracting frames from source and output and comparing them.finalize().decoderConfig.descriptionfor AAC. Chrome returns the bare AudioSpecificConfig; WebKit returns a whole ES_Descriptor. Written verbatim into anesds, WebKit's version produced a file whose video was perfect and whose audio read asobject 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:
<code>; withoutmax-w-full/overflow-hiddenon the alert a long line pushed the document wider than the viewport on a narrow screen.Separately — and this is a pre-existing bug, not introduced here — the editor could not be scrolled on a phone at all.
.app--splitis 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,htmlandbodybothoverflow: hidden, and a wheel event movedscrollYby zero.The shell (and the
html/bodylock inindex.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
node --test, no new test deps), including the portrait cap, bothdescriptionshapes, and two opt-in integration tests against real files viaPULSECLIP_TEST_MP4.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.organdpulseclip-dev2.os.mieweb.organd has been exercised by hand on an iPhone.No server changes.