feat(watch): refonte pro page lecture + dedup transcript

- Supprime le hint debug 'touche s' (raccourci conserve, tooltip discret)
- server/transcript.mjs: dedupeTranscriptLines collapse les auto-captions
  a fenetre glissante (12 lignes -> 3 sur l'exemple signale), applique dans
  parseJson3/parseVtt/parseXmlCaptions + filet avant cache API
- watch: layout pro (grille 1fr/360px, carte chaine+actions, labels FR,
  panneaus description/download, sidebar A suivre sticky, a11y/focus)
- transcript interactif: recherche, clic timestamp -> seek, suivi auto +
  surlignage ligne active, copie, retry
- tests: 4 nouveaux cas dedupe (23/23 pass), build prod OK
This commit is contained in:
2026-09-26 08:51:04 -04:00
parent 4bdcd393fe
commit 2db1f2deee
5 changed files with 948 additions and 335 deletions
+81
View File
@@ -13,7 +13,10 @@ import {
parseVtt,
parseTrackText,
parseXmlCaptions,
dedupeTranscriptLines,
orderedTracks,
translatedFallbacks,
firstPerLanguage,
looksLikeHtmlError,
capLines,
normalizeTranscriptProvider,
@@ -151,6 +154,84 @@ describe('orderedTracks + parseTrackText + XML captions', () => {
const v = parseTrackText('WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nHello\n', 'vtt');
assert.equal(v.length, 1);
});
it('translatedFallbacks appends &tlang= candidates from other languages only', () => { const cands = [
{ track: { url: 'https://x/t?lang=en-US', ext: 'json3' }, lang: 'en-us' },
{ track: { url: 'https://x/t?lang=fr', ext: 'json3' }, lang: 'fr' },
];
const t = translatedFallbacks(cands, 'en');
assert.equal(t.length, 1);
assert.equal(t[0].lang, 'en');
assert.match(t[0].track.url, /tlang=en/);
// Same primary language sources are skipped, not re-translated.
assert.equal(translatedFallbacks(cands, 'fr').length, 1);
assert.equal(translatedFallbacks(cands, '').length, 0);
});
it('firstPerLanguage keeps one candidate per language in order, bounded', () => {
const cands = [
{ track: { url: 'https://x/a' }, lang: 'en-US' },
{ track: { url: 'https://x/b' }, lang: 'en' },
{ track: { url: 'https://x/c' }, lang: 'fr' },
{ track: { url: 'https://x/d' }, lang: null },
];
const out = firstPerLanguage(cands, 10).map((c) => c.track.url);
assert.deepEqual(out, ['https://x/a', 'https://x/c', 'https://x/d']);
assert.equal(firstPerLanguage(cands, 2).length, 2);
});
});
describe('dedupeTranscriptLines (rolling-window auto-captions)', () => {
it('collapses prefix extensions and exact duplicates', () => {
const lines = dedupeTranscriptLines([
{ t: 0, dur: 1, text: 'Tac.' },
{ t: 1, dur: 1, text: 'Tac.' },
{ t: 1, dur: 2, text: 'Tac. David Prou est avec nous. David,' },
{ t: 2, dur: 1, text: 'David Prou est avec nous. David,' },
{ t: 2, dur: 2, text: 'David Prou est avec nous. David, bonjour.' },
{ t: 3, dur: 1, text: 'bonjour.' },
{ t: 3, dur: 2, text: 'bonjour. Bonjour Benois.' },
]);
assert.ok(lines.length < 7, `expected fewer lines, got ${lines.length}`);
assert.ok(lines.length >= 1);
const joined = lines.map((l) => l.text).join(' ');
assert.match(joined, /David Prou/);
assert.match(joined, /Benois/);
// No word lost, no exact consecutive duplicates remain.
for (let i = 1; i < lines.length; i++) {
assert.notEqual(lines[i].text.trim().toLowerCase(), lines[i - 1].text.trim().toLowerCase());
}
});
it('merges sliding partial overlaps without fragments', () => {
const lines = dedupeTranscriptLines([
{ t: 6, dur: 2, text: "Alors ce roman québécois c'était ça ou" },
{ t: 6, dur: 2, text: "Alors ce roman québécois c'était ça ou mourir ?" },
{ t: 7, dur: 1, text: 'mourir ?' },
]);
assert.equal(lines.length, 1);
assert.match(lines[0].text, /mourir/);
});
it('parseJson3 dedupes rolling events end-to-end', () => {
const lines = parseJson3({
events: [
{ tStartMs: 0, dDurationMs: 1000, segs: [{ utf8: 'Tac.' }] },
{ tStartMs: 1000, dDurationMs: 1000, segs: [{ utf8: 'Tac.' }] },
{ tStartMs: 1000, dDurationMs: 2000, segs: [{ utf8: 'Tac. David Prou est avec nous.' }] },
{ tStartMs: 2000, dDurationMs: 1000, segs: [{ utf8: 'David Prou est avec nous.' }] },
],
});
assert.equal(lines.length, 1);
assert.match(lines[0].text, /David Prou/);
});
it('keeps distinct sentences on separate cues', () => {
const lines = dedupeTranscriptLines([
{ t: 0, dur: 2, text: 'Bonjour à tous.' },
{ t: 10, dur: 2, text: 'On parle intelligence artificielle.' },
{ t: 20, dur: 2, text: 'Merci beaucoup.' },
]);
assert.equal(lines.length, 3);
});
});
describe('capLines + normalizeTranscriptProvider', () => { it('truncates very long transcripts', () => {