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..6cd14f2 100644 --- a/__TEST__/unit/hls-source.test.mjs +++ b/__TEST__/unit/hls-source.test.mjs @@ -8,10 +8,29 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const { isHlsUrl, parseMediaPlaylist } = require('../../js/hls-source.js'); +const { isHlsUrl, parseMediaPlaylist, classifyMediaUrl } = 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..72f1982 100644 --- a/js/hls-source.js +++ b/js/hls-source.js @@ -1,7 +1,7 @@ /** * hls-source.js * (C) The Hyperaudio Project - * @version 0.8.6 — last changed in release 0.8.6 + * @version 0.8.10 — last changed in release 0.8.10 * @license MIT * * Transcribe from a remote media URL — including HLS VOD (.m3u8) — by resolving @@ -88,8 +88,22 @@ async function classifyMediaUrl(url) { } } - if (response === null || !response.ok) { - throw new Error(`Could not fetch the media (HTTP ${response ? response.status : 'network / CORS error'}).`); + if (response === null) { + // fetch() rejected without a status: the host is either unreachable or — + // far more often — reachable but missing CORS headers. Disambiguate with a + // no-cors HEAD probe, which succeeds (opaquely) whenever the server + // responds at all, so a pass here pins the failure on CORS. The distinction + // matters because playback and fetching are gated differently: a URL that + // plays fine in the