import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { chromium, firefox, webkit } from '@playwright/test' import { startDevServer } from '../../scripts/library/server.ts' assert.equal(process.version.slice(1), fs.readFileSync('.node-version', 'utf8').trim()) assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Use yarn probe:auto-thumbnail-native') assert.equal(process.argv.length, 2, 'No probe arguments are supported') const fingerprint = data => createHash('sha256').update(data).digest('hex') const original = 'test/browser/media/auto-thumbnail-timeline.mp4' const originalBytes = fs.readFileSync(original) assert.equal(fingerprint(originalBytes), JSON.parse(fs.readFileSync('refactor/baselines/auto-thumbnail-timeline-media.json')).file.sha256) const directory = fs.mkdtempSync('refactor/.cache/auto-thumbnail-native-') fs.writeFileSync(path.join(directory, 'index.html'), 'Native decoder diagnostic') fs.copyFileSync(original, path.join(directory, 'original.mp4')) const ffmpeg = process.env.FFMPEG || 'ffmpeg' const ffprobe = process.env.FFPROBE || 'ffprobe' const args = ['-hide_banner', '-loglevel', 'error', '-n', '-i', original, '-map', '0:v:0', '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-bf', '0', '-g', '30', '-crf', '18', '-movflags', '+faststart', path.join(directory, 'no-b-frames.mp4')] execFileSync(ffmpeg, args, { windowsHide: true }) const ffmpegVersion = execFileSync(ffmpeg, ['-version'], { encoding: 'utf8', windowsHide: true }).split('\n')[0].trim() const media = ['original.mp4', 'no-b-frames.mp4'].map((name) => { const file = path.join(directory, name) const metadata = JSON.parse(execFileSync(ffprobe, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_name,profile,time_base,start_time,duration,has_b_frames', '-of', 'json', file], { encoding: 'utf8', windowsHide: true })) const raw = execFileSync(ffmpeg, ['-v', 'error', '-i', file, '-frames:v', '1', '-vf', 'scale=1:1', '-pix_fmt', 'rgba', '-f', 'rawvideo', 'pipe:1'], { windowsHide: true }) assert.equal(raw.length, 4) assert(raw[0] > 100 && raw[2] > 100 && raw[1] < 40, 'Both files must actually decode a purple first frame') return { file, sha256: fingerprint(fs.readFileSync(file)), metadata, ffmpegFirstPixel: [...raw] } }) const errors = [] const server = await startDevServer({ root: directory, port: 0, onError: error => errors.push(String(error)) }) const results = [] console.log(`Native decoder diagnostics: ${directory}`) try { for (const [engineName, engine] of Object.entries({ webkit, chromium, firefox })) { const browser = await engine.launch() try { for (const file of ['original.mp4', 'no-b-frames.mp4']) { for (const mode of ['paused', 'visible-paused', 'play-pause']) { const page = await browser.newPage() try { await page.goto(server.url) const result = await page.evaluate(async ({ file, mode }) => { const video = document.createElement('video') video.width = 320 video.height = 180 video.muted = true video.playsInline = true video.style.cssText = 'position:fixed;visibility:hidden;width:320px;height:180px' if (mode === 'visible-paused') video.style.visibility = 'visible' document.body.append(video) const canvas = document.createElement('canvas') canvas.width = 320 canvas.height = 180 const context = canvas.getContext('2d') const samples = [] const events = [] const started = performance.now() let timer const state = () => ({ elapsed: performance.now() - started, time: video.currentTime, readyState: video.readyState, paused: video.paused, seeking: video.seeking }) for (const name of ['loadedmetadata', 'loadeddata', 'canplay', 'play', 'playing', 'pause', 'seeked', 'error']) video.addEventListener(name, () => events.push({ name, ...state() })) try { const ready = new Promise((resolve, reject) => { video.onloadeddata = resolve video.onerror = () => reject(new Error(`Media error ${video.error?.code}`)) timer = setTimeout(() => reject(new Error('Native loadeddata timeout')), 8000) }) video.src = `/${file}` await ready clearTimeout(timer) if (mode === 'play-pause') { await Promise.race([ video.play(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Native play timeout')), 8000) }), ]) clearTimeout(timer) video.pause() } for (let index = 0; index < 31; index++) { context.clearRect(0, 0, 320, 180) context.drawImage(video, 0, 0) samples.push({ ...state(), rgba: [...context.getImageData(160, 90, 1, 1).data] }) await new Promise(resolve => setTimeout(resolve, 16)) } return { samples, events, mediaError: video.error?.code || 0, supportsFrames: typeof video.requestVideoFrameCallback === 'function' } } finally { clearTimeout(timer) video.pause() video.removeAttribute('src') video.load() video.remove() canvas.width = canvas.height = 0 } }, { file, mode }) const purple = result.samples.filter(({ rgba: [r, g, b, a] }) => a === 255 && Math.min(r, b) > 100 && Math.abs(r - b) < 30 && g < 40) const entry = { engine: engineName, browser: browser.version(), file, mode, purpleSamples: purple.length, ...result } results.push(entry) console.log(JSON.stringify({ engine: engineName, file, mode, purpleSamples: purple.length, first: result.samples[0], last: result.samples.at(-1) })) } finally { await page.close() } } } } finally { await browser.close() } } } finally { await server.close() assert.deepEqual(errors, []) assert.equal(fingerprint(fs.readFileSync(original)), fingerprint(originalBytes)) fs.writeFileSync(path.join(directory, 'report.json'), `${JSON.stringify({ task: 'PKG-AUTO-THUMB-03', capturedAt: new Date().toISOString(), node: process.version, platform: process.platform, ffmpegVersion, args, media, results, scope: 'Native video/canvas only; no ArtPlayer/plugin imported. No-seek hidden/visible paused and hidden play-pause controls capture31 draws separated by16ms requested timers; actual elapsed durations are recorded. The no-B-frame transcode is diagnostic only and never replaces the frozen acceptance fixture. Counts report observations, not acceptance or a production fix.' }, null, 2)}\n`) }