<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Fuad Laguda</title>
    <description>The latest articles on DEV Community by Fuad Laguda (@lagudafuad).</description>
    <link>https://dev.to/lagudafuad</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4112296%2F9b4691f7-ba2b-4267-8e40-2d7b8b4e6e4c.jpg</url>
      <title>DEV Community: Fuad Laguda</title>
      <link>https://dev.to/lagudafuad</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lagudafuad"/>
    <language>en</language>
    <item>
      <title>The iOS camera lies for about a tenth of a second, and that is why nobody noticed it</title>
      <dc:creator>Fuad Laguda</dc:creator>
      <pubDate>Fri, 18 Sep 2026 16:19:39 +0000</pubDate>
      <link>https://dev.to/lagudafuad/the-ios-camera-lies-for-about-a-tenth-of-a-second-and-that-is-why-nobody-noticed-it-5802</link>
      <guid>https://dev.to/lagudafuad/the-ios-camera-lies-for-about-a-tenth-of-a-second-and-that-is-why-nobody-noticed-it-5802</guid>
      <description>&lt;p&gt;Now, two weeks ago I wrote that the iOS camera track reports 1920 by 1080 while the browser draws portrait, and that you should measure the picture instead of trusting the track. Now, altough that is true, it turns out to be half the story, and the missing half is this. &lt;/p&gt;

&lt;p&gt;The track does not lie all the time, so maybe I have to change the title of my first post. &lt;/p&gt;

&lt;p&gt;But it does lie for about a tenth of a second, and that is exactly why it was not noticed.&lt;/p&gt;

&lt;p&gt;The first piece is here: &lt;a href="https://dev.to/lagudafuad/why-your-web-teleprompter-records-sideways-on-iphone-and-the-fix-549m"&gt;Why your web teleprompter records sideways on iPhone, and the fix&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was trying to give the fix away
&lt;/h2&gt;

&lt;p&gt;I had the fix working in my own product and I had put it on GitHub under MIT and I wanted a library to take it, to save the next person three nights.&lt;/p&gt;

&lt;p&gt;So I checked four libraries&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Merged pull requests&lt;/th&gt;
&lt;th&gt;What that told me&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;react-webcam&lt;/td&gt;
&lt;td&gt;0 merged since January 2025&lt;/td&gt;
&lt;td&gt;will not merge my work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;videojs-record&lt;/td&gt;
&lt;td&gt;6 merged since January 2025, all dependabot&lt;/td&gt;
&lt;td&gt;no human contributions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;react-media-recorder&lt;/td&gt;
&lt;td&gt;2 merged, then silent for 13 months&lt;/td&gt;
&lt;td&gt;effectively dead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;livekit/client-sdk-js&lt;/td&gt;
&lt;td&gt;125 merged against 10 closed unmerged since 1 May 2026&lt;/td&gt;
&lt;td&gt;alive&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The test said there was no bug four times.
&lt;/h2&gt;

&lt;p&gt;I built a small page against livekit-client 2.22.3 and ran it on my iPhone 15, iOS 26.6.&lt;/p&gt;

&lt;p&gt;LiveKit's default 720p preset: the track said portrait, the picture was portrait. The 1080p preset: same. Microphone in the same call: same. My own exact constraints with no LiveKit in the path at all: same.&lt;/p&gt;

&lt;p&gt;Four runs, no mismatch anywhere, then I sat with that for a while. I had a published article saying the track lies, and my own test was telling me it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fact that would not fit
&lt;/h2&gt;

&lt;p&gt;Same phone, same ten minutes, I opened the demo with the fix switched off and recorded a take, and there it was the bug.&lt;/p&gt;

&lt;p&gt;Those two things cannot both be true, if the track reports portrait, the fix has nothing to trigger on, and fix on and fix off would produce the same file, but they did not, so the phone was not wrong it is my test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window
&lt;/h2&gt;

&lt;p&gt;I stopped waiting for the preview and read the track the moment it existed, then kept reading. Again it was there I noticed something, it was quick, but i saw a flip because my android lags a lot. so I decided to check the numbers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;track created                     +1059 ms
track.dimensions immediately       1920 x 1080   landscape
waitForDimensions() returned       1920 x 1080   at +1071 ms
track.dimensions after 1st frame   1080 x 1920   portrait, at +1473 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A second, separate run: 1920 by 1080 at +1377 ms, then 1080 by 1920 at +1516 ms. A gap of 139 ms against 112 ms in the first run, same behaviour, different number. &lt;/p&gt;

&lt;p&gt;Keep that in mind, it matters later.&lt;/p&gt;

&lt;p&gt;So the camera reports the raw sensor frame until it has actually produced a picture, then it corrects itself. Read inside that window and you get landscape, then read outside it and you get portrait, so both of my earlier claims were true, they were just true at different milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the bug hides
&lt;/h2&gt;

&lt;p&gt;Every one of my four clean tests waited for the preview to appear before reading the numbers. That is the most natural thing in the world to write. It is also the one thing that puts you on the wrong side of the window every single time. If not for the fact that my Android is slow and lags, I would not have noticed that. &lt;/p&gt;

&lt;p&gt;A test written without knowing the window exists cannot see the bug, no matter how carefully it is written.&lt;/p&gt;

&lt;p&gt;That is not an iOS or Android lesson, any time a value is eventually right, a test that waits for things to settle will pass, and the code that did not wait will fail in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The year-old issue this explains
&lt;/h2&gt;

&lt;p&gt;LiveKit had &lt;a href="https://github.com/livekit/client-sdk-js/issues/1377" rel="noopener noreferrer"&gt;issue 1377&lt;/a&gt;, "Incorrect orientation of a remote video track's dimensions", opened in January 2025. Three separate people reported it over thirteen months. A core engineer replied, reopened it once when the bot closed it, and then it was closed again with nothing changed. Their comment on it was honest and nearly right: "browsers report wrong initial resolution on iOS."&lt;/p&gt;

&lt;p&gt;Here is the mechanism. &lt;code&gt;waitForDimensions&lt;/code&gt; sleeps 10 ms on iOS and then polls until dimensions exist. They exist immediately, and they are wrong, so it returns at about +12 ms with the landscape pair. That pair goes out to the room as &lt;code&gt;req.width&lt;/code&gt; and &lt;code&gt;req.height&lt;/code&gt;. A portrait phone announces itself as landscape, and everyone on the call lays it out sideways.&lt;/p&gt;

&lt;p&gt;Nobody had the window and without it, the bug looks like a flaky report that never reproduces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a longer sleep is not the fix
&lt;/h2&gt;

&lt;p&gt;112 and 139. Those are two runs on the same phone, minutes apart. The window changes with the device, the camera, the requested resolution and how busy the phone is. Any fixed delay is a race you will eventually lose, and it taxes every user who did not need the wait. The signal is the first frame, not the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;p&gt;Two shapes, and I have used both.&lt;/p&gt;

&lt;p&gt;Wait for a frame, not a duration. A detached video element with &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; fires when the first frame has actually gone through the pipeline. Safari 15.4 and up, Chrome too, nothing needs to be in the DOM.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;muted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playsInline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MediaStream&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;done&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSettings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// now correct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or stop trusting the report at all, draw one frame onto a 16 by 16 canvas and look at which corner got paint. One &lt;code&gt;drawImage&lt;/code&gt;, one &lt;code&gt;getImageData&lt;/code&gt;, deterministic. That is the probe in &lt;a href="https://github.com/lagudafuadtosin/web-teleprompter/blob/master/src/lib/camera.ts" rel="noopener noreferrer"&gt;camera.ts&lt;/a&gt; in the repo, and it is what my product ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened when the window was written down
&lt;/h2&gt;

&lt;p&gt;I filed the measurement as &lt;a href="https://github.com/livekit/client-sdk-js/issues/2099" rel="noopener noreferrer"&gt;issue 2099&lt;/a&gt; on 14 September with the timing table above and the first shape as a suggestion. A LiveKit engineer replied the same day and opened &lt;a href="https://github.com/livekit/client-sdk-js/pull/2100" rel="noopener noreferrer"&gt;PR 2100&lt;/a&gt;, which waits for the first frame on iOS. I ran their branch on the phone, normal power and Low Power Mode, and posted the readings as a review. One thing did not sit right: the gate was &lt;code&gt;getBrowser().os === 'iOS'&lt;/code&gt;, and an iPad sends a Mac user agent, so iPads would skip the wait. PR 2100 merged on 17 September.&lt;/p&gt;

&lt;p&gt;I filed the iPad gap as &lt;a href="https://github.com/livekit/client-sdk-js/issues/2107" rel="noopener noreferrer"&gt;issue 2107&lt;/a&gt; with two possible shapes. They measured the first, found the first frame can take 500 to 1000 ms on macOS, and took the second, which is treat Safari with a Mac user agent and more than one touch point as iPadOS. &lt;a href="https://github.com/livekit/client-sdk-js/pull/2110" rel="noopener noreferrer"&gt;PR 2110&lt;/a&gt; went up on 18 September and I ran that build on the iPhone and on an Android and posted the numbers.&lt;/p&gt;

&lt;p&gt;So, the first report to first fix was six hours. First report to second fix, four days. &lt;br&gt;
Thirteen months before that, with three reporters and nothing changed. The difference was not effort or skill on anyone's part, it was one measurement that nobody had taken, and who would care about what happened in the tenth of a second? Silly enough my app did.&lt;/p&gt;

&lt;p&gt;The probe, the planner and the wait for the first painted frame are now a package you can get here: &lt;code&gt;npm install portrait-camera&lt;/code&gt;, MIT, no dependencies. Source at &lt;a href="https://github.com/lagudafuadtosin/portrait-camera" rel="noopener noreferrer"&gt;github.com/lagudafuadtosin/portrait-camera&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ios</category>
      <category>webrtc</category>
    </item>
    <item>
      <title>Why your web teleprompter records sideways on iPhone and Android. Here is the fix</title>
      <dc:creator>Fuad Laguda</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:48:57 +0000</pubDate>
      <link>https://dev.to/lagudafuad/why-your-web-teleprompter-records-sideways-on-iphone-and-the-fix-549m</link>
      <guid>https://dev.to/lagudafuad/why-your-web-teleprompter-records-sideways-on-iphone-and-the-fix-549m</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you have ever asked &lt;code&gt;getUserMedia&lt;/code&gt; 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: &lt;a href="https://github.com/lagudafuadtosin/web-teleprompter" rel="noopener noreferrer"&gt;web-teleprompter&lt;/a&gt;. MIT, no dependencies beyond React.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A phone, held upright, front camera. The page asks for the camera, shows the preview in a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;, and records with &lt;code&gt;MediaRecorder&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory one: I need to ask for portrait
&lt;/h2&gt;

&lt;p&gt;My first request was the obvious one, because that is what I wanted&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. &lt;code&gt;exact&lt;/code&gt; instead of &lt;code&gt;ideal&lt;/code&gt; gave the same thing or an error. &lt;code&gt;aspectRatio: 9/16&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;That is the bug, I asked for portrait, got a landscape file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory two: make it portrait myself
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What fixed it: ask in landscape numbers
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;frameRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Belt and braces: measure the picture, not the track
&lt;/h2&gt;

&lt;p&gt;Even with the landscape request, the video track reports the sensor size. &lt;code&gt;getSettings()&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;measureDrawnFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reportedW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reportedH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;big&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reportedW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reportedH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;willReadFrequently&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;big&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;big&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;painted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wide&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;painted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;painted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;painted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;painted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wide&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;big&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reportedW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reportedH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tall&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reportedW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reportedH&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;big&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Portrait picture, and the track agrees - record the raw stream, because that is the best quality, nothing to do.&lt;/li&gt;
&lt;li&gt;Portrait picture, but the track says landscape - draw the picture to a canvas at its own size and record the canvas stream.&lt;/li&gt;
&lt;li&gt;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.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;planRecordingStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// raw | canvas-whole | canvas-crop&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pickMimeType&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;videoBitsPerSecond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onstop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;planRecordingStream&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing: Baseline
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MediaRecorder.isTypeSupported&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video/mp4;codecs=avc1.640028,mp4a.40.2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The bug report
&lt;/h2&gt;

&lt;p&gt;I filed it as &lt;a href="https://bugs.webkit.org/show_bug.cgi?id=323550" rel="noopener noreferrer"&gt;WebKit bug 323550&lt;/a&gt;. 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 &lt;a href="https://bugs.webkit.org/show_bug.cgi?id=198912" rel="noopener noreferrer"&gt;2020 fix&lt;/a&gt;, and Apple's &lt;a href="https://commits.webkit.org/294257@main" rel="noopener noreferrer"&gt;April 2025 commit&lt;/a&gt; for the WebM fix still describes mp4 as carrying that metadata. Reports of sideways front camera files start in June 2025 on an &lt;a href="https://developer.apple.com/forums/thread/786803" rel="noopener noreferrer"&gt;Apple Developer Forums thread&lt;/a&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I was doing this at all
&lt;/h2&gt;

&lt;p&gt;The teleprompter is part of &lt;a href="https://postbarrel.com" rel="noopener noreferrer"&gt;Postbarrel&lt;/a&gt;, 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: &lt;a href="https://github.com/lagudafuadtosin/web-teleprompter" rel="noopener noreferrer"&gt;github.com/lagudafuadtosin/web-teleprompter&lt;/a&gt;. If it saves you a night, a star helps other people find it.&lt;/p&gt;

&lt;p&gt;The probe, the planner and the wait for the first painted frame are now a package you can get here: &lt;code&gt;npm install portrait-camera&lt;/code&gt;, MIT, no dependencies. Source at &lt;a href="https://github.com/lagudafuadtosin/portrait-camera" rel="noopener noreferrer"&gt;github.com/lagudafuadtosin/portrait-camera&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ios</category>
      <category>webrtc</category>
    </item>
  </channel>
</rss>
