-
Notifications
You must be signed in to change notification settings - Fork 1
VTT Editor Pro v2.1 ‐ How It Works
RDTvlokip edited this page Nov 6, 2025
·
2 revisions
Technical documentation on the architecture and internal workings of VTT Editor Pro v2.1.
┌─────────────────────────────────────┐
│ HTML/CSS Interface │
│ (Single Page Application) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Vanilla JavaScript Core │
│ - Cue management │
│ - Audio synchronization │
│ - Event handlers │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Wavesurfer.js v7 │
│ - Waveform rendering │
│ - Audio playback │
│ - Regions plugin │
└─────────────────────────────────────┘
- All-in-one: Single HTML file contains everything
- No build: No compilation process
-
Inline CSS: Styles in
<style> -
Inline JavaScript: Code in
<script> -
Advantages:
- ✅ Portable (single file to share)
- ✅ No npm dependencies
- ✅ Works offline (except CDN dependencies)
- ✅ Easy to deploy
Each subtitle is represented by an object:
{
id: "cue-1234567890-abc123", // Unique UUID
start: 5.234, // Start timestamp (seconds)
end: 7.412, // End timestamp (seconds)
text: "Subtitle text", // Text content
color: "#1db954" // RGB/HEX color
}let cues = []; // Array containing all cuesOperations:
-
Add:
cues.push(newCue) -
Delete:
cues = cues.filter(c => c.id !== id) - Modify: Direct object mutation
-
Sort:
cues.sort((a, b) => a.start - b.start)
wavesurfer = WaveSurfer.create({
container: '#waveform',
waveColor: '#404040',
progressColor: '#1db954',
cursorColor: '#1db954',
height: 180,
normalize: true,
backend: 'WebAudio'
});regionsPlugin = wavesurfer.registerPlugin(
WaveSurfer.Regions.create()
);Features:
- Create colored regions on waveform
- Drag & drop to move
- Resize handles to adjust timing
- Events: update-start, update, update-end
Cues (Data) ←→ Regions (Visual)
Array Wavesurfer
function createRegion(start, end, color, id) {
const region = regionsPlugin.addRegion({
start: start,
end: end,
color: color + '40', // Alpha transparency
drag: true,
resize: true,
id: id
});
setupRegionEvents(region);
return region;
}function updateCueFromRegion(region) {
const cue = cues.find(c => c.id === region.id);
if (cue) {
cue.start = region.start;
cue.end = region.end;
}
}After VTT import or undo/redo:
function rebuildRegions() {
clearAllRegions();
cues.forEach(cue => {
createRegion(cue.start, cue.end, cue.color, cue.id);
});
}1. User Action
└─> Event Listener (click, keydown, etc.)
└─> Update Cue Data
└─> Update Region Visual
└─> Save to History
└─> Render UI
// 1. User clicks "Save"
elements.saveCueBtn.addEventListener('click', updateCue);
// 2. Function updates data
function updateCue() {
const cue = cues.find(c => c.id === currentCueId);
cue.text = elements.cueText.value; // Update data
// 3. Save history
saveToHistory();
// 4. Re-render UI
renderCues();
renderLyrics();
}let history = []; // State stack
let historyIndex = 0; // Current positionfunction saveToHistory() {
// Deep clone cues
const state = JSON.parse(JSON.stringify(cues));
// Truncate history if not at end
history = history.slice(0, historyIndex + 1);
// Add new state
history.push(state);
historyIndex++;
updateUndoRedoButtons();
}function undo() {
if (historyIndex > 0) {
historyIndex--;
cues = JSON.parse(JSON.stringify(history[historyIndex]));
rebuildRegions();
renderCues();
renderLyrics();
}
}function redo() {
if (historyIndex < history.length - 1) {
historyIndex++;
cues = JSON.parse(JSON.stringify(history[historyIndex]));
rebuildRegions();
renderCues();
renderLyrics();
}
}Principle: Display tooltip during handle drag.
Wavesurfer Events:
region.on('update-start', () => {
isResizing = true;
});
region.on('update', (region) => {
if (isResizing) {
showResizeTooltip(mouseX, mouseY, region.start);
}
});
region.on('update-end', () => {
isResizing = false;
hideResizeTooltip();
});Tooltip DOM:
function showResizeTooltip(x, y, time) {
tooltip.textContent = formatTimeVTT(time);
tooltip.style.display = 'block';
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y - 50}px`;
}Principle: Round timestamps to nearest interval.
Rounding function:
function snapToGrid(value) {
if (!snapEnabled) return value;
return Math.round(value / snapInterval) * snapInterval;
}Application on resize:
region.on('update-end', () => {
if (snapEnabled) {
const snappedStart = snapToGrid(region.start);
const snappedEnd = snapToGrid(region.end);
region.setOptions({
start: snappedStart,
end: snappedEnd
});
}
});Example:
Interval: 250ms (0.25s)
Value: 5.387s
Rounded: Math.round(5.387 / 0.25) * 0.25 = 5.375s
Principle: Apply transformation to multiple cues.
function batchFindReplace() {
const findStr = elements.findText.value;
const replaceStr = elements.replaceText.value;
const caseSensitive = elements.caseSensitive.checked;
const flags = caseSensitive ? 'g' : 'gi';
const regex = new RegExp(findStr, flags);
cues.forEach(cue => {
if (regex.test(cue.text)) {
cue.text = cue.text.replace(regex, replaceStr);
}
});
renderCues();
saveToHistory();
}function batchTransform(type) {
cues.forEach(cue => {
switch (type) {
case 'uppercase':
cue.text = cue.text.toUpperCase();
break;
case 'lowercase':
cue.text = cue.text.toLowerCase();
break;
case 'capitalize':
cue.text = cue.text.replace(/\b\w/g, c => c.toUpperCase());
break;
case 'sentence':
cue.text = cue.text.charAt(0).toUpperCase() +
cue.text.slice(1).toLowerCase();
break;
}
});
}function parseCueRange(rangeStr) {
if (rangeStr === 'all') {
return Array.from({length: cues.length}, (_, i) => i);
}
const indices = [];
const parts = rangeStr.split(',');
for (let part of parts) {
if (part.includes('-')) {
const [start, end] = part.split('-').map(s => parseInt(s) - 1);
for (let i = start; i <= end; i++) {
indices.push(i);
}
} else {
indices.push(parseInt(part) - 1);
}
}
return indices;
}function parseVTT(content) {
const lines = content.trim().split('\n');
const cues = [];
let i = 0;
let currentColor = '#1db954';
while (i < lines.length) {
const line = lines[i].trim();
// Detect NOTE color
if (line.startsWith('NOTE color:')) {
currentColor = extractColor(line);
}
// Detect timestamp
if (line.includes('-->')) {
const [startStr, endStr] = line.split('-->');
const start = parseVTTTime(startStr.trim());
const end = parseVTTTime(endStr.trim());
i++;
let text = '';
while (i < lines.length && lines[i].trim() !== '') {
text += lines[i].trim() + '\n';
i++;
}
cues.push({
id: generateId(),
start,
end,
text: text.trim(),
color: currentColor
});
}
i++;
}
return cues;
}function exportVTT() {
let vttContent = 'WEBVTT\n\n';
let lastColor = null;
cues.forEach(cue => {
if (cue.color !== lastColor) {
vttContent += `NOTE color:${cue.color}\n\n`;
lastColor = cue.color;
}
vttContent += `${formatTimeVTT(cue.start)} --> ${formatTimeVTT(cue.end)}\n`;
vttContent += `${cue.text}\n\n`;
});
// Download
const blob = new Blob([vttContent], {type: 'text/vtt'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'subtitles.vtt';
a.click();
}function renderCues() {
const container = elements.cuesList;
container.innerHTML = '';
cues.forEach((cue, index) => {
const cueEl = document.createElement('div');
cueEl.className = 'cue-item';
cueEl.dataset.cueId = cue.id;
cueEl.innerHTML = `
<div class="cue-number">#${index + 1}</div>
<div class="cue-time">
${formatTimeVTT(cue.start)} → ${formatTimeVTT(cue.end)}
</div>
<div class="cue-text">${cue.text}</div>
<div class="cue-actions">
<button onclick="editCue('${cue.id}')">✏️</button>
<button onclick="deleteCue('${cue.id}')">🗑️</button>
</div>
`;
container.appendChild(cueEl);
});
}wavesurfer.on('timeupdate', (currentTime) => {
// Find active cue
const activeCue = cues.find(c =>
currentTime >= c.start && currentTime < c.end
);
// Remove old active
document.querySelectorAll('.lyric-line.active')
.forEach(el => el.classList.remove('active'));
// Add new active
if (activeCue) {
const lyricEl = document.querySelector(
`.lyric-line[data-cue-id="${activeCue.id}"]`
);
if (lyricEl) {
lyricEl.classList.add('active');
lyricEl.scrollIntoView({block: 'center'});
}
}
});- Debouncing: Limited render events
- Event delegation: Listeners on parent containers
- DocumentFragment: Batch DOM insertions
- RequestAnimationFrame: Smooth animations
- Cleanup: Remove listeners on destroy
- Weak references: For temporary objects
- Garbage collection: No circular references
function detectOverlaps() {
const overlaps = [];
for (let i = 0; i < cues.length; i++) {
for (let j = i + 1; j < cues.length; j++) {
if (cues[i].end > cues[j].start &&
cues[i].start < cues[j].end) {
overlaps.push(cues[i].id, cues[j].id);
}
}
}
return [...new Set(overlaps)];
}function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('show');
}, 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}🆕 v3.x
📘 Guides