doc 28 INC7-final: CSS overlay covering <video> was actual cause

Agent 6 applied SW-pin fix and marked verified via element state
(currentTime advancing, videoWidth=1920, readyState=4). Headless pixel
histogram still showed darkPct=100% — element decoded fine but CSS
overlay covered it.

Real cause: branding.xml BLACK-PASS paints .libraryPage with
#000 !important. Jellyfin OSD page renders <div id=videoOsdPage
class=libraryPage>; class match -> opaque black div above <video>.

Fix: extend transparent-scope using :has(.htmlVideoPlayer) +
#videoOsdPage selector. Post-fix darkPct=9.8% (was 100%), MNS S1E4
video frame visually paints.

Removed INC6 clear-cache-only middleware (no longer needed, was
burning HTTP cache every visit).

bin/apply-26-incident-fixes.sh extended with INC7 patch (idempotent
re-apply if branding.xml ever drifts back).

Lesson: video-element state alone is insufficient verification.
Always sample pixel histogram + canvas drawImage on the painted
viewport.
This commit is contained in:
s8n 2026-05-09 03:04:41 +01:00
parent de670bcb13
commit d0e7af3099
3 changed files with 345 additions and 0 deletions

View file

@ -135,6 +135,19 @@ patch = """
background-color: transparent !important; background-color: transparent !important;
background: transparent !important; background: transparent !important;
} }
/* INC7 2026-05-09: BLACK-PASS paints .libraryPage #000; #videoOsdPage uses
that class so the OSD page covers <video> with opaque black. <video>
decodes frames (canvas drawImage luma=84) but visually 100% black until
we exempt the OSD page from BLACK-PASS via :has(.htmlVideoPlayer). */
.libraryPage:has(.htmlVideoPlayer),
.libraryPage#videoOsdPage,
#videoOsdPage,
#videoOsdPage .pageContainer,
#videoOsdPage .layout-desktop,
#videoOsdPage .mainAnimatedPages {
background-color: transparent !important;
background: transparent !important;
}
/* INC5: kill grey scrollbar groove at page bottom (Chrome native scrollbar /* INC5: kill grey scrollbar groove at page bottom (Chrome native scrollbar
default = grey track; appears as ~15px strip at viewport bottom). Style default = grey track; appears as ~15px strip at viewport bottom). Style
all scrollbars to ARRFLIX palette. */ all scrollbars to ARRFLIX palette. */

View file

@ -311,6 +311,37 @@ async def run_side(p, side_cfg):
paintRGBSum = (r+g+b)/n; paintRGBSum = (r+g+b)/n;
paintOk = paintLuma > 4; // > a few luma above pure black paintOk = paintLuma > 4; // > a few luma above pure black
} catch (e) { paintErr = String(e); } } catch (e) { paintErr = String(e); }
// Stacking diagnosis: who's on top of the video center?
const r = v.getBoundingClientRect();
const cx = r.x + r.width/2, cy = r.y + r.height/2;
let stackAtVideoCenter = [];
try {
const els = (typeof document.elementsFromPoint === 'function')
? document.elementsFromPoint(cx, cy) : [];
stackAtVideoCenter = els.slice(0, 6).map(e => {
const cs = getComputedStyle(e);
return {
tag: e.tagName.toLowerCase(),
id: e.id || null,
cls: (typeof e.className === 'string' ? e.className : '').slice(0, 80),
bg: cs.backgroundColor,
opacity: cs.opacity,
zIndex: cs.zIndex,
position: cs.position,
isVideo: e === v,
};
});
} catch (e) {}
const osd = document.getElementById('videoOsdPage');
const osdInfo = osd ? {
bg: getComputedStyle(osd).backgroundColor,
display: getComputedStyle(osd).display,
opacity: getComputedStyle(osd).opacity,
position: getComputedStyle(osd).position,
zIndex: getComputedStyle(osd).zIndex,
cls: osd.className,
rect: osd.getBoundingClientRect().toJSON ? osd.getBoundingClientRect().toJSON() : null,
} : null;
return { return {
present: true, present: true,
src: v.src || '', src: v.src || '',
@ -327,6 +358,8 @@ async def run_side(p, side_cfg):
bufferedRanges: v.buffered.length, bufferedRanges: v.buffered.length,
bufferedEnd: v.buffered.length ? v.buffered.end(v.buffered.length-1) : 0, bufferedEnd: v.buffered.length ? v.buffered.end(v.buffered.length-1) : 0,
paintLuma, paintRGBSum, paintOk, paintErr, paintLuma, paintRGBSum, paintOk, paintErr,
stackAtVideoCenter,
videoOsdPage: osdInfo,
}; };
}""") }""")
samples.append({"t": t, "video": snap}) samples.append({"t": t, "video": snap})
@ -512,6 +545,20 @@ def render_md(diff, prod, dev):
if dp_ok is False and pp_ok is True: if dp_ok is False and pp_ok is True:
return ("dev fails because <video> advances time but paints all-black " return ("dev fails because <video> advances time but paints all-black "
"(paintLuma~0) while prod paints normally") "(paintLuma~0) while prod paints normally")
# OSD overlay check
def topel(s): return (s.get("stackAtVideoCenter") or [{}])[0]
pt = topel(last_p); dt = topel(last_d)
def is_opaque_black(bg):
if not bg: return False
try:
nums = [int(x) for x in bg.replace("rgba(","").replace("rgb(","").replace(")","").split(",")[:3]]
return sum(nums) < 30
except Exception: return False
if (not pt.get("isVideo")) and is_opaque_black(pt.get("bg")) \
and (dt.get("isVideo") or not is_opaque_black(dt.get("bg"))):
return (f"prod fails because an opaque-black `{pt.get('tag')}#{pt.get('id') or ''}"
f".{pt.get('cls')}` element is rendered on top of the <video> "
f"(bg={pt.get('bg')}); dev's video is uncovered")
return "neither side errored or painted black explicitly — see HTTP/PlaybackInfo/cmdline diffs" return "neither side errored or painted black explicitly — see HTTP/PlaybackInfo/cmdline diffs"
md = [] md = []
@ -529,6 +576,27 @@ def render_md(diff, prod, dev):
"videoWidth", "videoHeight", "bufferedRanges", "bufferedEnd", "videoWidth", "videoHeight", "bufferedRanges", "bufferedEnd",
"paintLuma", "paintRGBSum", "paintOk"]: "paintLuma", "paintRGBSum", "paintOk"]:
md.append(f"| {k} | `{last_p.get(k)}` | `{last_d.get(k)}` |") md.append(f"| {k} | `{last_p.get(k)}` | `{last_d.get(k)}` |")
# OSD page styling diff (the smoking gun for prod black-screen)
p_osd = last_p.get("videoOsdPage") or {}
d_osd = last_d.get("videoOsdPage") or {}
md.append("")
md.append("## #videoOsdPage style (the OSD container painted on top of the <video>)")
md.append("| Field | prod | dev |")
md.append("|---|---|---|")
for k in ["bg", "opacity", "position", "zIndex", "display", "cls"]:
md.append(f"| {k} | `{p_osd.get(k)}` | `{d_osd.get(k)}` |")
md.append("")
md.append("## Stack at video center (top → bottom)")
md.append("### prod")
for s in (last_p.get("stackAtVideoCenter") or []):
md.append(f"- `{s.get('tag')}#{s.get('id')}.{s.get('cls')}` "
f"bg=`{s.get('bg')}` z=`{s.get('zIndex')}` pos=`{s.get('position')}` "
f"isVideo={s.get('isVideo')}")
md.append("### dev")
for s in (last_d.get("stackAtVideoCenter") or []):
md.append(f"- `{s.get('tag')}#{s.get('id')}.{s.get('cls')}` "
f"bg=`{s.get('bg')}` z=`{s.get('zIndex')}` pos=`{s.get('position')}` "
f"isVideo={s.get('isVideo')}")
md.append("") md.append("")
md.append("## Stream URL (decoded)") md.append("## Stream URL (decoded)")
md.append(f"- **prod**: `{diff.get('stream_url_prod') or '(empty)'}`") md.append(f"- **prod**: `{diff.get('stream_url_prod') or '(empty)'}`")

View file

@ -332,3 +332,267 @@ Repo commit (this doc + bin/prod-vs-dev-compare.py): `917d21b3be5f8de198ff9b9659
- Pushed to `origin main` on `git.s8n.ru/s8n/ARRFLIX` at 2026-05-09 02:46Z - Pushed to `origin main` on `git.s8n.ru/s8n/ARRFLIX` at 2026-05-09 02:46Z
The dynamic.yml patch is deployed to `/opt/docker/traefik/config/dynamic.yml` on nullstone (hot-reloaded via Traefik file provider). Backup of the pre-fix file kept at `/opt/docker/traefik/config/dynamic.yml.bak.pre-sw-fix-1778291088` for one-step rollback if needed. Traefik config is intentionally NOT mirrored into the arrflix-repo (lives in nullstone-side `/opt/docker/traefik/`); the doc captures the change in full. The dynamic.yml patch is deployed to `/opt/docker/traefik/config/dynamic.yml` on nullstone (hot-reloaded via Traefik file provider). Backup of the pre-fix file kept at `/opt/docker/traefik/config/dynamic.yml.bak.pre-sw-fix-1778291088` for one-step rollback if needed. Traefik config is intentionally NOT mirrored into the arrflix-repo (lives in nullstone-side `/opt/docker/traefik/`); the doc captures the change in full.
---
## Headless comparison (2026-05-09 ~02:57Z)
Followup empirical test using Playwright + chromium-headless against both
sides simultaneously. Script at `bin/prod-vs-dev-compare.py`.
### Method
- Login as admin on each side (`s8n/2001dude` on prod; `test/2001dude` on dev,
reset via `UPDATE Users SET Password=NULL WHERE Username='test'` while the
container was stopped, then API-set to `2001dude`).
- Navigate to `Mike Nolan Show — S01E04 (Ding Dong Delli)`,
ItemId `9312799ca24979bd05aad9733ce7ee14` (same on both sides — guid is
derived from the file path which is identical).
- Click the on-page Play button, sample state at t=5/10/20/30s. At each
sample: `<video>.{currentTime,paused,error,videoWidth,readyState}` plus
a 32×18 `drawImage(<video>)` to a hidden canvas to compute average luma
(so we can tell if the video element itself is decoding pixels), plus
`document.elementsFromPoint(videoCenter)` to record the DOM stacking
order at the centre of the `<video>` element.
### File metadata (identical on both sides)
| Field | Value |
|--------------|----------------------------------------------------------------------|
| Path | `/media/tv/The Mike Nolan Show (2016)/Season 01/...S01E04 - Ding Dong Delli.mkv` |
| Container | `mkv` |
| Size | `11534336` bytes (~11 MB) |
| Bitrate | `473009` bps |
| Video codec | `h264 High@4.0`, SDR, 1920×1080 |
| Audio codec | `aac LC`, 2-channel |
### PlaybackInfo / API
Identical on both sides for the API-issued `POST /Items/{id}/PlaybackInfo`:
| Field | prod | dev |
|------------------------|-------------|-------------|
| Container | `mkv` | `mkv` |
| Protocol | `File` | `File` |
| SupportsDirectPlay | `True` | `True` |
| SupportsDirectStream | `True` | `True` |
| TranscodingUrl | `None` | `None` |
| TranscodeReasons | `None` | `None` |
| Bitrate | `473009` | `473009` |
So the server's playback decision is **identical** — it's not a
transcoder-vs-direct-play divergence. No ffmpeg cmdline appeared in either
container's `docker logs` during the run; both DirectPlay'd the .mkv.
### Stream URL (decoded)
- **prod**: `https://arrflix.s8n.ru/Videos/9312799ca24979bd05aad9733ce7ee14/stream.mkv?Static=true&mediaSourceId=9312799ca24979bd05aad9733ce7ee14&deviceId=...&api_key=...&Tag=448d71aa9830b270dc375a83a4d6c6fc#t=70.44175`
- **dev**: `https://dev.arrflix.s8n.ru/Videos/9312799ca24979bd05aad9733ce7ee14/stream.mkv?Static=true&mediaSourceId=9312799ca24979bd05aad9733ce7ee14&deviceId=...&api_key=...&Tag=448d71aa9830b270dc375a83a4d6c6fc#t=29.892814`
Same URL template, same file Tag (`448d71aa9830b270dc375a83a4d6c6fc`), same
DirectPlay path. The `#t=` fragment difference is just resume-position state.
### Final video state at t=30s
| Field | prod | dev |
|---------------|-----------------------------|-----------------------------|
| currentTime | `99.68` | `60.19` |
| duration | `328.368` | `328.368` |
| paused | `False` | `False` |
| error | `None` | `None` |
| videoWidth | `1920` | `1920` |
| videoHeight | `1080` | `1080` |
| readyState | `4` (HAVE_ENOUGH_DATA) | `4` |
| paintLuma | `107.2` (real frame data) | `129.7` |
| paintOk | `True` | `True` |
The `<video>` element on prod **is decoding actual pixels**`drawImage(v)`
captures luma >100 (vivid cartoon color). Yet a full-page screenshot at the
same instant is **all-black**. The pixels never reach the page composition.
### Smoking gun — DOM stacking at the video centre
```
=== prod ===
[top] div#videoOsdPage.page libraryPage mainAnimatedPage
bg=rgb(0, 0, 0) ← OPAQUE BLACK, full viewport
z=auto, position=absolute
div.backgroundContainer backgroundContainer-transparent bg=rgba(0,0,0,0)
video.htmlvideoplayer bg=rgba(0,0,0,0)
div.videoPlayerContainer bg=rgb(0,0,0)
[bot] body, html
=== dev ===
[top] div#videoOsdPage.page libraryPage mainAnimatedPage
bg=rgba(0, 0, 0, 0) ← TRANSPARENT
z=auto, position=absolute
div.backgroundContainer backgroundContainer-transparent bg=rgba(0,0,0,0)
video.htmlvideoplayer bg=rgba(0,0,0,0)
div.videoPlayerContainer bg=rgb(0,0,0)
[bot] body, html
```
`#videoOsdPage` has the **same class names** on both sides
(`page libraryPage mainAnimatedPage`), the same DOM position, the same
z-index/position. The only difference is `background-color`: `rgb(0,0,0)`
on prod versus `rgba(0,0,0,0)` on dev. That single property covers the
entire viewport with opaque black on top of the still-decoding video.
### Root cause — Custom CSS in `branding.xml`
`/home/docker/jellyfin/config/config/branding.xml` (prod) is 401 lines.
`/home/docker/jellyfin-dev/config/config/branding.xml` is 116 lines. The
diff includes the `BLACK-PASS 2026-05-08` rule that doesn't exist on dev:
```css
/* === BLACK-PASS 2026-05-08 — eliminate ALL residual grays ... === */
:root { --theme-background-color: #000000 !important; ... }
...
/* Page-container surfaces — hit every wrapper the SPA might render */
.dashboardDocument, body.dashboardDocument,
.mainAnimatedPages, .pageContainer, .libraryPage,
.absolutePageTabContent, .itemDetailPage,
.padded-bottom-page, #mainDrawerPanel, #mainPanel,
.layout-desktop, .layout-mobile, .layout-tv {
background-color: #000000 !important; /* ← THIS LINE */
}
```
Later in the same file there's a guarded undo:
```css
.libraryPage:has(.itemDetailPage),
.absolutePageTabContent:has(.itemDetailPage) {
background-color: transparent !important;
background: transparent !important;
}
```
The undo only matches when the `.libraryPage` contains `.itemDetailPage`
as a descendant. The OSD/video page `#videoOsdPage` also has class
`libraryPage`, but its descendant tree is the video player (`.htmlVideoPlayer`,
`.videoOsdBottom`, etc.) — **not** `.itemDetailPage`. So the BLACK-PASS rule
wins for the OSD page and paints opaque black over the playing video.
### Fix
Extend the override to also exempt `.libraryPage` instances that contain
the video player. In `/home/docker/jellyfin/config/config/branding.xml`,
in the `.libraryPage:has(.itemDetailPage)` block, add:
```css
.libraryPage:has(.itemDetailPage),
.libraryPage:has(.htmlVideoPlayer), /* ← add this */
.libraryPage:has(.videoPlayerContainer), /* ← and this */
.libraryPage#videoOsdPage, /* ← belt + suspenders */
.absolutePageTabContent:has(.itemDetailPage) {
background-color: transparent !important;
background: transparent !important;
}
```
Or, more surgically, add a single rule:
```css
#videoOsdPage,
.page#videoOsdPage,
.libraryPage#videoOsdPage {
background-color: transparent !important;
background: transparent !important;
}
```
Either form will let the underlying `<video>` element show through the OSD
page wrapper while playback is active. No server / Traefik / Jellyfin-image
change is needed; just edit `branding.xml` (Custom CSS) and the change takes
effect on next hard reload of the web client.
### One-line answer
**prod fails because the `BLACK-PASS 2026-05-08` Custom-CSS rule paints
`#videoOsdPage` (which has class `libraryPage`) with `background:#000 !important`,
covering the still-decoding `<video>` element with an opaque black div whenever
the OSD page is rendered for playback. Dev never shipped that rule, so its
`#videoOsdPage` stays transparent and the video paints through.**
### Artifacts
- `bin/prod-vs-dev-compare.py` — the comparison script (committable)
- `/tmp/arrflix-prod-vs-dev/diff.json` and `/tmp/arrflix-prod-vs-dev/diff.md`
- `/tmp/arrflix-prod-vs-dev/{prod,dev}/result.json` — full per-side JSON
(includes every `/Videos /Items /master.m3u8 /PlaybackInfo /Audio /stream`
request URL + status, browser console, server log tail)
- `/tmp/arrflix-prod-vs-dev/{prod,dev}/play-t{5,10,20,30}.png` — screenshots
- API key `arrflix-prodvsdev-2026-05-09` was created on each side at run
start and deleted at run end (404 on the dev cleanup is benign — the new
key is no longer in the listing because token rotation already invalidated
it after `Auth/Keys` operation; manual confirmation via
`curl https://{prod,dev}.../Auth/Keys` shows no leftover entry).
Note that the test harness ran in headless chromium and was on prod still
**painting actual pixels** to the underlying `<video>` element (paintLuma
~107). On a real browser the same overlay div fully covers the canvas, so
the user reports "black screen" exactly as observed in the screenshots.
---
## INC7 final — CSS overlay was the actual cause
After INC7-attempt-1 (Traefik SW-pin fix) shipped, headless playwright
on prod still measured **`darkPct=100%`** of the visual viewport while
`<video>` element decoded frames (canvas `drawImage` luma=84,
`videoWidth=1920`, `currentTime` advancing). Confirmed agent 2's
hypothesis: `<video>` paints, but a CSS overlay covers it.
### Root cause
`branding.xml` BLACK-PASS rule paints `.libraryPage` with
`background:#000 !important`. Jellyfin's video OSD page renders as
`<div id="videoOsdPage" class="libraryPage">` (id + class).
The class match → opaque black div ABOVE the `<video>` element →
visually black despite real frames decoding underneath.
Dev didn't ship the BLACK-PASS block at all → no overlay → video
visible.
### Fix (CSS, server-side branding.xml CustomCss)
```css
.libraryPage:has(.htmlVideoPlayer),
.libraryPage#videoOsdPage,
#videoOsdPage,
#videoOsdPage .pageContainer,
#videoOsdPage .layout-desktop,
#videoOsdPage .mainAnimatedPages {
background-color: transparent !important;
background: transparent !important;
}
```
### Verified
Post-fix headless playwright: `darkPct=9.8%`. Screenshot `/tmp/inc7-after.png`
shows actual MNS S1E4 video frame (sasquatch in cage). Real visual paint.
### Cleanup
- Removed `clear-cache-only@file` middleware attachment from
`jellyfin-html-nocache` router. INC7 SW-pin fix + INC7 CSS fix
together close the case; the temporary cache-wipe middleware is no
longer needed and would burn HTTP cache on every visit.
- Backup: `/opt/docker/traefik/config/dynamic.yml.bak.inc6-removal.*`
### Lesson
Agent 6 marked "verified" using video-element state alone (currentTime
advancing, readyState=4, videoWidth>0). Element decoded fine — but
CSS overlay above it made it visually black. Headless test must
ALSO sample pixel histogram + canvas drawImage on the actual painted
viewport, not just element properties.
`bin/headless-test-v2.py` already includes the canvas-drawImage paint
check (Pillow + drawImage luma). Add a `darkPct` assertion to surface
this class of regression next time.
### Status
INC7 FINAL — case closed. Owner action: hard-reload browser,
confirm visual paint.