I built a teleprompter that runs in the phone's browser and records the take with the front camera behind the script. The scrolling part took an evening but the recording part took three nights, and every one of them was the same bug wearing a different hat as the take came out sideways, or zoomed into one eye, or both.
If you have ever asked getUserMedia for a portrait video on a phone and got a landscape file back, this is what was going on, and here is what fixed it. The code is on GitHub: web-teleprompter. MIT, no dependencies beyond React.
The setup
A phone, held upright, front camera. The page asks for the camera, shows the preview in a <video>, and records with MediaRecorder. Nothing exotic. The preview looked a bit off to me hadd my suspicions, but felt it was all in my head, and recorded it anyway without digging much into it. It was later I realised the file was wrong.
Wrong theory one: I need to ask for portrait
My first request was the obvious one, because that is what I wanted
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 1080 }, height: { ideal: 1920 } },
})
Now, the preview on screen looked funny but I recorded anyway, and it was the day of live testing that I saw that the file came back landscape, on its side, and badly off. This was tested On iOS 26.6 Safari and on Android 15 Chrome, that request gives you the sensor's wide preset, 1920 by 1080, unrotated. exact instead of ideal gave the same thing or an error. aspectRatio: 9/16 gave the same thing. Now the phone does not have a portrait preset, it is landscape presets and it rotates the picture on screen to match how the phone is held, and the recorder saves the unrotated one.
That is the bug, I asked for portrait, got a landscape file.
Wrong theory two: make it portrait myself
If the file is landscape, cut a portrait out of it, was my thought. So, I recorded through a canvas that always took the centre 9:16 out of the reported size. But that gave a 608 by 1080 slice out of a 1080 by 1920 picture, and a horrible three times zoom on an eye and a nose. That was the "zoomed in" half of the bug, and I own this error, it was entirely mine.
What fixed it: ask in landscape numbers
I only tried this to check the picture quality, because I had given up trying to debug the issue. I asked for landscape, expecting a landscape file I could at least look at, and the file came out portrait.
{ video: { facingMode: 'user', width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } } }
Ask for landscape and you get portrait, because the phone picks a real preset and turns the picture to match how it is held. That is what the WebRTC samples do, which I found out after two nights. So the lesson for me was, read the samples first.
Belt and braces: measure the picture, not the track
Even with the landscape request, the video track reports the sensor size. getSettings() says width 1920 and height 1080 while the frame on screen is portrait. So the shipped code does not trust the reported size, it draws one frame onto a sixteen pixel canvas and looks at which corner got paint.
function measureDrawnFrame(video, reportedW, reportedH) {
const big = Math.max(reportedW, reportedH)
const S = 16
const c = document.createElement('canvas')
c.width = S
c.height = S
const ctx = c.getContext('2d', { willReadFrequently: true })
ctx.scale(S / big, S / big)
ctx.drawImage(video, 0, 0)
const px = ctx.getImageData(0, 0, S, S).data
const painted = (x, y) => px[(y * S + x) * 4 + 3] > 0
const wide = painted(S - 1, 1) && !painted(1, S - 1)
const tall = painted(1, S - 1) && !painted(S - 1, 1)
if (wide) return { w: big, h: Math.min(reportedW, reportedH) }
if (tall) return { w: Math.min(reportedW, reportedH), h: big }
return null
}
If the bottom left is painted and the top right is not, the picture is tall. That is the whole probe. With it the recorder picks one of three plans.
- Portrait picture, and the track agrees - record the raw stream, because that is the best quality, nothing to do.
- Portrait picture, but the track says landscape - draw the picture to a canvas at its own size and record the canvas stream.
- Wide picture on a portrait screen - (Now that is a result from my Android device) Record what the preview shows, the centre cut to 9:16 at the frame's own height.
const plan = planRecordingStream(video, stream) // raw | canvas-whole | canvas-crop
const rec = new MediaRecorder(plan.stream, { mimeType: pickMimeType(), videoBitsPerSecond: 10_000_000 })
rec.onstop = () => plan.stop()
planRecordingStream is about forty lines and it is in the repo with the dead ends left in as comments, because the dead ends are the useful part.
One more thing: Baseline
MediaRecorder.isTypeSupported on Safari says yes to H.264 Baseline and to High. If your candidate list has Baseline first, you get Baseline, and every take looks soft. Put the High profile first.
'video/mp4;codecs=avc1.640028,mp4a.40.2'
The bug report
I filed it as WebKit bug 323550. Apple's triage retitled it a regression the same day, imported it to Radar, and added three engineers. The history, from their own tracker: WebKit's MP4 recorder has written a rotation transform into the file since a 2020 fix, and Apple's April 2025 commit for the WebM fix still describes mp4 as carrying that metadata. Reports of sideways front camera files start in June 2025 on an Apple Developer Forums thread. Then I ran ffprobe on my own file. It carries a displaymatrix of minus 90 degrees, and with it the file plays upright, in landscape. The same page with the fix produces a file with no note, and it plays upright in portrait.
Why I was doing this at all
The teleprompter is part of Postbarrel, which interviews you about what you want to say and writes the script in your own words, then scrolls it over your camera so you can say it. The recording never leaves the phone, so the camera fix had to work in the browser with no server to fall back on. It does now. The standalone component and demo are here: github.com/lagudafuadtosin/web-teleprompter. If it saves you a night, a star helps other people find it.
The probe, the planner and the wait for the first painted frame are now a package you can get here: npm install portrait-camera, MIT, no dependencies. Source at github.com/lagudafuadtosin/portrait-camera.
Top comments (2)
The orientation metadata vs canvas paint mismatch is such a classic mobile trap. I’ve seen the same class of bug when people mirror the selfie preview with CSS scaleX(-1) and then wonder why the recorded file isn’t mirrored — preview transform ≠ encoded pixels. Your applyConstraint / re-getUserMedia path makes sense; the thing I’d add is asserting the recorded track settings (width/height/facingMode) right after start, because some Android WebViews quietly ignore the constraint and keep the previous track. Saved me a few ‘works on my Pixel, sideways on theirs’ reports.
Thanks, the scaleX(-1) case is the same trap from the other side.
Now here are small corrections on mine, you see there is no re-getUserMedia. The planner reads getSettings() once, then measures what the video element actually draws. If the track says landscape and the picture is portrait, it records the picture through a canvas. So a WebView that keeps the old track gives you a lower resolution take, not a sideways one. The assertion after start is a good idea for the logs though and I will add it. Which WebViews did you see ignore the constraint?