subtitler 2.3.0

Parse, convert, validate, edit, and generate subtitles in 13 formats (SRT, VTT, ASS/SSA, MicroDVD, SubViewer, TTML, SBV, LRC, SAMI, MPL2, SCC, EBU STL). Full CLI included.
Documentation
<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }

  boot();
</script>
</body>
</html>