Transcript improvements
@@ -40,118 +40,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
export function dedupeAndMergeOverlappingCues(cues: VTTCue[], timeOffset = -1): VTTCue[] {
|
||||
const merged: VTTCue[] = [];
|
||||
|
||||
for (const cue of cues) {
|
||||
const trimmedText = cue.text.trim();
|
||||
if (!trimmedText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wasMerged = false;
|
||||
|
||||
for (let i = 0; i < merged.length; i++) {
|
||||
const existing = merged[i];
|
||||
|
||||
const timeGap = Math.abs(cue.startTime - existing.endTime);
|
||||
const isTimeAdjacent = timeGap <= 1.5;
|
||||
|
||||
if (isTimeAdjacent && areCuesTextSimilar(existing.text, cue.text)) {
|
||||
const mergedText = mergeCueText(existing.text, cue.text);
|
||||
|
||||
const newStart = Math.max(0, Math.min(existing.startTime, cue.startTime) + timeOffset);
|
||||
const newEnd = Math.max(existing.endTime, cue.endTime) + timeOffset;
|
||||
|
||||
const mergedCue = new VTTCue(newStart, newEnd, mergedText);
|
||||
copyCueMeta(mergedCue, cue);
|
||||
|
||||
merged[i] = mergedCue;
|
||||
wasMerged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasMerged) {
|
||||
const offsetCue = new VTTCue(
|
||||
Math.max(0, cue.startTime + timeOffset),
|
||||
cue.endTime + timeOffset,
|
||||
cue.text
|
||||
);
|
||||
copyCueMeta(offsetCue, cue);
|
||||
merged.push(offsetCue);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no overlapping cues (each cue ends before the next starts)
|
||||
for (let i = 0; i < merged.length - 1; i++) {
|
||||
const current = merged[i];
|
||||
const next = merged[i + 1];
|
||||
if (current.endTime > next.startTime) {
|
||||
current.endTime = Math.max(current.startTime, next.startTime - 0.01); // 10ms gap
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function areCuesTextSimilar(a: string, b: string): boolean {
|
||||
if (a === b) return true;
|
||||
|
||||
// Normalize and compare overlap ratio
|
||||
const cleanA = normalizeText(a);
|
||||
const cleanB = normalizeText(b);
|
||||
|
||||
const overlap = getTextOverlap(cleanA, cleanB);
|
||||
const minLength = Math.min(cleanA.length, cleanB.length);
|
||||
|
||||
// Consider similar if 50% or more overlap
|
||||
return overlap / minLength >= 0.5;
|
||||
}
|
||||
|
||||
function getTextOverlap(a: string, b: string): number {
|
||||
const maxOverlap = Math.min(a.length, b.length);
|
||||
for (let i = maxOverlap; i > 0; i--) {
|
||||
if (a.endsWith(b.slice(0, i)) || b.endsWith(a.slice(0, i))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeText(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[.,?!"'”“]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function mergeCueText(a: string, b: string): string {
|
||||
// Avoid duplicating overlap
|
||||
const overlapLen = getTextOverlap(a, b);
|
||||
if (a.endsWith(b.slice(0, overlapLen))) {
|
||||
return a + b.slice(overlapLen);
|
||||
}
|
||||
if (b.endsWith(a.slice(0, overlapLen))) {
|
||||
return b + a.slice(overlapLen);
|
||||
}
|
||||
return `${a} ${b}`;
|
||||
}
|
||||
|
||||
function copyCueMeta(target: VTTCue, source: VTTCue): void {
|
||||
target.region = source.region;
|
||||
target.vertical = source.vertical;
|
||||
target.snapToLines = source.snapToLines;
|
||||
target.line = source.line;
|
||||
target.lineAlign = source.lineAlign;
|
||||
target.position = source.position;
|
||||
target.positionAlign = source.positionAlign;
|
||||
target.size = source.size;
|
||||
target.align = source.align;
|
||||
target.style = source.style;
|
||||
}
|
||||
|
||||
async function loadTranscript() {
|
||||
if (!url) {
|
||||
transcript = null;
|
||||
@@ -160,11 +48,31 @@
|
||||
|
||||
isLoading = true;
|
||||
transcript = null;
|
||||
|
||||
const resp = await fetch(`${!video.fallbackPatch ? get(instanceStore) : ''}${url}`);
|
||||
transcript = await parseText(await resp.text(), { strict: false });
|
||||
|
||||
transcriptCues = dedupeAndMergeOverlappingCues(transcript.cues);
|
||||
// Group cues by Math.ceil(startTime)
|
||||
const startTimeMap = new Map<number, VTTCue[]>();
|
||||
for (const cue of transcript.cues) {
|
||||
const roundedTime = Math.ceil(cue.startTime);
|
||||
if (!startTimeMap.has(roundedTime)) {
|
||||
startTimeMap.set(roundedTime, []);
|
||||
}
|
||||
startTimeMap.get(roundedTime)!.push(cue);
|
||||
}
|
||||
|
||||
// Keep only the second cue if multiple exist for the same rounded time
|
||||
transcriptCues = [];
|
||||
for (const [, cues] of startTimeMap.entries()) {
|
||||
if (cues.length === 1) {
|
||||
transcriptCues.push(cues[0]);
|
||||
} else if (cues.length >= 2) {
|
||||
transcriptCues.push(cues[1]); // Keep the second one
|
||||
}
|
||||
}
|
||||
|
||||
transcript.cues = transcriptCues;
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
@@ -172,7 +80,7 @@
|
||||
if (!transcript) return;
|
||||
|
||||
if (search.trim() === '') {
|
||||
transcriptCues = dedupeAndMergeOverlappingCues(transcript.cues);
|
||||
transcriptCues = transcript.cues;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,8 +88,7 @@
|
||||
keys: ['text']
|
||||
});
|
||||
|
||||
const results = fuse.search(search).map((item) => item.item);
|
||||
transcriptCues = dedupeAndMergeOverlappingCues(results);
|
||||
transcriptCues = fuse.search(search).map((item) => item.item);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -260,4 +167,8 @@
|
||||
.transcript-text {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.transcript-line .chip {
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -606,7 +606,7 @@
|
||||
</nav>
|
||||
</summary>
|
||||
<div class="space"></div>
|
||||
<div>
|
||||
<div class="chapter-list">
|
||||
<ul class="list">
|
||||
{#each data.content.timestamps as timestamp}
|
||||
<li
|
||||
@@ -778,6 +778,11 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.chapter-list {
|
||||
max-height: 300px;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 1.9 MiB |