<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>subtitler WASM Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117;
color: #c9d1d9;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
}
.container { max-width: 900px; width: 100%; }
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
color: #58a6ff;
}
.subtitle { color: #8b949e; margin-bottom: 24px; font-size: 14px; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.tab {
padding: 8px 16px;
border: 1px solid #30363d;
background: #161b22;
color: #8b949e;
border-radius: 6px 6px 0 0;
cursor: pointer;
font-size: 14px;
}
.tab.active { background: #1c2129; color: #c9d1d9; border-bottom-color: #1c2129; }
textarea {
width: 100%;
height: 220px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
padding: 12px;
resize: vertical;
}
.row { display: flex; gap: 12px; margin: 12px 0; align-items: center; flex-wrap: wrap; }
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #238636; color: #fff; }
.btn-secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
select {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 7px 12px;
border-radius: 6px;
font-size: 14px;
}
.result {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 16px;
margin-top: 12px;
min-height: 80px;
font-size: 13px;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
.result .meta {
color: #8b949e;
margin-bottom: 8px;
}
.result .meta strong { color: #58a6ff; }
.result .error { color: #f85149; }
.drop-zone {
border: 2px dashed #30363d;
border-radius: 8px;
padding: 24px;
text-align: center;
color: #8b949e;
margin-bottom: 12px;
cursor: pointer;
transition: border-color 0.15s;
}
.drop-zone:hover, .drop-zone.drag-over { border-color: #58a6ff; color: #58a6ff; }
.status { color: #3fb950; font-size: 14px; margin-bottom: 8px; display: none; }
.status.show { display: block; }
.issue { padding: 4px 0; border-bottom: 1px solid #21262d; color: #f0883e; }
.file-name { color: #58a6ff; font-weight: 500; }
</style>
</head>
<body>
<div class="container">
<h1>subtitler WASM</h1>
<p class="subtitle">Parse, convert, validate, and normalize subtitles — running entirely in your browser.</p>
<div class="tabs">
<button class="tab active" data-panel="parse">Parse</button>
<button class="tab" data-panel="convert">Convert</button>
<button class="tab" data-panel="validate">Validate</button>
<button class="tab" data-panel="normalize">Normalize</button>
<button class="tab" data-panel="info">Info</button>
</div>
<div class="drop-zone" id="dropZone">
<div class="status" id="wasmStatus">WASM loaded</div>
<p>Drop a subtitle file here, or paste content below</p>
<p style="font-size:12px;margin-top:4px;opacity:0.6">SRT, VTT, ASS, SSA, TTML, SBV, MicroDVD, SubViewer, LRC, SAMI, MPL2, SCC, EBU STL</p>
</div>
<textarea id="input" placeholder="Paste subtitle content here..."></textarea>
<div class="row">
<span id="fileLabel" style="color:#8b949e;font-size:13px;"></span>
</div>
<div class="row" id="convertRow" style="display:none">
<span style="color:#8b949e;font-size:13px;">Target:</span>
<select id="targetFormat">
<option value="srt">SRT</option>
<option value="vtt">VTT</option>
<option value="ass">ASS</option>
<option value="ssa">SSA</option>
<option value="ttml">TTML</option>
<option value="sbv">SBV</option>
<option value="microdvd">MicroDVD</option>
<option value="subviewer">SubViewer</option>
<option value="lrc">LRC</option>
<option value="sami">SAMI</option>
<option value="mpl2">MPL2</option>
<option value="scc">SCC</option>
<option value="ebu_stl">EBU STL</option>
</select>
</div>
<div class="row">
<button class="btn btn-primary" id="btnExecute">Parse</button>
<button class="btn btn-secondary" id="btnClear">Clear</button>
</div>
<div class="result" id="result"></div>
</div>
<script type="module">
import init, {
parse_subtitles,
convert_format,
validate_subtitles,
detect,
get_info,
normalize_text,
} from './subtitler_wasm.js';
let activePanel = 'parse';
async function boot() {
await init();
document.getElementById('wasmStatus').classList.add('show');
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
activePanel = tab.dataset.panel;
document.getElementById('convertRow').style.display = activePanel === 'convert' ? 'flex' : 'none';
document.getElementById('btnExecute').textContent =
activePanel.charAt(0).toUpperCase() + activePanel.slice(1);
});
});
document.getElementById('btnClear').addEventListener('click', () => {
document.getElementById('input').value = '';
document.getElementById('result').innerHTML = '';
document.getElementById('fileLabel').textContent = '';
});
document.getElementById('btnExecute').addEventListener('click', () => {
const content = document.getElementById('input').value;
if (!content.trim()) return;
execute(activePanel, content);
});
const dropZone = document.getElementById('dropZone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (!file) return;
document.getElementById('fileLabel').innerHTML = `<span class="file-name">${file.name}</span> (${(file.size / 1024).toFixed(1)} KB)`;
const reader = new FileReader();
reader.onload = () => {
document.getElementById('input').value = reader.result;
execute(activePanel, reader.result);
};
reader.readAsText(file);
});
dropZone.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.srt,.vtt,.ass,.ssa,.ttml,.xml,.sbv,.lrc,.smi,.sub,.scc,.stl';
input.onchange = () => {
const file = input.files[0];
if (!file) return;
document.getElementById('fileLabel').innerHTML = `<span class="file-name">${file.name}</span> (${(file.size / 1024).toFixed(1)} KB)`;
const reader = new FileReader();
reader.onload = () => {
document.getElementById('input').value = reader.result;
execute(activePanel, reader.result);
};
reader.readAsText(file);
};
input.click();
});
function execute(panel, content) {
const resultDiv = document.getElementById('result');
try {
switch (panel) {
case 'parse': {
const r = parse_subtitles(content);
if (!r.is_ok) {
resultDiv.innerHTML = `<div class="error">${r.error}</div>`;
} else {
resultDiv.innerHTML = `<div class="meta">Detected: <strong>${r.format}</strong> | Subtitles: <strong>${r.subtitle_count}</strong></div>${escapeHtml(r.output)}`;
}
break;
}
case 'convert': {
const target = document.getElementById('targetFormat').value;
const r = convert_format(content, target);
if (!r.is_ok) {
resultDiv.innerHTML = `<div class="error">${r.error}</div>`;
} else {
resultDiv.innerHTML = `<div class="meta">Converted to: <strong>${r.format}</strong> | Subtitles: <strong>${r.subtitle_count}</strong></div>${escapeHtml(r.output)}`;
}
break;
}
case 'validate': {
const r = validate_subtitles(content);
const obj = JSON.parse(JSON.stringify(r));
if (obj.error) {
resultDiv.innerHTML = `<div class="error">${obj.error}</div>`;
} else {
let html = `<div class="meta">Format: <strong>${obj.format}</strong> | Subtitles: <strong>${obj.subtitle_count}</strong> | Issues: <strong>${obj.issue_count}</strong></div>`;
obj.issues.forEach(issue => { html += `<div class="issue">⚠ ${escapeHtml(issue)}</div>`; });
resultDiv.innerHTML = html;
}
break;
}
case 'normalize': {
const r = normalize_text(content);
resultDiv.innerHTML = `<div class="meta">Normalized output:</div>${escapeHtml(r)}`;
break;
}
case 'info': {
const r = get_info(content);
const obj = JSON.parse(JSON.stringify(r));
if (obj.error) {
resultDiv.innerHTML = `<div class="error">${obj.error}</div>`;
} else {
resultDiv.innerHTML = `<div class="meta">
Format: <strong>${obj.format}</strong><br>
Subtitles: <strong>${obj.subtitle_count}</strong><br>
Duration: <strong>${(obj.total_duration_ms / 1000).toFixed(1)}s</strong><br>
First: <strong>${obj.first_timestamp}ms</strong> |
Last: <strong>${obj.last_timestamp}ms</strong>
</div>`;
}
break;
}
}
} catch (e) {
resultDiv.innerHTML = `<div class="error">${e.message || e}</div>`;
}
}
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
boot();
</script>
</body>
</html>