Short answer: The colors in a video file are not a picture. They are numbers plus a small set of instructions for how to turn those numbers back into a picture. Android documents those instructions as optional keys, documents a default for only one of the three, and then hands the file to a decoder, a compositor, and eventually a browser — and the specification that defines
<video>says nothing about color at all. A recording can be perfectly intact, decode without a single dropped frame, and still look wrong on the screen you chose to watch it on.
If you point an old Android phone at a hallway, record for an hour, and play the file back on the phone, it looks the way you expect.
Then you open the same file on a laptop over your network and the picture is flat and grey. Or too contrasty. Or the whites are blown out.
Nothing failed. No error was raised. The file is not corrupt, the encoder did not choke, the network delivered every byte. This article is about the part of the pipeline that has no failure mode at all, because every machine in it did exactly what it was told — and what it was told was ambiguous.
Why does a video that plays fine look wrong on another screen?
Because the file does not contain colors. It contains component values, plus metadata describing how to interpret them. If any machine downstream interprets that metadata differently — or the metadata was never written, or the machine ignores it — you get a picture that is technically correct and visibly wrong.
On Android specifically, three pieces of metadata decide this, and all three are documented as optional.
The three keys, and what Android says about leaving them out
In MediaFormat, the relevant keys are KEY_COLOR_STANDARD, KEY_COLOR_TRANSFER, and KEY_COLOR_RANGE. The javadoc wording is nearly identical for each. KEY_COLOR_TRANSFER is:
"An optional key describing the opto-electronic transfer function used for the video content. The associated value is an integer: 0 if unspecified, or one of the COLOR_TRANSFER_ values."
Note "optional" and note "0 if unspecified". The API has a defined way to say I am not telling you.
The three do different jobs:
-
Color standard — which primaries and white point.
COLOR_STANDARD_BT709is described as "BT.709 color chromaticity coordinates with KR = 0.2126, KB = 0.0722";COLOR_STANDARD_BT2020is the wide-gamut one used for HDR. -
Color transfer — the curve.
COLOR_TRANSFER_SDR_VIDEOis "the curve used by most non-HDR video content".COLOR_TRANSFER_HLGis the hybrid log-gamma curve;COLOR_TRANSFER_ST2084is the PQ curve. - Color range — how much of the numeric space is used.
That last one is the most concrete, and Android gives exact numbers. COLOR_RANGE_LIMITED:
"Limited range. Y component values range from 16 to 235 for 8-bit content. Cr, Cy values range from 16 to 240 for 8-bit content. This is the default for video content."
versus COLOR_RANGE_FULL:
"Full range. Y, Cr and Cb component values range from 0 to 255 for 8-bit content."
Two files can carry identical bytes and mean different pictures depending on which of those two rules applies. If a player assumes the wrong one, black stops being black.
Here is the detail worth sitting with: COLOR_RANGE_LIMITED is the only one of the three that the documentation calls a default. I read the whole MediaFormat page looking for an equivalent sentence for any COLOR_STANDARD_* or COLOR_TRANSFER_* constant. There isn't one. The keys are optional, "0 if unspecified" is a legal value, and the page does not state what anything downstream should assume when it gets a zero.
Android 13 made HLG10 mandatory — on the devices that have it at all
This is where a lot of writing on the subject overreaches, so it is worth quoting precisely.
The HDR video capture guide says:
"Starting in Android 13, camera devices with 10-bit camera output must support the HLG10 format for HDR capture and playback."
Read the subject of that sentence. It is not "devices". It is "camera devices with 10-bit camera output". The requirement is conditional, and the Android 16 CDD keeps it that way:
"If device implementations support HDR 10-bit output capability, then they: [C-2-1] MUST support at least the HLG HDR profile for every camera device that supports 10-bit output."
Every HDR-capture requirement I could find in the CDD is gated on a precondition like that one, or on Media Performance Class. There is no unconditional, device-wide requirement that an Android phone be able to capture HDR video at all.
That matters more outside the phone-review press than inside it. A large share of the phones actually doing camera duty in the world — the budget handset, the four-year-old handset, the one that got repurposed precisely because nobody wanted to carry it anymore — simply do not have a 10-bit sensor path, and no version of Android has ever required one. For those devices this entire class of problem never arises, and the color range question is the only one of the three that still bites.
The same guide adds a limitation that surprises people who assume an app can inspect what it recorded:
"Camera apps don't have access to HDR metadata via the Camera2 APIs or any Media APIs in Android 13."
Query it. Do not infer it from the spec sheet.
The supported profiles live in CameraCharacteristics.REQUEST_AVAILABLE_DYNAMIC_RANGE_PROFILES, and the javadoc closes with the sentence that should govern how you write the code:
"Optional - The value for this key may be
nullon some devices."
So the honest sequence is: check whether REQUEST_AVAILABLE_CAPABILITIES contains the 10-bit capability, then read the profile list, then handle null. DynamicRangeProfiles confirms what you can rely on if the device is in the club:
"All 10-bit output capable devices are required to support this profile." (on
HLG10)
And it warns that profiles are not freely combinable — some devices cannot mix 8-bit and 10-bit outputs in one capture request, and asking anyway throws IllegalArgumentException. That constraint sits in the same family as the stream-configuration limits I wrote about in One Camera, Two Consumers: what the hardware level lets you ask for is a real, queryable, per-device fact, and inferring it from a model name is guessing.
Android is unusually candid about playback losing the picture
Most platforms describe playback as a solved problem. Android does not. Three first-party statements, all of them worth quoting to anyone who thinks "it played, therefore it's fine".
From AOSP's HDR video playback page:
"Only tunneled decoders are guaranteed to play back HDR content. Playback by non-tunneled decoders may result in the HDR information being lost and the content being flattened into an SDR color volume."
From the developer-facing playback guide, on a very commonly used view:
"HDR playback has limited support on TextureView in Android 13 (API layer 33) and higher. When playing back HDR video content, TextureView transcodes the video from HDR to SDR, resulting in playback with possible loss of detail including clipped colors and video banding."
And the CDD requires devices to be able to do the downgrade at all:
"[C-6-6] MUST support HDR to SDR tone mapping in the default hardware-accelerated decoder for the captured profile."
The most direct sentence Google publishes about how this looks when the metadata is disregarded is in the Media3 Transformer tone mapping docs, describing the mode that deliberately reinterprets HDR as SDR:
"Contents will likely have a washed out look and may be displayed incorrectly."
That is a description of a processing mode that ignores the transfer function on purpose. I am quoting it because it is the clearest first-party statement of the symptom — not because Google anywhere says a correct HDR file looks washed out on an SDR panel. It does not say that. See the section below.
Then the file leaves the phone, and the guarantees stop
Everything above is Android documenting its own behaviour, sometimes unflatteringly. The moment the video is served over your network and opened in a browser, that documentation stops applying — and nothing equivalent replaces it.
I measured this rather than assuming it. I pulled the HTML Standard's media chapter — the section that defines the <video> element — extracted the text, and counted. Across 251,549 characters:
| Term | Occurrences |
|---|---|
color space / colour space
|
0 |
HDR |
0 |
dynamic range |
0 |
tone map / tonemap
|
0 |
gamut |
0 |
color primaries |
0 |
transfer characteristic |
0 |
full range |
0 |
16-235 |
0 |
The specification that defines <video> does not mention color once. Whatever your browser does with that colr metadata, it is not doing it because the HTML spec told it to.
The obvious next thought is: fine, the Media Capabilities API can tell me whether the browser handles HDR. Read its own introduction (W3C Working Draft, 9 June 2026):
"This specification focuses on encoding and decoding capabilities. It is expected to be used with other web APIs that provide information about the display properties, such as supported color gamut or dynamic range capabilities, which enable web applications to pick the right content for the display and to, for example, avoid providing HDR content to an SDR display."
The API's own scope statement pushes the display out of scope. And when you follow its algorithm, smooth is defined as whether the agent "is able to decode the media represented by configuration at the indicated framerate" — decoding, not rendering. supported is computed from a MIME-type and codec-validity check. Nothing in the algorithm inspects the screen you are looking at.
The member definitions are written in more reassuring language than the algorithm delivers. colorGamut says the agent "needs to be able to cause the output device to render the appropriate color, or something close enough". transferFunction describes a function "supported by the rendering capabilities of a user agent, independent of the display".
Then there is the implementation reality. Chrome's own platform status entry for this feature, restated in the Chrome 120 beta notes:
"Chromium implements its own tone-mapping algorithms so will always return true for HDR10 (smpteSt2086) static metadata."
Always return true. The answer to "can you handle this?" is a constant, decided by what Chromium's code contains — not by an inspection of the monitor in front of you. It is a truthful answer to the question the API actually asks. It is not an answer to the question you wanted to ask.
What I could not verify
This is the part I think is most useful, because the gaps here are exactly where confident-sounding advice comes from.
No Google page states that a correct HDR file looks washed out on an SDR display. The two "washed out" phrases I found both describe a mode that deliberately ignores the transfer function. I grepped MediaFormat, Display, DynamicRangeProfiles, CameraCharacteristics, the AOSP HDR pages, the supported-formats page and the full Android 16 CDD for "washed out" and "desaturated": zero relevant hits. The connection between "metadata was misread" and "your picture looks flat" is an inference. It is mine, and I am labelling it as mine.
The washed-out-blacks direction of a range mismatch is folklore as far as I could source it. Microsoft's YUV Format Ranges page documents the mismatch in exactly one direction — full-range content misread as studio range, which "results in reduced dynamic range and often harsh contrast", with "highlights becom[ing] excessively bright before pure white is reached". That is crushed blacks and clipped highlights. The opposite case, studio-range content read as full range producing grey lifted blacks, is the one everyone repeats, including me above, and I did not find it stated on an authoritative page. Treat it as the inverse of a documented case, not as a documented case.
No page I found states what a browser does with an HLG MP4. Not MDN, not the HTML Standard, not Chrome's developer documentation. If you have seen an authoritative source on this, I would genuinely like it.
No page states what happens when the color metadata is absent entirely. MediaFormat says the value is "0 if unspecified" and stops. It does not say what a decoder, the compositor, or a player then assumes.
I could not verify a first-party statement that Android writes a colr box into the recorded MP4, or which component does it. The MediaMuxer documentation says nothing about color at all.
And a process note: a research pass I ran produced a WebCodecs quote about user agents replacing null color values with "guessed values as determined by implementer defined heuristics". I could not reproduce that text when I fetched the spec myself, so I have left the claim out rather than publish a quote I could not confirm.
What to actually do about it
Every instinct here is to want a compatibility table. Resist it. The useful move is a measurement, and it takes about four minutes.
- Record thirty seconds of the actual scene, on the actual phone, in the actual light. Not a test pattern — the hallway at dusk is the case that breaks.
- Open that file on the phone. This is your reference. It is the closest thing you have to what the encoder intended.
- Open the same file on the machine you will really use — the laptop, the tablet, the desktop browser you will have open at work. Same file, not a re-encode, not a copy sent through a messaging app.
- Put them side by side and look at the blacks. Not the color balance, not the brightness. The darkest part of the frame is where a range or transfer mismatch shows up first and most obviously.
- If they differ, the difference is not in your recording. Change the viewing end — a different browser, a different player, the phone itself — before you change a single capture setting.
That last step is the one that saves time. The reflex when a picture looks wrong is to go adjust the camera. If the file looks right on the device that made it and wrong somewhere else, the camera is not the variable.
If you are watching over the LAN through a browser — which is how Background Camera RemoteStream serves its live view, via a small HTTP server on the phone — this test is the whole diagnosis. The browser is a machine you did not configure, running a rendering path nobody specified, on a display whose capabilities no API on the page can reliably read.
The part no app can fix
I want to be straight about the boundary, because it is not flattering.
None of this is fixable by a camera app, ours included. What a browser does with a transfer function is that browser's code on that operating system with that display. It is not our code, it is not reachable from our code, and there is no Android API that makes it reachable. An app can choose what it writes; it cannot choose what anything downstream believes.
Whether your phone can capture 10-bit at all is a hardware fact, settled before you installed anything, and the CDD has never required it.
And I am not publishing a browser compatibility table, because I would have to test every browser against every operating system against every display class to build an honest one, I have not done that, and — as the section above shows — the platform documentation does not contain one either. A table I cannot stand behind would be worse than the absence of a table.
The parts you can control are the ones in the four-minute test: what you record, where you watch it, and whether you found out on a quiet Tuesday or on the morning you actually needed the footage.
Related reading:
- Camera2 API: Handling Orientation, Focus, and Exposure in Background — the capture-side half of this problem, including why locking white balance matters for a recorder that runs through a sunset.
- Can an Old Android Phone See in the Dark? — the sensor-side half, and why a phone's bonded IR-cut filter is not something software can switch off.
- One Camera, Two Consumers — what your phone's Camera2 hardware level actually guarantees when two things want the lens.
- Denied, Dismissed, Delayed — what Android guarantees a person in the room will be shown while a camera is running.
Background Camera RemoteStream records with the screen off, stores locally, and serves a live view from a small web server on the phone itself. It is on Google Play, and there is more about how it is built at superfunicular.com.
Top comments (0)