Skip to content
11 min read

Video is black on iPhones and fine everywhere else — the level your camera declares

Android plays it, desktop plays it, the iPhone shows a black rectangle and reports no errors. The cause is one number in the SPS, the tell is two counters in getStats, and the fix costs no CPU at all.

Vyacheslav Pankratov· Fullstack Developer
Cover art for “Video is black on iPhones and fine everywhere else — the level your camera declares”
On this page

TL;DR: Video plays on Android and desktop but is black on iOS Safari, and it started when you turned transcoding off? iOS negotiates H.264 Constrained Baseline level 3.1 and silently discards every frame of a stream whose SPS declares a higher level. The tell is bytesReceived climbing while framesReceived stays at zero. The fix is to rewrite the declared level in flight with a bitstream filter — no decode, no encode, no measurable CPU.

The symptom sorts by platform, which is what sends people to the wrong place. Android plays the stream. Desktop Chrome plays the stream. The iPhone shows a black rectangle, throws no error, logs nothing useful, and the connection reports itself as perfectly healthy the whole time.

Because it is platform-shaped, the first hypotheses are all about the phone or the path to it: a weak mobile network, a Safari bug, an autoplay policy, a codec the device does not have. In our case the first hypothesis was the network. It was wrong, and the evidence that killed it is the same evidence that identifies the real cause in about two minutes.

Why does this start the day you turn transcoding off?

Because a transcoder was doing a second job that nobody wrote down. When a media server transcodes, it decodes whatever the source sent and re-encodes it on its own terms — and its own terms are conservative, browser-friendly H.264. Every quirk of the source encoder is normalised on the way through, invisibly and for free.

Turn transcoding off — usually to stop paying for transcode minutes, a trade with its own accounting — and the pipeline becomes passthrough. Now the camera's own bitstream reaches the browser exactly as the camera produced it. Nothing is broken by that change; something is merely no longer being fixed. The bill goes down and a compatibility layer you did not know you had disappears at the same moment, which is why the two events look unrelated in a postmortem.

That decision has a second consequence in the same moment, and it is worth knowing before you make it: without a transcoder the media server also stops being able to hand a weak connection a smaller layer, because it only ever forwards what the publisher encoded. That half is its own article; this one is about what reaches the decoder.

This is the general shape worth carrying beyond video: when you remove a component to save money, inventory what it was doing besides the thing you were paying for. A normaliser that runs silently and always succeeds leaves no trace in any document, and its absence shows up as a defect somewhere that looks unconnected.

What is iOS actually rejecting?

The level in the SPS, compared against the level it negotiated in SDP.

H.264 in WebRTC carries profile-level-id in the SDP — three bytes whose last one is the level, defined by RFC 6184 — and RFC 7742 is explicit that it is not decoration: implementations "MUST include this parameter within SDP and MUST interpret it when receiving it", with Constrained Baseline as the floor every implementation has to support.

Read off the SDP of our own production sessions, iOS Safari offers profile-level-id=42e01f: profile 42 with the constraint flags that make it Constrained Baseline, and 1f = 31 = level 3.1. That is our observation rather than a line in a spec, so confirm it on your own device — it is one field in the answer, and it takes a minute.

Cameras habitually declare something higher. The stream is still H.264; its SPS simply announces a level above the one that was agreed. iOS takes that announcement at face value and refuses the stream. It does not warn, it does not renegotiate, it does not request a keyframe — it discards every frame as it arrives. Chrome and Android tolerate the same mismatch, decode the stream, and show a picture, which is precisely why this reads as "the iPhone is broken" rather than "our stream is out of spec".

The nastiest property of this failure is that everything upstream looks correct. The ICE connection is established, DTLS completed, packets flow, bytes accumulate, and nothing is lost in transit. The picture is black because the decoder was never allowed to start.

  1. CameraSPS declares level 4.0
  2. Passthroughnothing is rewritten any more
  3. iOS Safarinegotiated Constrained Baseline 3.1
  4. Every frame discardedsilently — the transport looks healthy
Nothing on this path is broken. The camera declares a level, the pipeline stopped rewriting it, and the phone applies the rule it negotiated.

How do you confirm it in two minutes?

Two checks, and they are independent — one on the phone, one on the source. Either alone is suggestive; together they are conclusive.

On the iPhone, read the inbound stats. Attach Safari's Web Inspector to the device and look at getStats() for the inbound video track. This signature means rejection, not loss:

bytesReceived  > 0     the data arrived
framesReceived = 0     none of it was accepted
packetsLost    = 0     and nothing went missing on the way

Compare that against the two causes it is most often confused with. A weak network shows packetsLost rising and framesReceived still climbing, slowly. A missing keyframe shows framesReceived advancing while framesDecoded stays behind and pliCount climbs. Neither of those holds framesReceived at exactly zero while bytes accumulate.

On the source, read the declared level. No phone required, and you can run it before you ship anything:

ffprobe -v error -rtsp_transport tcp -select_streams v:0 \
  -show_entries stream=profile,level,width,height,pix_fmt \
  -of default=noprint_wrappers=1 "rtsp://<camera>/<path>"

level comes back as an integer ten times the real one: 31 is 3.1, 40 is 4.0, 51 is 5.1. Anything above 31 is the condition. Check pix_fmt in the same output while you are there — a yuvj420p source is signalling full range, which trips the same rejection independently of the level.

Worth knowing before you assume this is exotic: probing a real camera on a staging rig for this article, its two streams declared different levels — the 1080p main stream at 4.0, the 720p sub-stream at 3.1. One of them would be refused by an iPhone as it stands and the other would not, from the same device, on the same firmware, at the same moment. Measured with the command above on 2026-08-01; we did not put that particular camera in front of an iPhone, so what is verified here is the declared level, and the rejection rule is what our own production fleet demonstrated.

The fix that costs no CPU

Changing a declared level does not require re-encoding, because the level is metadata in the SPS rather than a property of the coded picture data. ffmpeg's h264_metadata bitstream filter rewrites it in place — the documentation describes it as performing "bitstream level modifications without performing decoding", and its level option takes exactly the value we need (ffmpeg bitstream filters):

ffmpeg -rtsp_transport tcp -i "$RTSP" \
  -an -c:v copy -bsf:v h264_metadata=level=3.1:video_full_range_flag=0 \
  -f h264 pipe:1

-c:v copy is the entire point: no decoder is instantiated and no encoder runs, so CPU is indistinguishable from plain passthrough. Clear the full-range flag in the same pass — it is one more option on the same filter, and leaving it set reproduces the same black screen for a different reason.

Then feed that elementary stream into whatever publishes to your media server. We emit a raw Annex-B stream (-f h264) rather than wrapping it in a container, deliberately: a container carries the camera's own timestamps, and those turn out to be the next problem for some sources. A raw stream carries none, so the downstream element stamps by arrival and the cadence is monotonic no matter what the camera thinks the time is.

Two ways to get this fix wrong

Both are the kind of reasonable-sounding adjustment that a reviewer would wave through.

Do not "match the level to the resolution". 1080p is nominally a level 4.0 picture, so declaring 4.0 feels like the correct, honest value. It fails exactly like 5.1 did, because 4.0 is still above the negotiated 3.1 and the comparison is all iOS performs. Declare 3.1 and stop. Once the level matches, VideoToolbox sizes its buffers from the width and height in the stream and decodes 1080p without complaint — level_idc is advisory to it, not a constraint it enforces. Verified on 1080p.

Do not reach for a re-encode first. It works, and it is the wrong first move: re-encoding costs roughly 1 vCPU per camera on a software path, which is affordable for one camera and decides your entire hosting bill for a fleet. The bitstream rewrite costs nothing and fixes the majority of cases. Keep re-encoding as the exception you apply to named sources, not as the default you apply to everything.

What if the rewrite doesn't fix it?

Some sources stay black on iOS with a correctly rewritten SPS, and it is worth knowing that in advance so the fix's partial success does not read as a failed diagnosis.

We chased this one properly, because the obvious next suspects were all in the bitstream: B-frames, a non-baseline profile, CABAC entropy coding. So we dumped and compared the headers of a failing source against a working one with trace_headers, expecting a discriminator. The SPS and PPS came back byte-identical — same profile, same rewritten level, same entropy coding mode, same constraint flags. That is a genuinely useful negative result: it rules out every static bitstream property at once, and it means no header probe, however thorough, can sort these sources automatically. We tried; it cannot be done from headers.

What is left is stream dynamics — timestamp cadence and GOP structure, things that live in the sequence of packets rather than in any field. Re-stamping arrival times was not enough on its own for these sources. The stopgap that does work is a real re-encode for those specific sources, which regenerates clean timestamps and a clean GOP along with the picture, at the ~1 vCPU each named above. The honest status: the fix is a routing decision made per source, and the root cause of that remaining class is still open at the time of writing.

The tempting wrong number

1080p is nominally a level 4.0 picture, so declaring 4.0 feels like the honest value. It is rejected exactly like 5.1 was, because the only thing iOS checks is whether the declared level exceeds the one it negotiated. Declare 3.1 and stop — VideoToolbox sizes its buffers from width and height, not from the level.

FAQ

Is this a bandwidth problem? No, and ruling that out early saves a day. A bandwidth problem degrades — frames arrive late, quality drops, packetsLost rises. This does not degrade; it produces nothing at all while the transport reports perfect health. If framesReceived is exactly zero while bytesReceived climbs, no amount of bandwidth will change the outcome.

Why does it work on Android and desktop Chrome? Because they are lenient about the same mismatch. The level in the SPS is an announcement about what a decoder needs to be able to handle; Chrome tries anyway and succeeds, iOS declines. Neither is wrong — but only one of them is telling you your stream is out of spec.

Do I have to change the camera's settings instead? You can, if the camera exposes the level and you own every camera in the fleet. In practice the setting is often absent, differs by firmware, and reverts on a factory reset, which is why fixing it in the pipeline is more durable: one filter argument covers every source you will ever attach.

Does the sub-stream avoid this? Sometimes, and it is worth checking before building anything, because a lower-resolution stream is often configured with a lower level. On the camera we probed, the sub-stream declared 3.1 while the main declared 4.0 — so the second encoding the camera already produces would have played on iOS untouched. That is luck rather than a rule; measure it.

The three things worth taking away

  1. Removing a transcoder removes a normaliser. The saving is real and so is the compatibility layer you lose with it. Inventory what a component does besides the thing you pay for, before you switch it off.
  2. framesReceived = 0 with bytesReceived > 0 is rejection, not loss. That one comparison separates this from a weak network and from a missing keyframe, and it takes a minute on the device.
  3. A declared level is metadata, so fix it as metadata. A bitstream filter rewrites it at no CPU cost; re-encoding costs about a vCPU per camera and should be the exception you route to deliberately, not the reflex.

If you have just switched transcoding off to cut a bill, check an iPhone before you celebrate — this failure is silent, platform-specific, and entirely invisible to the dashboards that told you the change was safe.


Running a real-time video system where a change like this reaches production before anyone notices? That is what our Run & Scale engagement is for — instrumenting the live path and catching the platform-shaped failures that only one client class ever sees.

The class of failure this belongs to — silent, platform-shaped, invisible from an office — is what real-time video for streaming platforms is about.

Was this helpful?

// Build it

Running this in production?

Real-time video breaks in the places a demo never reaches — negotiation, weak networks, the transcoding bill. Tell us what breaks and where; we reply within a day with a concrete next step.