PLAN: the Ham Share player and the Transcode device setting; e2e-player.mjs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8c69826547
commit
d1b3ef7efd
@@ -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/<token>` — 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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user