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-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. */
|
||||||
|
|
|
||||||
|
|
@ -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)'}`")
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue