commit 806171e41e5c4e1ad7493dba48e7af94850f8de0
Author: jens <jens.se@icloud.com>
AuthorDate: Mon Apr 20 19:49:43 2026 +0200
Commit: jens <jens.se@icloud.com>
CommitDate: Mon Apr 20 19:49:43 2026 +0200
first commit
---
.DS_Store | Bin 0 -> 6148 bytes
app.py | 514 ++++++++++++++++++++++
index.html | 1296 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
requirements.txt | 5 +
start.sh | 9 +
5 files changed, 1824 insertions(+)
diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..2b25708
Binary files /dev/null and b/.DS_Store differ
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..a364ff8
--- /dev/null
+++ b/app.py
@@ -0,0 +1,514 @@
+import os
+import json
+import subprocess
+import tempfile
+import shutil
+import urllib.request
+import urllib.parse
+from pathlib import Path
+
+import whisper
+from fastapi import FastAPI, File, UploadFile, Form, HTTPException
+from fastapi.responses import FileResponse, HTMLResponse
+from fastapi.staticfiles import StaticFiles
+
+app = FastAPI()
+
+UPLOAD_DIR = Path("uploads")
+OUTPUT_DIR = Path("outputs")
+UPLOAD_DIR.mkdir(exist_ok=True)
+OUTPUT_DIR.mkdir(exist_ok=True)
+
+_model_cache = {}
+
+
+def get_model(model_name: str):
+ if model_name not in _model_cache:
+ _model_cache[model_name] = whisper.load_model(model_name)
+ return _model_cache[model_name]
+
+
+@app.get("/", response_class=HTMLResponse)
+async def index():
+ return open("index.html").read()
+
+
+@app.post("/transcribe")
+async def transcribe(
+ file: UploadFile = File(...),
+ model: str = Form("base"),
+ language: str = Form("auto"),
+):
+ suffix = Path(file.filename).suffix
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=UPLOAD_DIR) as tmp:
+ shutil.copyfileobj(file.file, tmp)
+ tmp_path = tmp.name
+
+ try:
+ m = get_model(model)
+ opts = {"word_timestamps": True}
+ if language != "auto":
+ opts["language"] = language
+
+ result = m.transcribe(tmp_path, **opts)
+
+ segments = []
+ for seg in result["segments"]:
+ words = []
+ for w in seg.get("words", []):
+ words.append({
+ "word": w["word"],
+ "start": round(w["start"], 3),
+ "end": round(w["end"], 3),
+ })
+ segments.append({
+ "id": seg["id"],
+ "start": round(seg["start"], 3),
+ "end": round(seg["end"], 3),
+ "text": seg["text"].strip(),
+ "words": words,
+ })
+
+ return {"segments": segments, "language": result.get("language", "unknown")}
+ finally:
+ os.unlink(tmp_path)
+
+
+@app.post("/export/srt")
+async def export_srt(data: dict):
+ segments = data.get("segments", [])
+ words_per_chunk = int(data.get("words_per_chunk", 3))
+
+ lines = []
+ idx = 1
+ for seg in segments:
+ words = seg.get("words", [])
+ if not words:
+ start = seg["start"]
+ end = seg["end"]
+ text = seg["text"]
+ lines.append(f"{idx}\n{_srt_time(start)} --> {_srt_time(end)}\n{text}\n")
+ idx += 1
+ continue
+
+ chunks = [words[i:i+words_per_chunk] for i in range(0, len(words), words_per_chunk)]
+ for chunk in chunks:
+ start = chunk[0]["start"]
+ end = chunk[-1]["end"]
+ text = "".join(w["word"] for w in chunk).strip()
+ lines.append(f"{idx}\n{_srt_time(start)} --> {_srt_time(end)}\n{text}\n")
+ idx += 1
+
+ srt_path = OUTPUT_DIR / "subtitles.srt"
+ srt_path.write_text("\n".join(lines))
+ return FileResponse(srt_path, filename="subtitles.srt", media_type="text/plain")
+
+
+@app.post("/export/ass")
+async def export_ass(data: dict):
+ segments = data.get("segments", [])
+ style = data.get("style", {})
+ words_per_chunk = int(data.get("words_per_chunk", 3))
+
+ font = style.get("font", "Arial Black")
+ fontsize = style.get("fontsize", 22)
+ primary = style.get("primary_color", "&H00FFFFFF")
+ outline_color = style.get("outline_color", "&H00000000")
+ highlight_color = style.get("highlight_color", "&H0000F0FF")
+ bold = style.get("bold", True)
+ outline = style.get("outline", 3)
+ shadow = style.get("shadow", 0)
+ margin_v = style.get("margin_v", 80)
+ alignment = style.get("alignment", 2) # bottom center
+
+ header = f"""[Script Info]
+ScriptType: v4.00+
+PlayResX: 1080
+PlayResY: 1920
+WrapStyle: 0
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,{font},{fontsize},{primary},&H00FFFFFF,{outline_color},&H00000000,{int(bold)},0,0,0,100,100,0,0,1,{outline},{shadow},{alignment},10,10,{margin_v},1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+"""
+
+ events = []
+ for seg in segments:
+ words = seg.get("words", [])
+ 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']}")
+ continue
+
+ chunks = [words[i:i+words_per_chunk] for i in range(0, len(words), words_per_chunk)]
+ for chunk in chunks:
+ chunk_start = chunk[0]["start"]
+ chunk_end = chunk[-1]["end"]
+ line_parts = []
+ 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)
+ events.append(f"Dialogue: 0,{_ass_time(chunk_start)},{_ass_time(chunk_end)},Default,,0,0,0,,{text}")
+
+ ass_path = OUTPUT_DIR / "subtitles.ass"
+ ass_path.write_text(header + "\n".join(events))
+ return FileResponse(ass_path, filename="subtitles.ass", media_type="text/plain")
+
+
+@app.post("/export/video")
+async def export_video(
+ file: UploadFile = File(...),
+ segments_json: str = Form(...),
+ style_json: str = Form(...),
+ words_per_chunk: int = Form(3),
+):
+ segments = json.loads(segments_json)
+ style = json.loads(style_json)
+
+ suffix = Path(file.filename).suffix
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=UPLOAD_DIR) as tmp:
+ shutil.copyfileobj(file.file, tmp)
+ video_path = tmp.name
+
+ ass_path = OUTPUT_DIR / "burn_subs.ass"
+ out_path = OUTPUT_DIR / "output_with_subs.mp4"
+
+ ass_data = {"segments": segments, "style": style, "words_per_chunk": words_per_chunk}
+
+ font = style.get("font", "Arial Black")
+ fontsize = style.get("fontsize", 22)
+ primary = style.get("primary_color", "&H00FFFFFF")
+ outline_color = style.get("outline_color", "&H00000000")
+ highlight_color = style.get("highlight_color", "&H0000F0FF")
+ bold = int(style.get("bold", True))
+ outline = style.get("outline", 3)
+ shadow = style.get("shadow", 0)
+ margin_v = style.get("margin_v", 80)
+
+ header = f"""[Script Info]
+ScriptType: v4.00+
+PlayResX: 1080
+PlayResY: 1920
+WrapStyle: 0
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,{font},{fontsize},{primary},&H00FFFFFF,{outline_color},&H00000000,{bold},0,0,0,100,100,0,0,1,{outline},{shadow},2,10,10,{margin_v},1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+"""
+ events = []
+ 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']}")
+ continue
+ chunks = [words[i:i+words_per_chunk] for i in range(0, len(words), words_per_chunk)]
+ for chunk in chunks:
+ chunk_start = chunk[0]["start"]
+ chunk_end = chunk[-1]["end"]
+ line_parts = []
+ 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)
+ 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}",
+ "-c:a", "copy",
+ str(out_path)
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ os.unlink(video_path)
+
+ if result.returncode != 0:
+ raise HTTPException(500, f"ffmpeg error: {result.stderr[-500:]}")
+
+ return FileResponse(out_path, filename="output_with_subs.mp4", media_type="video/mp4")
+
+
+@app.get("/search_gifs")
+async def search_gifs(q: str, api_key: str = "", limit: int = 16):
+ key = api_key.strip() or "dc6zaTOxFJmzC"
+ params = urllib.parse.urlencode({"api_key": key, "q": q, "limit": limit, "rating": "g"})
+ try:
+ with urllib.request.urlopen(f"https://api.giphy.com/v1/gifs/search?{params}", timeout=8) as r:
+ data = json.loads(r.read())
+ except Exception as e:
+ raise HTTPException(502, f"Giphy error: {e}")
+ results = []
+ for g in data.get("data", []):
+ images = g["images"]
+ results.append({
+ "id": g["id"],
+ "preview": images["fixed_height_small"]["url"],
+ "mp4": images.get("original_mp4", {}).get("mp4") or images["original"].get("mp4", ""),
+ "title": g.get("title", ""),
+ })
+ return {"results": results}
+
+
+@app.post("/crop")
+async def crop_video(
+ file: UploadFile = File(...),
+ aspect: str = Form("9:16"),
+ offset_x: float = Form(0.5),
+ offset_y: float = Form(0.5),
+):
+ suffix = Path(file.filename).suffix
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=UPLOAD_DIR) as tmp:
+ shutil.copyfileobj(file.file, tmp)
+ video_path = tmp.name
+
+ probe = subprocess.run(
+ ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", video_path],
+ capture_output=True, text=True,
+ )
+ info = json.loads(probe.stdout)
+ vstream = next(s for s in info["streams"] if s["codec_type"] == "video")
+ src_w, src_h = int(vstream["width"]), int(vstream["height"])
+
+ if aspect == "original":
+ os.unlink(video_path)
+ raise HTTPException(400, "Already original aspect")
+
+ a_num, a_den = map(int, aspect.split(":"))
+ target_ratio = a_num / a_den
+ src_ratio = src_w / src_h
+
+ if src_ratio > target_ratio:
+ crop_h = src_h
+ crop_w = int(src_h * target_ratio) & ~1
+ crop_x = int((src_w - crop_w) * max(0.0, min(1.0, offset_x)))
+ crop_y = 0
+ else:
+ crop_w = src_w
+ crop_h = int(src_w / target_ratio) & ~1
+ crop_x = 0
+ crop_y = int((src_h - crop_h) * max(0.0, min(1.0, offset_y)))
+
+ TARGETS = {"9:16": (1080, 1920), "1:1": (1080, 1080), "4:5": (1080, 1350), "16:9": (1920, 1080)}
+ out_w, out_h = TARGETS.get(aspect, (crop_w, crop_h))
+
+ out_path = OUTPUT_DIR / f"cropped_{aspect.replace(':','x')}.mp4"
+ cmd = [
+ "ffmpeg", "-y", "-i", video_path,
+ "-vf", f"crop={crop_w}:{crop_h}:{crop_x}:{crop_y},scale={out_w}:{out_h}",
+ "-c:v", "libx264", "-crf", "18", "-preset", "fast",
+ "-c:a", "aac", str(out_path),
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ os.unlink(video_path)
+
+ if result.returncode != 0:
+ raise HTTPException(500, f"ffmpeg error: {result.stderr[-500:]}")
+
+ return FileResponse(out_path, filename=out_path.name, media_type="video/mp4")
+
+
+@app.post("/analyze_highlights")
+async def analyze_highlights(data: dict):
+ segments = data.get("segments", [])
+ target_pct = float(data.get("target_pct", 0.5))
+ api_key = data.get("api_key", "").strip()
+
+ if not segments:
+ raise HTTPException(400, "No segments provided")
+
+ if api_key:
+ try:
+ return _analyze_with_claude(segments, target_pct, api_key)
+ except Exception as e:
+ # Fallback to local on any Claude error
+ result = _local_scoring(segments, target_pct)
+ result["warning"] = f"Claude failed ({e}), used local scoring"
+ return result
+ return _local_scoring(segments, target_pct)
+
+
+_ENERGY_WORDS = {
+ "wow","amazing","incredible","crazy","insane","unbelievable","shocking","never",
+ "always","everyone","nobody","secret","reveal","key","important","critical",
+ "best","worst","perfect","terrible","huge","massive","biggest","smallest",
+ "fantastisk","otrolig","galen","otroligt","aldrig","alltid","alla","ingen",
+ "viktig","hemlig","avslöja","bäst","sämst","perfekt","enorm","störst",
+}
+
+_IMPORTANCE_STARTERS = [
+ "the key","most important","remember","here's why","the reason","this is why",
+ "you need to","the secret","what you","the problem","the solution","the truth",
+ "det viktigaste","kom ihåg","anledningen","hemligheten","problemet","lösningen",
+ "sanningen","du måste","vad du",
+]
+
+
+def _score_segment(seg: dict) -> float:
+ text = seg.get("text", "").lower()
+ dur = seg.get("end", 0) - seg.get("start", 0)
+ score = 1.0
+
+ words_in_seg = text.split()
+ score += sum(2.0 for w in words_in_seg if w.strip(".,!?") in _ENERGY_WORDS)
+ score += text.count("!") * 1.5
+ score += text.count("?") * 1.0
+
+ if dur > 0:
+ wps = len(seg.get("words") or words_in_seg) / dur
+ if wps > 2.5:
+ score += 1.0
+
+ if dur < 1.0:
+ score *= 0.5
+
+ for starter in _IMPORTANCE_STARTERS:
+ if text.startswith(starter):
+ score += 2.0
+ break
+
+ return score
+
+
+def _local_scoring(segments: list, target_pct: float) -> dict:
+ raw_scores = [_score_segment(s) for s in segments]
+ max_s = max(raw_scores) if raw_scores else 1.0
+ scores = [round(s / max_s, 3) for s in raw_scores]
+
+ total_dur = sum(s.get("end", 0) - s.get("start", 0) for s in segments)
+ target_dur = total_dur * target_pct
+
+ # Sort by score desc, greedily fill target duration
+ ranked = sorted(range(len(segments)), key=lambda i: -scores[i])
+ selected_set = set()
+ selected_dur = 0.0
+ for i in ranked:
+ seg_dur = segments[i].get("end", 0) - segments[i].get("start", 0)
+ if selected_dur + seg_dur <= target_dur * 1.15 or not selected_set:
+ selected_set.add(i)
+ selected_dur += seg_dur
+ if selected_dur >= target_dur:
+ break
+
+ return {"scores": scores, "selected": sorted(selected_set)}
+
+
+def _analyze_with_claude(segments: list, target_pct: float, api_key: str) -> dict:
+ import anthropic
+
+ total_dur = sum(s.get("end", 0) - s.get("start", 0) for s in segments)
+ lines = "\n".join(
+ f"[{i}] {s['start']:.1f}s-{s['end']:.1f}s: {s['text'].strip()}"
+ for i, s in enumerate(segments)
+ )
+
+ client = anthropic.Anthropic(api_key=api_key)
+ msg = client.messages.create(
+ model="claude-haiku-4-5-20251001",
+ max_tokens=1024,
+ messages=[{
+ "role": "user",
+ "content": (
+ f"Analyze this video transcript and select the most important/engaging segments "
+ f"for a highlight reel.\n\nTranscript:\n{lines}\n\n"
+ f"Select segments totaling ~{int(target_pct*100)}% of the full {total_dur:.0f}s duration "
+ f"(target ~{total_dur*target_pct:.0f}s).\n"
+ f"Prioritize: key insights, surprising moments, emotional peaks, memorable quotes.\n"
+ f"Avoid: filler, repetition, greetings, transitions.\n\n"
+ f"Reply ONLY with valid JSON: "
+ f'{{ "selected": [list of segment indices], "scores": [0.0-1.0 score per segment in order] }}'
+ ),
+ }],
+ )
+
+ import re
+ match = re.search(r"\{.*\}", msg.content[0].text, re.DOTALL)
+ if not match:
+ raise ValueError("No JSON in response")
+ parsed = json.loads(match.group())
+ scores = [round(float(x), 3) for x in parsed.get("scores", [0.5] * len(segments))]
+ selected = sorted(int(x) for x in parsed.get("selected", []))
+ return {"scores": scores, "selected": selected}
+
+
+@app.post("/export/highlights")
+async def export_highlights(
+ file: UploadFile = File(...),
+ ranges_json: str = Form(...),
+ padding: float = Form(0.15),
+):
+ ranges = json.loads(ranges_json)
+ if not ranges:
+ raise HTTPException(400, "No ranges provided")
+
+ suffix = Path(file.filename).suffix
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=UPLOAD_DIR) as tmp:
+ shutil.copyfileobj(file.file, tmp)
+ video_path = tmp.name
+
+ out_path = OUTPUT_DIR / "highlights.mp4"
+
+ # Pad each range and clamp to 0
+ padded = [[max(0.0, s - padding), e + padding] for s, e in ranges]
+
+ if len(padded) == 1:
+ s, e = padded[0]
+ cmd = [
+ "ffmpeg", "-y", "-ss", str(s), "-to", str(e), "-i", video_path,
+ "-c:v", "libx264", "-crf", "18", "-preset", "fast", "-c:a", "aac",
+ str(out_path),
+ ]
+ else:
+ v_parts, a_parts = [], []
+ for i, (s, e) in enumerate(padded):
+ v_parts.append(f"[0:v]trim=start={s}:end={e},setpts=PTS-STARTPTS[v{i}]")
+ a_parts.append(f"[0:a]atrim=start={s}:end={e},asetpts=PTS-STARTPTS[a{i}]")
+ n = len(padded)
+ concat_in = "".join(f"[v{i}][a{i}]" for i in range(n))
+ filter_complex = (
+ ";".join(v_parts + a_parts)
+ + f";{concat_in}concat=n={n}:v=1:a=1[vout][aout]"
+ )
+ cmd = [
+ "ffmpeg", "-y", "-i", video_path,
+ "-filter_complex", filter_complex,
+ "-map", "[vout]", "-map", "[aout]",
+ "-c:v", "libx264", "-crf", "18", "-preset", "fast", "-c:a", "aac",
+ str(out_path),
+ ]
+
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ os.unlink(video_path)
+
+ if result.returncode != 0:
+ raise HTTPException(500, f"ffmpeg error: {result.stderr[-500:]}")
+
+ return FileResponse(out_path, filename="highlights.mp4", media_type="video/mp4")
+
+
+def _srt_time(seconds: float) -> str:
+ h = int(seconds // 3600)
+ m = int((seconds % 3600) // 60)
+ s = int(seconds % 60)
+ ms = int((seconds % 1) * 1000)
+ return f"{h:02}:{m:02}:{s:02},{ms:03}"
+
+
+def _ass_time(seconds: float) -> str:
+ h = int(seconds // 3600)
+ m = int((seconds % 3600) // 60)
+ s = seconds % 60
+ return f"{h}:{m:02}:{s:05.2f}"
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..119b9b4
--- /dev/null
+++ b/index.html
@@ -0,0 +1,1296 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>SubTok — TikTok Subtitles</title>
+<style>
+ :root {
+ --bg: #0a0a0f;
+ --surface: #13131a;
+ --surface2: #1c1c26;
+ --border: #2a2a3a;
+ --accent: #fe2c55;
+ --accent2: #25f4ee;
+ --text: #f0f0f0;
+ --muted: #888;
+ --radius: 12px;
+ }
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ body { background: var(--bg); color: var(--text); font-family: 'Inter', -apple-system, sans-serif; min-height: 100vh; }
+
+ header {
+ display: flex; align-items: center; gap: 12px;
+ padding: 20px 32px; border-bottom: 1px solid var(--border);
+ background: var(--surface);
+ }
+ .logo { font-size: 24px; font-weight: 900; background: linear-gradient(135deg, var(--accent), var(--accent2)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
+ .logo-sub { font-size: 13px; color: var(--muted); font-weight: 400; }
+
+ .layout { display: grid; grid-template-columns: 380px 1fr; height: calc(100vh - 65px); overflow: hidden; }
+
+ .sidebar {
+ background: var(--surface); border-right: 1px solid var(--border);
+ overflow-y: auto; display: flex; flex-direction: column; gap: 0;
+ }
+ .section { padding: 20px; border-bottom: 1px solid var(--border); }
+ .section-title { font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 14px; }
+
+ .main { display: flex; flex-direction: column; overflow: hidden; }
+
+ /* Upload zone */
+ .upload-zone {
+ border: 2px dashed var(--border); border-radius: var(--radius);
+ padding: 32px 20px; text-align: center; cursor: pointer;
+ transition: all 0.2s; background: var(--surface2);
+ }
+ .upload-zone:hover, .upload-zone.drag { border-color: var(--accent); background: rgba(254,44,85,0.05); }
+ .upload-icon { font-size: 36px; margin-bottom: 10px; }
+ .upload-label { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
+ .upload-sub { font-size: 12px; color: var(--muted); }
+ .upload-zone input { display: none; }
+
+ /* Controls */
+ label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; margin-top: 12px; }
+ label:first-of-type { margin-top: 0; }
+ select, input[type=range], input[type=color], input[type=number] {
+ width: 100%; background: var(--surface2); border: 1px solid var(--border);
+ color: var(--text); border-radius: 8px; padding: 8px 10px; font-size: 13px;
+ appearance: none; outline: none;
+ }
+ select:focus, input:focus { border-color: var(--accent2); }
+ input[type=range] { padding: 4px 0; cursor: pointer; accent-color: var(--accent); }
+ .row { display: flex; gap: 8px; }
+ .row > * { flex: 1; }
+
+ .color-row { display: flex; gap: 8px; align-items: center; }
+ .color-row input[type=color] { width: 42px; height: 36px; padding: 2px; cursor: pointer; border-radius: 8px; flex-shrink: 0; }
+ .color-row span { font-size: 12px; color: var(--muted); }
+
+ /* Preset chips */
+ .presets { display: flex; flex-wrap: wrap; gap: 6px; }
+ .preset-chip {
+ padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
+ border: 1px solid var(--border); cursor: pointer; transition: all 0.15s;
+ background: var(--surface2);
+ }
+ .preset-chip:hover { border-color: var(--accent2); color: var(--accent2); }
+ .preset-chip.active { background: var(--accent); border-color: var(--accent); color: white; }
+
+ /* Btn */
+ .btn {
+ width: 100%; padding: 12px; border-radius: var(--radius); font-size: 14px;
+ font-weight: 700; border: none; cursor: pointer; transition: all 0.15s;
+ display: flex; align-items: center; justify-content: center; gap: 8px;
+ }
+ .btn-primary { background: linear-gradient(135deg, var(--accent), #ff3b5c); color: white; }
+ .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(254,44,85,0.4); }
+ .btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
+ .btn-secondary:hover { border-color: var(--accent2); color: var(--accent2); }
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; }
+
+ /* Progress */
+ .progress-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin: 12px 0; }
+ .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 2px; transition: width 0.3s; width: 0; }
+ .status-text { font-size: 12px; color: var(--muted); text-align: center; min-height: 18px; }
+
+ /* Main content */
+ .preview-area { flex: 1; display: flex; gap: 0; overflow: hidden; }
+
+ .video-panel {
+ flex: 1; display: flex; flex-direction: column; align-items: center;
+ justify-content: center; padding: 24px; background: #050508; position: relative;
+ }
+ .video-wrapper { position: relative; max-width: 100%; max-height: 100%; }
+ video { max-width: 100%; max-height: calc(100vh - 200px); border-radius: var(--radius); display: block; }
+ .video-placeholder {
+ width: 300px; height: 500px; border-radius: var(--radius);
+ background: var(--surface2); border: 2px dashed var(--border);
+ display: flex; align-items: center; justify-content: center;
+ flex-direction: column; gap: 12px; color: var(--muted);
+ }
+ .video-placeholder .ph-icon { font-size: 48px; }
+
+ /* Subtitle canvas overlay */
+ #sub-canvas {
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+ pointer-events: none; border-radius: var(--radius);
+ }
+
+ /* Segments panel */
+ .segments-panel {
+ width: 340px; background: var(--surface); border-left: 1px solid var(--border);
+ display: flex; flex-direction: column; overflow: hidden;
+ }
+ .segments-header {
+ padding: 16px 20px; border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; justify-content: space-between;
+ }
+ .segments-header h3 { font-size: 13px; font-weight: 700; }
+ .seg-count { font-size: 11px; color: var(--muted); background: var(--surface2); padding: 2px 8px; border-radius: 10px; }
+ .segments-list { flex: 1; overflow-y: auto; padding: 8px; }
+ .segment-card {
+ padding: 10px 12px; border-radius: 8px; margin-bottom: 4px;
+ background: var(--surface2); border: 1px solid transparent;
+ cursor: pointer; transition: all 0.15s;
+ }
+ .segment-card:hover { border-color: var(--border); }
+ .segment-card.active { border-color: var(--accent2); background: rgba(37,244,238,0.05); }
+ .seg-time { font-size: 10px; color: var(--accent2); font-family: monospace; margin-bottom: 4px; }
+ .seg-text { font-size: 13px; line-height: 1.4; }
+ .seg-words { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
+ .seg-word {
+ font-size: 11px; padding: 2px 7px; border-radius: 4px;
+ background: var(--border); color: var(--muted);
+ }
+ .seg-word.highlight { background: var(--accent); color: white; }
+
+ /* Export bar */
+ .export-bar {
+ padding: 16px 20px; border-top: 1px solid var(--border);
+ background: var(--surface); display: flex; gap: 8px; align-items: center;
+ }
+ .export-bar .btn { width: auto; padding: 10px 18px; font-size: 13px; }
+
+ /* Toggle */
+ .toggle-row { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
+ .toggle-label { font-size: 13px; }
+ .toggle {
+ width: 40px; height: 22px; background: var(--border); border-radius: 11px;
+ cursor: pointer; position: relative; transition: background 0.2s;
+ }
+ .toggle.on { background: var(--accent); }
+ .toggle::after {
+ content: ''; position: absolute; width: 16px; height: 16px;
+ background: white; border-radius: 50%; top: 3px; left: 3px; transition: left 0.2s;
+ }
+ .toggle.on::after { left: 21px; }
+
+ /* Scrollbar */
+ ::-webkit-scrollbar { width: 4px; }
+ ::-webkit-scrollbar-track { background: transparent; }
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+ ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
+
+ /* Animations */
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
+ .pulsing { animation: pulse 1.5s infinite; }
+
+ .lang-badge {
+ display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px;
+ background: rgba(37,244,238,0.15); color: var(--accent2); margin-left: 8px;
+ }
+
+ /* Emoji */
+ .seg-emoji-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
+ .seg-emoji-badge {
+ font-size: 20px; cursor: pointer; border-radius: 6px; padding: 2px 4px;
+ background: var(--border); transition: transform 0.1s;
+ position: relative;
+ }
+ .seg-emoji-badge:hover { transform: scale(1.2); }
+ .seg-emoji-badge .remove-emoji {
+ display: none; position: absolute; top: -6px; right: -6px;
+ background: var(--accent); color: white; border-radius: 50%;
+ width: 14px; height: 14px; font-size: 9px; line-height: 14px; text-align: center;
+ }
+ .seg-emoji-badge:hover .remove-emoji { display: block; }
+ .add-emoji-btn {
+ font-size: 14px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
+ background: var(--surface2); border: 1px dashed var(--border); color: var(--muted);
+ transition: all 0.15s;
+ }
+ .add-emoji-btn:hover { border-color: var(--accent2); color: var(--accent2); }
+
+ .emoji-picker {
+ position: fixed; z-index: 1000; background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 12px; width: 280px;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
+ }
+ .emoji-picker-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
+ .emoji-picker-title { font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; }
+ .emoji-picker-close { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 16px; padding: 0; }
+ .emoji-category { font-size: 10px; color: var(--muted); margin: 8px 0 4px; text-transform: uppercase; letter-spacing: 0.5px; }
+ .emoji-grid { display: flex; flex-wrap: wrap; gap: 4px; }
+ .emoji-opt {
+ font-size: 22px; padding: 4px; border-radius: 6px; cursor: pointer;
+ transition: background 0.1s; line-height: 1;
+ }
+ .emoji-opt:hover { background: var(--surface2); transform: scale(1.15); }
+ .emoji-opt.selected { background: rgba(254,44,85,0.2); outline: 1px solid var(--accent); }
+ .emoji-autodetect-btn { margin-bottom: 10px; }
+ .emoji-toggle-row { margin-top: 10px; }
+
+ /* GIF */
+ .gif-search-row { display: flex; gap: 6px; margin-bottom: 10px; }
+ .gif-search-row input { flex: 1; }
+ .gif-search-btn { background: var(--accent2); color: #000; border: none; border-radius: 8px; padding: 8px 12px; cursor: pointer; font-size: 14px; flex-shrink: 0; font-weight: 700; }
+ .gif-search-btn:hover { opacity: 0.85; }
+ .gif-results { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-height: 200px; overflow-y: auto; margin-top: 8px; }
+ .gif-thumb { border-radius: 6px; overflow: hidden; cursor: pointer; aspect-ratio: 1; background: var(--surface2); position: relative; border: 2px solid transparent; transition: border-color 0.15s; }
+ .gif-thumb:hover { border-color: var(--accent2); }
+ .gif-thumb.selected { border-color: var(--accent); }
+ .gif-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+ .gif-assign-target { margin-bottom: 10px; }
+ .seg-gif-badge { display: flex; align-items: center; gap: 6px; margin-top: 6px; background: var(--border); border-radius: 6px; padding: 4px 6px; }
+ .seg-gif-badge img { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; }
+ .seg-gif-badge .gif-remove { background: var(--accent); border: none; color: white; border-radius: 50%; width: 16px; height: 16px; font-size: 10px; cursor: pointer; margin-left: auto; flex-shrink: 0; }
+ .gif-overlay-item { position: absolute; pointer-events: none; border-radius: 4px; overflow: hidden; }
+ .gif-overlay-item video { width: 100%; height: 100%; object-fit: cover; display: block; }
+
+ /* Crop */
+ .crop-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: none; border-radius: var(--radius); }
+ .crop-darken { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
+ .crop-window { position: absolute; background: transparent; box-shadow: 0 0 0 9999px rgba(0,0,0,0.55); border: 2px solid var(--accent2); box-sizing: border-box; }
+ .crop-label { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); background: var(--accent2); color: #000; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; white-space: nowrap; }
+ .crop-aspect-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
+ .crop-chip { padding: 6px 10px; border-radius: 20px; font-size: 12px; font-weight: 700; border: 1px solid var(--border); cursor: pointer; background: var(--surface2); transition: all 0.15s; }
+ .crop-chip:hover { border-color: var(--accent2); }
+ .crop-chip.active { background: var(--accent); border-color: var(--accent); color: white; }
+ .crop-export-btn { margin-top: 12px; }
+ .gif-search-section { margin-top: 10px; }
+ .gif-position-label { margin-top: 10px; }
+ .gif-show-toggle-row { margin-top: 10px; }
+ .giphy-key-input { margin-top: 10px; font-size: 11px; opacity: 0.6; }
+ .crop-toggle-row { margin-top: 10px; }
+
+ /* AI Highlights */
+ .hl-actions { display: flex; gap: 6px; margin-top: 12px; }
+ .hl-actions .btn { flex: 1; padding: 10px 8px; font-size: 12px; }
+ .hl-divider { height: 1px; background: var(--border); margin: 14px 0; }
+ .hl-claude-row { display: flex; gap: 6px; margin-bottom: 8px; }
+ .hl-claude-row input { flex: 1; }
+ .hl-claude-btn { padding: 8px 10px; font-size: 12px; }
+ .hl-export-btn { margin-top: 12px; }
+ .hl-stats { font-size: 11px; color: var(--accent2); margin-top: 8px; min-height: 16px; text-align: center; }
+
+ /* 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; }
+ .seg-score-bar-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
+ .seg-score-pct { font-size: 10px; color: var(--muted); width: 30px; text-align: right; flex-shrink: 0; }
+ .seg-include-toggle { width: 14px; height: 14px; border-radius: 3px; border: 1.5px solid var(--muted); cursor: pointer; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: all 0.15s; }
+ .seg-include-toggle.included { background: #22c55e; border-color: #22c55e; }
+ .seg-include-toggle.included::after { content: '✓'; font-size: 9px; color: white; }
+ .segment-card.hl-excluded { opacity: 0.45; }
+</style>
+</head>
+<body>
+
+<header>
+ <div>
+ <div class="logo">SubTok</div>
+ <div class="logo-sub">TikTok-style subtitles with local Whisper</div>
+ </div>
+</header>
+
+<div class="layout">
+ <!-- Sidebar -->
+ <div class="sidebar">
+
+ <!-- Upload -->
+ <div class="section">
+ <div class="section-title">Video</div>
+ <div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
+ <div class="upload-icon">🎬</div>
+ <div class="upload-label">Drop video here</div>
+ <div class="upload-sub">MP4, MOV, MKV, WebM</div>
+ <input type="file" id="file-input" accept="video/*">
+ </div>
+ <div id="file-name" style="font-size:12px;color:var(--muted);margin-top:8px;text-align:center;"></div>
+ </div>
+
+ <!-- Whisper settings -->
+ <div class="section">
+ <div class="section-title">Whisper</div>
+ <label>Model</label>
+ <select id="model-select">
+ <option value="tiny">Tiny — fastest</option>
+ <option value="base" selected>Base — balanced</option>
+ <option value="small">Small — better</option>
+ <option value="medium">Medium — accurate</option>
+ <option value="large">Large — best</option>
+ </select>
+ <label>Language</label>
+ <select id="lang-select">
+ <option value="auto">Auto detect</option>
+ <option value="sv">Swedish</option>
+ <option value="en">English</option>
+ <option value="de">German</option>
+ <option value="fr">French</option>
+ <option value="es">Spanish</option>
+ <option value="pt">Portuguese</option>
+ <option value="it">Italian</option>
+ <option value="ja">Japanese</option>
+ <option value="ko">Korean</option>
+ <option value="zh">Chinese</option>
+ </select>
+ <div style="margin-top:14px;">
+ <button class="btn btn-primary" id="transcribe-btn" disabled onclick="transcribe()">
+ <span>🎙</span> Transcribe
+ </button>
+ </div>
+ <div class="progress-bar" id="progress-bar" style="display:none">
+ <div class="progress-fill pulsing" id="progress-fill" style="width:100%"></div>
+ </div>
+ <div class="status-text" id="status-text"></div>
+ </div>
+
+ <!-- Style presets -->
+ <div class="section">
+ <div class="section-title">Style Preset</div>
+ <div class="presets">
+ <div class="preset-chip active" onclick="applyPreset('tiktok')">TikTok</div>
+ <div class="preset-chip" onclick="applyPreset('viral')">Viral White</div>
+ <div class="preset-chip" onclick="applyPreset('neon')">Neon</div>
+ <div class="preset-chip" onclick="applyPreset('minimal')">Minimal</div>
+ <div class="preset-chip" onclick="applyPreset('fire')">Fire</div>
+ <div class="preset-chip" onclick="applyPreset('shadow')">Shadow</div>
+ </div>
+ </div>
+
+ <!-- Style settings -->
+ <div class="section">
+ <div class="section-title">Style</div>
+
+ <label>Font</label>
+ <select id="font-select" onchange="updatePreview()">
+ <option value="Arial Black">Arial Black</option>
+ <option value="Impact">Impact</option>
+ <option value="Arial Rounded MT Bold">Arial Rounded</option>
+ <option value="Helvetica Neue">Helvetica Neue</option>
+ <option value="Georgia">Georgia</option>
+ </select>
+
+ <label>Font Size: <span id="fontsize-val">52</span>px</label>
+ <input type="range" id="fontsize" min="20" max="120" value="52" oninput="document.getElementById('fontsize-val').textContent=this.value;updatePreview()">
+
+ <label>Words per chunk</label>
+ <select id="words-per-chunk" onchange="updatePreview()">
+ <option value="1">1 word</option>
+ <option value="2">2 words</option>
+ <option value="3" selected>3 words</option>
+ <option value="4">4 words</option>
+ <option value="5">5 words</option>
+ </select>
+
+ <label>Text Color</label>
+ <div class="color-row">
+ <input type="color" id="text-color" value="#ffffff" onchange="updatePreview()">
+ <span>Main text</span>
+ </div>
+
+ <label>Highlight Color</label>
+ <div class="color-row">
+ <input type="color" id="highlight-color" value="#FEE800" onchange="updatePreview()">
+ <span>Active word</span>
+ </div>
+
+ <label>Outline Color</label>
+ <div class="color-row">
+ <input type="color" id="outline-color" value="#000000" onchange="updatePreview()">
+ <span>Stroke</span>
+ </div>
+
+ <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>
+ <input type="range" id="position" min="10" max="98" value="85" oninput="document.getElementById('pos-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>
+ </div>
+ <div class="toggle-row">
+ <span class="toggle-label">Word highlight</span>
+ <div class="toggle on" id="highlight-toggle" onclick="this.classList.toggle('on');updatePreview()"></div>
+ </div>
+ </div>
+
+ <!-- Emoji Reactions -->
+ <div class="section">
+ <div class="section-title">Emoji Reactions</div>
+ <button type="button" class="btn btn-secondary emoji-autodetect-btn" onclick="autoDetectEmojis()">✨ Auto-detect</button>
+ <label for="emoji-position">Position</label>
+ <select id="emoji-position" title="Emoji position relative to subtitle" onchange="updatePreview()">
+ <option value="above">Above subtitle</option>
+ <option value="below" selected>Below subtitle</option>
+ <option value="right">Right of subtitle</option>
+ </select>
+ <label for="emoji-size">Size: <span id="emoji-size-val">48</span>px</label>
+ <input type="range" id="emoji-size" title="Emoji size in pixels" min="20" max="120" value="48" oninput="document.getElementById('emoji-size-val').textContent=this.value;updatePreview()">
+ <div class="toggle-row emoji-toggle-row">
+ <span class="toggle-label">Show emojis</span>
+ <div class="toggle on" id="emoji-toggle" onclick="this.classList.toggle('on');updatePreview()"></div>
+ </div>
+ </div>
+
+ <!-- GIF Reactions -->
+ <div class="section">
+ <div class="section-title">GIF Reactions</div>
+ <label for="gif-assign-target" class="gif-assign-target">Assign to segment</label>
+ <select id="gif-assign-target" title="Target segment for GIF assignment"></select>
+ <div class="gif-search-row gif-search-section">
+ <input type="text" id="gif-search" placeholder="Search GIFs…" title="Search GIFs" onkeydown="if(event.key==='Enter')searchGifs()">
+ <button type="button" class="gif-search-btn" onclick="searchGifs()">🔍</button>
+ </div>
+ <div class="gif-results" id="gif-results"></div>
+ <label class="gif-position-label" for="gif-position">Position</label>
+ <select id="gif-position" title="GIF position on video" onchange="updateGifOverlay(document.getElementById('video-player').currentTime)">
+ <option value="top">Top</option>
+ <option value="center" selected>Center</option>
+ <option value="bottom">Bottom</option>
+ </select>
+ <label for="gif-size">Size: <span id="gif-size-val">50</span>%</label>
+ <input type="range" id="gif-size" title="GIF size as % of video width" min="10" max="100" value="50" oninput="document.getElementById('gif-size-val').textContent=this.value;updateGifOverlay(document.getElementById('video-player').currentTime)">
+ <label for="gif-opacity">Opacity: <span id="gif-opacity-val">90</span>%</label>
+ <input type="range" id="gif-opacity" title="GIF opacity" min="10" max="100" value="90" oninput="document.getElementById('gif-opacity-val').textContent=this.value;updateGifOverlay(document.getElementById('video-player').currentTime)">
+ <div class="toggle-row gif-show-toggle-row">
+ <span class="toggle-label">Show GIFs</span>
+ <div class="toggle on" id="gif-toggle" onclick="this.classList.toggle('on');updateGifOverlay(document.getElementById('video-player').currentTime)"></div>
+ </div>
+ <input type="text" id="giphy-key" class="giphy-key-input" placeholder="Giphy API key (optional)" title="Your Giphy API key">
+ </div>
+
+ <!-- Crop to Format -->
+ <div class="section">
+ <div class="section-title">Crop to Format</div>
+ <div class="crop-aspect-chips">
+ <div class="crop-chip active" onclick="setCropAspect('9:16',this)">9:16 TikTok</div>
+ <div class="crop-chip" onclick="setCropAspect('1:1',this)">1:1 Square</div>
+ <div class="crop-chip" onclick="setCropAspect('4:5',this)">4:5 Feed</div>
+ <div class="crop-chip" onclick="setCropAspect('16:9',this)">16:9 Wide</div>
+ </div>
+ <label for="crop-x">Horizontal: <span id="crop-x-val">50</span>%</label>
+ <input type="range" id="crop-x" title="Horizontal crop position" min="0" max="100" value="50" oninput="document.getElementById('crop-x-val').textContent=this.value;updateCropOverlay()">
+ <label for="crop-y">Vertical: <span id="crop-y-val">50</span>%</label>
+ <input type="range" id="crop-y" title="Vertical crop position" min="0" max="100" value="50" oninput="document.getElementById('crop-y-val').textContent=this.value;updateCropOverlay()">
+ <div class="toggle-row crop-toggle-row">
+ <span class="toggle-label">Preview crop</span>
+ <div class="toggle on" id="crop-preview-toggle" onclick="this.classList.toggle('on');toggleCropPreview()"></div>
+ </div>
+ <button type="button" class="btn btn-primary crop-export-btn" id="crop-export-btn" disabled onclick="exportCropped()">✂️ Export Cropped</button>
+ </div>
+
+ <!-- AI Highlights -->
+ <div class="section">
+ <div class="section-title">AI Highlights</div>
+ <label for="hl-target">Target length: <span id="hl-target-val">50</span>%</label>
+ <input type="range" id="hl-target" title="Target highlight duration as % of original" min="10" max="90" value="50" oninput="document.getElementById('hl-target-val').textContent=this.value">
+ <div class="hl-actions">
+ <button type="button" class="btn btn-secondary" id="hl-local-btn" disabled onclick="analyzeHighlights(false)">⚡ Analyze</button>
+ <button type="button" class="btn btn-primary" id="hl-export-btn" disabled onclick="exportHighlights()">🎬 Export Reel</button>
+ </div>
+ <div class="hl-stats" id="hl-stats"></div>
+ <div class="hl-divider"></div>
+ <label for="hl-claude-key">Anthropic API Key (optional)</label>
+ <div class="hl-claude-row">
+ <input type="password" id="hl-claude-key" title="Anthropic API key for Claude analysis" placeholder="sk-ant-…">
+ <button type="button" class="btn btn-secondary hl-claude-btn" id="hl-claude-btn" onclick="analyzeHighlights(true)">✨ Claude</button>
+ </div>
+ </div>
+
+ </div>
+
+ <!-- Main -->
+ <div class="main">
+ <div class="preview-area">
+
+ <!-- Video -->
+ <div class="video-panel" id="video-panel"
+ ondrop="handleDrop(event)" ondragover="event.preventDefault();document.getElementById('upload-zone').classList.add('drag')"
+ ondragleave="document.getElementById('upload-zone').classList.remove('drag')">
+ <div class="video-wrapper" id="video-wrapper">
+ <div class="video-placeholder" id="video-placeholder">
+ <div class="ph-icon">🎞</div>
+ <div>Upload a video to start</div>
+ </div>
+ <video id="video-player" style="display:none" controls></video>
+ <canvas id="sub-canvas" style="display:none"></canvas>
+ <div id="gif-overlay"></div>
+ <div class="crop-overlay" id="crop-overlay">
+ <div class="crop-window" id="crop-window">
+ <span class="crop-label" id="crop-label">9:16</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Segments -->
+ <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>
+ <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
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Export bar -->
+ <div class="export-bar" id="export-bar">
+ <span style="font-size:12px;color:var(--muted);flex:1">Export</span>
+ <button class="btn btn-secondary" id="export-srt-btn" disabled onclick="exportSRT()">↓ SRT</button>
+ <button class="btn btn-secondary" id="export-ass-btn" disabled onclick="exportASS()">↓ ASS</button>
+ <button class="btn btn-primary" id="export-video-btn" disabled onclick="exportVideo()">🔥 Burn to Video</button>
+ </div>
+ </div>
+</div>
+
+<script>
+let videoFile = null;
+let segments = [];
+let currentTime = 0;
+let animFrameId = null;
+let segmentEmojis = {};
+let emojiPickerTarget = null;
+let segmentGifs = {};
+let cropAspect = '9:16';
+let highlightScores = [];
+let highlightSelected = new Set();
+
+// ── File handling ──────────────────────────────────────────────
+document.getElementById('file-input').addEventListener('change', e => {
+ const f = e.target.files[0];
+ if (f) loadVideo(f);
+});
+
+function handleDrop(e) {
+ e.preventDefault();
+ document.getElementById('upload-zone').classList.remove('drag');
+ const f = e.dataTransfer.files[0];
+ if (f && f.type.startsWith('video/')) loadVideo(f);
+}
+
+function loadVideo(f) {
+ videoFile = f;
+ document.getElementById('file-name').textContent = f.name;
+ document.getElementById('transcribe-btn').disabled = false;
+ const url = URL.createObjectURL(f);
+ const vid = document.getElementById('video-player');
+ vid.src = url;
+ vid.style.display = 'block';
+ document.getElementById('video-placeholder').style.display = 'none';
+ document.getElementById('sub-canvas').style.display = 'block';
+ vid.addEventListener('loadedmetadata', () => resizeCanvas());
+ vid.addEventListener('timeupdate', () => {
+ renderSubtitles(vid.currentTime);
+ updateGifOverlay(vid.currentTime);
+ });
+ window.addEventListener('resize', () => { resizeCanvas(); updateCropOverlay(); });
+ document.getElementById('crop-export-btn').disabled = false;
+}
+
+function resizeCanvas() {
+ const vid = document.getElementById('video-player');
+ const canvas = document.getElementById('sub-canvas');
+ canvas.width = vid.videoWidth || vid.offsetWidth;
+ canvas.height = vid.videoHeight || vid.offsetHeight;
+ canvas.style.width = vid.offsetWidth + 'px';
+ canvas.style.height = vid.offsetHeight + 'px';
+}
+
+// ── Transcription ─────────────────────────────────────────────
+async function transcribe() {
+ if (!videoFile) return;
+ const btn = document.getElementById('transcribe-btn');
+ btn.disabled = true;
+ document.getElementById('progress-bar').style.display = 'block';
+ setStatus('Loading Whisper model & transcribing… (may take a minute)');
+
+ const fd = new FormData();
+ fd.append('file', videoFile);
+ fd.append('model', document.getElementById('model-select').value);
+ fd.append('language', document.getElementById('lang-select').value);
+
+ try {
+ const res = await fetch('/transcribe', { 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();
+ setStatus(`Done! Detected language: ${data.language}`);
+ showLangBadge(data.language);
+ document.getElementById('progress-bar').style.display = 'none';
+ } catch(err) {
+ setStatus('Error: ' + err.message);
+ document.getElementById('progress-bar').style.display = 'none';
+ }
+ btn.disabled = false;
+}
+
+function setStatus(msg) {
+ document.getElementById('status-text').textContent = msg;
+}
+
+function showLangBadge(lang) {
+ const el = document.getElementById('lang-badge');
+ el.innerHTML = `<span class="lang-badge">${lang}</span>`;
+}
+
+// ── Segments list ─────────────────────────────────────────────
+function renderSegmentsList() {
+ const list = document.getElementById('segments-list');
+ document.getElementById('seg-count').textContent = `${segments.length} segments`;
+ list.innerHTML = '';
+ segments.forEach((seg, i) => {
+ const card = document.createElement('div');
+ card.className = 'segment-card';
+ card.id = `seg-${i}`;
+ 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>`
+ ).join('');
+ const emojis = segmentEmojis[i] || [];
+ const emojiHtml = emojis.map(e =>
+ `<span class="seg-emoji-badge" onclick="event.stopPropagation();removeEmoji(${i},'${e}')" title="Click to remove">
+ ${e}<span class="remove-emoji">×</span>
+ </span>`
+ ).join('');
+ const gif = segmentGifs[i];
+ const gifHtml = gif
+ ? `<div class="seg-gif-badge"><img src="${gif.preview}" alt="GIF"><span style="font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${gif.title||'GIF'}</span><button type="button" class="gif-remove" onclick="event.stopPropagation();removeSegmentGif(${i})">×</button></div>`
+ : '';
+
+ const score = highlightScores[i];
+ const hasScores = highlightScores.length > 0;
+ const included = highlightSelected.has(i);
+ const barColor = included ? '#22c55e' : '#6b7280';
+ const scorePct = hasScores ? Math.round(score * 100) : 0;
+ const scoreHtml = hasScores ? `
+ <div class="seg-score-row" onclick="event.stopPropagation()">
+ <div class="seg-include-toggle ${included ? 'included' : ''}" onclick="toggleHighlightSegment(${i})" title="Toggle include in highlight reel"></div>
+ <div class="seg-score-bar-bg" onclick="toggleHighlightSegment(${i})">
+ <div class="seg-score-bar-fill" style="width:${scorePct}%;background:${barColor}"></div>
+ </div>
+ <span class="seg-score-pct">${scorePct}%</span>
+ </div>` : '';
+
+ card.innerHTML = `
+ <div class="seg-time">${ts}</div>
+ <div class="seg-text">${seg.text}</div>
+ <div class="seg-words">${wordsHtml}</div>
+ <div class="seg-emoji-row" id="emoji-row-${i}">
+ ${emojiHtml}
+ <button type="button" class="add-emoji-btn" onclick="event.stopPropagation();openEmojiPicker(${i},this)" title="Add emoji reaction">+ 😀</button>
+ </div>
+ <div id="gif-badge-${i}">${gifHtml}</div>
+ ${scoreHtml}`;
+ if (hasScores) card.classList.toggle('hl-excluded', !included);
+ card.onclick = () => {
+ document.getElementById('video-player').currentTime = seg.start;
+ };
+ list.appendChild(card);
+ });
+}
+
+function fmt(s) {
+ const m = Math.floor(s/60), sec = (s%60).toFixed(1).padStart(4,'0');
+ return `${m}:${sec}`;
+}
+
+// ── Subtitle rendering on canvas ──────────────────────────────
+function getStyle() {
+ return {
+ font: document.getElementById('font-select').value,
+ fontSize: parseInt(document.getElementById('fontsize').value),
+ textColor: document.getElementById('text-color').value,
+ highlightColor: document.getElementById('highlight-color').value,
+ outlineColor: document.getElementById('outline-color').value,
+ outlineWidth: parseInt(document.getElementById('outline-width').value),
+ position: parseInt(document.getElementById('position').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),
+ };
+}
+
+function renderSubtitles(time) {
+ const canvas = document.getElementById('sub-canvas');
+ const ctx = canvas.getContext('2d');
+ const vid = document.getElementById('video-player');
+ if (!canvas.width) resizeCanvas();
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ if (!segments.length) return;
+
+ const s = getStyle();
+ const wpc = s.wordsPerChunk;
+
+ // Collect all word-chunks
+ const chunks = [];
+ for (const seg of segments) {
+ const words = seg.words || [];
+ if (!words.length) {
+ chunks.push({ words: [{ word: seg.text, start: seg.start, end: seg.end }], start: seg.start, end: seg.end });
+ continue;
+ }
+ for (let i = 0; i < words.length; i += wpc) {
+ const chunk = words.slice(i, i + wpc);
+ chunks.push({ words: chunk, start: chunk[0].start, end: chunk[chunk.length-1].end });
+ }
+ }
+
+ const active = chunks.find(c => time >= c.start && time <= c.end + 0.05);
+ if (!active) {
+ updateActiveSegment(time);
+ return;
+ }
+
+ const scaleFactor = canvas.width / (vid.videoWidth || canvas.width);
+ const fontSize = Math.round(s.fontSize * scaleFactor * (canvas.width / 1080));
+ ctx.font = `900 ${fontSize}px "${s.font}"`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'bottom';
+
+ const y = canvas.height * s.position;
+ const cx = canvas.width / 2;
+
+ if (s.highlightEnabled) {
+ // Render word by word with highlight
+ const wordTexts = active.words.map(w => s.uppercase ? w.word.toUpperCase() : w.word);
+ const fullText = wordTexts.join(' ');
+ const totalW = ctx.measureText(fullText).width;
+ let x = cx - totalW / 2;
+
+ active.words.forEach((w, idx) => {
+ const txt = s.uppercase ? w.word.toUpperCase() : w.word;
+ const isActive = time >= w.start && time <= w.end + 0.05;
+ const wordW = ctx.measureText(txt).width;
+ const spaceW = idx < active.words.length - 1 ? ctx.measureText(' ').width : 0;
+ const wordCx = x + wordW / 2;
+
+ ctx.lineWidth = s.outlineWidth * scaleFactor;
+ ctx.strokeStyle = s.outlineColor;
+ ctx.strokeText(txt, wordCx, y);
+ ctx.fillStyle = isActive ? s.highlightColor : s.textColor;
+ ctx.fillText(txt, wordCx, y);
+ x += wordW + spaceW;
+ });
+ } else {
+ const txt = s.uppercase
+ ? active.words.map(w => w.word).join(' ').toUpperCase()
+ : active.words.map(w => w.word).join(' ');
+ ctx.lineWidth = s.outlineWidth * scaleFactor;
+ ctx.strokeStyle = s.outlineColor;
+ ctx.strokeText(txt, cx, y);
+ ctx.fillStyle = s.textColor;
+ ctx.fillText(txt, cx, y);
+ }
+
+ // Emoji overlay
+ const emojiOn = document.getElementById('emoji-toggle').classList.contains('on');
+ if (emojiOn) {
+ const activeSeg = segments.findIndex(sg => time >= sg.start && time <= sg.end + 0.05);
+ const emojis = activeSeg >= 0 ? (segmentEmojis[activeSeg] || []) : [];
+ if (emojis.length) {
+ const emojiPx = parseInt(document.getElementById('emoji-size').value);
+ const emojiSize = Math.round(emojiPx * scaleFactor * (canvas.width / 1080));
+ const pos = document.getElementById('emoji-position').value;
+ ctx.font = `${emojiSize}px serif`;
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'center';
+ const totalW = emojis.length * (emojiSize * 1.2);
+ let ex = cx - totalW / 2 + emojiSize * 0.6;
+ let ey;
+ if (pos === 'above') ey = y - fontSize - emojiSize * 0.6;
+ else if (pos === 'below') ey = y + emojiSize * 0.8;
+ else { ex = cx + ctx.measureText(active.words.map(w=>w.word).join(' ')).width / 2 + emojiSize; ey = y - fontSize / 2; }
+ emojis.forEach(e => { ctx.fillText(e, ex, ey); ex += emojiSize * 1.2; });
+ }
+ }
+
+ updateActiveSegment(time);
+}
+
+function updateActiveSegment(time) {
+ const idx = segments.findIndex(s => time >= s.start && time <= s.end + 0.1);
+ document.querySelectorAll('.segment-card').forEach((el, i) => {
+ el.classList.toggle('active', i === idx);
+ });
+ if (idx >= 0) {
+ const el = document.getElementById(`seg-${idx}`);
+ if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+
+ // highlight individual words
+ document.querySelectorAll(`#seg-${idx} .seg-word`).forEach(w => {
+ const ws = parseFloat(w.dataset.start);
+ const we = parseFloat(w.dataset.end);
+ w.classList.toggle('highlight', time >= ws && time <= we + 0.05);
+ });
+ }
+}
+
+function updatePreview() {
+ const vid = document.getElementById('video-player');
+ if (vid.style.display !== 'none') renderSubtitles(vid.currentTime);
+}
+
+// ── Presets ───────────────────────────────────────────────────
+const PRESETS = {
+ tiktok: { font: 'Arial Black', fontSize: 52, textColor: '#ffffff', highlightColor: '#FEE800', outlineColor: '#000000', outlineWidth: 4, position: 85, uppercase: true },
+ viral: { font: 'Impact', fontSize: 60, textColor: '#ffffff', highlightColor: '#ff3b5c', outlineColor: '#000000', outlineWidth: 5, position: 80, uppercase: true },
+ neon: { font: 'Arial Black', fontSize: 48, textColor: '#25f4ee', highlightColor: '#fe2c55', outlineColor: '#000000', outlineWidth: 3, position: 85, uppercase: false },
+ minimal: { font: 'Helvetica Neue', fontSize: 44, textColor: '#ffffff', highlightColor: '#ffffff', outlineColor: '#000000', outlineWidth: 2, position: 90, uppercase: false },
+ fire: { font: 'Impact', fontSize: 58, textColor: '#ff6600', highlightColor: '#ffcc00', outlineColor: '#000000', outlineWidth: 6, position: 82, uppercase: true },
+ shadow: { font: 'Arial Rounded MT Bold', fontSize: 50, textColor: '#ffffff', highlightColor: '#c0ff00', outlineColor: '#1a1a1a', outlineWidth: 5, position: 87, uppercase: false },
+};
+
+function applyPreset(name) {
+ document.querySelectorAll('.preset-chip').forEach(c => c.classList.toggle('active', c.textContent.toLowerCase().replace(' ','') === name));
+ const p = PRESETS[name];
+ if (!p) return;
+ document.getElementById('font-select').value = p.font;
+ document.getElementById('fontsize').value = p.fontSize;
+ document.getElementById('fontsize-val').textContent = p.fontSize;
+ document.getElementById('text-color').value = p.textColor;
+ document.getElementById('highlight-color').value = p.highlightColor;
+ document.getElementById('outline-color').value = p.outlineColor;
+ document.getElementById('outline-width').value = p.outlineWidth;
+ document.getElementById('outline-val').textContent = p.outlineWidth;
+ document.getElementById('position').value = Math.round(p.position);
+ document.getElementById('pos-val').textContent = Math.round(p.position);
+ if (p.uppercase) document.getElementById('uppercase-toggle').classList.add('on');
+ else document.getElementById('uppercase-toggle').classList.remove('on');
+ updatePreview();
+}
+
+// ── Export ────────────────────────────────────────────────────
+function enableExports() {
+ ['export-srt-btn','export-ass-btn','export-video-btn'].forEach(id => {
+ document.getElementById(id).disabled = false;
+ });
+}
+
+function getAssStyle() {
+ const s = getStyle();
+ const hexToAss = (hex, alpha = '00') => {
+ const r = parseInt(hex.slice(1,3),16);
+ const g = parseInt(hex.slice(3,5),16);
+ const b = parseInt(hex.slice(5,7),16);
+ return `&H${alpha}${b.toString(16).padStart(2,'0').toUpperCase()}${g.toString(16).padStart(2,'0').toUpperCase()}${r.toString(16).padStart(2,'0').toUpperCase()}`;
+ };
+ return {
+ font: s.font,
+ fontsize: Math.round(s.fontSize * 0.6),
+ primary_color: hexToAss(s.textColor),
+ highlight_color: hexToAss(s.highlightColor),
+ outline_color: hexToAss(s.outlineColor),
+ bold: true,
+ outline: s.outlineWidth,
+ shadow: 0,
+ margin_v: Math.round((1 - s.position / 100) * 1920 * 0.15 + 40),
+ };
+}
+
+async function exportSRT() {
+ const res = await fetch('/export/srt', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({ segments, words_per_chunk: document.getElementById('words-per-chunk').value })
+ });
+ downloadBlob(await res.blob(), 'subtitles.srt');
+}
+
+async function exportASS() {
+ const res = await fetch('/export/ass', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({ segments, style: getAssStyle(), words_per_chunk: document.getElementById('words-per-chunk').value })
+ });
+ downloadBlob(await res.blob(), 'subtitles.ass');
+}
+
+async function exportVideo() {
+ if (!videoFile) return;
+ const btn = document.getElementById('export-video-btn');
+ btn.disabled = true;
+ btn.textContent = '⏳ Burning…';
+ const fd = new FormData();
+ fd.append('file', videoFile);
+ fd.append('segments_json', JSON.stringify(segments));
+ fd.append('style_json', JSON.stringify(getAssStyle()));
+ fd.append('words_per_chunk', document.getElementById('words-per-chunk').value);
+ try {
+ const res = await fetch('/export/video', { method: 'POST', body: fd });
+ if (!res.ok) throw new Error(await res.text());
+ downloadBlob(await res.blob(), 'output_with_subs.mp4');
+ } catch(e) {
+ alert('Export failed: ' + e.message);
+ }
+ btn.disabled = false;
+ btn.textContent = '🔥 Burn to Video';
+}
+
+function downloadBlob(blob, name) {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = name;
+ a.click();
+}
+
+// ── AI Highlights ─────────────────────────────────────────────
+async function analyzeHighlights(useClaude) {
+ if (!segments.length) return;
+ const btn = useClaude ? document.getElementById('hl-claude-btn') : document.getElementById('hl-local-btn');
+ const prevText = btn.textContent;
+ btn.disabled = true;
+ btn.textContent = '⏳ Analyzing…';
+ document.getElementById('hl-stats').textContent = '';
+
+ const target_pct = parseInt(document.getElementById('hl-target').value) / 100;
+ const api_key = useClaude ? document.getElementById('hl-claude-key').value.trim() : '';
+
+ try {
+ const res = await fetch('/analyze_highlights', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ segments, target_pct, api_key }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+
+ highlightScores = data.scores || [];
+ highlightSelected = new Set(data.selected || []);
+
+ if (data.warning) document.getElementById('hl-stats').textContent = '⚠️ ' + data.warning;
+
+ renderSegmentsList();
+ updateHlStats();
+ document.getElementById('hl-export-btn').disabled = !videoFile || !highlightSelected.size;
+ } catch (e) {
+ document.getElementById('hl-stats').textContent = 'Error: ' + e.message;
+ }
+ btn.disabled = false;
+ btn.textContent = prevText;
+}
+
+function toggleHighlightSegment(idx) {
+ if (highlightSelected.has(idx)) highlightSelected.delete(idx);
+ else highlightSelected.add(idx);
+ // Update just this card's score row + class without full re-render
+ const card = document.getElementById(`seg-${idx}`);
+ if (!card) return;
+ const included = highlightSelected.has(idx);
+ card.classList.toggle('hl-excluded', !included);
+ const toggle = card.querySelector('.seg-include-toggle');
+ const fill = card.querySelector('.seg-score-bar-fill');
+ if (toggle) toggle.classList.toggle('included', included);
+ if (fill) fill.style.background = included ? '#22c55e' : '#6b7280';
+ updateHlStats();
+ document.getElementById('hl-export-btn').disabled = !videoFile || !highlightSelected.size;
+}
+
+function updateHlStats() {
+ const sel = [...highlightSelected];
+ if (!sel.length || !segments.length) { document.getElementById('hl-stats').textContent = ''; return; }
+ const dur = sel.reduce((sum, i) => sum + (segments[i].end - segments[i].start), 0);
+ const total = segments.reduce((sum, s) => sum + (s.end - s.start), 0);
+ const pct = Math.round(dur / total * 100);
+ document.getElementById('hl-stats').textContent =
+ `${sel.length} segments · ${fmt(dur)} of ${fmt(total)} (${pct}%)`;
+}
+
+async function exportHighlights() {
+ if (!videoFile || !highlightSelected.size) return;
+ const btn = document.getElementById('hl-export-btn');
+ btn.disabled = true;
+ btn.textContent = '⏳ Cutting…';
+
+ const ranges = [...highlightSelected].sort((a,b)=>a-b).map(i => [segments[i].start, segments[i].end]);
+ const fd = new FormData();
+ fd.append('file', videoFile);
+ fd.append('ranges_json', JSON.stringify(ranges));
+ fd.append('padding', '0.15');
+
+ try {
+ const res = await fetch('/export/highlights', { method: 'POST', body: fd });
+ if (!res.ok) throw new Error(await res.text());
+ downloadBlob(await res.blob(), 'highlights.mp4');
+ } catch (e) {
+ alert('Export failed: ' + e.message);
+ }
+ btn.disabled = false;
+ btn.textContent = '🎬 Export Reel';
+}
+
+// ── GIF reactions ─────────────────────────────────────────────
+function refreshGifTargetDropdown() {
+ const sel = document.getElementById('gif-assign-target');
+ sel.innerHTML = segments.map((s, i) =>
+ `<option value="${i}">[${fmt(s.start)}] ${s.text.slice(0,40)}</option>`
+ ).join('');
+}
+
+async function searchGifs() {
+ const q = document.getElementById('gif-search').value.trim();
+ if (!q) return;
+ const key = document.getElementById('giphy-key').value.trim();
+ const url = `/search_gifs?q=${encodeURIComponent(q)}${key ? '&api_key=' + encodeURIComponent(key) : ''}`;
+ const res = await fetch(url);
+ if (!res.ok) { alert('GIF search failed'); return; }
+ const data = await res.json();
+ const grid = document.getElementById('gif-results');
+ grid.innerHTML = data.results.map((g, i) =>
+ `<div class="gif-thumb" data-idx="${i}" onclick="assignGif(${i})" title="${g.title}">
+ <img src="${g.preview}" alt="${g.title}" loading="lazy">
+ </div>`
+ ).join('');
+ grid._data = data.results;
+}
+
+function assignGif(thumbIdx) {
+ const grid = document.getElementById('gif-results');
+ const gif = grid._data[thumbIdx];
+ if (!gif) return;
+ const segIdx = parseInt(document.getElementById('gif-assign-target').value);
+ if (isNaN(segIdx)) return;
+ segmentGifs[segIdx] = gif;
+ document.querySelectorAll('.gif-thumb').forEach((el, i) => el.classList.toggle('selected', i === thumbIdx));
+ refreshGifBadge(segIdx);
+ updateGifOverlay(document.getElementById('video-player').currentTime);
+ document.getElementById('crop-export-btn').disabled = false;
+}
+
+function removeSegmentGif(segIdx) {
+ delete segmentGifs[segIdx];
+ refreshGifBadge(segIdx);
+ updateGifOverlay(document.getElementById('video-player').currentTime);
+}
+
+function refreshGifBadge(segIdx) {
+ const el = document.getElementById(`gif-badge-${segIdx}`);
+ if (!el) return;
+ const gif = segmentGifs[segIdx];
+ el.innerHTML = gif
+ ? `<div class="seg-gif-badge"><img src="${gif.preview}" alt="GIF"><span style="font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${gif.title||'GIF'}</span><button type="button" class="gif-remove" onclick="event.stopPropagation();removeSegmentGif(${segIdx})">×</button></div>`
+ : '';
+}
+
+function updateGifOverlay(time) {
+ const overlay = document.getElementById('gif-overlay');
+ const vid = document.getElementById('video-player');
+ if (!overlay || vid.style.display === 'none') return;
+
+ const enabled = document.getElementById('gif-toggle').classList.contains('on');
+ overlay.innerHTML = '';
+ if (!enabled) return;
+
+ const activeSeg = segments.findIndex(s => time >= s.start && time <= s.end + 0.05);
+ const gif = activeSeg >= 0 ? segmentGifs[activeSeg] : null;
+ if (!gif || !gif.mp4) return;
+
+ const sizePct = parseInt(document.getElementById('gif-size').value) / 100;
+ const opacity = parseInt(document.getElementById('gif-opacity').value) / 100;
+ const pos = document.getElementById('gif-position').value;
+ const vw = vid.offsetWidth, vh = vid.offsetHeight;
+ const gw = Math.round(vw * sizePct);
+ const gh = Math.round(gw * 0.75);
+
+ let top;
+ if (pos === 'top') top = 12;
+ else if (pos === 'bottom') top = vh - gh - 12;
+ else top = Math.round((vh - gh) / 2);
+
+ const item = document.createElement('div');
+ item.className = 'gif-overlay-item';
+ item.style.cssText = `width:${gw}px;height:${gh}px;top:${top}px;left:${Math.round((vw-gw)/2)}px;opacity:${opacity}`;
+ item.innerHTML = `<video autoplay loop muted playsinline src="${gif.mp4}"></video>`;
+ overlay.appendChild(item);
+}
+
+// ── Crop ──────────────────────────────────────────────────────
+function setCropAspect(aspect, el) {
+ cropAspect = aspect;
+ document.querySelectorAll('.crop-chip').forEach(c => c.classList.remove('active'));
+ el.classList.add('active');
+ updateCropOverlay();
+ document.getElementById('crop-export-btn').disabled = !videoFile;
+}
+
+function updateCropOverlay() {
+ const vid = document.getElementById('video-player');
+ const overlay = document.getElementById('crop-overlay');
+ const win = document.getElementById('crop-window');
+ const lbl = document.getElementById('crop-label');
+ if (!vid || vid.style.display === 'none') return;
+
+ lbl.textContent = cropAspect;
+ const [aN, aD] = cropAspect.split(':').map(Number);
+ const targetRatio = aN / aD;
+ const srcRatio = vid.videoWidth / vid.videoHeight;
+ const dw = vid.offsetWidth, dh = vid.offsetHeight;
+
+ let cw, ch, cx, cy;
+ const offX = parseInt(document.getElementById('crop-x').value) / 100;
+ const offY = parseInt(document.getElementById('crop-y').value) / 100;
+
+ if (srcRatio > targetRatio) {
+ ch = dh;
+ cw = Math.round(dh * targetRatio);
+ cx = Math.round((dw - cw) * offX);
+ cy = 0;
+ } else {
+ cw = dw;
+ ch = Math.round(dw / targetRatio);
+ cx = 0;
+ cy = Math.round((dh - ch) * offY);
+ }
+
+ win.style.cssText = `left:${cx}px;top:${cy}px;width:${cw}px;height:${ch}px`;
+}
+
+function toggleCropPreview() {
+ const overlay = document.getElementById('crop-overlay');
+ const on = document.getElementById('crop-preview-toggle').classList.contains('on');
+ overlay.style.display = on ? 'block' : 'none';
+ if (on) updateCropOverlay();
+}
+
+async function exportCropped() {
+ if (!videoFile) return;
+ const btn = document.getElementById('crop-export-btn');
+ btn.disabled = true;
+ btn.textContent = '⏳ Cropping…';
+ const fd = new FormData();
+ fd.append('file', videoFile);
+ fd.append('aspect', cropAspect);
+ fd.append('offset_x', parseInt(document.getElementById('crop-x').value) / 100);
+ fd.append('offset_y', parseInt(document.getElementById('crop-y').value) / 100);
+ try {
+ const res = await fetch('/crop', { method: 'POST', body: fd });
+ if (!res.ok) throw new Error(await res.text());
+ downloadBlob(await res.blob(), `cropped_${cropAspect.replace(':','x')}.mp4`);
+ } catch(e) {
+ alert('Crop failed: ' + e.message);
+ }
+ btn.disabled = false;
+ btn.textContent = '✂️ Export Cropped';
+}
+
+// ── Emoji reactions ───────────────────────────────────────────
+const EMOJI_CATEGORIES = {
+ 'Reactions': ['😂','🤣','😭','😱','🤯','😍','🥰','😎','🤔','😤','😡','🥺','😢','🤩','😏'],
+ 'Hype': ['🔥','💯','⚡','💥','🎉','🎊','✨','💪','👏','🙌','🤙','👍','💃','🕺'],
+ 'Symbols': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','💔','‼️','⁉️','🆘','✅','❌'],
+ 'Nature': ['😈','👻','💀','🤖','👽','🎭','🎬','🎵','🎶','🌟','⭐','🌈','🌊','🍿'],
+};
+
+const EMOJI_KEYWORDS = [
+ { words: ['haha','lol','funny','skratt','rolig','😂','😄'], emojis: ['😂','🤣'] },
+ { words: ['wow','omg','crazy','galen','vansinnig','shock'], emojis: ['😱','🤯'] },
+ { words: ['love','kärlek','älskar','hjärta','heart'], emojis: ['❤️','😍'] },
+ { words: ['fire','bäst','best','amazing','grym','🔥'], emojis: ['🔥','💯'] },
+ { words: ['sad','ledsen','sorglig','cry','gråter'], emojis: ['😢','😭'] },
+ { words: ['angry','arg','förbannad','mad','rage'], emojis: ['😡','😤'] },
+ { words: ['cool','nice','snygg','awesome','dope'], emojis: ['😎','✨'] },
+ { words: ['yes','ja','perfect','perfekt','exakt'], emojis: ['✅','👍'] },
+ { words: ['no','nej','wrong','fel','never','aldrig'], emojis: ['❌','🙅'] },
+ { words: ['party','fest','celebrate','fira','wins','vinner'],emojis: ['🎉','🥳'] },
+ { words: ['music','musik','dance','dansa','beat','låt'], emojis: ['🎵','💃'] },
+ { words: ['food','mat','eat','äter','hungry','hungrig'], emojis: ['🍕','😋'] },
+ { words: ['run','spring','fast','snabb','speed'], emojis: ['⚡','💨'] },
+ { words: ['think','tänk','hmm','maybe','kanske'], emojis: ['🤔','💭'] },
+];
+
+function autoDetectEmojis() {
+ if (!segments.length) return;
+ segments.forEach((seg, i) => {
+ const text = seg.text.toLowerCase();
+ const found = new Set(segmentEmojis[i] || []);
+ for (const rule of EMOJI_KEYWORDS) {
+ if (rule.words.some(w => text.includes(w))) {
+ rule.emojis.forEach(e => found.add(e));
+ }
+ }
+ if (found.size) segmentEmojis[i] = [...found];
+ });
+ renderSegmentsList();
+ updatePreview();
+}
+
+function openEmojiPicker(segIdx, anchorEl) {
+ closeEmojiPicker();
+ emojiPickerTarget = segIdx;
+
+ const picker = document.createElement('div');
+ picker.className = 'emoji-picker';
+ picker.id = 'emoji-picker';
+
+ const current = segmentEmojis[segIdx] || [];
+ let html = `<div class="emoji-picker-header">
+ <span class="emoji-picker-title">Add reaction</span>
+ <button type="button" class="emoji-picker-close" onclick="closeEmojiPicker()">×</button>
+ </div>`;
+
+ for (const [cat, emojis] of Object.entries(EMOJI_CATEGORIES)) {
+ html += `<div class="emoji-category">${cat}</div><div class="emoji-grid">`;
+ html += emojis.map(e =>
+ `<span class="emoji-opt${current.includes(e) ? ' selected' : ''}" onclick="toggleEmoji(${segIdx},'${e}',this)" title="${e}">${e}</span>`
+ ).join('');
+ html += '</div>';
+ }
+ picker.innerHTML = html;
+ document.body.appendChild(picker);
+
+ const rect = anchorEl.getBoundingClientRect();
+ picker.style.top = `${Math.min(rect.bottom + 6, window.innerHeight - picker.offsetHeight - 10)}px`;
+ picker.style.left = `${Math.min(rect.left, window.innerWidth - 290)}px`;
+
+ setTimeout(() => document.addEventListener('click', outsidePickerClick), 0);
+}
+
+function outsidePickerClick(e) {
+ const picker = document.getElementById('emoji-picker');
+ if (picker && !picker.contains(e.target)) closeEmojiPicker();
+}
+
+function closeEmojiPicker() {
+ document.getElementById('emoji-picker')?.remove();
+ document.removeEventListener('click', outsidePickerClick);
+ emojiPickerTarget = null;
+}
+
+function toggleEmoji(segIdx, emoji, el) {
+ const arr = segmentEmojis[segIdx] || [];
+ const idx = arr.indexOf(emoji);
+ if (idx >= 0) { arr.splice(idx, 1); el.classList.remove('selected'); }
+ else { arr.push(emoji); el.classList.add('selected'); }
+ segmentEmojis[segIdx] = arr;
+ refreshEmojiRow(segIdx);
+ updatePreview();
+}
+
+function removeEmoji(segIdx, emoji) {
+ const arr = segmentEmojis[segIdx] || [];
+ segmentEmojis[segIdx] = arr.filter(e => e !== emoji);
+ refreshEmojiRow(segIdx);
+ updatePreview();
+}
+
+function refreshEmojiRow(segIdx) {
+ const row = document.getElementById(`emoji-row-${segIdx}`);
+ if (!row) return;
+ const emojis = segmentEmojis[segIdx] || [];
+ const badges = emojis.map(e =>
+ `<span class="seg-emoji-badge" onclick="event.stopPropagation();removeEmoji(${segIdx},'${e}')" title="Click to remove">
+ ${e}<span class="remove-emoji">×</span>
+ </span>`
+ ).join('');
+ row.innerHTML = badges +
+ `<button type="button" class="add-emoji-btn" onclick="event.stopPropagation();openEmojiPicker(${segIdx},this)" title="Add emoji reaction">+ 😀</button>`;
+}
+</script>
+</body>
+</html>
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..d85dd9f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+openai-whisper
+fastapi
+uvicorn[standard]
+python-multipart
+anthropic
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..c918fd1
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+echo "Installing dependencies..."
+pip3 install -r requirements.txt
+
+echo ""
+echo "Starting SubTok..."
+echo "Open http://localhost:8000 in your browser"
+echo ""
+python3 app.py