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