From 1296d8acd441ab109c64675d8f1b3dcd33991343 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Tue, 28 Jul 2026 22:54:49 +0200 Subject: [PATCH 1/2] Fix remote/HLS transcription: load the player with the new media and reference it correctly on export (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transcribing a remote/HLS URL left the PREVIOUS media in the player, and the interactive-transcript export then linked that stale source. Two root causes: 1. hls.js never loaded on servers that serve .mjs with an empty/incorrect MIME type. Browsers reject module scripts unless served as JavaScript, so import('hls.js') threw, the attach silently caught it, and the HLS player stayed blank (transcription still worked — it uses a separate byte-range path). Rename js/vendor/hls-1.6.16.mjs -> .js (content unchanged): .js is served as JavaScript everywhere and import() still parses it as a module. Update the importmap and service-worker precache to match, and add a unit guard that no vendored module is served as .mjs. 2. attachMediaPlayback tore down a prior hls.js instance but not a plain-URL src left by earlier media; hls.js won't reliably override an existing src, so the old media remained. Clear the source (removeAttribute('src') + load()) before (re)attaching. Also surface fatal hls.js errors instead of swallowing them. For the export dialog, record the real media reference on #hyperplayer.dataset. mediaRef (the filename for a local file via the central capture, the original URL for remote/HLS via attachMediaPlayback — HLS turns player.src into an opaque blob: MediaSource URL). guessMediaSrc now prefers an http(s) src (so a fresh remote URL wins) and otherwise uses the stamped ref; the dialog refreshes the field on every open; storage clears the ref on Recents load. Tests: e2e for local filename, plain remote URL replacing a stale source, HLS (blob src -> original URL in the export), fresh-URL-beats-stale-ref, and refresh-on-open; a unit guard against .mjs vendored modules. Cache-bust the three changed scripts to 0.8.10. --- .../e2e/interactive-export-filename.spec.mjs | 65 ++++++++++++++----- __TEST__/unit/hls-source.test.mjs | 19 ++++++ index.html | 6 +- js/editor-core.js | 36 +++++----- js/hls-source.js | 17 +++++ js/hyperaudio-lite-editor-storage.js | 6 ++ js/vendor/{hls-1.6.16.mjs => hls-1.6.16.js} | 0 serviceworker.js | 2 +- 8 files changed, 116 insertions(+), 35 deletions(-) rename js/vendor/{hls-1.6.16.mjs => hls-1.6.16.js} (100%) diff --git a/__TEST__/e2e/interactive-export-filename.spec.mjs b/__TEST__/e2e/interactive-export-filename.spec.mjs index ef30453..0990d19 100644 --- a/__TEST__/e2e/interactive-export-filename.spec.mjs +++ b/__TEST__/e2e/interactive-export-filename.spec.mjs @@ -1,13 +1,15 @@ -// Interactive-transcript export: pre-fill the media reference from the real -// filename of a locally-loaded file, and clear a stale value when new media is -// loaded. A local file is a blob: URL with no usable path, so previously the -// export modal's "Media file or URL" field started empty and whatever the user -// last typed (famously "test") could linger and be exported into src="…". +// Interactive-transcript export: the "Media file or URL" field must reference the +// CURRENT media — a local file's real name, a plain remote URL, or an HLS source's +// original URL — and never a value left over from a previous clip. +// +// A local upload is a blob: URL and an HLS source is a blob: MediaSource URL, so +// neither carries a usable reference in player.src; those are stamped on +// #hyperplayer.dataset.mediaRef (the filename by the central file-input capture, +// the URL by attachMediaPlayback). guessMediaSrc prefers an http(s) src (so a fresh +// remote URL always wins) and otherwise uses the stamped ref. The dialog refreshes +// the field to the current media every time it opens. import { test, expect } from '@playwright/test'; -// Simulate a local media load: a blob: player src (so the URL branch of -// guessMediaSrc doesn't win) plus a media file selected on a throwaway input, -// which the central capture listener reads for its name. const loadLocalMedia = async (page, name, mimeType) => { await page.evaluate(() => { document.getElementById('hyperplayer').src = 'blob:http://localhost/fake-local-media'; @@ -41,23 +43,54 @@ test('pre-fills the export modal with a locally-loaded media filename', async ({ expect(await openExportModalValue(page)).toBe('my-clip.mp4'); }); -test('loading new media clears a stale filename so it cannot be exported by mistake', async ({ page }) => { +test('the field refreshes to the current media on open, dropping a stale/typed value', async ({ page }) => { await loadLocalMedia(page, 'first-take.mp4', 'video/mp4'); // user types something bogus into the field (the "test" footgun) await page.evaluate(() => { document.getElementById('interactive-media-filename').value = 'test'; }); - // loading a different clip must drop that stale value… + // loading a different clip then re-opening must show the NEW clip's name, not "test" await loadLocalMedia(page, 'second-take.webm', 'video/webm'); - expect(await page.evaluate(() => document.getElementById('interactive-media-filename').value)).toBe(''); - // …and (re)opening offers the new clip's real name, not "test" expect(await openExportModalValue(page)).toBe('second-take.webm'); }); -test('non-media (import) file inputs do not overwrite the export filename', async ({ page }) => { +test('non-media (import) file inputs do not change the media reference', async ({ page }) => { await loadLocalMedia(page, 'interview.mp3', 'audio/mpeg'); - await page.evaluate(() => { document.getElementById('interactive-media-filename').value = 'interview.mp3'; }); - // selecting a JSON/SRT/VTT import file must NOT clear or change the media name + // selecting a JSON/SRT/VTT import file must NOT overwrite the media reference await page.setInputFiles('#__test_media_input', { name: 'transcript.json', mimeType: 'application/json', buffer: Buffer.from('{}'), }); - expect(await page.evaluate(() => document.getElementById('interactive-media-filename').value)).toBe('interview.mp3'); + expect(await openExportModalValue(page)).toBe('interview.mp3'); +}); + +test('a plain remote URL is linked directly and replaces a stale previous source', async ({ page }) => { + await page.evaluate(async () => { + const v = document.getElementById('hyperplayer'); + v.src = 'https://lab.hyperaud.io/audio/HLE_Intro_3.mp3'; // previous media + await window.attachMediaPlayback(v, 'https://example.com/new/clip.mp4', false); + }); + // the player src is the new URL, and the export references it + expect(await page.evaluate(() => document.getElementById('hyperplayer').src)) + .toBe('https://example.com/new/clip.mp4'); + expect(await openExportModalValue(page)).toBe('https://example.com/new/clip.mp4'); +}); + +test('an HLS source: player src becomes a blob but the export references the original URL', async ({ page }) => { + const HLS = 'https://stream.place/xrpc/place.stream.playback.getVideoPlaylist?uri=at%3A%2F%2Fexample'; + await page.evaluate(async (hls) => { + const v = document.getElementById('hyperplayer'); + v.src = 'https://lab.hyperaud.io/audio/HLE_Intro_3.mp3'; // stale previous media + await window.attachMediaPlayback(v, hls, true); // HLS → hls.js MediaSource + }, HLS); + // player src is now an opaque blob: (the previous mp3 is gone), and the export + // links the real HLS URL rather than the blob or the stale mp3 + expect(await page.evaluate(() => document.getElementById('hyperplayer').src.startsWith('blob:'))).toBe(true); + expect(await openExportModalValue(page)).toBe(HLS); +}); + +test('a fresh remote URL wins over a stale stamped local reference', async ({ page }) => { + await page.evaluate(() => { + const v = document.getElementById('hyperplayer'); + v.dataset.mediaRef = 'old-local.mp4'; // left over from a prior local file + v.src = 'https://example.com/fresh/remote.mp3'; // a cloud engine set a new URL + }); + expect(await openExportModalValue(page)).toBe('https://example.com/fresh/remote.mp3'); }); diff --git a/__TEST__/unit/hls-source.test.mjs b/__TEST__/unit/hls-source.test.mjs index 202f8bd..cec5e2f 100644 --- a/__TEST__/unit/hls-source.test.mjs +++ b/__TEST__/unit/hls-source.test.mjs @@ -9,9 +9,28 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { isHlsUrl, parseMediaPlaylist } = require('../../js/hls-source.js'); +const { readFileSync, readdirSync } = require('node:fs'); const BASE = 'https://example.com/vod/playlist.m3u8'; +test('vendored ES modules are served as .js, not .mjs (strict-MIME safe)', () => { + // Some static servers return an empty/incorrect MIME for .mjs, which browsers + // reject for module scripts — silently breaking dynamic import() (this is what + // stopped hls.js loading, so HLS playback showed a blank player). Keep vendored + // modules as .js so they load on any server; guard both the importmap and the + // service-worker precache, and that no .mjs lingers in js/vendor. + const root = new URL('../../', import.meta.url); + const html = readFileSync(new URL('index.html', root), 'utf8'); + const importmap = (html.match(/ - + @@ -1013,7 +1013,7 @@

Load from Local Storage

- + diff --git a/js/editor-core.js b/js/editor-core.js index 8daaa5e..ce2fdd3 100644 --- a/js/editor-core.js +++ b/js/editor-core.js @@ -98,22 +98,25 @@ const iaModal = document.getElementById('interactive-export-modal'); const iaInput = document.getElementById('interactive-media-filename'); const iaDownload = document.getElementById('interactive-export-download'); - let lastLoadedMediaName = ''; - // Remote media can be linked by its URL as-is. A blob:/data: source (local - // upload) has no usable path, so we fall back to the real filename captured - // when the file was loaded (below) — which lets the field pre-fill for local - // files instead of forcing the user to type the reference by hand. + // Best media reference to link in the exported interactive transcript, for the + // CURRENT media. A plain remote URL is usable directly from the player src; but + // a local upload (blob:) and an HLS source (blob: MediaSource) carry no usable + // URL, so those paths stamp the real reference on #hyperplayer.dataset.mediaRef + // (the filename for a local file, the original URL for remote/HLS — set by the + // local-file capture below and by attachMediaPlayback in hls-source.js). Prefer + // the http(s) src when present so a fresh remote URL always wins over a stale + // ref; otherwise use the stamped ref. const guessMediaSrc = () => { - const src = document.querySelector('#hyperplayer') ? document.querySelector('#hyperplayer').src : ''; - return /^https?:/i.test(src) ? src : lastLoadedMediaName; + const player = document.querySelector('#hyperplayer'); + const src = player ? player.src : ''; + if (/^https?:/i.test(src)) return src; + return (player && player.dataset.mediaRef) || ''; }; - // Capture the real filename of a locally-loaded media file so guessMediaSrc - // can offer it (a blob: URL carries no name). Read centrally from any media - // file input; import inputs (JSON/SRT/VTT) are skipped by the media check. - // Loading new media also clears the field, so a name typed for the PREVIOUS - // clip can't linger and be exported by mistake (the src="test" footgun). + // Record the real filename of a locally-loaded media file (a blob: URL carries + // no name). Read centrally from any media file input; import inputs (JSON/SRT/ + // VTT) are skipped by the media check. const MEDIA_EXTENSION = /\.(mp4|webm|ogv|ogg|mov|m4v|mkv|mp3|m4a|wav|aac|flac|opus)$/i; document.addEventListener('change', (e) => { const input = e.target; @@ -121,13 +124,16 @@ const file = input.files[0]; const isMedia = /^(audio|video)\//i.test(file.type || '') || MEDIA_EXTENSION.test(file.name); if (!isMedia) return; - lastLoadedMediaName = file.name; - if (iaInput !== null) iaInput.value = ''; + const player = document.querySelector('#hyperplayer'); + if (player) player.dataset.mediaRef = file.name; }, true); if (iaModal !== null && iaInput !== null) { + // Refresh the field to the current media every time the dialog opens, so a + // value left over from a previous clip (or a name typed and abandoned) can + // never be exported by mistake — the field always reflects what's loaded now. iaModal.addEventListener('change', () => { - if (iaModal.checked && iaInput.value.trim() === '') { + if (iaModal.checked) { iaInput.value = guessMediaSrc(); } }); diff --git a/js/hls-source.js b/js/hls-source.js index 2b754d6..598e6dc 100644 --- a/js/hls-source.js +++ b/js/hls-source.js @@ -136,6 +136,17 @@ function detachHls(videoEl) { async function attachMediaPlayback(videoEl, url, isHls) { detachHls(videoEl); + // Clear any previous source before (re)attaching. detachHls only tears down a + // prior hls.js instance; a plain-URL source from earlier media leaves + // videoEl.src set, and hls.js will not reliably replace an existing src — which + // left the PREVIOUS media in the player after transcribing a new remote/HLS URL. + // Also record the real, user-facing URL: the HLS path turns videoEl.src into an + // opaque blob: MediaSource URL, so the interactive-transcript export needs the + // original URL from here to reference the right media. + videoEl.removeAttribute('src'); + videoEl.load(); + videoEl.dataset.mediaRef = url; + if (isHls === undefined) { try { isHls = (await classifyMediaUrl(url)).isHls; } catch (e) { isHls = isHlsUrl(url); } @@ -150,6 +161,12 @@ async function attachMediaPlayback(videoEl, url, isHls) { if (Hls && Hls.isSupported()) { const hls = new Hls(); videoEl._hls = hls; + // Surface fatal playback errors instead of failing silently — the caller + // wraps this in a catch (transcription must not be blocked by a playback + // issue), so without this an hls.js error left the player mysteriously blank. + hls.on(Hls.Events.ERROR, (evt, data) => { + if (data && data.fatal) console.warn('HLS playback error:', data.type, data.details); + }); hls.loadSource(url); hls.attachMedia(videoEl); } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index 60a03da..5c4a6ad 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -71,6 +71,12 @@ function renderTranscript( // file, so a stale cue from the old transcript can't stay painted (#356/#287). resetCaptionTrack(videoDomId, vttId); + // Drop any media reference stamped by a previous local/remote load so the + // interactive-transcript export can't offer it for this Recents entry (a remote + // http src is read from the player directly; a local one falls back to empty). + const loadedVideo = document.getElementById(videoDomId); + if (loadedVideo) delete loadedVideo.dataset.mediaRef; + let hypertranscriptElement = document.getElementById(hypertranscriptDomId); if (hypertranscriptElement) { diff --git a/js/vendor/hls-1.6.16.mjs b/js/vendor/hls-1.6.16.js similarity index 100% rename from js/vendor/hls-1.6.16.mjs rename to js/vendor/hls-1.6.16.js diff --git a/serviceworker.js b/serviceworker.js index 9947914..78fccdf 100644 --- a/serviceworker.js +++ b/serviceworker.js @@ -10,7 +10,7 @@ self.addEventListener("install", function (e) { "./js/vendor/mediabunny-mp3-encoder-1.50.3.min.js", "./js/vendor/soundtouchjs-0.3.0.js", // Vendored HLS / remote-URL dependencies (#412) — likewise. - "./js/vendor/hls-1.6.16.mjs", + "./js/vendor/hls-1.6.16.js", "./js/vendor/mp4box-0.5.2.all.min.js", ]); }) From decc70a4c25a187a4787555b47a8452c60d6b393 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Tue, 28 Jul 2026 23:28:49 +0200 Subject: [PATCH 2/2] hls-source: distinguish a CORS-blocked host from an unreachable one, with a clearer message (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cross-origin media URL that plays fine in the