audit: rick-and-morty color/HDR diagnosis

This commit is contained in:
s8n 2026-05-08 17:45:34 +01:00
parent efdd43d1a8
commit 2f8c02a2d3

View file

@ -0,0 +1,482 @@
# 21 — Rick and Morty Color / HDR Audit (Read-Only)
> Status: **read-only audit**, executed 2026-05-08 against
> `https://arrflix.s8n.ru` (Jellyfin 10.10.3 on nullstone). Scope:
> diagnose why **Rick and Morty looks "kind of gray / washed-out"**
> while other titles render normally. **No fixes applied. No state
> mutated. No transcode triggered.**
>
> Inputs: `ffprobe` via `docker exec jellyfin /usr/lib/jellyfin-ffmpeg/ffprobe`
> against on-disk media; Jellyfin REST `/Items/{id}/PlaybackInfo`,
> `/System/Configuration/encoding`, `/Branding/Configuration` (auth
> `X-Emby-Token: ${JELLYFIN_API_TOKEN}`); contrast probe against
> *The Mandalorian* as a known-good SDR title; review of `CustomCss`
> against the inventory in doc 14 §1b.
---
## 1. Executive summary
**Confirmed root cause:** the Rick and Morty release on disk is an
**HDR10 4K HEVC Main 10 (PQ / BT.2020) "AI Upscale"** of an originally
SDR animated show. Jellyfin classifies it as `VideoRange=HDR`
`VideoRangeType=HDR10` and forces the browser onto the **transcode
path** (`TranscodeReasons=ContainerNotSupported, AudioCodecNotSupported,
SubtitleCodecNotSupported` — every browser session triggers this). The
encoding config has **`EnableTonemapping=false` and
`HardwareAccelerationType=none`**, so ffmpeg software-decodes the
HDR10 source, then h264-encodes **without applying a tonemap**, then
the browser interprets the resulting BT.2020 PQ pixel data as plain
BT.709 SDR. That mis-interpretation is the textbook signature of the
washed-out grey look.
**One-line remediation (lowest blast radius):** in
`/System/Configuration/encoding`, set `EnableTonemapping=true` (the
algorithm `bt2390` is already correctly selected) — this enables CPU
tonemap on the existing software pipeline; CSS, hardware, and source
files do not need to change.
CSS / theme is **ruled out** as a cause — `CustomCss` contains zero
`grayscale(`, zero `saturate(`, zero `hue-rotate(` filters.
---
## 2. ffprobe table — Rick and Morty (Season 01)
All probes via `docker exec jellyfin /usr/lib/jellyfin-ffmpeg/ffprobe -v error -select_streams v:0 …`.
| File | Codec | Profile | Pix fmt | color_space | color_transfer | color_primaries | range | W×H | Bitrate | Size | HDR side-data |
|---|---|---|---|---|---|---|---|---|---|---|---|
| S01E01 — Pilot | hevc | Main 10 | yuv420p10le | bt2020nc | **smpte2084** (PQ) | bt2020 | pc | 3840×2160 | 8.13 Mbit/s | 1.34 GB | **none** (no MasteringDisplay / CLL block) |
| S01E05 — Meeseeks and Destroy | hevc | Main 10 | yuv420p10le | bt2020nc | **smpte2084** | bt2020 | pc | 3840×2160 | 7.97 Mbit/s | 1.26 GB | not present |
| S01E08 — Rixty Minutes | hevc | Main 10 | yuv420p10le | bt2020nc | **smpte2084** | (BT.2020) | (pc) | 3840×2160 | n/a | 1.34 GB | not present |
| S01E11 — Ricksy Business | hevc | Main 10 | yuv420p10le | bt2020nc | **smpte2084** | bt2020 | pc | 3840×2160 | 8.86 Mbit/s | 1.49 GB | not present |
**Reading:**
- `color_transfer=smpte2084` (a.k.a. ST 2084 / PQ) is the **HDR10
transfer function**. All R&M S01 episodes ship with HDR10 tagging.
- `color_primaries=bt2020` + `color_space=bt2020nc` are the BT.2020
wide-gamut primaries (the HDR colour space).
- `pix_fmt=yuv420p10le` = 10-bit-per-component, 4:2:0 chroma sub-
sampling. Required for HDR10 content.
- `color_range=pc` = full-range (01023 for 10-bit) rather than the
TV-range (64940) usually expected. **This is unusual** — most HDR10
Blu-ray / streaming sources are TV-range. PC-range mis-interpreted
as TV-range is itself a contrast/saturation hit, layered on top of
the HDR-as-SDR hit.
- **No HDR side-data** (`MasteringDisplayMetadata`,
`ContentLightLevelMetadata`) is present in any episode — the source
declares HDR10 but ships without the static-metadata blocks that a
proper HDR display or tonemapper would consume. This is a
fingerprint of a **fake HDR10** AI upscale (the file's own embedded
title is `"Rick and Morty - S01E01 - Pilot - 2160p HDR Ai Upscale -Mesc"`).
- 4K x 24 fps x ~8 Mbit/s × 1320 s = file size matches container
declaration, no surprises in muxing.
- The poster art / show landing page itself is rendered by the SPA
from JF's image cache (PNG / JPEG, sRGB) — those are not affected by
HDR. Only the **video element** is washed-out.
### 2a. Comparison vs. The Mandalorian (known-good SDR)
| File | Codec | Profile | Pix fmt | color_space | color_transfer | W×H | Bitrate |
|---|---|---|---|---|---|---|---|
| Mandalorian S01E01 | hevc | Main 10 | yuv420p10le | **bt709** | **bt709** | 1920×804 | 6.69 Mbit/s |
| Mandalorian S02E01 | hevc | Main 10 | yuv420p10le | **bt709** | **bt709** | (1920×…) | n/a |
| Mandalorian S03E01 | hevc | Main 10 | yuv420p10le | **bt709** | **bt709** | 1920×804 | 6.72 Mbit/s |
**Reading:** Mandalorian is **plain SDR BT.709** (the same colour space
the browser's `<video>` defaults to assume). 10-bit pixels here are
fine because the *transfer* is BT.709 SDR, not PQ — the browser /
ffmpeg pipeline sees this and renders it correctly. This is the
control sample that proves the difference is *content-side*, not
config-side.
---
## 3. Jellyfin encoding config — relevant fields
Source: `GET /System/Configuration/encoding`.
| Field | Value | Comment |
|---|---|---|
| `HardwareAccelerationType` | `"none"` | **GPU is dead** (host has no nvidia driver — see doc 13 finding 02). Every transcode is software ffmpeg. |
| `EnableHardwareEncoding` | `true` | No-op while `HardwareAccelerationType=none`. |
| `EnableTonemapping` | **`false`** | **THE BUG.** Software-tonemap is disabled. With HDR source + `=none` HW + tonemap off, the output is HDR pixels with no SDR conversion. |
| `EnableVppTonemapping` | `false` | Intel-VPP path, not relevant for CPU. |
| `EnableVideoToolboxTonemapping` | `false` | macOS path, not relevant. |
| `TonemappingAlgorithm` | `"bt2390"` | **Good choice** when enabled — the BT.2390 EETF is the modern recommendation. (`hable` is the legacy fallback; `mobius` and `reinhard` are alternatives.) |
| `TonemappingMode` | `"auto"` | Fine. |
| `TonemappingRange` | `"auto"` | Fine. |
| `TonemappingDesat` | `0` | Default. |
| `TonemappingPeak` | `100` | Target SDR peak nits — default. |
| `TonemappingParam` | `0` | Algorithm-specific; 0 = default. |
| `EnableDecodingColorDepth10Hevc` | `true` | 10-bit HEVC decode permitted. |
| `H264Crf` | `23` | h264 quality target for transcode output (default for JF). |
| `H265Crf` | `28` | h265 quality target (unused — `AllowHevcEncoding=false`). |
| `AllowHevcEncoding` | `false` | Cannot transcode-out as HEVC (forces h264 output). |
| `AllowAv1Encoding` | `false` | Cannot transcode-out as AV1. |
| `EnableThrottling` | `false` | Per doc 13 finding 03 — separate issue. |
| `EnableSegmentDeletion` | `false` | Per doc 13 finding 05 — separate issue. |
| `MaxMuxingQueueSize` | `2048` | Per doc 13 — separate issue. |
| `EncoderAppPathDisplay` | `/usr/lib/jellyfin-ffmpeg/ffmpeg` | Bundled jellyfin-ffmpeg, not host. |
| `VaapiDevice` | `/dev/dri/renderD128` | Empty on host (no Intel iGPU on AMD Ryzen). |
| `EncodingThreadCount` | `-1` | Auto = all cores. |
**Net:** the *one* knob standing between "washed-out grey" and
"correctly tonemapped SDR" is `EnableTonemapping`. The algorithm is
already set correctly (`bt2390`). Flipping the bool to `true` is a
single POST-able field-edit and applies to every future transcode.
### 3a. Live PlaybackInfo for R&M S01E01 (browser DeviceProfile)
Simulated browser PlaybackInfo (DeviceProfile: Chrome, h264 / aac /
mp3 / ac3 / eac3, hls):
```
SupportsDirectPlay: false
SupportsDirectStream: false
SupportsTranscoding: true
TranscodingSubProtocol: hls
TranscodingUrl:
/videos/<id>/master.m3u8
?VideoCodec=h264
&AudioCodec=aac,mp3,ac3,eac3
&VideoBitrate=139616000
&SegmentContainer=ts
&hevc-level=150
&hevc-videobitdepth=10
&hevc-profile=main10
&TranscodeReasons=
ContainerNotSupported,
AudioCodecNotSupported,
SubtitleCodecNotSupported
```
**Reading:** every browser session for R&M is forced into transcode by
three independent reasons (container `mkv`, audio `truehd` / `ac3`,
subtitle `pgs` / `ass` — confirmed by MediaInfo). It's not just an HDR
issue — the file *cannot* direct-play in any browser, so the transcode
path is mandatory, and inside that path tonemap is currently off.
For comparison, an SDR Mandalorian episode would still hit the
transcode path for the same container/audio reasons, but the
tonemap-off flag wouldn't matter because the source is already BT.709.
---
## 4. Theme / CSS rule-out check
Inspected `/Branding/Configuration → CustomCss` (25 225 chars, full
inventory in doc 14 §1b). Searched the live string for any
filter / saturation / hue-rotate / opacity rule that could desaturate
the video element or its container.
| Filter pattern | Matches in CustomCss | Verdict |
|---|---|---|
| `grayscale(` | **0** | ✓ |
| `saturate(` | **0** | ✓ |
| `hue-rotate(` | **0** | ✓ |
| `sepia(` | **0** | ✓ |
| `brightness(` | **0** | ✓ |
| `contrast(` | **0** | ✓ |
| `invert(` | **0** | ✓ |
| `mix-blend-mode` | **0** | ✓ |
| `filter:` | **0** | ✓ |
| `backdrop-filter:` | **0** | ✓ |
| `opacity:` (on `.itemBackdrop` / `video` / `.osdContainer`) | **0** | ✓ |
Also checked the doc 14 §7 detail-page backdrop rules just landed
(`linear-gradient(90deg, rgba(0,0,0,0.95) 0%, …)`) — that gradient is
applied to `.layout-desktop .backgroundContainer.withBackdrop`, NOT to
the `<video>` element. It tints the *backdrop poster behind the
detail-page header*, not playback. **Not the cause.**
`web-overrides/index.html` (the bind-mounted critical-path style): no
`filter:`, no `mix-blend-mode`, no animation that would alter video.
`ARRFLIX-SHIM` JavaScript only touches `document.title`, favicon, and
`mypreferencesmenu` drawer entries — does not touch playback DOM.
**Theme / CSS rule-out: PASS.** The greyness is in the pixel data
delivered to the browser, not in any post-render CSS effect.
---
## 5. Source-file integrity rule-outs
Already visible in §2, but stated explicitly so each candidate root
cause is closed:
| Hypothesis | Evidence | Verdict |
|---|---|---|
| (a) HDR file + CPU tone-map | All R&M S01 = HDR10 (`smpte2084`/`bt2020`). Encoding config `EnableTonemapping=false`, `HardwareAccelerationType=none`. | **CONFIRMED** root cause. |
| (b) CSS filter on theme | §4 shows zero filter/saturation rules. | RULED OUT. |
| (c) Direct-play tag mismatch | PlaybackInfo §3a shows `SupportsDirectPlay=false` — browser is on transcode path, no chance of DP-tag confusion. | RULED OUT. |
| (d) Source is genuinely SDR but graded flat (wrong tags) | ffprobe reports HDR10 tags consistently across 4 episodes, and Jellyfin agrees (`VideoRangeType=HDR10`). Title-string `"2160p HDR Ai Upscale"` confirms intent. | RULED OUT — the source IS HDR10, just badly so. |
| (e) Container / bit-depth / browser HW-decode bit-crush | Browser never receives the 10-bit HEVC because transcode is mandatory; output is 8-bit h264. So no client-side bit-depth issue is possible. | RULED OUT. |
| (f) Missing Mastering Display / CLL metadata makes tonemap target unknown | True — files have no static HDR metadata. Once tonemap is enabled, ffmpeg will fall back to defaults (peak 1000 nits, etc.) which is fine for cartoon AI-upscale content; better than no tonemap. | NOT a blocker for the fix. |
| (g) `color_range=pc` (full-range) | Full-range PC pixels reinterpreted as TV-range = an additional contrast bump. Tonemap filter handles range conversion. | Subsumed by (a) — same fix. |
---
## 6. Concrete remediation list (ranked: effort vs blast-radius)
### #1 — Enable software tonemap (recommended)
**Action:** flip a single bool in encoding config.
```
PUT /System/Configuration/encoding
EnableTonemapping = true
(TonemappingAlgorithm already = "bt2390" — leave as-is)
(TonemappingPeak already = 100 — leave as-is)
(TonemappingMode already = "auto" — leave as-is)
(TonemappingRange already = "auto" — leave as-is)
(TonemappingDesat already = 0 — leave as-is)
```
(Or via UI: *Dashboard → Playback → Transcoding → "Enable
tone-mapping"*.)
**Effect:** every future HDR-source transcode applies BT.2390 EETF +
gamut conversion (BT.2020 → BT.709) before h264 encoding. Output looks
right in any SDR browser.
**Cost:** zero seek time, no restart needed.
**Blast radius:** **low.** Only HDR sources (currently: Rick and
Morty S01) are affected. SDR sources (Mandalorian etc.) already have
BT.709 tags so the tonemap filter is a no-op for them.
**Caveat:** software tonemap on a 4K HEVC source on the existing
host load (doc 13 finding 01: load 11.4, swap 6.8 GiB) will add
~1.52× extra CPU per stream compared to a tonemap-off transcode.
Pair this with **doc 13 finding 03 (`EnableThrottling=true`)** so a
client-cancelled stream stops burning CPU; otherwise a stalled R&M
playback will eat a core for 12 minutes (`SegmentKeepSeconds=720`).
**Risk of "looks worse than expected":** AI-upscale R&M has no real
HDR — the wide-gamut tonemap will give a result that is more saturated
than the original Adult Swim broadcast (cartoon flat colours pushed
through BT.2020 round-trip), but visibly correct relative to current
washed-out grey. If the operator wants the original cartoon look,
remediation #3 below.
### #2 — Pair tonemap-on with throttling-on (doc 13 finding 03)
**Action:** when applying #1, also set:
```
EnableThrottling = true
EnableSegmentDeletion = true
```
**Effect:** caps wasted ffmpeg CPU after a client disconnects — already
recommended in doc 13 audit, doubly important once we add tonemap
overhead.
**Cost:** zero additional. Same UI page as #1.
### #3 — Replace R&M with a properly-graded SDR release (highest fidelity, highest effort)
**Action:** swap the `Rick.and.Morty.S01...2160p.HDR.Ai.Upscale-Mesc`
files for a native SDR encode (e.g., the original Adult Swim
1080p / WEB-DL releases or the 2160p SDR remasters where they exist).
**Effect:** zero tonemap cost (source is already BT.709), faster
transcodes, files shrink ~3-4× (8 Mbit/s 4K HDR → ~2 Mbit/s 1080p
SDR for a 22-min cartoon is plenty), consistent look with rest of
library.
**Cost:** medium — re-acquisition + re-import + re-scan + 90 GB disk
freed on `/home` which is currently 90% full (doc 13 finding 01).
**Blast radius:** medium. Watched-state and metadata stay (Sonarr
will re-match by `(2013)` + episode index), but each episode item ID
in JF will change → existing playback positions on R&M are lost.
### #4 — Pre-transcode R&M S01 to SDR offline (middle-ground)
**Action:** run `ffmpeg` once (outside Jellyfin) with the same
tonemap pipeline, write SDR-tagged HEVC files alongside, swap them in.
```sh
# Per episode (CPU intensive, ~1 hr per 22-min episode on this host):
ffmpeg -i in.mkv \
-map 0:v:0 -map 0:a -map 0:s? \
-vf "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=bt2390:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p10le" \
-c:v libx265 -preset slow -crf 22 -profile:v main10 \
-c:a copy -c:s copy out.mkv
```
**Effect:** Jellyfin no longer needs to tonemap on every play —
files are SDR-tagged at rest. CPU at playback drops to a normal
HEVC-software-decode-then-h264-software-encode (still no GPU but
no extra tonemap stage).
**Cost:** ~11 hours wall-clock on the existing 12-core box for
S01's 11 episodes (CPU-only HEVC encode); +20 GB during transcode,
files end ~30% smaller than HDR originals.
**Blast radius:** medium-low. Rewrites only R&M — other library
entries untouched. Item IDs change (same as #3).
### #5 — Wait for GPU restoration, then enable VPP / NVENC tonemap
**Action:** once nvidia driver is back on the host (doc 13
finding 02), set:
```
HardwareAccelerationType = nvenc
EnableTonemapping = true
EnableVppTonemapping = true (if Intel — N/A on Ryzen)
HardwareDecodingCodecs = [hevc, h264, vc1] (add hevc)
```
**Effect:** GPU does the HEVC decode + tonemap + h264 encode. No CPU
load, real-time on 4K. This is the long-term right answer.
**Cost:** L (host driver work). Already on doc 13 fix-list.
**Blast radius:** large but already planned. Until GPU is back, do
remediation #1.
### Recommended order
1. **Apply #1 + #2 today** (single Playback-settings page edit). Cost
~30 s of ops time, immediate visual fix on R&M, no media churn.
2. Re-test R&M playback (see §7).
3. If the tonemapped result still feels "wrong" because R&M is a
cartoon and the AI-upscale's HDR is a fiction, go to **#4 or #3**
for the long-term cure.
4. Park #5 behind the GPU restoration backlog.
---
## 7. Test plan to verify after fix
### 7a. Pre-fix baseline (capture now, before flipping the bool)
1. Open `https://arrflix.s8n.ru/web/#/details?id=324f75b84f394a5d9b0749c0679f23b9`
in Chrome/Firefox on onyx.
2. Hit Play. Pause at ~30 s in.
3. Take a screenshot (full-window). File:
`evidence/21-pre-fix-rm-s01e01-30s.png`.
4. Note the visible characteristics: Rick's lab-coat (should be pure
white but currently looks pale-grey), background green of the
garage, skin tones.
### 7b. Apply remediation #1 + #2
UI path: *Dashboard → Playback → Transcoding*:
- Enable "Tone-mapping"
- Enable "Throttle transcodes"
- Enable "Delete transcode segments"
(Or POST `/System/Configuration/encoding` directly with the three
bools flipped.)
No restart needed — Jellyfin re-reads `encoding.xml` per request.
### 7c. Post-fix verification
1. **New playback session** — close and reopen the browser tab so the
SPA requests a fresh `PlaybackInfo` and a fresh `master.m3u8`
(existing in-flight transcode is locked to the pre-fix ffmpeg
command line). Easiest: hard-reload (`Ctrl-Shift-R`) and re-click
Play.
2. Pause at the same ~30 s mark.
3. Screenshot to `evidence/21-post-fix-rm-s01e01-30s.png`.
4. **Side-by-side compare** the two images. Expectations:
- Whites are noticeably whiter (lab coat, ship hull).
- Saturation is higher (garage greens, sky blues, characters).
- Black-level remains similar (or slightly deeper).
- Skin tones look natural rather than greenish-grey.
### 7d. Server-side sanity checks (5 min after first post-fix play)
```sh
# Confirm tonemap is in the actual ffmpeg command line for this stream
ssh user@192.168.0.100 \
"docker exec jellyfin ps -ef | grep ffmpeg | grep -E 'tonemap|zscale' | head"
# Expected: a process line containing `zscale=...:t=linear:...,tonemap=bt2390,...`
# If the line lacks `tonemap`, the encoding.xml change didn't apply or
# JF has a cached transcode session — bounce the container.
# Confirm HDR-aware filter graph fed only by HDR sources (Mandalorian
# should NOT have tonemap in its ffmpeg cmdline)
ssh user@192.168.0.100 \
"docker exec jellyfin tail -200 /config/log/log_*.log | grep -E 'tonemap|smpte2084'"
```
### 7e. Negative test (other libraries unaffected)
Play one episode of:
- Mandalorian (SDR BT.709) — should look identical pre/post.
- Futurama / American Dad / Obi-Wan — same expectation (probe these
if you want to be thorough; they're outside this audit's scope).
If any of these now look *over*-saturated or *under*-saturated post-fix,
the tonemap is leaking onto SDR sources — open a bug, set
`TonemappingMode` from `auto` to a stricter mode.
### 7f. Performance check (CPU is the operative resource)
While the post-fix R&M episode is playing:
```sh
ssh user@192.168.0.100 "uptime && top -bn1 -p \$(pgrep -f 'ffmpeg.*Rick.and.Morty' | head -1) | tail -5"
```
- Expect ffmpeg to consume ~600900 % CPU (69 cores) on this host
for a 4K HEVC→h264 + tonemap pipeline.
- If load average climbs past 16 sustained or swap usage grows past
baseline 6.8 GiB, escalate doc 13 finding 01 — pair with
remediation #4 (pre-transcode the season) sooner rather than later.
### 7g. Long-term verification
A week after the fix, check:
```sh
# Number of 499 client-cancel events on jellyfin@docker
docker logs traefik --since 168h 2>&1 | grep '"jellyfin@docker"' | grep ' 499 ' | wc -l
```
Should be ≤ pre-fix baseline (currently 2 / hour, doc 13 finding 03).
If it climbs after enabling tonemap (because the tonemap stage
slowed transcodes enough to let the client time out), that's the
trigger to invest in remediation #4 or #5.
---
## 8. What was NOT touched during this audit
- No POST/PUT to `/System/Configuration/encoding`.
- No POST to `/System/Configuration/branding`.
- No `docker exec jellyfin` writes (read-only `ls`, `cat`, `ffprobe`).
- No `docker compose` action, no container restart.
- No file modification on `/home/user/media/`.
- No transcode triggered (PlaybackInfo simulation only — that endpoint
decides codec paths but does not start ffmpeg).
---
## 9. Sign-off
- **Auditor:** s8n (audit pass, 2026-05-08)
- **Live config at audit time:** Jellyfin 10.10.3,
`EnableTonemapping=false`, `HardwareAccelerationType=none`,
`TonemappingAlgorithm=bt2390`. CSS = Cineplex v1.0.6 + ARRFLIX
brand layer (no greyscale filters).
- **Confirmed root cause:** HDR10 source (R&M S01) + CPU-only
pipeline + tonemap disabled = HDR pixels delivered as SDR =
washed-out grey.
- **Recommended fix:** flip `EnableTonemapping=true` (one
Playback-settings checkbox) AND `EnableThrottling=true` +
`EnableSegmentDeletion=true` (pair-finding from doc 13).
- **Next audit due:** **2026-08-08** alongside doc 13's quarterly
rotation, or sooner if a new HDR source lands in another library.