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
69 changes: 69 additions & 0 deletions __TEST__/e2e/caption-track-reset.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// #356/#287 regression — the "double captions" bug that kept coming back.
//
// The <video> and its <track id="hyperplayer-vtt"> are reused across media
// swaps. Left in 'showing' mode, Chromium keeps the PREVIOUS media's active cue
// painted after track.src is reassigned, so a freshly transcribed clip shows its
// captions on top of the old clip's line. The load path (renderTranscript) was
// fixed by resetCaptionTrack — removing the stale <track> and inserting a fresh
// empty one — but the transcribe/regenerate path (hyperaudioGenerateCaptions-
// FromTranscript) reused the track and only reassigned .src, so the bug survived
// on every second transcription.
//
// This drives the from-scratch regenerate entry point twice (what each
// transcription client dispatches) and asserts the caption <track> is torn down
// and rebuilt each time: a fresh element, and never more than one track /
// TextTrack. On the pre-fix code the element was reused, so `sameElement` is true
// and the stale cue can linger — this test fails there.
import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
await page.goto('/index.html');
await page.waitForSelector('#hypertranscript [data-m]');
});

test('regenerating captions resets the <track>, so a prior cue cannot linger (#356/#287)', async ({ page }) => {
const result = await page.evaluate(() => {
const video = document.getElementById('hyperplayer');

// First transcription: a non-audio src (so captions go 'showing', not the
// mp3/m4a 'hidden' branch) plus a two-word transcript, then regenerate.
video.src = 'data:video/mp4;base64,';
document.getElementById('hypertranscript').innerHTML =
'<article><section><p>' +
'<span data-m="0" data-d="500">Alpha </span>' +
'<span data-m="500" data-d="500">alpha </span></p></section></article>';
document.dispatchEvent(new CustomEvent('hyperaudioGenerateCaptionsFromTranscript'));

const firstTrack = document.getElementById('hyperplayer-vtt');
firstTrack.dataset.gen = 'A'; // stamp the gen-A element
const trackCountAfterA = video.querySelectorAll('track').length;

// Second transcription: new media + a different transcript, regenerate again.
video.src = 'data:video/mp4;base64,';
document.getElementById('hypertranscript').innerHTML =
'<article><section><p>' +
'<span data-m="0" data-d="500">Bravo </span></p></section></article>';
document.dispatchEvent(new CustomEvent('hyperaudioGenerateCaptionsFromTranscript'));

const secondTrack = document.getElementById('hyperplayer-vtt');
return {
trackCountAfterA,
trackCountAfterB: video.querySelectorAll('track').length,
textTrackCount: video.textTracks.length,
sameElement: secondTrack === firstTrack, // pre-fix: true (reused)
stampSurvived: secondTrack.dataset.gen === 'A', // pre-fix: true (reused)
finalMode: video.textTracks[0] && video.textTracks[0].mode,
};
});

expect(result.trackCountAfterA).toBe(1); // exactly one track after gen A
expect(result.trackCountAfterB).toBe(1); // no track accumulation
expect(result.textTrackCount).toBe(1); // no stale TextTrack left behind
expect(result.sameElement).toBe(false); // the fix: fresh element each regenerate
expect(result.stampSurvived).toBe(false);
// The ::cue ghost-flush toggles mode hidden→showing; it must settle back on
// 'showing' (the flush is a no-op if the track ends up hidden). The stale-paint
// itself is native ::cue rendering and not inspectable headlessly — this guards
// that the toggle path doesn't leave captions disabled.
expect(result.finalMode).toBe('showing');
});
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="version" content="0.8.7">
<meta name="version" content="0.8.9">
<title>Hyperaudio Lite Editor</title>
<link rel="apple-touch-icon" href="images/icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="images/favicon-32x32.png">
Expand Down Expand Up @@ -965,7 +965,7 @@ <h3 class="font-bold text-lg">Caption Regeneration </h3>
<script src="js/hyperaudio-lite-extension.js?v=0.7.2"></script>
<script src="js/caption.js?v=0.8.6"></script>
<script src="js/transcript-serializer.js?v=0.8.5"></script>
<script src="js/editor-core.js?v=0.8.6"></script>
<script src="js/editor-core.js?v=0.8.9"></script>


<import-deepgram-json></import-deepgram-json>
Expand Down
28 changes: 27 additions & 1 deletion js/editor-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -782,9 +782,35 @@

function hyperaudioGenerateCaptionsFromTranscript() {
let sourceMedia = document.querySelector("#hyperplayer").src;
let track = document.querySelector('#hyperplayer-vtt');

// Tear down the previous media's caption <track> before regenerating. A fresh
// transcription reuses the same <video>/<track>; left in 'showing' mode the old
// track keeps the PREVIOUS media's cue painted, so the new captions render on
// top of the stale line (the "double captions" of #356/#287). The Recents-load
// path already resets via resetCaptionTrack (storage.js) — the transcribe /
// regenerate path must too. Fall back to the existing track if storage.js is
// absent. (Note: this is the from-scratch entry point; the live-edit sanitise
// path calls generateCaptionsFromTranscript directly and must NOT reset here,
// or every keystroke would swap the track and churn the caption paint.)
let track = (typeof resetCaptionTrack === 'function' && resetCaptionTrack())
|| document.querySelector('#hyperplayer-vtt');

populateCaptionEditor(generateCaptionsFromTranscript(getTranscriptData(), sourceMedia, track));

// Swapping the <track> element drops the old cue's DATA, but a PAUSED video
// won't re-composite its native caption overlay on its own — so the previous
// cue's PIXELS stay stranded on screen under the new captions (the remaining
// half of #356/#287; the load path avoids it because loading new media forces
// a full video relayout, which the transcribe path never triggers). Toggling
// the track's display mode forces the overlay to rebuild, flushing the stale
// ::cue paint. Only meaningful while the intended mode is 'showing' — mp3/m4a
// are deliberately 'hidden' by generateCaptionsFromTranscript, nothing to flush.
const player = document.getElementById('hyperplayer');
const captionTrack = player && player.textTracks[0];
if (captionTrack && captionTrack.mode === 'showing') {
captionTrack.mode = 'hidden';
captionTrack.mode = 'showing';
}
}

function generateCaptionsFromTranscript(hypertranscript, sourceMedia, track) {
Expand Down
Loading