Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions __TEST__/e2e/interactive-export-filename.spec.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
61 changes: 60 additions & 1 deletion __TEST__/unit/hls-source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<script type="importmap">([\s\S]*?)<\/script>/) || [])[1] || '';
assert.ok(!/\.mjs"/.test(importmap), 'importmap must map to .js, not .mjs');

const sw = readFileSync(new URL('serviceworker.js', root), 'utf8');
assert.ok(!/\.mjs"/.test(sw), 'service worker must precache .js, not .mjs');

const vendor = readdirSync(new URL('js/vendor/', root));
assert.deepEqual(vendor.filter((f) => f.endsWith('.mjs')), [], 'no .mjs in js/vendor');
});

test('isHlsUrl: .m3u8 with query/fragment yes, plain media no', () => {
assert.ok(isHlsUrl('https://x.test/a.m3u8'));
assert.ok(isHlsUrl('https://x.test/a.M3U8?token=1'));
Expand Down Expand Up @@ -70,6 +89,46 @@ test('SAMPLE-AES is likewise rejected', () => {
assert.throws(() => parseMediaPlaylist(text, BASE), /encrypted/i);
});

// classifyMediaUrl error paths: a rejected fetch() carries no status, so the
// code probes with a no-cors HEAD to tell "host unreachable" apart from "host
// reachable but missing CORS headers". The CORS case matters most — the URL
// still PLAYS in the <video> element (playback is CORS-exempt), so without a
// message that says so, users read the failure as an app bug.
function withMockFetch(impl, run) {
const original = global.fetch;
global.fetch = impl;
return Promise.resolve().then(run).finally(() => { global.fetch = original; });
}

test('classifyMediaUrl: reachable host without CORS headers names CORS, suggests local file', () =>
withMockFetch(
(url, options) => (options && options.mode === 'no-cors')
? Promise.resolve({ ok: false, type: 'opaque' }) // probe reaches the server
: Promise.reject(new TypeError('Failed to fetch')), // normal fetch is CORS-blocked
() => assert.rejects(
classifyMediaUrl('https://media.example.com/clip.mp4'),
(e) => /CORS/.test(e.message) && /play/.test(e.message) && /local file/.test(e.message),
),
));

test('classifyMediaUrl: unreachable host reports a network error, not CORS', () =>
withMockFetch(
() => Promise.reject(new TypeError('Failed to fetch')),
() => assert.rejects(
classifyMediaUrl('https://media.example.com/clip.mp4'),
(e) => /network error/.test(e.message) && !/CORS/.test(e.message),
),
));

test('classifyMediaUrl: an HTTP error status is reported as-is', () =>
withMockFetch(
() => Promise.resolve({ ok: false, status: 404 }),
() => assert.rejects(
classifyMediaUrl('https://media.example.com/clip.mp4'),
/HTTP 404/,
),
));

test('METHOD=NONE is not encryption and still parses', () => {
const { segments } = parseMediaPlaylist([
'#EXTM3U',
Expand Down
6 changes: 3 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@
"mediabunny": "./js/vendor/mediabunny-1.50.3.min.js",
"@mediabunny/mp3-encoder": "./js/vendor/mediabunny-mp3-encoder-1.50.3.min.js",
"soundtouchjs": "./js/vendor/soundtouchjs-0.3.0.js",
"hls.js": "./js/vendor/hls-1.6.16.mjs"
"hls.js": "./js/vendor/hls-1.6.16.js"
}
}
</script>

<script src="js/audio-source.js?v=0.6.24"></script>
<script src="js/hls-source.js?v=0.8.6"></script>
<script src="js/hls-source.js?v=0.8.10"></script>
<script src="js/hyperaudio-lite-editor-deepgram.js?v=0.6.7"></script>
<script src="js/hyperaudio-lite-editor-assemblyai.js?v=0.8.1"></script>
<script src="js/hyperaudio-lite-editor-parakeet.js?v=0.8.2"></script>
Expand Down Expand Up @@ -1013,7 +1013,7 @@ <h3 class="font-bold text-lg">Load from Local Storage</h3>
</div>


<script src="./js/hyperaudio-lite-editor-storage.js?v=0.8.5"></script>
<script src="./js/hyperaudio-lite-editor-storage.js?v=0.8.10"></script>

<script src="js/editor-file-menu.js?v=0.6.12"></script>

Expand Down
36 changes: 21 additions & 15 deletions js/editor-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,36 +98,42 @@
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;
if (!input || input.type !== 'file' || !input.files || input.files.length === 0) return;
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();
}
});
Expand Down
40 changes: 36 additions & 4 deletions js/hls-source.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <video> element can still be unreadable to fetch(),
// which reads as an app bug unless the message says otherwise.
const reachable = await fetch(url, { method: 'HEAD', mode: 'no-cors' }).then(() => true, () => false);
if (reachable) {
throw new Error("This media host doesn't allow web pages to read it (no CORS headers), so it can play but can't be transcribed from a URL. Download it and transcribe the local file, or enable CORS on the host.");
}
throw new Error('Could not fetch the media (network error — check the URL and your connection).');
}
if (!response.ok) {
throw new Error(`Could not fetch the media (HTTP ${response.status}).`);
}

const contentType = (response.headers.get('content-type') || '').toLowerCase();
Expand Down Expand Up @@ -136,6 +150,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); }
Expand All @@ -150,6 +175,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')) {
Expand Down Expand Up @@ -529,6 +560,7 @@ async function decodeFragmentedMp4ToMono16k(arrayBuffer) {
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
isHlsUrl,
parseMediaPlaylist
parseMediaPlaylist,
classifyMediaUrl
};
}
6 changes: 6 additions & 0 deletions js/hyperaudio-lite-editor-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion serviceworker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
})
Expand Down
Loading