aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 05:54:23 -0700
committerAdam Soltys <[email protected]>2026-10-18 05:54:23 -0700
commitb9297719d294779a2ea14c77a67975308bace65c (patch)
treed005658191f0ce4dec1a97212d6f4525900b60c0 /tools
parent31661e28b1a010fa67b3a2527c43a129ab2a41cc (diff)
downloadcoinosv3-b9297719d294779a2ea14c77a67975308bace65c.tar.gz
coinosv3-b9297719d294779a2ea14c77a67975308bace65c.zip
HLS for long videos: vmedia serves a long clip as a 360p/720p stream made as it's watched (/vmedia/hls.m3u8), the feed and pager play long clips and posted .m3u8 streams through the browser's own HLS, and a long video posted from the app goes up as an HLS stream made on the device (master playlist on Blossom).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'tools')
-rw-r--r--tools/hls-upload-test.js92
-rw-r--r--tools/video-hls-play-test.js123
-rw-r--r--tools/vmedia-hls-test.js84
3 files changed, 299 insertions, 0 deletions
diff --git a/tools/hls-upload-test.js b/tools/hls-upload-test.js
new file mode 100644
index 0000000..0a3feea
--- /dev/null
+++ b/tools/hls-upload-test.js
@@ -0,0 +1,92 @@
+// A long video posted from the app goes up as an HLS stream: made on the
+// device (two renditions, a file each, read in ranges), put on one Blossom
+// host with the playlists naming the files' addresses, and the master
+// playlist's address is what the post carries. Chrome plays it from there
+// and seeks in it. A short clip is no stream.
+// Run: bun tools/hls-upload-test.js
+import puppeteer from './puppeteer.js';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/hls-upload-');
+const make = (secs, out) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', String(secs), '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '60', '-c:a', 'aac', '-movflags', '+faststart', out]);
+make(95, dir + '/long.mp4');
+make(20, dir + '/short.mp4');
+const built = await Bun.build({ entrypoints: ['src/video-compress.js', 'src/media-upload.js'], target: 'browser', format: 'esm', splitting: true });
+if (!built.success) throw new Error(built.logs.join('\n'));
+const js = new Map();
+for (const o of built.outputs) js.set('/' + o.path.replace(/^\.\//, ''), await o.text());
+// a Blossom host: PUT /upload keeps the body under its hash; GET /<hash>.<ext> serves it in ranges
+const blobs = new Map(), types = new Map();
+const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
+ const u = new URL(req.url);
+ if (js.has(u.pathname)) return new Response(js.get(u.pathname), { headers: { 'content-type': 'text/javascript' } });
+ if (u.pathname === '/long.mp4' || u.pathname === '/short.mp4') return new Response(Bun.file(dir + u.pathname));
+ if (u.pathname === '/upload' && req.method === 'HEAD') return new Response(null, { status: 401 });
+ if (u.pathname === '/upload' && req.method === 'PUT') {
+ const body = new Uint8Array(await req.arrayBuffer());
+ const x = createHash('sha256').update(body).digest('hex');
+ blobs.set(x, body); types.set(x, req.headers.get('content-type'));
+ return Response.json({ url: `${u.origin}/${x}`, sha256: x, size: body.length });
+ }
+ const m = /^\/([0-9a-f]{64})(\.\w+)?$/.exec(u.pathname);
+ if (m && blobs.has(m[1])) {
+ const buf = blobs.get(m[1]), r = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const headers = { 'content-type': types.get(m[1]), 'accept-ranges': 'bytes' };
+ if (r) { const a = +r[1], e = r[2] ? Math.min(+r[2], buf.length - 1) : buf.length - 1; return new Response(buf.subarray(a, e + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${a}-${e}/${buf.length}` } }); }
+ return new Response(buf, { headers });
+ }
+ return new Response('<!doctype html><video muted></video>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const run = (name) => page.evaluate(async (name) => {
+ const vc = await import('/video-compress.js'), mu = await import('/media-upload.js');
+ const blob = await (await fetch('/' + name)).blob();
+ const file = new File([blob], name, { type: 'video/mp4' });
+ const t0 = performance.now();
+ const stream = await vc.streamVideo(file);
+ if (!stream) return { stream: null };
+ const made = Math.round(performance.now() - t0);
+ let progress = 0;
+ const url = await mu.uploadStream(stream, async (ev) => ({ ...ev, id: 'x', pubkey: 'y', sig: 'z' }), { servers: [location.origin], onProgress: (p) => { progress = p; } });
+ return { url, made, progress, files: [...stream.files.keys()], dim: stream.dim, seconds: stream.seconds };
+ }, name);
+ const short = await run('short.mp4');
+ check('a 20 s clip is not made a stream', short.stream === null);
+ const long = await run('long.mp4');
+ check('a 95 s clip is: a master playlist, one per rendition and the sound, a file each', long.url && long.files.length >= 7, (long.files || []).join(' ') + ' in ' + long.made + ' ms');
+ check('...its address is the master playlist on the host', /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(long.url || ''), long.url);
+ check('...the upload reported its progress to the end', long.progress === 1, String(long.progress));
+ check('...said as 1280x720, 95 s', long.dim === '1280x720' && Math.round(long.seconds) === 95, long.dim + ', ' + long.seconds);
+ const master = await (await fetch(long.url)).text();
+ const lists = master.split('\n').flatMap((l) => [...l.matchAll(/URI="([^"]+)"/g)].map((m) => m[1]).concat(l && !l.startsWith('#') ? [l] : []));
+ check('the master playlist names its playlists by their addresses on the host', lists.length >= 3 && lists.every((u) => /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(u)), lists.join(' '));
+ check('...720p and 360p', /RESOLUTION=1280x720/.test(master) && /RESOLUTION=640x360/.test(master));
+ const media = await (await fetch(lists.at(-1))).text();
+ check('each playlist names its file by its address too', media.split('\n').filter((l) => l && !l.startsWith('#')).every((u) => /\/[0-9a-f]{64}\.mp4$/.test(u)) && /URI="http[^"]+\.mp4"/.test(media));
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 2 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, dur: Math.round(v.duration), err: v.error && v.error.message };
+ v.currentTime = 70;
+ const t1 = performance.now();
+ while (v.currentTime < 71 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, long.url);
+ check('Chrome plays the stream from its address', played.first.t >= 2 && played.first.w > 0 && played.first.dur === 95, JSON.stringify(played.first));
+ check('...and seeks in it', played.after >= 71, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ a long video goes up as an HLS stream' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js
new file mode 100644
index 0000000..5bf6809
--- /dev/null
+++ b/tools/video-hls-play-test.js
@@ -0,0 +1,123 @@
+// A long clip in the feed plays as HLS from vmedia (a rendition the
+// connection carries, in pieces made as watched), a short one as it is,
+// and a post linking an .m3u8 stream plays that. vmedia runs here for real,
+// on a local copy of the clip.
+// Run: bun tools/video-hls-play-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { readFileSync, rmSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const DIR = process.env.CLIP_DIR || '/tmp';
+const ff = (out, codec) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=128x128:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3',
+ '-t', '3', ...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', out]);
+ if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); }
+ return readFileSync(out);
+};
+const SHORT = ff(DIR + '/coinos-hls-short.mp4', ['-c:v', 'libx264']);
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '100', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-long.mp4']);
+const LONG = readFileSync(DIR + '/coinos-hls-long.mp4');
+// vmedia itself, reading the clip from here
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(LONG) });
+const LOCAL_SRC = `http://127.0.0.1:${files.port}/long.mp4`;
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = DIR + '/coinos-hls-vmedia';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64', '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8' };
+const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300)];
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = []; // [kind, clip url]
+// vmedia's answers, from the real thing; the clip's host serves the files
+const vmedia = async (u) => {
+ if (u.pathname === '/vmedia/hls.m3u8' && u.searchParams.get('u') === CLIP.long) u.searchParams.set('u', LOCAL_SRC);
+ const res = await vm.handle(new Request('http://127.0.0.1' + u.pathname + u.search));
+ return { status: res.status, contentType: res.headers.get('content-type') || 'text/plain', body: Buffer.from(await res.arrayBuffer()), headers: { 'access-control-allow-origin': '*' } };
+};
+page.on('request', async (r) => {
+ const u = new URL(r.url());
+ if (u.pathname.startsWith('/vmedia/hls')) {
+ asked.push(['hls', u.pathname]);
+ return r.respond(await vmedia(u));
+ }
+ if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
+ if (u.host === 'media.test') {
+ asked.push([r.resourceType(), r.url()]);
+ // the stream a post links: vmedia's of the long clip, served from its host
+ if (u.pathname === '/show/master.m3u8') { const x = new URL('https://v3.coinos.io/vmedia/hls.m3u8'); x.searchParams.set('u', CLIP.long); return r.respond(await vmedia(x)); }
+ return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : SHORT, headers: { 'access-control-allow-origin': '*' } });
+ }
+ r.continue();
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const box = (word) => `[...document.querySelectorAll('.note-video-box')].find((b) => b.closest('[data-key]') && b.closest('[data-key]').textContent.includes('${word}'))`;
+const player = (word) => page.evaluate(new Function(`const b = ${box(word)}; const v = b && b.querySelector('video');
+ return v && { src: v.getAttribute('src') || '', poster: v.getAttribute('poster') || '', w: v.videoWidth, t: v.currentTime, nopic: !!b.querySelector('.vid-nopic'), note: (b.querySelector('.vid-nopic') || {}).textContent || '' };`));
+const show = (word) => page.evaluate(new Function(`const b = ${box(word)}; b.scrollIntoView({ block: 'center' }); const c = b.querySelector('.vid-heavy'); if (c) c.click(); return !!b;`));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the three clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 3, null, 20000),
+ String(await page.evaluate(() => document.querySelectorAll('.note-video-box video').length)));
+
+ console.log('\n[a long clip]');
+ await show('lecture');
+ check('plays as HLS from vmedia', await waitFor(new Function(`const v = ${box('lecture')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('lecture')));
+ check('...in its pieces', asked.some(([k, p]) => k === 'hls' && /\.ts$/.test(p)), JSON.stringify(asked.filter(([k]) => k === 'hls').slice(0, 4)));
+
+ console.log('\n[a short clip]');
+ await show('quick');
+ check('plays the original', await waitFor(new Function(`const v = ${box('quick')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.short}' && v.videoWidth > 0;`), null, 15000),
+ JSON.stringify(await player('quick')));
+
+ console.log('\n[a post linking a stream]');
+ await show('episode');
+ check('the .m3u8 plays in the feed', await waitFor(new Function(`const v = ${box('episode')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.stream}' && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('episode')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); files.stop(true); rmSync(DIR + '/coinos-hls-vmedia', { recursive: true, force: true }); }
+console.log(ok ? '\n✅ long clips and streams play as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/vmedia-hls-test.js b/tools/vmedia-hls-test.js
new file mode 100644
index 0000000..05961a8
--- /dev/null
+++ b/tools/vmedia-hls-test.js
@@ -0,0 +1,84 @@
+// vmedia's HLS: a clip's playlists come from its length before anything is
+// made, segments are made from wherever the player asks (a seek starts the
+// encoder there), the encoder waits once well ahead of the player, and
+// Chrome plays the stream and seeks in it.
+// Run: bun tools/vmedia-hls-test.js
+import puppeteer from './puppeteer.js';
+import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/vmedia-hls-');
+const clip = dir + '/clip.mp4';
+// a minute of 1080p with sound
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '90', '-c:a', 'aac', '-movflags', '+faststart', clip]);
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(Bun.file(clip)) });
+const SRC = `http://127.0.0.1:${files.port}/clip.mp4`;
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = dir + '/data';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const api = Bun.serve({ port: 0, hostname: '127.0.0.1', idleTimeout: 0, fetch: vm.handle });
+const V = `http://127.0.0.1:${api.port}/vmedia`;
+const page0 = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => {
+ const u = new URL(req.url);
+ if (u.pathname.startsWith('/vmedia')) return fetch(V.replace('/vmedia', '') + u.pathname + u.search, { headers: req.headers });
+ return new Response('<!doctype html><video muted playsinline></video>', { headers: { 'content-type': 'text/html' } });
+} });
+let browser;
+try {
+ console.log('[the playlists]');
+ const master = await (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent(SRC)}`)).text();
+ check('a master playlist with a 360p and a 720p rendition', /RESOLUTION=640x360/.test(master) && /RESOLUTION=1280x720/.test(master), master.replace(/\n/g, ' | ').slice(0, 300));
+ const key = /\/vmedia\/hls\/([0-9a-f]{64})\/360\.m3u8/.exec(master)?.[1];
+ const media = await (await fetch(`${V}/hls/${key}/720.m3u8`)).text();
+ const segs = media.split('\n').filter((l) => /\.ts$/.test(l));
+ check('the 720p playlist lists the whole minute in 4 s segments, made or not', segs.length === 15 && /#EXT-X-ENDLIST/.test(media), segs.length + ' segments');
+ check('...and nothing has been encoded yet', !readdirSync(dir + '/data/hls/' + key).some((f) => f === '720'));
+ check('a source that is not public https is refused', (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent('http://10.0.0.1/x.mp4')}`)).status === 400);
+
+ console.log('\n[segments, made as asked]');
+ let t0 = Date.now();
+ const s0 = await fetch(`${V}/hls/${key}/720/00000.ts`);
+ const b0 = await s0.arrayBuffer();
+ check('the first segment comes back as MPEG-TS', s0.status === 200 && new Uint8Array(b0)[0] === 0x47, s0.status + ', ' + b0.byteLength + ' bytes in ' + (Date.now() - t0) + ' ms');
+ t0 = Date.now();
+ const s10 = await fetch(`${V}/hls/${key}/720/00012.ts`);
+ await s10.arrayBuffer();
+ check('a segment far ahead (a seek) is made from there, not after everything before it', s10.status === 200 && Date.now() - t0 < 8000, (Date.now() - t0) + ' ms');
+ const probe = (n) => JSON.parse(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_entries', 'format=start_time,duration', '-of', 'json', `${dir}/data/hls/${key}/720/${String(n).padStart(5, '0')}.ts`]).stdout.toString()).format;
+ const p0 = probe(0), p12 = probe(12);
+ check('...its times are where the clip has them (48 s on from the first)', Math.abs((+p12.start_time - +p0.start_time) - 48) < 0.2, p0.start_time + ' → ' + p12.start_time);
+ await sleep(4000);
+ const made = readdirSync(`${dir}/data/hls/${key}/720`).filter((f) => /^\d{5}\.ts$/.test(f));
+ check('the encoder runs on from the seek to the end', made.includes('00014.ts'), made.join(' '));
+
+ console.log('\n[Chrome plays it]');
+ browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+ const page = await browser.newPage();
+ await page.goto(page0.url.href);
+ const hlsSrc = `${page0.url.origin}/vmedia/hls.m3u8?u=${encodeURIComponent(SRC)}`;
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 1.5 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, err: v.error && v.error.message, dur: v.duration };
+ v.currentTime = 30;
+ const t1 = performance.now();
+ while (v.currentTime < 31.5 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, hlsSrc);
+ check('it plays from the start', played.first.t >= 1.5 && played.first.w > 0, JSON.stringify(played.first));
+ check('...knows the whole length', Math.abs(played.first.dur - 60) < 1, String(played.first.dur));
+ check('...and plays on after a seek to the middle', played.after >= 31.5, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ if (browser) await browser.close();
+ files.stop(true); api.stop(true); page0.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ vmedia serves long clips as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);