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:
parent
de670bcb13
commit
d0e7af3099
3 changed files with 345 additions and 0 deletions
|
|
@ -135,6 +135,19 @@ patch = """
|
|||
background-color: 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
|
||||
default = grey track; appears as ~15px strip at viewport bottom). Style
|
||||
all scrollbars to ARRFLIX palette. */
|
||||
|
|
|
|||
|
|
@ -311,6 +311,37 @@ async def run_side(p, side_cfg):
|
|||
paintRGBSum = (r+g+b)/n;
|
||||
paintOk = paintLuma > 4; // > a few luma above pure black
|
||||
} 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 {
|
||||
present: true,
|
||||
src: v.src || '',
|
||||
|
|
@ -327,6 +358,8 @@ async def run_side(p, side_cfg):
|
|||
bufferedRanges: v.buffered.length,
|
||||
bufferedEnd: v.buffered.length ? v.buffered.end(v.buffered.length-1) : 0,
|
||||
paintLuma, paintRGBSum, paintOk, paintErr,
|
||||
stackAtVideoCenter,
|
||||
videoOsdPage: osdInfo,
|
||||
};
|
||||
}""")
|
||||
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:
|
||||
return ("dev fails because <video> advances time but paints all-black "
|
||||
"(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"
|
||||
|
||||
md = []
|
||||
|
|
@ -529,6 +576,27 @@ def render_md(diff, prod, dev):
|
|||
"videoWidth", "videoHeight", "bufferedRanges", "bufferedEnd",
|
||||
"paintLuma", "paintRGBSum", "paintOk"]:
|
||||
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("## Stream URL (decoded)")
|
||||
md.append(f"- **prod**: `{diff.get('stream_url_prod') or '(empty)'}`")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue