|
| 1 | +// Video playback diagnostic — written for the "videos do not play on macOS" |
| 2 | +// reports (2026-07-16). Connects to a running dk instance over CDP and: |
| 3 | +// A. reports the renderer's codec support matrix (canPlayType) — tells a |
| 4 | +// missing-ffmpeg / proprietary-codec problem apart from a render one, |
| 5 | +// B. plays a known-good archive.org h.264 mp4 in a synthetic muted <video> |
| 6 | +// and asserts currentTime advances AND videoWidth > 0 (frames decoded), |
| 7 | +// C. (DRIVE_MOVIES=1) drives the real Movies room: clicks a film row and |
| 8 | +// measures the app's own .ia-video element the same way. |
| 9 | +// Platform-neutral: run on Linux for a baseline, on the mac-smoke runner for |
| 10 | +// the real answer. Exit 0 = video pipeline works, 1 = something failed. |
| 11 | +// |
| 12 | +// Usage: node tools/video-playback-probe.mjs |
| 13 | +// env CDP_PORT=9222 (default) DRIVE_MOVIES=1 (optional section C) |
| 14 | +// The app must be running with --remote-debugging-port=$CDP_PORT. |
| 15 | +// Run standalone against any live instance, or via packaged-smoke.mjs |
| 16 | +// SMOKE_VIDEO=1 (how the mac-smoke workflow runs it). Lives in tools/ |
| 17 | +// because claude/ is gitignored and CI needs this file in the checkout. |
| 18 | +// Needs node ≥22 (global WebSocket/fetch) — locally use |
| 19 | +// `ELECTRON_RUN_AS_NODE=1 npx electron tools/video-playback-probe.mjs`. |
| 20 | + |
| 21 | +const PORT = process.env.CDP_PORT || 9222; |
| 22 | +const DRIVE = process.env.DRIVE_MOVIES === '1'; |
| 23 | +// h.264 + AAC, public item, streams via range requests (we need ~3s of it). |
| 24 | +const TEST_MP4 = 'https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4'; |
| 25 | + |
| 26 | +const targets = await (await fetch(`http://localhost:${PORT}/json`)).json(); |
| 27 | +const page = targets.find(t => t.type === 'page' && /index\.html/.test(t.url)) || targets.find(t => t.type === 'page'); |
| 28 | +if (!page) { console.error('no page target on CDP port', PORT); process.exit(1); } |
| 29 | +const ws = new WebSocket(page.webSocketDebuggerUrl); |
| 30 | +let id = 0; const pending = new Map(); |
| 31 | +const send = (method, params = {}) => new Promise((res, rej) => { |
| 32 | + const i = ++id; pending.set(i, { res, rej }); |
| 33 | + ws.send(JSON.stringify({ id: i, method, params })); |
| 34 | +}); |
| 35 | +ws.onmessage = m => { const d = JSON.parse(m.data); |
| 36 | + if (d.id && pending.has(d.id)) { const { res, rej } = pending.get(d.id); pending.delete(d.id); |
| 37 | + d.error ? rej(new Error(d.error.message)) : res(d.result); } }; |
| 38 | +await new Promise(r => ws.onopen = r); |
| 39 | +await send('Runtime.enable'); |
| 40 | +const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 41 | +async function evalJS(expr) { |
| 42 | + const r = await send('Runtime.evaluate', { expression: `(async()=>{${expr}})()`, awaitPromise: true, returnByValue: true }); |
| 43 | + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); |
| 44 | + return r.result?.value; |
| 45 | +} |
| 46 | +let fails = 0; |
| 47 | +const check = (label, ok, detail = '') => { console.log(`${ok ? '✔' : '✘'} ${label}${detail ? ' — ' + detail : ''}`); if (!ok) fails++; }; |
| 48 | + |
| 49 | +const ver = await send('Browser.getVersion').catch(() => null); |
| 50 | +console.log('BROWSER:', ver ? `${ver.product} on ${ver.userAgent.match(/\((.*?)\)/)?.[1]}` : 'unknown'); |
| 51 | + |
| 52 | +// ---- A. codec support matrix ------------------------------------------- |
| 53 | +const codecs = await evalJS(` |
| 54 | + const v = document.createElement('video'); |
| 55 | + const t = s => v.canPlayType(s) || '(no)'; |
| 56 | + return { |
| 57 | + 'mp4 h.264+aac': t('video/mp4; codecs="avc1.42E01E, mp4a.40.2"'), |
| 58 | + 'mp4 h.264 high': t('video/mp4; codecs="avc1.64001F"'), |
| 59 | + 'mp4 mpeg4-part2': t('video/mp4; codecs="mp4v.20.240"'), |
| 60 | + 'ogg theora': t('video/ogg; codecs="theora"'), |
| 61 | + 'webm vp8': t('video/webm; codecs="vp8, vorbis"'), |
| 62 | + 'webm vp9': t('video/webm; codecs="vp9"'), |
| 63 | + 'audio mp3': t('audio/mpeg'), |
| 64 | + 'audio aac': t('audio/mp4; codecs="mp4a.40.2"'), |
| 65 | + }; |
| 66 | +`); |
| 67 | +console.log('CODECS:', JSON.stringify(codecs, null, 1)); |
| 68 | +check('h.264 mp4 decodable (proprietary codecs present)', /probably|maybe/.test(codecs['mp4 h.264+aac']), codecs['mp4 h.264+aac']); |
| 69 | +check('mp3 decodable', /probably|maybe/.test(codecs['audio mp3']), codecs['audio mp3']); |
| 70 | + |
| 71 | +// ---- B. synthetic playback of a known-good h.264 mp4 -------------------- |
| 72 | +// archive.org's /download/ endpoint 500s transiently — retry once before |
| 73 | +// calling the pipeline broken. |
| 74 | +console.log('SYNTHETIC PLAY:', TEST_MP4); |
| 75 | +const playOnce = () => evalJS(` |
| 76 | + const v = document.createElement('video'); |
| 77 | + v.muted = true; v.playsInline = true; v.preload = 'auto'; |
| 78 | + v.style.cssText = 'position:fixed;left:0;top:0;width:320px;height:180px;opacity:0.01;pointer-events:none;z-index:-1'; |
| 79 | + v.src = ${JSON.stringify(TEST_MP4)}; |
| 80 | + document.body.appendChild(v); |
| 81 | + const errInfo = () => v.error ? { code: v.error.code, message: v.error.message } : null; |
| 82 | + try { |
| 83 | + // wait for enough data or an error (network on CI can be slow) |
| 84 | + await new Promise((res, rej) => { |
| 85 | + const to = setTimeout(() => rej(new Error('timeout waiting for canplay (45s)')), 45000); |
| 86 | + v.addEventListener('canplay', () => { clearTimeout(to); res(); }, { once: true }); |
| 87 | + v.addEventListener('error', () => { clearTimeout(to); rej(new Error('media error')); }, { once: true }); |
| 88 | + }); |
| 89 | + await v.play(); |
| 90 | + await new Promise(r => setTimeout(r, 3000)); |
| 91 | + const t1 = v.currentTime; |
| 92 | + await new Promise(r => setTimeout(r, 2000)); |
| 93 | + const t2 = v.currentTime; |
| 94 | + return { ok: true, t1, t2, advanced: t2 > t1, videoWidth: v.videoWidth, videoHeight: v.videoHeight, |
| 95 | + readyState: v.readyState, decodedFrames: (v.getVideoPlaybackQuality?.() || {}).totalVideoFrames ?? null, |
| 96 | + droppedFrames: (v.getVideoPlaybackQuality?.() || {}).droppedVideoFrames ?? null, error: errInfo() }; |
| 97 | + } catch (e) { |
| 98 | + return { ok: false, why: String(e && e.message || e), readyState: v.readyState, |
| 99 | + networkState: v.networkState, error: errInfo() }; |
| 100 | + } finally { v.pause(); v.removeAttribute('src'); v.load(); v.remove(); } |
| 101 | +`); |
| 102 | +let play = await playOnce(); |
| 103 | +if (!play.ok) { |
| 104 | + console.log(' first attempt failed, retrying in 5s…', JSON.stringify(play)); |
| 105 | + await sleep(5000); |
| 106 | + play = await playOnce(); |
| 107 | +} |
| 108 | +console.log('RESULT:', JSON.stringify(play, null, 1)); |
| 109 | +check('media loaded + play() resolved', play.ok === true, play.ok ? '' : `${play.why} mediaError=${JSON.stringify(play.error)} networkState=${play.networkState}`); |
| 110 | +if (play.ok) { |
| 111 | + check('currentTime advances', play.advanced, `t1=${play.t1?.toFixed(2)} t2=${play.t2?.toFixed(2)}`); |
| 112 | + check('frames decoded (videoWidth > 0)', play.videoWidth > 0, `${play.videoWidth}x${play.videoHeight}`); |
| 113 | + check('decoder produced frames', play.decodedFrames === null || play.decodedFrames > 0, `decoded=${play.decodedFrames} dropped=${play.droppedFrames}`); |
| 114 | +} |
| 115 | + |
| 116 | +// ---- C. optional: drive the real Movies room ---------------------------- |
| 117 | +if (DRIVE) { |
| 118 | + console.log('DRIVING the Movies room…'); |
| 119 | + const nav = await evalJS(` |
| 120 | + function deepQueryAll(sel, root = document) { |
| 121 | + const hits = [...root.querySelectorAll(sel)]; |
| 122 | + for (const el of root.querySelectorAll('*')) if (el.shadowRoot) hits.push(...deepQueryAll(sel, el.shadowRoot)); |
| 123 | + return hits; |
| 124 | + } |
| 125 | + // desktop tab strip lives in sol-tabs' shadow root |
| 126 | + const tabs = deepQueryAll('[role="tab"], .sol-tab, button'); |
| 127 | + const movies = tabs.find(t => /movies/i.test(t.textContent || '')); |
| 128 | + if (!movies) return 'no-movies-tab'; |
| 129 | + movies.click(); |
| 130 | + return 'clicked:' + movies.textContent.trim().slice(0, 30); |
| 131 | + `); |
| 132 | + console.log(' nav:', nav); |
| 133 | + // lazy ui:module mount + cold-cache library load — poll up to 90s for a |
| 134 | + // visible room, re-clicking the Movies trigger every 20s (a click that |
| 135 | + // landed before the menu tree finished wiring selects nothing). |
| 136 | + let mounted = false; |
| 137 | + const t0 = Date.now(); |
| 138 | + for (let i = 0; i < 45 && !mounted; i++) { |
| 139 | + await sleep(2000); |
| 140 | + const st = await evalJS(` |
| 141 | + function deepQueryAll(sel, root = document) { |
| 142 | + const hits = [...root.querySelectorAll(sel)]; |
| 143 | + for (const el of root.querySelectorAll('*')) if (el.shadowRoot) hits.push(...deepQueryAll(sel, el.shadowRoot)); |
| 144 | + return hits; |
| 145 | + } |
| 146 | + const apps = deepQueryAll('.ia-player-app.media-video'); |
| 147 | + return { visible: apps.some(a => a.offsetParent !== null), present: apps.length }; |
| 148 | + `); |
| 149 | + mounted = st.visible; |
| 150 | + if (!mounted && i > 0 && i % 10 === 0) { |
| 151 | + const re = await evalJS(` |
| 152 | + function deepQueryAll(sel, root = document) { |
| 153 | + const hits = [...root.querySelectorAll(sel)]; |
| 154 | + for (const el of root.querySelectorAll('*')) if (el.shadowRoot) hits.push(...deepQueryAll(sel, el.shadowRoot)); |
| 155 | + return hits; |
| 156 | + } |
| 157 | + const tabs = deepQueryAll('[role="tab"], .sol-tab, button'); |
| 158 | + const movies = tabs.find(t => /movies/i.test(t.textContent || '')); |
| 159 | + if (movies) movies.click(); |
| 160 | + return movies ? 're-clicked' : 'no-trigger'; |
| 161 | + `); |
| 162 | + console.log(` …not mounted after ${Math.round((Date.now() - t0) / 1000)}s (present=${st.present}) — ${re}`); |
| 163 | + } |
| 164 | + } |
| 165 | + check('movies room mounted', mounted, `after ${Math.round((Date.now() - t0) / 1000)}s`); |
| 166 | + if (mounted) { |
| 167 | + // Drive the browse cascade: (All film types) → first collection → first |
| 168 | + // film. In movies each film (album row) plays on click — loaded paused |
| 169 | + // behind the film-intro overlay; we then call play() directly. |
| 170 | + const room = await evalJS(` |
| 171 | + function deepQueryAll(sel, root = document) { |
| 172 | + const hits = [...root.querySelectorAll(sel)]; |
| 173 | + for (const el of root.querySelectorAll('*')) if (el.shadowRoot) hits.push(...deepQueryAll(sel, el.shadowRoot)); |
| 174 | + return hits; |
| 175 | + } |
| 176 | + const app = deepQueryAll('.ia-player-app.media-video').find(a => a.offsetParent !== null); |
| 177 | + const rowsIn = col => [...app.querySelectorAll('[data-column="' + col + '"] .ia-listbox-item')] |
| 178 | + .filter(li => !li.classList.contains('ia-listbox-all') && li.offsetParent !== null); |
| 179 | + const firstArtist = rowsIn('artist')[0]; |
| 180 | + if (firstArtist) { firstArtist.click(); await new Promise(r => setTimeout(r, 8000)); } |
| 181 | + const films = rowsIn('album'); |
| 182 | + if (!films.length) return { films: 0, artistClicked: firstArtist?.textContent.trim().slice(0, 50) || null }; |
| 183 | + films[0].click(); |
| 184 | + // wait for the app to fetch item metadata and set the video src |
| 185 | + const v = app.querySelector('.ia-video'); |
| 186 | + for (let i = 0; i < 20 && !(v && v.src); i++) await new Promise(r => setTimeout(r, 1000)); |
| 187 | + if (!v || !v.src) return { films: films.length, clicked: films[0].textContent.trim().slice(0, 60), loaded: false }; |
| 188 | + v.muted = true; |
| 189 | + try { await v.play(); } catch (e) { return { loaded: true, src: v.src.slice(0, 110), playRejected: String(e) }; } |
| 190 | + await new Promise(r => setTimeout(r, 5000)); |
| 191 | + return { films: films.length, clicked: films[0].textContent.trim().slice(0, 60), loaded: true, |
| 192 | + src: v.src.slice(0, 110), t: v.currentTime, videoWidth: v.videoWidth, readyState: v.readyState, |
| 193 | + error: v.error ? { code: v.error.code, message: v.error.message } : null }; |
| 194 | + `); |
| 195 | + console.log(' room:', JSON.stringify(room, null, 1)); |
| 196 | + if (room.loaded) { |
| 197 | + check('film plays (currentTime > 0)', room.t > 0 && !room.error && !room.playRejected, |
| 198 | + `t=${room.t?.toFixed?.(2)} err=${JSON.stringify(room.error)} rejected=${room.playRejected || 'no'}`); |
| 199 | + check('film frames decoded', room.videoWidth > 0, `videoWidth=${room.videoWidth}`); |
| 200 | + } else { |
| 201 | + check('film loaded a src', false, JSON.stringify(room)); |
| 202 | + } |
| 203 | + } |
| 204 | +} |
| 205 | + |
| 206 | +console.log(fails ? `FAILED: ${fails} check(s)` : 'ALL CHECKS PASSED'); |
| 207 | +ws.close(); |
| 208 | +process.exit(fails ? 1 : 0); |
0 commit comments