foxygit / Subtitles Log in
commit 53413712f5c35d67b37b9c1e04947ef24a455d37
Author:     jens <jens.se@icloud.com>
AuthorDate: Tue Apr 21 14:49:24 2026 +0200
Commit:     jens <jens.se@icloud.com>
CommitDate: Tue Apr 21 14:49:24 2026 +0200

    start of a new era
---
 .gitignore |   2 +
 app.py     | 125 ++++++++++++-
 index.html | 596 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 711 insertions(+), 12 deletions(-)

diff --git a/.gitignore b/.gitignore
index 21d0b89..b628a7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
 .venv/
+/outputs/
+/uploads/
diff --git a/app.py b/app.py
index a364ff8..f2f4f18 100644
--- a/app.py
+++ b/app.py
@@ -1,4 +1,5 @@
 import os
+import re
 import json
 import subprocess
 import tempfile
@@ -95,7 +96,7 @@ async def export_srt(data: dict):
         for chunk in chunks:
             start = chunk[0]["start"]
             end = chunk[-1]["end"]
-            text = "".join(w["word"] for w in chunk).strip()
+            text = " ".join(w["word"].strip() for w in chunk).strip()
             lines.append(f"{idx}\n{_srt_time(start)} --> {_srt_time(end)}\n{text}\n")
             idx += 1

@@ -120,6 +121,9 @@ async def export_ass(data: dict):
     shadow = style.get("shadow", 0)
     margin_v = style.get("margin_v", 80)
     alignment = style.get("alignment", 2)  # bottom center
+    pos_x = round(1080 * float(style.get("pos_x_frac", 0.5)))
+    pos_y = round(1920 * float(style.get("pos_y_frac", 0.85)))
+    pos_tag = f"{{\\pos({pos_x},{pos_y})}}"

     header = f"""[Script Info]
 ScriptType: v4.00+
@@ -141,7 +145,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
         if not words:
             start = _ass_time(seg["start"])
             end = _ass_time(seg["end"])
-            events.append(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{seg['text']}")
+            events.append(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{pos_tag}{seg['text']}")
             continue

         chunks = [words[i:i+words_per_chunk] for i in range(0, len(words), words_per_chunk)]
@@ -152,7 +156,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
             for w in chunk:
                 word_dur = int((w["end"] - w["start"]) * 100)
                 line_parts.append(f"{{\\k{word_dur}\\1c{highlight_color}}}{w['word'].strip()}{{\\1c{primary}}}")
-            text = " ".join(line_parts)
+            text = pos_tag + " ".join(line_parts)
             events.append(f"Dialogue: 0,{_ass_time(chunk_start)},{_ass_time(chunk_end)},Default,,0,0,0,,{text}")

     ass_path = OUTPUT_DIR / "subtitles.ass"
@@ -189,6 +193,9 @@ async def export_video(
     outline = style.get("outline", 3)
     shadow = style.get("shadow", 0)
     margin_v = style.get("margin_v", 80)
+    pos_x = round(1080 * float(style.get("pos_x_frac", 0.5)))
+    pos_y = round(1920 * float(style.get("pos_y_frac", 0.85)))
+    pos_tag = f"{{\\pos({pos_x},{pos_y})}}"

     header = f"""[Script Info]
 ScriptType: v4.00+
@@ -207,7 +214,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
     for seg in segments:
         words = seg.get("words", [])
         if not words:
-            events.append(f"Dialogue: 0,{_ass_time(seg['start'])},{_ass_time(seg['end'])},Default,,0,0,0,,{seg['text']}")
+            events.append(f"Dialogue: 0,{_ass_time(seg['start'])},{_ass_time(seg['end'])},Default,,0,0,0,,{pos_tag}{seg['text']}")
             continue
         chunks = [words[i:i+words_per_chunk] for i in range(0, len(words), words_per_chunk)]
         for chunk in chunks:
@@ -217,14 +224,14 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
             for w in chunk:
                 word_dur = int((w["end"] - w["start"]) * 100)
                 line_parts.append(f"{{\\k{word_dur}\\1c{highlight_color}}}{w['word'].strip()}{{\\1c{primary}}}")
-            text = " ".join(line_parts)
+            text = pos_tag + " ".join(line_parts)
             events.append(f"Dialogue: 0,{_ass_time(chunk_start)},{_ass_time(chunk_end)},Default,,0,0,0,,{text}")

     ass_path.write_text(header + "\n".join(events))

     cmd = [
         "ffmpeg", "-y", "-i", video_path,
-        "-vf", f"ass={ass_path}",
+        "-vf", f"ass={ass_path.resolve()}",
         "-c:a", "copy",
         str(out_path)
     ]
@@ -429,7 +436,6 @@ def _analyze_with_claude(segments: list, target_pct: float, api_key: str) -> dic
         }],
     )

-    import re
     match = re.search(r"\{.*\}", msg.content[0].text, re.DOTALL)
     if not match:
         raise ValueError("No JSON in response")
@@ -494,6 +500,111 @@ async def export_highlights(
     return FileResponse(out_path, filename="highlights.mp4", media_type="video/mp4")


+def _srt_time_to_seconds(t: str) -> float:
+    t = t.replace(',', '.')
+    parts = t.split(':')
+    h, m, s = int(parts[0]), int(parts[1]), float(parts[2])
+    return h * 3600 + m * 60 + s
+
+
+def _ass_time_to_seconds(t: str) -> float:
+    parts = t.strip().split(':')
+    h, m, s = int(parts[0]), int(parts[1]), float(parts[2])
+    return h * 3600 + m * 60 + s
+
+
+def _distribute_words(text: str, start: float, end: float) -> list:
+    words = text.split()
+    if not words:
+        return []
+    dur = (end - start) / len(words)
+    return [
+        {"word": w, "start": round(start + i * dur, 3), "end": round(start + (i + 1) * dur, 3)}
+        for i, w in enumerate(words)
+    ]
+
+
+def _parse_srt(content: str) -> list:
+    segments = []
+    for block in re.split(r'\n{2,}', content.strip()):
+        lines = block.strip().splitlines()
+        time_match = None
+        text_start = 0
+        for li, line in enumerate(lines):
+            m = re.match(r'(\d+:\d+:\d+[,\.]\d+)\s*-->\s*(\d+:\d+:\d+[,\.]\d+)', line)
+            if m:
+                time_match = m
+                text_start = li + 1
+                break
+        if not time_match or text_start >= len(lines):
+            continue
+        start = _srt_time_to_seconds(time_match.group(1))
+        end = _srt_time_to_seconds(time_match.group(2))
+        text = re.sub(r'<[^>]+>', '', ' '.join(lines[text_start:])).strip()
+        if not text:
+            continue
+        segments.append({
+            "id": len(segments),
+            "start": round(start, 3),
+            "end": round(end, 3),
+            "text": text,
+            "words": _distribute_words(text, start, end),
+        })
+    return segments
+
+
+def _parse_ass(content: str) -> list:
+    segments = []
+    in_events = False
+    format_cols = None
+    for line in content.splitlines():
+        stripped = line.strip()
+        if stripped == '[Events]':
+            in_events = True
+            continue
+        if stripped.startswith('[') and stripped.endswith(']') and in_events:
+            break
+        if not in_events:
+            continue
+        if stripped.startswith('Format:'):
+            format_cols = [c.strip().lower() for c in stripped[7:].split(',')]
+            continue
+        if stripped.startswith('Dialogue:') and format_cols:
+            parts = stripped[9:].split(',', len(format_cols) - 1)
+            if len(parts) < len(format_cols):
+                continue
+            row = dict(zip(format_cols, parts))
+            try:
+                start = _ass_time_to_seconds(row['start'])
+                end = _ass_time_to_seconds(row['end'])
+            except (KeyError, ValueError, IndexError):
+                continue
+            text = re.sub(r'\{[^}]*\}', '', row.get('text', '')).strip()
+            if not text:
+                continue
+            segments.append({
+                "id": len(segments),
+                "start": round(start, 3),
+                "end": round(end, 3),
+                "text": text,
+                "words": _distribute_words(text, start, end),
+            })
+    return segments
+
+
+@app.post("/import/subtitles")
+async def import_subtitles(file: UploadFile = File(...)):
+    content = (await file.read()).decode('utf-8-sig', errors='replace')
+    fname = file.filename.lower()
+    if fname.endswith('.srt'):
+        segs = _parse_srt(content)
+    elif fname.endswith(('.ass', '.ssa')):
+        segs = _parse_ass(content)
+    else:
+        raise HTTPException(400, "Unsupported format. Upload .srt or .ass")
+    return {"segments": segs, "language": "unknown"}
+
+
 def _srt_time(seconds: float) -> str:
     h = int(seconds // 3600)
     m = int((seconds % 3600) // 60)
diff --git a/index.html b/index.html
index 119b9b4..e2db35f 100644
--- a/index.html
+++ b/index.html
@@ -264,6 +264,70 @@
   .hl-export-btn { margin-top: 12px; }
   .hl-stats { font-size: 11px; color: var(--accent2); margin-top: 8px; min-height: 16px; text-align: center; }

+  /* Subtitle import zone */
+  .sub-upload-zone {
+    border: 1px dashed var(--border); border-radius: var(--radius);
+    padding: 14px 12px; text-align: center; cursor: pointer;
+    background: var(--surface2); transition: all 0.2s; font-size: 12px;
+    color: var(--muted);
+  }
+  .sub-upload-zone:hover { border-color: var(--accent2); color: var(--accent2); }
+  .sub-upload-zone input { display: none; }
+
+  /* Edit mode */
+  .segments-header .edit-toggle-btn {
+    padding: 3px 10px; font-size: 11px; font-weight: 700; border-radius: 6px;
+    border: 1px solid var(--border); background: var(--surface2); color: var(--muted);
+    cursor: pointer; transition: all 0.15s;
+  }
+  .segments-header .edit-toggle-btn:hover { border-color: var(--accent2); color: var(--accent2); }
+  .segments-header .edit-toggle-btn.active { background: var(--accent); border-color: var(--accent); color: white; }
+
+  .seg-edit-time-row { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
+  .seg-edit-time-row input {
+    width: 70px; background: var(--surface); border: 1px solid var(--border);
+    color: var(--accent2); border-radius: 6px; padding: 3px 6px; font-size: 11px;
+    font-family: monospace; text-align: center; outline: none;
+  }
+  .seg-edit-time-row input:focus { border-color: var(--accent2); }
+  .seg-edit-time-row span { color: var(--muted); font-size: 11px; }
+  .seg-edit-delete-btn {
+    margin-left: auto; background: none; border: 1px solid var(--border);
+    color: var(--muted); border-radius: 6px; padding: 2px 7px; font-size: 13px;
+    cursor: pointer; transition: all 0.15s; flex-shrink: 0;
+  }
+  .seg-edit-delete-btn:hover { border-color: var(--accent); color: var(--accent); }
+  textarea.seg-edit-text {
+    width: 100%; background: var(--surface); border: 1px solid var(--border);
+    color: var(--text); border-radius: 6px; padding: 6px 8px; font-size: 13px;
+    resize: vertical; outline: none; font-family: inherit; min-height: 52px; line-height: 1.4;
+  }
+  textarea.seg-edit-text:focus { border-color: var(--accent2); }
+  .seg-add-btn {
+    display: block; width: 100%; margin-top: 4px; padding: 4px;
+    background: none; border: 1px dashed var(--border); color: var(--muted);
+    border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 0.15s;
+  }
+  .seg-add-btn:hover { border-color: var(--accent2); color: var(--accent2); }
+
+  /* Timeline */
+  .timeline-bar {
+    border-top: 1px solid var(--border); background: #060609;
+    display: flex; flex-direction: column; flex-shrink: 0; height: 158px;
+  }
+  .timeline-header {
+    display: flex; align-items: center; justify-content: space-between;
+    padding: 5px 14px; border-bottom: 1px solid var(--border); height: 28px; flex-shrink: 0;
+  }
+  .timeline-zoom-controls { display: flex; align-items: center; gap: 5px; }
+  .tl-btn {
+    background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
+    border-radius: 5px; padding: 1px 8px; font-size: 14px; cursor: pointer;
+    line-height: 1.5; transition: all 0.15s;
+  }
+  .tl-btn:hover { border-color: var(--accent2); color: var(--accent2); }
+  #timeline-canvas { flex: 1; width: 100%; display: block; cursor: crosshair; }
+
   /* Segment score bar */
   .seg-score-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
   .seg-score-bar-bg { flex: 1; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; cursor: pointer; }
@@ -300,6 +364,15 @@
       <div id="file-name" style="font-size:12px;color:var(--muted);margin-top:8px;text-align:center;"></div>
     </div>

+    <!-- Import subtitles -->
+    <div class="section">
+      <div class="section-title">Import Subtitles</div>
+      <div class="sub-upload-zone" onclick="document.getElementById('sub-file-input').click()">
+        πŸ“„ Upload SRT or ASS file
+        <input type="file" id="sub-file-input" accept=".srt,.ass,.ssa">
+      </div>
+    </div>
+
     <!-- Whisper settings -->
     <div class="section">
       <div class="section-title">Whisper</div>
@@ -395,9 +468,12 @@
       <label>Outline Width: <span id="outline-val">4</span>px</label>
       <input type="range" id="outline-width" min="0" max="12" value="4" oninput="document.getElementById('outline-val').textContent=this.value;updatePreview()">

-      <label>Position: <span id="pos-val">85</span>%</label>
+      <label>Vertical: <span id="pos-val">85</span>%</label>
       <input type="range" id="position" min="10" max="98" value="85" oninput="document.getElementById('pos-val').textContent=this.value;updatePreview()">

+      <label>Horizontal: <span id="pos-x-val">50</span>%</label>
+      <input type="range" id="position-x" min="5" max="95" value="50" oninput="document.getElementById('pos-x-val').textContent=this.value;updatePreview()">
+
       <div class="toggle-row">
         <span class="toggle-label">Uppercase</span>
         <div class="toggle on" id="uppercase-toggle" onclick="this.classList.toggle('on');updatePreview()"></div>
@@ -521,16 +597,33 @@
       <div class="segments-panel">
         <div class="segments-header">
           <h3>Transcript <span id="lang-badge"></span></h3>
-          <span class="seg-count" id="seg-count">0 segments</span>
+          <div style="display:flex;align-items:center;gap:8px;">
+            <span class="seg-count" id="seg-count">0 segments</span>
+            <button type="button" class="edit-toggle-btn" id="edit-mode-btn" onclick="toggleEditMode()">Edit</button>
+          </div>
         </div>
         <div class="segments-list" id="segments-list">
           <div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:13px;">
-            Transcribe a video to see segments
+            Transcribe a video or import subtitles
           </div>
         </div>
       </div>
     </div>

+    <!-- Timeline (shown in edit mode) -->
+    <div class="timeline-bar" id="timeline-bar" style="display:none">
+      <div class="timeline-header">
+        <span style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px">Timeline</span>
+        <div class="timeline-zoom-controls">
+          <button type="button" class="tl-btn" onclick="zoomTimeline(-1)" title="Zoom out">βˆ’</button>
+          <span id="tl-zoom-label" style="font-size:11px;color:var(--muted);min-width:30px;text-align:center">1Γ—</span>
+          <button type="button" class="tl-btn" onclick="zoomTimeline(1)" title="Zoom in">+</button>
+          <button type="button" class="tl-btn" onclick="fitTimeline()" style="font-size:10px;padding:1px 8px" title="Fit all">Fit</button>
+        </div>
+      </div>
+      <canvas id="timeline-canvas"></canvas>
+    </div>
+
     <!-- Export bar -->
     <div class="export-bar" id="export-bar">
       <span style="font-size:12px;color:var(--muted);flex:1">Export</span>
@@ -552,6 +645,15 @@ let segmentGifs = {};
 let cropAspect = '9:16';
 let highlightScores = [];
 let highlightSelected = new Set();
+let editMode = false;
+
+// Timeline state
+let waveformData = null;   // { peaks: Float32Array, duration, numPeaks }
+let tlZoom = 100;          // px/sec
+let tlOffset = 0;          // seconds at left edge
+let tlDrag = null;         // active drag: { type, segIdx, startX, origStart, origEnd }
+let tlHoverSeg = -1;
+let tlRafId = null;

 // ── File handling ──────────────────────────────────────────────
 document.getElementById('file-input').addEventListener('change', e => {
@@ -559,6 +661,39 @@ document.getElementById('file-input').addEventListener('change', e => {
   if (f) loadVideo(f);
 });

+document.getElementById('sub-file-input').addEventListener('change', e => {
+  const f = e.target.files[0];
+  if (f) importSubtitles(f);
+  e.target.value = '';
+});
+
+async function importSubtitles(f) {
+  const zone = document.querySelector('.sub-upload-zone');
+  zone.textContent = '⏳ Importing…';
+  const fd = new FormData();
+  fd.append('file', f);
+  try {
+    const res = await fetch('/import/subtitles', { method: 'POST', body: fd });
+    if (!res.ok) throw new Error(await res.text());
+    const data = await res.json();
+    segments = data.segments;
+    segmentEmojis = {};
+    segmentGifs = {};
+    highlightScores = [];
+    highlightSelected = new Set();
+    renderSegmentsList();
+    refreshGifTargetDropdown();
+    document.getElementById('hl-local-btn').disabled = false;
+    enableExports();
+    showLangBadge(data.language !== 'unknown' ? data.language : '');
+    setStatus(`Imported ${segments.length} subtitles from ${f.name}`);
+  } catch (err) {
+    setStatus('Import error: ' + err.message);
+    alert('Import failed: ' + err.message);
+  }
+  zone.innerHTML = 'πŸ“„ Upload SRT or ASS file';
+}
+
 function handleDrop(e) {
   e.preventDefault();
   document.getElementById('upload-zone').classList.remove('drag');
@@ -581,8 +716,22 @@ function loadVideo(f) {
     renderSubtitles(vid.currentTime);
     updateGifOverlay(vid.currentTime);
   });
-  window.addEventListener('resize', () => { resizeCanvas(); updateCropOverlay(); });
+  vid.addEventListener('play', () => { if (editMode) startTlRaf(); });
+  vid.addEventListener('pause', () => stopTlRaf());
+  vid.addEventListener('ended', () => stopTlRaf());
+  window.addEventListener('resize', () => {
+    resizeCanvas();
+    updateCropOverlay();
+    if (document.getElementById('timeline-bar').style.display !== 'none') {
+      const bar = document.getElementById('timeline-bar');
+      const canvas = document.getElementById('timeline-canvas');
+      canvas.width = bar.clientWidth;
+      canvas.height = bar.clientHeight - 28;
+      drawTimeline();
+    }
+  });
   document.getElementById('crop-export-btn').disabled = false;
+  generateWaveform(f);
 }

 function resizeCanvas() {
@@ -644,10 +793,35 @@ function renderSegmentsList() {
   const list = document.getElementById('segments-list');
   document.getElementById('seg-count').textContent = `${segments.length} segments`;
   list.innerHTML = '';
+
+  if (!segments.length) {
+    list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:13px;">Transcribe a video or import subtitles</div>';
+    return;
+  }
+
   segments.forEach((seg, i) => {
     const card = document.createElement('div');
     card.className = 'segment-card';
     card.id = `seg-${i}`;
+
+    if (editMode) {
+      card.innerHTML = `
+        <div class="seg-edit-time-row">
+          <input type="text" value="${fmt(seg.start)}" title="Start time (m:ss.s)"
+            onblur="updateSegmentTime(${i},'start',this.value)"
+            onkeydown="if(event.key==='Enter')this.blur()">
+          <span>β†’</span>
+          <input type="text" value="${fmt(seg.end)}" title="End time (m:ss.s)"
+            onblur="updateSegmentTime(${i},'end',this.value)"
+            onkeydown="if(event.key==='Enter')this.blur()">
+          <button type="button" class="seg-edit-delete-btn" onclick="deleteSegment(${i})" title="Delete segment">πŸ—‘</button>
+        </div>
+        <textarea class="seg-edit-text" oninput="updateSegmentText(${i},this.value)">${seg.text}</textarea>
+        <button type="button" class="seg-add-btn" onclick="addSegmentAfter(${i})">+ Add segment after</button>`;
+      list.appendChild(card);
+      return;
+    }
+
     const ts = `${fmt(seg.start)} β†’ ${fmt(seg.end)}`;
     const wordsHtml = (seg.words || []).map(w =>
       `<span class="seg-word" data-start="${w.start}" data-end="${w.end}">${w.word.trim()}</span>`
@@ -695,6 +869,110 @@ function renderSegmentsList() {
   });
 }

+// ── Edit mode ─────────────────────────────────────────────────
+function toggleEditMode() {
+  editMode = !editMode;
+  const btn = document.getElementById('edit-mode-btn');
+  btn.classList.toggle('active', editMode);
+  btn.textContent = editMode ? 'Done' : 'Edit';
+  const bar = document.getElementById('timeline-bar');
+  bar.style.display = editMode ? 'flex' : 'none';
+  if (editMode) {
+    requestAnimationFrame(() => initTimeline());
+    const vid = document.getElementById('video-player');
+    if (vid.style.display !== 'none' && !vid.paused) startTlRaf();
+  } else {
+    stopTlRaf();
+  }
+  renderSegmentsList();
+}
+
+function parseFmt(str) {
+  const parts = str.trim().split(':');
+  if (parts.length !== 2) return NaN;
+  return parseInt(parts[0], 10) * 60 + parseFloat(parts[1]);
+}
+
+function distributeWords(text, start, end) {
+  const words = text.trim().split(/\s+/).filter(Boolean);
+  if (!words.length) return [];
+  const dur = (end - start) / words.length;
+  return words.map((w, i) => ({
+    word: w,
+    start: Math.round((start + i * dur) * 1000) / 1000,
+    end: Math.round((start + (i + 1) * dur) * 1000) / 1000,
+  }));
+}
+
+function updateSegmentText(i, text) {
+  segments[i].text = text;
+  segments[i].words = distributeWords(text, segments[i].start, segments[i].end);
+  updatePreview();
+  drawTimeline();
+}
+
+function updateSegmentTime(i, field, value) {
+  const t = parseFmt(value);
+  if (isNaN(t) || t < 0) return;
+  segments[i][field] = Math.round(t * 1000) / 1000;
+  segments[i].words = distributeWords(segments[i].text, segments[i].start, segments[i].end);
+  // Refresh just the time input to show normalised value
+  const card = document.getElementById(`seg-${i}`);
+  if (card) {
+    const inputs = card.querySelectorAll('.seg-edit-time-row input');
+    if (field === 'start' && inputs[0]) inputs[0].value = fmt(segments[i].start);
+    if (field === 'end' && inputs[1]) inputs[1].value = fmt(segments[i].end);
+  }
+  updatePreview();
+  drawTimeline();
+}
+
+function deleteSegment(i) {
+  segments.splice(i, 1);
+  // Re-index ids
+  segments.forEach((s, idx) => { s.id = idx; });
+  // Shift emoji/gif maps
+  const newEmojis = {}, newGifs = {};
+  Object.entries(segmentEmojis).forEach(([k, v]) => { const n = parseInt(k); if (n < i) newEmojis[n] = v; else if (n > i) newEmojis[n-1] = v; });
+  Object.entries(segmentGifs).forEach(([k, v]) => { const n = parseInt(k); if (n < i) newGifs[n] = v; else if (n > i) newGifs[n-1] = v; });
+  segmentEmojis = newEmojis;
+  segmentGifs = newGifs;
+  highlightScores = [];
+  highlightSelected = new Set();
+  renderSegmentsList();
+  refreshGifTargetDropdown();
+  updatePreview();
+}
+
+function addSegmentAfter(i) {
+  const prevEnd = segments[i].end;
+  const nextStart = segments[i + 1]?.start ?? prevEnd + 3;
+  const newSeg = {
+    id: i + 1,
+    start: Math.round(prevEnd * 1000) / 1000,
+    end: Math.round(Math.min(prevEnd + 2, nextStart) * 1000) / 1000,
+    text: '',
+    words: [],
+  };
+  segments.splice(i + 1, 0, newSeg);
+  segments.forEach((s, idx) => { s.id = idx; });
+  // Shift emoji/gif maps for indices > i
+  const newEmojis = {}, newGifs = {};
+  Object.entries(segmentEmojis).forEach(([k, v]) => { const n = parseInt(k); newEmojis[n <= i ? n : n + 1] = v; });
+  Object.entries(segmentGifs).forEach(([k, v]) => { const n = parseInt(k); newGifs[n <= i ? n : n + 1] = v; });
+  segmentEmojis = newEmojis;
+  segmentGifs = newGifs;
+  highlightScores = [];
+  highlightSelected = new Set();
+  renderSegmentsList();
+  refreshGifTargetDropdown();
+  // Focus the new card's textarea
+  setTimeout(() => {
+    const card = document.getElementById(`seg-${i + 1}`);
+    if (card) { card.querySelector('textarea')?.focus(); card.scrollIntoView({ block: 'nearest' }); }
+  }, 50);
+}
+
 function fmt(s) {
   const m = Math.floor(s/60), sec = (s%60).toFixed(1).padStart(4,'0');
   return `${m}:${sec}`;
@@ -710,6 +988,7 @@ function getStyle() {
     outlineColor: document.getElementById('outline-color').value,
     outlineWidth: parseInt(document.getElementById('outline-width').value),
     position: parseInt(document.getElementById('position').value) / 100,
+    positionX: parseInt(document.getElementById('position-x').value) / 100,
     uppercase: document.getElementById('uppercase-toggle').classList.contains('on'),
     highlightEnabled: document.getElementById('highlight-toggle').classList.contains('on'),
     wordsPerChunk: parseInt(document.getElementById('words-per-chunk').value),
@@ -755,7 +1034,7 @@ function renderSubtitles(time) {
   ctx.textBaseline = 'bottom';

   const y = canvas.height * s.position;
-  const cx = canvas.width / 2;
+  const cx = canvas.width * s.positionX;

   if (s.highlightEnabled) {
     // Render word by word with highlight
@@ -891,6 +1170,8 @@ function getAssStyle() {
     outline: s.outlineWidth,
     shadow: 0,
     margin_v: Math.round((1 - s.position / 100) * 1920 * 0.15 + 40),
+    pos_x_frac: s.positionX,
+    pos_y_frac: s.position,
   };
 }

@@ -1279,6 +1560,311 @@ function removeEmoji(segIdx, emoji) {
   updatePreview();
 }

+// ── Timeline ──────────────────────────────────────────────────
+async function generateWaveform(file) {
+  try {
+    const ab = await file.arrayBuffer();
+    const ac = new (window.AudioContext || window.webkitAudioContext)();
+    const buf = await ac.decodeAudioData(ab);
+    const data = buf.getChannelData(0);
+    const dur = buf.duration;
+    const numPeaks = Math.min(8192, Math.ceil(dur * 100));
+    const blockSize = Math.floor(data.length / numPeaks);
+    const peaks = new Float32Array(numPeaks);
+    for (let i = 0; i < numPeaks; i++) {
+      let max = 0;
+      const base = i * blockSize;
+      for (let j = 0; j < blockSize; j++) {
+        const abs = Math.abs(data[base + j] || 0);
+        if (abs > max) max = abs;
+      }
+      peaks[i] = max;
+    }
+    waveformData = { peaks, duration: dur, numPeaks };
+    ac.close();
+    if (document.getElementById('timeline-bar').style.display !== 'none') drawTimeline();
+  } catch (e) {
+    console.warn('Waveform failed:', e);
+  }
+}
+
+function tlTotalDuration() {
+  const vid = document.getElementById('video-player');
+  if (vid.style.display !== 'none' && vid.duration > 0) return vid.duration;
+  if (segments.length) return Math.max(...segments.map(s => s.end)) + 1;
+  return 60;
+}
+
+function initTimeline() {
+  const bar = document.getElementById('timeline-bar');
+  const canvas = document.getElementById('timeline-canvas');
+  canvas.width = bar.clientWidth;
+  canvas.height = bar.clientHeight - 28;
+  canvas.onmousedown = tlMouseDown;
+  canvas.onmousemove = tlMouseMove;
+  canvas.onmouseup = tlMouseUp;
+  canvas.onmouseleave = tlMouseLeave;
+  canvas.addEventListener('wheel', tlWheel, { passive: false });
+  fitTimeline();
+}
+
+function fitTimeline() {
+  const canvas = document.getElementById('timeline-canvas');
+  if (!canvas) return;
+  const W = canvas.width || 800;
+  const dur = tlTotalDuration();
+  tlZoom = dur > 0 ? Math.max(10, (W - 20) / dur) : 100;
+  tlOffset = 0;
+  updateTlZoomLabel();
+  drawTimeline();
+}
+
+function zoomTimeline(dir) {
+  const canvas = document.getElementById('timeline-canvas');
+  const W = canvas.width;
+  const centerT = tlOffset + W / 2 / tlZoom;
+  tlZoom = Math.max(10, Math.min(3000, tlZoom * (dir > 0 ? 1.6 : 0.625)));
+  tlOffset = Math.max(0, centerT - W / 2 / tlZoom);
+  updateTlZoomLabel();
+  drawTimeline();
+}
+
+function updateTlZoomLabel() {
+  const z = tlZoom / 100;
+  const lbl = document.getElementById('tl-zoom-label');
+  if (lbl) lbl.textContent = z >= 10 ? `${Math.round(z)}Γ—` : z >= 1 ? `${z.toFixed(1)}Γ—` : `${z.toFixed(2)}Γ—`;
+}
+
+function tlDims() {
+  const canvas = document.getElementById('timeline-canvas');
+  const W = canvas.width, H = canvas.height;
+  const RULER = 20, WAVE = 54;
+  const SUBS_Y = RULER + WAVE + 3;
+  const SUBS_H = H - SUBS_Y - 3;
+  return { W, H, RULER, WAVE, SUBS_Y, SUBS_H };
+}
+
+function tlX(t) { return (t - tlOffset) * tlZoom; }
+function tlT(x) { return tlOffset + x / tlZoom; }
+
+function drawTimeline() {
+  const canvas = document.getElementById('timeline-canvas');
+  if (!canvas) return;
+  const ctx = canvas.getContext('2d');
+  const { W, H, RULER, WAVE, SUBS_Y, SUBS_H } = tlDims();
+  if (!W || !H) return;
+
+  ctx.clearRect(0, 0, W, H);
+  ctx.fillStyle = '#060609';
+  ctx.fillRect(0, 0, W, H);
+
+  // ── Ruler ──
+  ctx.fillStyle = '#10101a';
+  ctx.fillRect(0, 0, W, RULER);
+  const tick = tlNiceTick(tlZoom);
+  const t0 = Math.floor(tlOffset / tick) * tick;
+  ctx.font = '9px monospace';
+  ctx.textBaseline = 'middle';
+  for (let t = t0; t < tlOffset + W / tlZoom + tick; t += tick) {
+    const x = Math.round(tlX(t));
+    if (x < -30 || x > W + 10) continue;
+    ctx.fillStyle = '#333';
+    ctx.fillRect(x, RULER - 5, 1, 5);
+    ctx.fillStyle = '#666';
+    ctx.textAlign = 'left';
+    ctx.fillText(tlFmtTime(t), x + 2, RULER / 2);
+  }
+  ctx.fillStyle = '#222';
+  ctx.fillRect(0, RULER, W, 1);
+
+  // ── Waveform ──
+  ctx.fillStyle = '#0c0c16';
+  ctx.fillRect(0, RULER, W, WAVE);
+  if (waveformData) {
+    const { peaks, duration, numPeaks } = waveformData;
+    const yCtr = RULER + WAVE / 2;
+    ctx.fillStyle = '#1e6e6e';
+    ctx.fillRect(0, yCtr, W, 1);
+    for (let px = 0; px < W; px++) {
+      const t = tlT(px);
+      if (t < 0 || t > duration) continue;
+      const idx = Math.min(numPeaks - 1, Math.floor((t / duration) * numPeaks));
+      const amp = Math.round(peaks[idx] * (WAVE / 2 - 2));
+      if (amp < 1) continue;
+      ctx.fillStyle = '#25f4ee60';
+      ctx.fillRect(px, yCtr - amp, 1, amp * 2);
+    }
+  }
+
+  // ── Subtitle blocks ──
+  ctx.fillStyle = '#111118';
+  ctx.fillRect(0, SUBS_Y - 3, W, 3);
+  ctx.fillStyle = '#0c0c14';
+  ctx.fillRect(0, SUBS_Y, W, SUBS_H);
+
+  segments.forEach((seg, i) => {
+    const x1 = tlX(seg.start), x2 = tlX(seg.end);
+    if (x2 < -1 || x1 > W + 1) return;
+    const bx = Math.max(0, x1), bx2 = Math.min(W, x2), bw = bx2 - bx;
+    if (bw <= 0) return;
+    const isDragging = tlDrag?.segIdx === i;
+    const isHovered = tlHoverSeg === i;
+    ctx.fillStyle = isDragging ? '#fe2c55dd' : isHovered ? '#fe2c55bb' : '#fe2c5580';
+    tlRoundRect(ctx, bx, SUBS_Y + 1, bw, SUBS_H - 2, 3);
+    ctx.fill();
+    const hw = Math.min(6, bw / 3);
+    if (x1 >= 0 && bw > 8) { ctx.fillStyle = '#0007'; tlRoundRect(ctx, bx, SUBS_Y + 1, hw, SUBS_H - 2, 3); ctx.fill(); }
+    if (x2 <= W && bw > 8) { ctx.fillStyle = '#0007'; tlRoundRect(ctx, bx2 - hw, SUBS_Y + 1, hw, SUBS_H - 2, 3); ctx.fill(); }
+    if (bw > 18) {
+      ctx.save();
+      ctx.beginPath(); ctx.rect(bx + hw + 2, SUBS_Y, bw - hw * 2 - 4, SUBS_H); ctx.clip();
+      ctx.fillStyle = 'rgba(255,255,255,.88)';
+      ctx.font = '10px Inter, sans-serif';
+      ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
+      ctx.fillText(seg.text, bx + hw + 4, SUBS_Y + SUBS_H / 2);
+      ctx.restore();
+    }
+  });
+
+  // ── Playhead ──
+  const vid = document.getElementById('video-player');
+  const ph = vid.style.display !== 'none' ? vid.currentTime : 0;
+  const phX = Math.round(tlX(ph));
+  if (phX >= 0 && phX <= W) {
+    ctx.strokeStyle = '#fe2c55';
+    ctx.lineWidth = 1.5;
+    ctx.beginPath(); ctx.moveTo(phX, 0); ctx.lineTo(phX, H); ctx.stroke();
+    ctx.fillStyle = '#fe2c55';
+    ctx.beginPath(); ctx.moveTo(phX - 5, 0); ctx.lineTo(phX + 5, 0); ctx.lineTo(phX, 8); ctx.closePath(); ctx.fill();
+  }
+}
+
+function tlRoundRect(ctx, x, y, w, h, r) {
+  r = Math.min(r, w / 2, h / 2);
+  ctx.beginPath();
+  ctx.moveTo(x + r, y);
+  ctx.arcTo(x + w, y, x + w, y + h, r);
+  ctx.arcTo(x + w, y + h, x, y + h, r);
+  ctx.arcTo(x, y + h, x, y, r);
+  ctx.arcTo(x, y, x + w, y, r);
+  ctx.closePath();
+}
+
+function tlNiceTick(pxPerSec) {
+  const sec = 80 / pxPerSec;
+  for (const n of [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]) {
+    if (n >= sec) return n;
+  }
+  return 600;
+}
+
+function tlFmtTime(s) {
+  if (s < 0) return '';
+  return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
+}
+
+function tlGetHit(x) {
+  const { SUBS_Y, SUBS_H } = tlDims();
+  for (let i = segments.length - 1; i >= 0; i--) {
+    const x1 = tlX(segments[i].start), x2 = tlX(segments[i].end);
+    const hw = Math.min(8, Math.max(4, (x2 - x1) / 4));
+    if (x < x1 - 2 || x > x2 + 2) continue;
+    if (x <= x1 + hw) return { type: 'resize-start', segIdx: i };
+    if (x >= x2 - hw) return { type: 'resize-end', segIdx: i };
+    return { type: 'move', segIdx: i };
+  }
+  return null;
+}
+
+function tlMouseDown(e) {
+  const x = e.offsetX, y = e.offsetY;
+  const { SUBS_Y, SUBS_H } = tlDims();
+  if (y < SUBS_Y || y > SUBS_Y + SUBS_H) {
+    const vid = document.getElementById('video-player');
+    if (vid.style.display !== 'none') vid.currentTime = Math.max(0, tlT(x));
+    drawTimeline();
+    return;
+  }
+  const hit = tlGetHit(x);
+  if (!hit) {
+    const vid = document.getElementById('video-player');
+    if (vid.style.display !== 'none') vid.currentTime = Math.max(0, tlT(x));
+    return;
+  }
+  tlDrag = { type: hit.type, segIdx: hit.segIdx, startX: x, origStart: segments[hit.segIdx].start, origEnd: segments[hit.segIdx].end };
+  e.currentTarget.style.cursor = hit.type === 'move' ? 'grabbing' : 'ew-resize';
+  e.preventDefault();
+}
+
+function tlMouseMove(e) {
+  const x = e.offsetX, y = e.offsetY;
+  const { SUBS_Y, SUBS_H } = tlDims();
+  if (tlDrag) {
+    const dt = (x - tlDrag.startX) / tlZoom;
+    const seg = segments[tlDrag.segIdx];
+    const MIN = 0.05;
+    if (tlDrag.type === 'move') {
+      const dur = tlDrag.origEnd - tlDrag.origStart;
+      seg.start = Math.round(Math.max(0, tlDrag.origStart + dt) * 1000) / 1000;
+      seg.end = Math.round((seg.start + dur) * 1000) / 1000;
+    } else if (tlDrag.type === 'resize-start') {
+      seg.start = Math.round(Math.min(tlDrag.origEnd - MIN, Math.max(0, tlDrag.origStart + dt)) * 1000) / 1000;
+    } else {
+      seg.end = Math.round(Math.max(tlDrag.origStart + MIN, tlDrag.origEnd + dt) * 1000) / 1000;
+    }
+    seg.words = distributeWords(seg.text, seg.start, seg.end);
+    updateTimeInputs(tlDrag.segIdx);
+    updatePreview();
+    drawTimeline();
+    return;
+  }
+  if (y >= SUBS_Y && y <= SUBS_Y + SUBS_H) {
+    const hit = tlGetHit(x);
+    tlHoverSeg = hit ? hit.segIdx : -1;
+    e.currentTarget.style.cursor = !hit ? 'crosshair' : hit.type === 'move' ? 'grab' : 'ew-resize';
+  } else {
+    tlHoverSeg = -1;
+    e.currentTarget.style.cursor = 'crosshair';
+  }
+  drawTimeline();
+}
+
+function tlMouseUp() {
+  if (tlDrag) { renderSegmentsList(); tlDrag = null; }
+  const canvas = document.getElementById('timeline-canvas');
+  if (canvas) canvas.style.cursor = 'crosshair';
+}
+
+function tlMouseLeave() {
+  if (tlDrag) { renderSegmentsList(); tlDrag = null; }
+  tlHoverSeg = -1;
+  drawTimeline();
+}
+
+function tlWheel(e) {
+  e.preventDefault();
+  if (e.ctrlKey || e.metaKey) {
+    const curT = tlT(e.offsetX);
+    tlZoom = Math.max(10, Math.min(3000, tlZoom * (e.deltaY < 0 ? 1.12 : 0.89)));
+    tlOffset = Math.max(0, curT - e.offsetX / tlZoom);
+    updateTlZoomLabel();
+  } else {
+    tlOffset = Math.max(0, tlOffset + e.deltaY / tlZoom);
+  }
+  drawTimeline();
+}
+
+function startTlRaf() {
+  if (tlRafId) return;
+  const loop = () => { drawTimeline(); tlRafId = requestAnimationFrame(loop); };
+  tlRafId = requestAnimationFrame(loop);
+}
+function stopTlRaf() {
+  if (tlRafId) { cancelAnimationFrame(tlRafId); tlRafId = null; }
+  drawTimeline();
+}
+
+// ── Emoji reactions ───────────────────────────────────────────
 function refreshEmojiRow(segIdx) {
   const row = document.getElementById(`emoji-row-${segIdx}`);
   if (!row) return;