From d1b3ef7efda25ed9cc269449c2f434f2aea7b65b Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 23 Oct 2026 15:00:32 -0700 Subject: [PATCH] PLAN: the Ham Share player and the Transcode device setting; e2e-player.mjs Co-Authored-By: Claude Opus 5.5 --- PLAN.md | 45 ++++++++++++++++ web/scripts/e2e-player.mjs | 107 +++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 web/scripts/e2e-player.mjs diff --git a/PLAN.md b/PLAN.md index 2fb3216..861614e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -810,6 +810,51 @@ something long in one, switch away and back, refresh — it's all still there. - Tested (`TestSeekRestartsTheEncode`): a 64 s 1080p clip, a request for segment 12 right after the start runs from 48 s, the gap is filled, all 16 segments joined behind the one init decode with no hole. +- **The player is Ham Share's; a Transcode device setting** — **2026-10-23** (v0.1.0-226, + `internal/media/{encode,play}.go`, `internal/api/play.go`, `Player.tsx`, `vr.ts`, + `Settings.tsx`). Asked for as "redo the HamLab one with the one you sent … a 'Transcode + Device' setting … the A380 on Grid selected … other options being the other transcode + devices like the iGPUs on the various CPUs that have HamLab installed". **Replaces** the + previous two entries' design: no codec/size picker, no single-quality re-encode, no + repackaging; the old streamer (`hls.go`) is gone. + - **YouTube's way:** Auto (hls.js reads the connection), any quality by hand, or Original + (the file as it is, `/api/files/read`, falling back to Auto when the browser can't). A + ladder by size (`Ladder`): flat 2160p/1440p/1080p/720p/480p by the short side, plus the + file's own size on top when it sits between steps (a 2292×960 capture gets 960p); 360 + files by width, named as YouTube does (2160s 3840 … 720s). 720 and down at ≤30 fps. + H.264 High + AAC only. + - **Pieces made when asked for**, 3 s MPEG-TS, every quality's playlist written whole up + front. One ffmpeg per play: it starts at the piece asked, runs ahead, and is paused + (SIGSTOP; a root run through `run-ffmpeg`, which now reads `stop`/`cont` on stdin) 10 + pieces ahead; a piece more than 3 past what's made, or at another quality, starts a new + run there. Keyframes only on the 3 s marks and every run's times offset alike + (`-output_ts_offset start+10`), so pieces from any run and quality join (`TestPieces` + checks it; `SRC=… DEVICE=… go test ./internal/media -run Pieces` on a machine with a GPU). + At most 3 runs a machine, the one asked of least lately ended for a fourth; 2 GiB of + pieces a play; forgotten after 30 min. + - **hls.js is patched** (`PatientAbr`): the wait for a piece being made isn't counted + against the connection, and a piece isn't abandoned before its first byte. Without it + Auto dropped to the bottom after every seek. + - **Transcode device** (Settings, hub-side, `store.Transcode`): every machine's devices + asked live (`GET /api/files/play-devices` on each), unusable ones greyed with the reason + (hamtv's UHD 600: the render permission; mac and gate: no ffmpeg). Set to **grid's Arc + A380**. "The machine the file is on" is the other choice. `POST /api/play` on the hub + opens the play where the setting says; **a machine transcoding another's file reads it + through the hub** at `/api/play-src/` — a random token good for that one file, 12 + h from its last use, ranges passed through to the file's machine (ffmpeg reconnects). + If that machine fails, the file's own makes it and the player says so. + - Recipes per device: same-card decode first, then CPU decode + upload, then libx264 as + the fallback whatever was asked. A card without `vainfo` (core) is tried anyway: core's + UHD encodes. **Rotation:** the card path turns a phone's picture itself + (`transpose_vaapi`, `-noautorotate`; ffmpeg's autorotate never applied to VAAPI frames, + so iPhone clips came out sideways). **HLG** is shown as it is (tonemap_vaapi refuses it); + HDR10 is tone-mapped on the card, else zscale/hable before the upload. + - 360 files: the look-around view, zoom 8°–300° (ordinary lens into stereographic, the + "little planet"), drag/pinch/wheel, phone motion. Full screen is the element's, or the + whole window on an iPhone. + - Measured (`web/scripts/e2e-player.mjs`, Chromium): an iPhone HLG clip on `core` made on + grid's A380 — 3.2 s to play, a seek 1.0 s, a quality change 1.5 s; the 8K 360 Disney day on + grid — 3.1 s, 2160s on Auto, a seek 4.9 s (10 s source keyframes). - **Files edge sweep** — **2026-10-11** (v0.1.0-214), asked for as "a full bug sweep of the Files section, every example and edge case". Every handler and both UI files read through with odd inputs in mind, then a battery of ~60 requests against the live hub diff --git a/web/scripts/e2e-player.mjs b/web/scripts/e2e-player.mjs new file mode 100644 index 0000000..5c5ef5c --- /dev/null +++ b/web/scripts/e2e-player.mjs @@ -0,0 +1,107 @@ +// The Files player, end to end in Chromium against a running hub. Playwright is borrowed from +// the wedding site. +// +// BASE=http://10.0.0.234:8100 USER=… PASS=… MACHINE=hub FILE=/path/to/video node scripts/e2e-player.mjs +// +// Opens FILE in the Files viewer on MACHINE ("hub" for the hub itself), plays it on Auto, +// checks the 360 view if it is one, seeks into the middle, picks the lowest quality, tries +// Original, and reads the menu's "Made on …". Changes nothing. DEVICE="Pixel 7" for a phone; +// TRACE=1 prints every piece asked for. Screenshots land in $SHOTS (default /tmp/hamlab-shots). +import { createRequire } from 'node:module' +import { mkdirSync } from 'node:fs' +const require = createRequire(import.meta.url) +const { chromium, devices } = require('/home/core/projects/wedding-website/node_modules/playwright') + +const { BASE = 'http://10.0.0.234:8100', USER, PASS, MACHINE = 'hub', FILE, DEVICE, TRACE } = process.env +const SHOTS = process.env.SHOTS ?? '/tmp/hamlab-shots' +if (!USER || !PASS || !FILE) { console.error('USER, PASS and FILE are required'); process.exit(2) } +mkdirSync(SHOTS, { recursive: true }) + +let failed = 0 +const check = (name, ok, extra = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); if (!ok) failed++ } +const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }) +const ctx = await browser.newContext({ ...(DEVICE ? devices[DEVICE] : { viewport: { width: 1280, height: 860 } }), colorScheme: 'dark' }) +check('signs in', (await ctx.request.post(BASE + '/api/login', { data: { username: USER, password: PASS } })).ok()) +const page = await ctx.newPage() +const errors = [] +page.on('pageerror', (e) => errors.push(String(e))) +page.on('console', (m) => { if (m.type() === 'error' && !/401|404|favicon/.test(m.text())) errors.push(m.text()) }) +const T0 = Date.now(), trace = [] +const short = (u) => u.replace(/.*\/files\/play\/[0-9a-f]+\//, '') +page.on('request', (r) => { if (/\/files\/play\/.*\.ts$/.test(r.url())) trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} ask ${short(r.url())}`) }) +page.on('requestfinished', (r) => { if (/\/files\/play\/.*\.ts$/.test(r.url())) trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} got ${short(r.url())}`) }) +const mark = (m) => trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} -- ${m}`) + +const dir = FILE.slice(0, FILE.lastIndexOf('/')) || '/', name = FILE.slice(FILE.lastIndexOf('/') + 1) +await page.goto(`${BASE}/#/${MACHINE}/files/${encodeURIComponent(dir)}`) +await page.locator('.file-row', { hasText: name }).first().click() +await page.locator('.vp').waitFor() + +const vstate = () => page.evaluate(() => { const v = document.querySelector('.vp video'); return v ? { t: v.currentTime, paused: v.paused, w: v.videoWidth, h: v.videoHeight, rs: v.readyState, d: v.duration } : null }) +async function playingPast(t, ms = 60000) { + const t0 = Date.now() + while (Date.now() - t0 < ms) { + const s = await vstate() + if (s && s.t > t && !s.paused && s.rs >= 3) return Date.now() - t0 + await page.waitForTimeout(200) + } + return -1 +} +async function controls() { + if (await page.locator('.vp.bare').count()) { + const b = await page.locator('.vp').boundingBox() + if (DEVICE) await page.touchscreen.tap(b.x + 40, b.y + 40) + else await page.mouse.move(b.x + b.width / 2 + Math.random() * 40, b.y + b.height / 3) + await page.waitForTimeout(300) + } +} + +let took = await playingPast(1) +check('plays on Auto', took >= 0, `${((Date.now() - T0) / 1000).toFixed(1)} s from opening`) +await page.waitForTimeout(6000) +let s = await vstate() +check('Auto settles on a quality', s?.w > 0, `${s?.w}×${s?.h}; “${await page.locator('.vp-q span').textContent()}”`) +if (await page.locator('canvas.vp-sphere').count()) { + const px = await page.evaluate(() => { const c = document.querySelector('canvas.vp-sphere'); const g = c.getContext('webgl'); const d = new Uint8Array(64); g.readPixels(c.width >> 1, c.height >> 1, 4, 4, g.RGBA, g.UNSIGNED_BYTE, d); return d.reduce((a, b) => a + b, 0) }) + check('the 360° view draws', px > 0) +} +await page.screenshot({ path: `${SHOTS}/h1-auto.png` }) + +const mid = Math.floor(s.d / 2) +mark('seek') +await page.evaluate((m) => { document.querySelector('.vp video').currentTime = m }, mid) +took = await playingPast(mid + 0.5) +check('a seek into the middle plays again', took >= 0, `${(took / 1000).toFixed(1)} s`) + +await controls() +await page.locator('.vp-q').click() +await page.waitForTimeout(2500) // the menu asks the machine what it is doing +const items = page.locator('.vp-menu button') +const n = await items.count() +check('the menu lists Auto, Original and the qualities', n >= 3, (await items.allTextContents()).join(' | ')) +const foot = await page.locator('.vp-menu-f').textContent() +check('it says where it is made', /Made on \S+/.test(foot), foot) +await page.screenshot({ path: `${SHOTS}/h2-menu.png` }) +const lowLabel = await items.nth(n - 1).locator('span').nth(1).textContent() +mark('pick ' + lowLabel) +const before = (await vstate()).t +const t0 = Date.now() +await items.nth(n - 1).click() +let ok = false +for (let i = 0; i < 200 && !ok; i++) { await page.waitForTimeout(200); s = await vstate(); ok = s.t > before + 1 && !s.paused && (await page.locator('.vp-q span').textContent()) === lowLabel } +check(`picking ${lowLabel} switches to it`, ok, `${s.w}×${s.h} after ${((Date.now() - t0) / 1000).toFixed(1)} s`) + +await controls() +await page.locator('.vp-q').click() +await page.locator('.vp-menu button', { hasText: 'Original' }).click() +await page.waitForTimeout(4000) +const label = await page.locator('.vp-q span').textContent() +const note = await page.locator('.vp-note').count() ? await page.locator('.vp-note').textContent() : '' +check('Original plays, or falls back to Auto and says so', label === 'Original' || (/^Auto/.test(label) && /can’t play/.test(note)), `${label}${note ? ' — ' + note : ''}`) +check('still playing', (await playingPast((await vstate()).t + 0.5)) >= 0) +await page.screenshot({ path: `${SHOTS}/h3-after-original.png` }) +check('no page errors', errors.length === 0, errors.slice(0, 3).join(' / ')) +if (TRACE) console.log(trace.join('\n')) +await browser.close() +console.log(failed ? `${failed} failed` : 'all ok') +process.exit(failed ? 1 : 0)