commit a5126b31f943daf66af4c560c283eaac526b075c
Author: jens <jens.se@icloud.com>
AuthorDate: Wed Apr 22 10:15:54 2026 +0200
Commit: jens <jens.se@icloud.com>
CommitDate: Wed Apr 22 10:15:54 2026 +0200
mycket förändingar
---
app.py | 170 ++++++++++--
fonts/Permanent_Marker.ttf | Bin 0 -> 70600 bytes
index.html | 630 ++++++++++++++++++++++++++++++++++++++-------
install.sh | 40 +++
start.sh | 12 +-
5 files changed, 738 insertions(+), 114 deletions(-)
diff --git a/app.py b/app.py
index d60199b..5ff01af 100644
--- a/app.py
+++ b/app.py
@@ -17,18 +17,78 @@ app = FastAPI()
UPLOAD_DIR = Path("uploads")
OUTPUT_DIR = Path("outputs")
+FONTS_DIR = Path("fonts")
UPLOAD_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
+FONTS_DIR.mkdir(exist_ok=True)
+
+
+def _has_libass(ffmpeg_bin: str) -> bool:
+ try:
+ r = subprocess.run([ffmpeg_bin, "-buildconf"], capture_output=True, text=True, timeout=5)
+ return "--enable-libass" in r.stdout
+ except Exception:
+ return False
+
+
+def _find_ffmpeg() -> str:
+ """Return an ffmpeg binary that has libass support, falling back to PATH default."""
+ candidates = [
+ shutil.which("ffmpeg"),
+ "/opt/homebrew/bin/ffmpeg", # Apple Silicon Homebrew
+ "/usr/local/bin/ffmpeg", # Intel Homebrew
+ "/usr/bin/ffmpeg",
+ ]
+ for path in candidates:
+ if path and os.path.isfile(path) and _has_libass(path):
+ return path
+ # No libass-capable build found — return whatever is in PATH
+ return shutil.which("ffmpeg") or "ffmpeg"
+
+
+FFMPEG = _find_ffmpeg()
+FFMPEG_HAS_LIBASS = _has_libass(FFMPEG)
_model_cache = {}
+def _ensure_font(font_name: str) -> str | None:
+ """Download a Google Font TTF into fonts/ if not already cached.
+ Returns the absolute fonts dir path on success, None on failure."""
+ safe = re.sub(r'[^a-zA-Z0-9 ]', '', font_name).strip()
+ dest = FONTS_DIR / f"{safe.replace(' ', '_')}.ttf"
+ if dest.exists():
+ return str(FONTS_DIR.resolve())
+ try:
+ family = urllib.parse.quote(safe)
+ # Request CSS v1 with a basic UA so Google returns TTF (not WOFF2)
+ req = urllib.request.Request(
+ f"https://fonts.googleapis.com/css?family={family}",
+ headers={"User-Agent": "Mozilla/4.0"},
+ )
+ with urllib.request.urlopen(req, timeout=10) as r:
+ css = r.read().decode()
+ m = re.search(r'url\((https://fonts\.gstatic\.com/[^)]+\.ttf)\)', css)
+ if not m:
+ return None
+ with urllib.request.urlopen(m.group(1), timeout=15) as r:
+ dest.write_bytes(r.read())
+ return str(FONTS_DIR.resolve())
+ except Exception:
+ return None
+
+
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("/api/status")
+async def api_status():
+ return {"ffmpeg": FFMPEG, "libass": FFMPEG_HAS_LIBASS}
+
+
@app.get("/", response_class=HTMLResponse)
async def index():
return open("index.html").read()
@@ -181,34 +241,43 @@ async def export_video(
ass_path = OUTPUT_DIR / "burn_subs.ass"
out_path = OUTPUT_DIR / "output_with_subs.mp4"
+ abs_video = str(Path(video_path).resolve())
- ass_data = {"segments": segments, "style": style, "words_per_chunk": words_per_chunk}
+ # Probe actual video dimensions so ASS PlayRes and positions scale correctly
+ probe = subprocess.run(
+ ["ffprobe", "-v", "quiet", "-print_format", "json",
+ "-show_streams", "-select_streams", "v:0", abs_video],
+ capture_output=True, text=True,
+ )
+ try:
+ pinfo = json.loads(probe.stdout)
+ vid_w = int(pinfo["streams"][0].get("width", 1080))
+ vid_h = int(pinfo["streams"][0].get("height", 1920))
+ except Exception:
+ vid_w, vid_h = 1080, 1920
font = style.get("font", "Arial Black")
- fontsize = style.get("fontsize", 22)
+ # Scale fontsize the same way the canvas preview does: relative to 1080px width baseline
+ fontsize = round(float(style.get("fontsize", 52)) * vid_w / 1080)
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)
- 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_x = round(vid_w * float(style.get("pos_x_frac", 0.5)))
+ pos_y = round(vid_h * float(style.get("pos_y_frac", 0.85)))
pos_tag = f"{{\\pos({pos_x},{pos_y})}}"
header = f"""[Script Info]
ScriptType: v4.00+
-PlayResX: 1080
-PlayResY: 1920
+PlayResX: {vid_w}
+PlayResY: {vid_h}
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
+Style: Default,{font},{fontsize},{primary},&H00FFFFFF,{outline_color},&H00000000,{bold},0,0,0,100,100,0,0,1,{outline},{shadow},2,10,10,40,1
"""
events = []
for seg in segments:
@@ -229,16 +298,23 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
ass_path.write_text(header + "\n".join(events))
- # Run ffmpeg from OUTPUT_DIR so the ass= filter gets a plain filename
- # (absolute paths with slashes confuse ffmpeg's filter graph parser on macOS)
+ if FFMPEG_HAS_LIBASS:
+ fontsdir = _ensure_font(font)
+ fd_arg = f":fontsdir={fontsdir}" if fontsdir else ""
+ vf = f"subtitles=filename={ass_path.name}{fd_arg}"
+ cwd = str(OUTPUT_DIR.resolve())
+ else:
+ vf = _build_drawtext_vf(segments, style, words_per_chunk, vid_w, vid_h)
+ cwd = None
+
cmd = [
- "ffmpeg", "-y",
- "-i", str(Path(video_path).resolve()),
- "-vf", f"ass={ass_path.name}",
+ FFMPEG, "-y",
+ "-i", abs_video,
+ "-vf", vf,
"-c:a", "copy",
str(out_path.resolve()),
]
- result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(OUTPUT_DIR.resolve()))
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
os.unlink(video_path)
if result.returncode != 0:
@@ -249,7 +325,9 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
@app.get("/search_gifs")
async def search_gifs(q: str, api_key: str = "", limit: int = 16):
- key = api_key.strip() or "dc6zaTOxFJmzC"
+ key = api_key.strip()
+ if not key:
+ return {"results": [], "hint": "Ange en Giphy API-nyckel (gratis på developers.giphy.com)"}
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:
@@ -312,7 +390,7 @@ async def crop_video(
out_path = OUTPUT_DIR / f"cropped_{aspect.replace(':','x')}.mp4"
cmd = [
- "ffmpeg", "-y", "-i", video_path,
+ 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),
@@ -471,7 +549,7 @@ async def export_highlights(
if len(padded) == 1:
s, e = padded[0]
cmd = [
- "ffmpeg", "-y", "-ss", str(s), "-to", str(e), "-i", video_path,
+ FFMPEG, "-y", "-ss", str(s), "-to", str(e), "-i", video_path,
"-c:v", "libx264", "-crf", "18", "-preset", "fast", "-c:a", "aac",
str(out_path),
]
@@ -487,7 +565,7 @@ async def export_highlights(
+ f";{concat_in}concat=n={n}:v=1:a=1[vout][aout]"
)
cmd = [
- "ffmpeg", "-y", "-i", video_path,
+ FFMPEG, "-y", "-i", video_path,
"-filter_complex", filter_complex,
"-map", "[vout]", "-map", "[aout]",
"-c:v", "libx264", "-crf", "18", "-preset", "fast", "-c:a", "aac",
@@ -516,6 +594,56 @@ def _ass_time_to_seconds(t: str) -> float:
return h * 3600 + m * 60 + s
+def _ass_to_hex(ass: str) -> str:
+ """Convert ASS &HAABBGGRR to ffmpeg 0xRRGGBB@alpha."""
+ c = ass.lstrip("&Hh")
+ aa = c[0:2] if len(c) >= 8 else "00"
+ bb = c[2:4] if len(c) >= 4 else "00"
+ gg = c[4:6] if len(c) >= 6 else "00"
+ rr = c[6:8] if len(c) >= 8 else (c[4:6] if len(c) >= 6 else "FF")
+ alpha = round(1.0 - int(aa, 16) / 255, 2)
+ return f"0x{rr}{gg}{bb}@{alpha}"
+
+
+def _dt_escape(s: str) -> str:
+ return s.replace("\\", "\\\\").replace("'", "\\'").replace(":", "\\:")
+
+
+def _build_drawtext_vf(segments: list, style: dict, words_per_chunk: int,
+ vid_w: int = 1080, vid_h: int = 1920) -> str:
+ """Fallback subtitle filter using drawtext (no libass required)."""
+ # Scale fontsize the same way the canvas preview does: relative to 1080px width baseline
+ fontsize = round(int(style.get("fontsize", 52)) * vid_w / 1080)
+ font = style.get("font", "Arial Black")
+ fg = _ass_to_hex(style.get("primary_color", "&H00FFFFFF"))
+ border = _ass_to_hex(style.get("outline_color", "&H00000000"))
+ bw = round(int(style.get("outline", 3)) * vid_w / 1080)
+ px = float(style.get("pos_x_frac", 0.5))
+ py = float(style.get("pos_y_frac", 0.85))
+ x_expr = f"(w*{px}-text_w/2)"
+ y_expr = f"h*{py}-text_h"
+
+ parts = []
+ for seg in segments:
+ words = seg.get("words", [])
+ if not words:
+ chunks = [{"text": seg["text"], "start": seg["start"], "end": seg["end"]}]
+ else:
+ raw = [words[i:i + words_per_chunk] for i in range(0, len(words), words_per_chunk)]
+ chunks = [{"text": " ".join(w["word"].strip() for w in c),
+ "start": c[0]["start"], "end": c[-1]["end"]} for c in raw]
+ for c in chunks:
+ txt = _dt_escape(c["text"])
+ parts.append(
+ f"drawtext=text='{txt}'"
+ f":font='{font}':fontsize={fontsize}:fontcolor={fg}"
+ f":bordercolor={border}:borderw={bw}"
+ f":x={x_expr}:y={y_expr}"
+ f":enable='between(t,{c['start']},{c['end']})'"
+ )
+ return ",".join(parts) if parts else "null"
+
+
def _distribute_words(text: str, start: float, end: float) -> list:
words = text.split()
if not words:
diff --git a/fonts/Permanent_Marker.ttf b/fonts/Permanent_Marker.ttf
new file mode 100644
index 0000000..356a097
Binary files /dev/null and b/fonts/Permanent_Marker.ttf differ
diff --git a/index.html b/index.html
index cdbe8ab..179cf74 100644
--- a/index.html
+++ b/index.html
@@ -238,8 +238,21 @@
.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; }
+ .gif-overlay-item { position: absolute; pointer-events: none; border-radius: 4px; overflow: visible; transform-origin: center center; }
+ .gif-overlay-item video { width: 100%; height: auto; display: block; border-radius: 4px; }
+
+ @keyframes anim-fadein { from { opacity:0 } to { opacity:1 } }
+ @keyframes anim-slideup { from { opacity:0; transform:translateY(40px) } to { opacity:1; transform:translateY(0) } }
+ @keyframes anim-bounceup { 0%{opacity:0;transform:translateY(50px)} 60%{transform:translateY(-12px)} 80%{transform:translateY(6px)} 100%{opacity:1;transform:translateY(0)} }
+ @keyframes anim-zoomin { from { opacity:0; transform:scale(0.2) } to { opacity:1; transform:scale(1) } }
+ @keyframes anim-spin { from { opacity:0; transform:rotate(-180deg) scale(0.2) } to { opacity:1; transform:rotate(0deg) scale(1) } }
+ @keyframes anim-shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-10px)} 40%{transform:translateX(10px)} 60%{transform:translateX(-6px)} 80%{transform:translateX(6px)} }
+ .gif-anim-fadein { animation: anim-fadein 0.35s ease-out both }
+ .gif-anim-slideup { animation: anim-slideup 0.35s ease-out both }
+ .gif-anim-bounceup { animation: anim-bounceup 0.5s ease-out both }
+ .gif-anim-zoomin { animation: anim-zoomin 0.35s ease-out both }
+ .gif-anim-spin { animation: anim-spin 0.45s ease-out both }
+ .gif-anim-shake { animation: anim-shake 0.5s ease-in-out both }
/* Crop */
.crop-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: none; border-radius: var(--radius); }
@@ -252,6 +265,20 @@
.crop-chip.active { background: var(--accent); border-color: var(--accent); color: white; }
.crop-export-btn { margin-top: 12px; }
.gif-search-section { margin-top: 10px; }
+
+ /* Reaction editor panel */
+ .re-item { display:grid; grid-template-columns:auto 1fr 1fr auto; gap:6px; align-items:center; background:var(--surface2); border-radius:8px; padding:7px 10px; font-size:12px; }
+ .re-item-emoji { background:rgba(254,232,0,.1); border-color:rgba(254,232,0,.3); }
+ .re-item-gif { background:rgba(100,180,255,.07); border-color:rgba(100,180,255,.3); }
+ .re-badge { font-size:18px; min-width:28px; text-align:center; }
+ .re-field { display:flex; flex-direction:column; gap:3px; }
+ .re-field label { font-size:10px; color:var(--muted); }
+ .re-field input[type=number] { width:64px; background:var(--bg); border:1px solid var(--border); border-radius:5px; color:var(--text); padding:2px 5px; font-size:12px; }
+ .re-field input[type=range] { width:80px; }
+ .re-field select { background:var(--bg); border:1px solid var(--border); border-radius:5px; color:var(--text); padding:2px 4px; font-size:11px; }
+ .re-row2 { grid-column:1/-1; display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
+ .re-del { background:none; border:none; color:var(--muted); cursor:pointer; font-size:16px; padding:0 4px; line-height:1; }
+ .re-del:hover { color:var(--accent); }
.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; }
@@ -316,7 +343,7 @@
/* Timeline */
.timeline-bar {
border-top: 1px solid var(--border); background: #060609;
- display: flex; flex-direction: column; flex-shrink: 0; height: 158px;
+ display: flex; flex-direction: column; flex-shrink: 0; height: 210px;
}
.timeline-header {
display: flex; align-items: center; justify-content: space-between;
@@ -350,6 +377,11 @@
<div class="logo-sub">TikTok-style subtitles with local Whisper</div>
</div>
</header>
+<div id="libass-warning" style="display:none;background:#7c2d12;color:#fef3c7;padding:8px 20px;font-size:12px;border-bottom:1px solid #92400e;">
+ ⚠ ffmpeg saknar libass — video-export använder drawtext (enklare stil, ingen karaoke).
+ Kör <strong>brew install ffmpeg</strong> och starta om appen för full styling.
+ <button onclick="document.getElementById('libass-warning').style.display='none'" style="float:right;background:none;border:none;color:inherit;cursor:pointer;font-size:14px;">×</button>
+</div>
<div class="layout">
<!-- Sidebar -->
@@ -430,7 +462,7 @@
<div class="section-title">Style</div>
<label>Font</label>
- <select id="font-select" onchange="updatePreview()">
+ <select id="font-select" onchange="document.fonts.load(`900 52px '${this.value}'`).then(()=>updatePreview())">
<optgroup label="── Hype / TikTok ──">
<option value="Arial Black">Arial Black</option>
<option value="Impact">Impact</option>
@@ -524,9 +556,17 @@
<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>
+ <option value="above">Ovanför text</option>
+ <option value="below" selected>Under text</option>
+ <option value="right">Till höger om text</option>
+ </select>
+ <label for="emoji-effect">Effekt</label>
+ <select id="emoji-effect" title="Emoji-animationseffekt" onchange="updatePreview()">
+ <option value="none">Ingen</option>
+ <option value="fadein">Tona fram</option>
+ <option value="bounceup" selected>Studsa upp</option>
+ <option value="zoomin">Zooma in</option>
+ <option value="shake">Skaka</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()">
@@ -546,21 +586,29 @@
<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>
+ <label>Effekt</label>
+ <select id="gif-effect" title="GIF-animationseffekt" onchange="updateGifOverlay(document.getElementById('video-player').currentTime)">
+ <option value="none">Ingen</option>
+ <option value="fadein">Tona fram</option>
+ <option value="slideup" selected>Glid upp</option>
+ <option value="bounceup">Studsa upp</option>
+ <option value="zoomin">Zooma in</option>
+ <option value="spin">Snurra in</option>
+ <option value="shake">Skaka</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)">
+ <label>Position X: <span id="gif-pos-x-val">50</span>%</label>
+ <input type="range" id="gif-pos-x" title="GIF horisontell position" min="5" max="95" value="50" oninput="document.getElementById('gif-pos-x-val').textContent=this.value;updateGifOverlay(document.getElementById('video-player').currentTime)">
+ <label>Position Y: <span id="gif-pos-y-val">30</span>%</label>
+ <input type="range" id="gif-pos-y" title="GIF vertikal position" min="5" max="95" value="30" oninput="document.getElementById('gif-pos-y-val').textContent=this.value;updateGifOverlay(document.getElementById('video-player').currentTime)">
+ <label for="gif-size">Storlek: <span id="gif-size-val">50</span>%</label>
+ <input type="range" id="gif-size" 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">Opacitet: <span id="gif-opacity-val">90</span>%</label>
+ <input type="range" id="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>
+ <span class="toggle-label">Visa 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">
+ <input type="text" id="giphy-key" class="giphy-key-input" placeholder="Giphy API key (gratis på developers.giphy.com)" title="Din Giphy API-nyckel">
</div>
<!-- Crop to Format -->
@@ -658,6 +706,18 @@
<canvas id="timeline-canvas"></canvas>
</div>
+ <!-- Emoji / GIF item editor (shown in edit mode) -->
+ <div id="reaction-editor" style="display:none;border-top:1px solid var(--border);background:var(--surface);max-height:220px;overflow-y:auto">
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:6px 14px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--surface);z-index:1">
+ <span style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px">Emoji & GIF — tidslinje</span>
+ <div style="display:flex;gap:6px">
+ <button type="button" class="tl-btn" onclick="addEmojiItemManual()" title="Lägg till emoji-block">+ Emoji</button>
+ <button type="button" class="tl-btn" onclick="addGifItemManual()" title="Lägg till GIF-block (välj GIF i söken först)">+ GIF</button>
+ </div>
+ </div>
+ <div id="reaction-list" style="padding:6px 10px;display:flex;flex-direction:column;gap:6px"></div>
+ </div>
+
<!-- Export bar -->
<div class="export-bar" id="export-bar">
<span style="font-size:12px;color:var(--muted);flex:1">Export</span>
@@ -689,6 +749,11 @@ let tlDrag = null; // active drag: { type, segIdx, startX, origStart, or
let tlHoverSeg = -1;
let tlRafId = null;
+// Check ffmpeg/libass status on load
+fetch('/api/status').then(r => r.json()).then(s => {
+ if (!s.libass) document.getElementById('libass-warning').style.display = 'block';
+}).catch(() => {});
+
// Apply each font to its own <option> so the dropdown previews them visually
document.fonts.ready.then(() => {
document.querySelectorAll('#font-select option[value]').forEach(opt => {
@@ -919,10 +984,12 @@ function toggleEditMode() {
btn.textContent = editMode ? 'Done' : 'Edit';
const bar = document.getElementById('timeline-bar');
bar.style.display = editMode ? 'flex' : 'none';
+ document.getElementById('reaction-editor').style.display = editMode ? 'block' : 'none';
if (editMode) {
requestAnimationFrame(() => initTimeline());
const vid = document.getElementById('video-player');
if (vid.style.display !== 'none' && !vid.paused) startTlRaf();
+ renderReactionList();
} else {
stopTlRaf();
}
@@ -1116,9 +1183,26 @@ function renderSubtitles(time) {
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 emojiPx = parseInt(document.getElementById('emoji-size').value);
const emojiSize = Math.round(emojiPx * scaleFactor * (canvas.width / 1080));
- const pos = document.getElementById('emoji-position').value;
+ const pos = document.getElementById('emoji-position').value;
+ const effect = document.getElementById('emoji-effect').value;
+
+ // Animation progress (first 0.4 s of segment)
+ const elapsed = Math.max(0, time - (activeSeg >= 0 ? segments[activeSeg].start : time));
+ const t = Math.min(1, elapsed / 0.4);
+ let animDy = 0, animAlpha = 1, animScale = 1;
+ if (effect === 'fadein') { animAlpha = t; }
+ else if (effect === 'bounceup') {
+ animDy = t < 1 ? (1 - (t < 0.6 ? t/0.6 : 1 + Math.sin((t-0.6)/0.4*Math.PI)*0.25)) * emojiSize * 1.5 : 0;
+ animAlpha = Math.min(1, t * 2);
+ }
+ else if (effect === 'zoomin') { animScale = 0.2 + 0.8 * t; animAlpha = t; }
+ else if (effect === 'shake') {
+ const st = Math.min(1, elapsed / 0.5);
+ animDy = st < 1 ? Math.sin(st * Math.PI * 5) * emojiSize * 0.3 * (1 - st) : 0;
+ }
+
ctx.font = `${emojiSize}px serif`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
@@ -1128,10 +1212,50 @@ function renderSubtitles(time) {
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; });
+
+ ctx.save();
+ ctx.globalAlpha = animAlpha;
+ emojis.forEach(e => {
+ ctx.save();
+ ctx.translate(ex, ey + animDy);
+ ctx.scale(animScale, animScale);
+ ctx.fillText(e, 0, 0);
+ ctx.restore();
+ ex += emojiSize * 1.2;
+ });
+ ctx.restore();
}
}
+ // emojiItems track — render independently of segments
+ const trackEmojis = emojiItems.filter(it => time >= it.start && time <= it.end);
+ trackEmojis.forEach(item => {
+ if (!item.emojis.length) return;
+ const emojiPx = parseInt(document.getElementById('emoji-size').value);
+ const emojiSize2 = Math.round(emojiPx * scaleFactor * (canvas.width / 1080));
+ const elapsed2 = Math.max(0, time - item.start);
+ const t2 = Math.min(1, elapsed2 / 0.4);
+ const effect2 = document.getElementById('emoji-effect').value;
+ let dy2 = 0, alpha2 = 1, sc2 = 1;
+ if (effect2 === 'fadein') alpha2 = t2;
+ else if (effect2 === 'bounceup') { dy2 = t2 < 1 ? (1 - (t2 < 0.6 ? t2/0.6 : 1 + Math.sin((t2-0.6)/0.4*Math.PI)*0.25)) * emojiSize2 * 1.5 : 0; alpha2 = Math.min(1, t2*2); }
+ else if (effect2 === 'zoomin') { sc2 = 0.2 + 0.8*t2; alpha2 = t2; }
+ else if (effect2 === 'shake') { dy2 = t2 < 1 ? Math.sin(t2 * Math.PI * 5) * emojiSize2 * 0.3 * (1-t2) : 0; }
+ const ey2 = canvas.height * s.position - emojiSize2;
+ const cx2 = canvas.width * s.positionX;
+ const totalW2 = item.emojis.length * (emojiSize2 * 1.2);
+ let ex2 = cx2 - totalW2 / 2 + emojiSize2 * 0.6;
+ ctx.save();
+ ctx.font = `${emojiSize2}px serif`;
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.globalAlpha = alpha2;
+ item.emojis.forEach(e => {
+ ctx.save(); ctx.translate(ex2, ey2 + dy2); ctx.scale(sc2, sc2); ctx.fillText(e, 0, 0); ctx.restore();
+ ex2 += emojiSize2 * 1.2;
+ });
+ ctx.restore();
+ });
+
updateActiveSegment(time);
}
@@ -1204,7 +1328,7 @@ function getAssStyle() {
};
return {
font: s.font,
- fontsize: Math.round(s.fontSize * 0.6),
+ fontsize: s.fontSize,
primary_color: hexToAss(s.textColor),
highlight_color: hexToAss(s.highlightColor),
outline_color: hexToAss(s.outlineColor),
@@ -1365,6 +1489,16 @@ async function searchGifs() {
if (!res.ok) { alert('GIF search failed'); return; }
const data = await res.json();
const grid = document.getElementById('gif-results');
+ if (data.hint) {
+ grid.innerHTML = `<p style="grid-column:1/-1;color:var(--muted);font-size:12px;padding:8px 0">${data.hint}</p>`;
+ grid._data = [];
+ return;
+ }
+ if (!data.results.length) {
+ grid.innerHTML = `<p style="grid-column:1/-1;color:var(--muted);font-size:12px;padding:8px 0">Inga resultat.</p>`;
+ grid._data = [];
+ return;
+ }
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">
@@ -1377,6 +1511,7 @@ function assignGif(thumbIdx) {
const grid = document.getElementById('gif-results');
const gif = grid._data[thumbIdx];
if (!gif) return;
+ selectedGif = gif;
const segIdx = parseInt(document.getElementById('gif-assign-target').value);
if (isNaN(segIdx)) return;
segmentGifs[segIdx] = gif;
@@ -1407,30 +1542,51 @@ function updateGifOverlay(time) {
if (!overlay || vid.style.display === 'none') return;
const enabled = document.getElementById('gif-toggle').classList.contains('on');
- overlay.innerHTML = '';
- if (!enabled) return;
+ if (!enabled) { overlay.innerHTML = ''; return; }
+
+ // Check gifItems track first (per-item settings), then fall back to segment-based gifs
+ const trackGifItem = gifItems.find(it => time >= it.start && time <= it.end);
+ const activeSeg = segments.findIndex(s => time >= s.start && time <= s.end + 0.05);
+
+ let gif, sizePct, opacity, posX, posY, effect;
+ if (trackGifItem) {
+ gif = trackGifItem.gif;
+ sizePct = (trackGifItem.size ?? parseInt(document.getElementById('gif-size').value)) / 100;
+ opacity = (trackGifItem.opacity ?? parseInt(document.getElementById('gif-opacity').value)) / 100;
+ posX = (trackGifItem.posX ?? parseInt(document.getElementById('gif-pos-x').value)) / 100;
+ posY = (trackGifItem.posY ?? parseInt(document.getElementById('gif-pos-y').value)) / 100;
+ effect = trackGifItem.effect ?? document.getElementById('gif-effect').value;
+ } else {
+ gif = activeSeg >= 0 ? segmentGifs[activeSeg] : null;
+ sizePct = parseInt(document.getElementById('gif-size').value) / 100;
+ opacity = parseInt(document.getElementById('gif-opacity').value) / 100;
+ posX = parseInt(document.getElementById('gif-pos-x').value) / 100;
+ posY = parseInt(document.getElementById('gif-pos-y').value) / 100;
+ effect = document.getElementById('gif-effect').value;
+ }
- 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;
+ if (!gif?.mp4) { overlay.innerHTML = ''; 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);
+ const gw = Math.round(vw * sizePct);
+ const left = Math.round(vw * posX - gw / 2);
+ const top = Math.round(vh * posY);
+
+ // Reuse existing video element to prevent flicker — only recreate when gif changes
+ let item = overlay.querySelector('.gif-overlay-item');
+ let vidEl = item?.querySelector('video');
+ if (!item || vidEl?.dataset.src !== gif.mp4) {
+ overlay.innerHTML = '';
+ item = document.createElement('div');
+ item.className = 'gif-overlay-item' + (effect !== 'none' ? ` gif-anim-${effect}` : '');
+ vidEl = document.createElement('video');
+ vidEl.autoplay = true; vidEl.loop = true; vidEl.muted = true; vidEl.playsInline = true;
+ vidEl.dataset.src = gif.mp4;
+ vidEl.src = gif.mp4;
+ item.appendChild(vidEl);
+ overlay.appendChild(item);
+ }
+ item.style.cssText = `width:${gw}px;top:${top}px;left:${left}px;opacity:${opacity}`;
}
// ── Crop ──────────────────────────────────────────────────────
@@ -1585,6 +1741,152 @@ function closeEmojiPicker() {
emojiPickerTarget = null;
}
+function openEmojiPickerForItem(itemId, clientX, clientY) {
+ closeEmojiPicker();
+ emojiPickerTarget = { type: 'item', id: itemId };
+ const item = emojiItems.find(it => it.id === itemId);
+ if (!item) return;
+
+ const picker = document.createElement('div');
+ picker.className = 'emoji-picker';
+ picker.id = 'emoji-picker';
+
+ let html = `<div class="emoji-picker-header">
+ <span class="emoji-picker-title">Emoji på tidslinjen</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${item.emojis.includes(e) ? ' selected' : ''}" onclick="toggleEmojiItem(${itemId},'${e}',this)">${e}</span>`
+ ).join('');
+ html += '</div>';
+ }
+ // Delete button
+ html += `<button type="button" style="margin-top:8px;width:100%;background:var(--accent);border:none;color:white;padding:5px;border-radius:6px;cursor:pointer;font-size:12px" onclick="removeEmojiItem(${itemId})">🗑 Ta bort</button>`;
+ picker.innerHTML = html;
+ document.body.appendChild(picker);
+
+ picker.style.top = `${Math.min(clientY, window.innerHeight - picker.offsetHeight - 10)}px`;
+ picker.style.left = `${Math.min(clientX, window.innerWidth - 290)}px`;
+ setTimeout(() => document.addEventListener('click', outsidePickerClick), 0);
+}
+
+function toggleEmojiItem(itemId, emoji, el) {
+ const item = emojiItems.find(it => it.id === itemId);
+ if (!item) return;
+ const idx = item.emojis.indexOf(emoji);
+ if (idx >= 0) { item.emojis.splice(idx, 1); el.classList.remove('selected'); }
+ else { item.emojis.push(emoji); el.classList.add('selected'); }
+ drawTimeline();
+ updatePreview();
+}
+
+function removeEmojiItem(itemId) {
+ emojiItems = emojiItems.filter(it => it.id !== itemId);
+ closeEmojiPicker();
+ drawTimeline();
+ renderReactionList();
+ updatePreview();
+}
+
+function removeGifItem(itemId) {
+ gifItems = gifItems.filter(it => it.id !== itemId);
+ drawTimeline();
+ renderReactionList();
+ updateGifOverlay(document.getElementById('video-player').currentTime);
+}
+
+function _fmtSec(s) {
+ const m = Math.floor(s / 60), sec = (s % 60).toFixed(1);
+ return `${m}:${sec.padStart(4,'0')}`;
+}
+function _parseSec(str) {
+ const [m, s] = str.split(':').map(Number);
+ return (m || 0) * 60 + (s || 0);
+}
+
+function renderReactionList() {
+ const el = document.getElementById('reaction-list');
+ if (!el) return;
+ const EFFECTS = ['none','fadein','slideup','bounceup','zoomin','spin','shake'];
+ const EFFECT_LABELS = { none:'Ingen', fadein:'Tona fram', slideup:'Glid upp', bounceup:'Studsa upp', zoomin:'Zooma in', spin:'Snurra in', shake:'Skaka' };
+
+ const rows = [
+ ...emojiItems.map(item => ({ type:'emoji', item })),
+ ...gifItems.map(item => ({ type:'gif', item })),
+ ].sort((a, b) => a.item.start - b.item.start);
+
+ if (!rows.length) { el.innerHTML = '<p style="font-size:12px;color:var(--muted);text-align:center;padding:8px">Inga emoji- eller GIF-block. Klicka i spåret eller använd + knapparna.</p>'; return; }
+
+ el.innerHTML = rows.map(({ type, item }) => {
+ const badge = type === 'emoji' ? (item.emojis.slice(0,3).join('')||'😶') : '🎞';
+ const effOpts = EFFECTS.map(e => `<option value="${e}"${(item.effect||'none')===e?' selected':''}>${EFFECT_LABELS[e]}</option>`).join('');
+ const posX = item.posX ?? 50;
+ const posY = item.posY ?? 30;
+ const extra = type === 'gif'
+ ? `<div class="re-field"><label>Storlek %</label><input type="number" min="10" max="100" value="${item.size??50}" onchange="updateReItem('${type}',${item.id},'size',+this.value);"></div>
+ <div class="re-field"><label>Opacitet %</label><input type="number" min="10" max="100" value="${item.opacity??90}" onchange="updateReItem('${type}',${item.id},'opacity',+this.value);"></div>`
+ : `<button type="button" class="tl-btn" style="font-size:11px" onclick="openEmojiPickerForItem(${item.id},event.clientX,event.clientY)">Välj emojis</button>`;
+ return `<div class="re-item re-item-${type}">
+ <span class="re-badge">${badge}</span>
+ <div class="re-field">
+ <label>Start</label>
+ <input type="number" step="0.1" value="${item.start.toFixed(1)}" onchange="updateReItem('${type}',${item.id},'start',+this.value);" style="width:60px">
+ </div>
+ <div class="re-field">
+ <label>Slut</label>
+ <input type="number" step="0.1" value="${item.end.toFixed(1)}" onchange="updateReItem('${type}',${item.id},'end',+this.value);" style="width:60px">
+ </div>
+ <button type="button" class="re-del" onclick="removeReItem('${type}',${item.id})">×</button>
+ <div class="re-row2">
+ <div class="re-field"><label>Pos X %</label><input type="number" min="0" max="100" value="${posX}" onchange="updateReItem('${type}',${item.id},'posX',+this.value);" style="width:52px"></div>
+ <div class="re-field"><label>Pos Y %</label><input type="number" min="0" max="100" value="${posY}" onchange="updateReItem('${type}',${item.id},'posY',+this.value);" style="width:52px"></div>
+ <div class="re-field"><label>Effekt</label><select onchange="updateReItem('${type}',${item.id},'effect',this.value)">${effOpts}</select></div>
+ ${extra}
+ </div>
+ </div>`;
+ }).join('');
+}
+
+function updateReItem(type, id, field, value) {
+ const arr = type === 'emoji' ? emojiItems : gifItems;
+ const item = arr.find(it => it.id === id);
+ if (!item) return;
+ if (field === 'start') item.start = Math.max(0, value);
+ else if (field === 'end') item.end = Math.max(item.start + 0.1, value);
+ else item[field] = value;
+ drawTimeline();
+ updatePreview();
+ updateGifOverlay(document.getElementById('video-player').currentTime);
+}
+
+function removeReItem(type, id) {
+ if (type === 'emoji') { emojiItems = emojiItems.filter(it => it.id !== id); updatePreview(); }
+ else { gifItems = gifItems.filter(it => it.id !== id); updateGifOverlay(document.getElementById('video-player').currentTime); }
+ drawTimeline();
+ renderReactionList();
+}
+
+function addEmojiItemManual() {
+ const vid = document.getElementById('video-player');
+ const t = vid.style.display !== 'none' ? vid.currentTime : 0;
+ const item = { id: nextItemId++, start: t, end: t + 2, emojis: [], posX: 50, posY: 30, effect: 'bounceup' };
+ emojiItems.push(item);
+ drawTimeline();
+ renderReactionList();
+ openEmojiPickerForItem(item.id, window.innerWidth / 2, window.innerHeight / 2);
+}
+
+function addGifItemManual() {
+ if (!selectedGif) { alert('Välj en GIF i sökresultaten först.'); return; }
+ const vid = document.getElementById('video-player');
+ const t = vid.style.display !== 'none' ? vid.currentTime : 0;
+ gifItems.push({ id: nextItemId++, start: t, end: t + 3, gif: selectedGif, posX: 50, posY: 30, size: 50, opacity: 90, effect: 'slideup' });
+ drawTimeline();
+ renderReactionList();
+}
+
function toggleEmoji(segIdx, emoji, el) {
const arr = segmentEmojis[segIdx] || [];
const idx = arr.indexOf(emoji);
@@ -1638,6 +1940,10 @@ function tlTotalDuration() {
}
let tlDragCanvasRect = null;
+let emojiItems = []; // [{id,start,end,emojis:[]}]
+let gifItems = []; // [{id,start,end,gif:{}}]
+let nextItemId = 0;
+let selectedGif = null; // currently selected gif from search
function initTimeline() {
const bar = document.getElementById('timeline-bar');
@@ -1683,8 +1989,12 @@ function tlDims() {
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 };
+ const SUBS_H = 36;
+ const EMOJI_Y = SUBS_Y + SUBS_H + 2;
+ const EMOJI_H = 26;
+ const GIF_Y = EMOJI_Y + EMOJI_H + 2;
+ const GIF_H = H - GIF_Y - 3;
+ return { W, H, RULER, WAVE, SUBS_Y, SUBS_H, EMOJI_Y, EMOJI_H, GIF_Y, GIF_H };
}
function tlX(t) { return (t - tlOffset) * tlZoom; }
@@ -1813,6 +2123,77 @@ function drawTimeline() {
}
});
+ // ── Emoji track ──
+ const { EMOJI_Y, EMOJI_H, GIF_Y, GIF_H } = tlDims();
+ ctx.fillStyle = '#08080f';
+ ctx.fillRect(0, EMOJI_Y, W, EMOJI_H);
+ ctx.fillStyle = '#1a1a28';
+ ctx.font = '8px monospace';
+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
+ ctx.fillText('EMOJI', 2, EMOJI_Y + EMOJI_H / 2);
+
+ emojiItems.forEach((item, i) => {
+ const ex1 = tlX(item.start), ex2 = tlX(item.end);
+ if (ex2 < 0 || ex1 > W) return;
+ const ey1 = EMOJI_Y + 2, ey2 = EMOJI_Y + EMOJI_H - 2;
+ const isActive = tlDrag?.track === 'emoji' && tlDrag.segIdx === i;
+ ctx.fillStyle = isActive ? 'rgba(37,244,238,.25)' : 'rgba(254,220,0,.15)';
+ ctx.fillRect(Math.max(0, ex1), ey1, Math.min(W, ex2) - Math.max(0, ex1), ey2 - ey1);
+ ctx.strokeStyle = isActive ? '#25f4ee' : '#fee800';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(Math.max(0, ex1), ey1); ctx.lineTo(Math.min(W, ex2), ey1);
+ ctx.moveTo(Math.max(0, ex1), ey2); ctx.lineTo(Math.min(W, ex2), ey2);
+ ctx.stroke();
+ if (ex1 >= 0 && ex1 <= W) { ctx.beginPath(); ctx.moveTo(ex1, ey1); ctx.lineTo(ex1, ey2); ctx.stroke(); }
+ if (ex2 >= 0 && ex2 <= W) { ctx.beginPath(); ctx.moveTo(ex2, ey1); ctx.lineTo(ex2, ey2); ctx.stroke(); }
+ const label = item.emojis.slice(0, 4).join(' ') || '+';
+ const bw = ex2 - ex1;
+ if (bw > 16) {
+ ctx.save();
+ ctx.beginPath(); ctx.rect(Math.max(0, ex1) + 2, ey1, Math.min(W, ex2) - Math.max(0, ex1) - 4, ey2 - ey1); ctx.clip();
+ ctx.font = `${Math.min(14, EMOJI_H - 6)}px serif`;
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText(label, ex1 + bw / 2, EMOJI_Y + EMOJI_H / 2);
+ ctx.restore();
+ }
+ });
+
+ // ── GIF track ──
+ ctx.fillStyle = '#08080f';
+ ctx.fillRect(0, GIF_Y, W, GIF_H);
+ ctx.fillStyle = '#1a1a28';
+ ctx.font = '8px monospace';
+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
+ ctx.fillText('GIF', 2, GIF_Y + GIF_H / 2);
+
+ gifItems.forEach((item, i) => {
+ const gx1 = tlX(item.start), gx2 = tlX(item.end);
+ if (gx2 < 0 || gx1 > W) return;
+ const gy1 = GIF_Y + 2, gy2 = GIF_Y + GIF_H - 2;
+ const isActive = tlDrag?.track === 'gif' && tlDrag.segIdx === i;
+ ctx.fillStyle = isActive ? 'rgba(37,244,238,.25)' : 'rgba(100,180,255,.13)';
+ ctx.fillRect(Math.max(0, gx1), gy1, Math.min(W, gx2) - Math.max(0, gx1), gy2 - gy1);
+ ctx.strokeStyle = isActive ? '#25f4ee' : '#64b4ff';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(Math.max(0, gx1), gy1); ctx.lineTo(Math.min(W, gx2), gy1);
+ ctx.moveTo(Math.max(0, gx1), gy2); ctx.lineTo(Math.min(W, gx2), gy2);
+ ctx.stroke();
+ if (gx1 >= 0 && gx1 <= W) { ctx.beginPath(); ctx.moveTo(gx1, gy1); ctx.lineTo(gx1, gy2); ctx.stroke(); }
+ if (gx2 >= 0 && gx2 <= W) { ctx.beginPath(); ctx.moveTo(gx2, gy1); ctx.lineTo(gx2, gy2); ctx.stroke(); }
+ const bw = gx2 - gx1;
+ if (bw > 16 && item.gif?.title) {
+ ctx.save();
+ ctx.beginPath(); ctx.rect(Math.max(0, gx1) + 2, gy1, Math.min(W, gx2) - Math.max(0, gx1) - 4, gy2 - gy1); ctx.clip();
+ ctx.fillStyle = isActive ? '#25f4ee' : '#64b4ffcc';
+ ctx.font = '9px Inter, sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText(item.gif.title, gx1 + bw / 2, GIF_Y + GIF_H / 2);
+ ctx.restore();
+ }
+ });
+
// ── Playhead ──
const vid = document.getElementById('video-player');
const ph = vid.style.display !== 'none' ? vid.currentTime : 0;
@@ -1850,15 +2231,15 @@ function tlFmtTime(s) {
return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
}
-function tlGetHit(x) {
- const HW = 10; // hit-width for edges in px
- for (let i = segments.length - 1; i >= 0; i--) {
- const x1 = tlX(segments[i].start), x2 = tlX(segments[i].end);
+function tlGetHit(x, track, items) {
+ const HW = 10;
+ for (let i = items.length - 1; i >= 0; i--) {
+ const x1 = tlX(items[i].start), x2 = tlX(items[i].end);
const hw = Math.min(HW, 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 };
+ if (x <= x1 + hw) return { track, type: 'resize-start', segIdx: i };
+ if (x >= x2 - hw) return { track, type: 'resize-end', segIdx: i };
+ return { track, type: 'move', segIdx: i };
}
return null;
}
@@ -1878,25 +2259,46 @@ function tlNeighbours(segIdx) {
function tlApplyDrag(clientX) {
if (!tlDrag) return;
const x = clientX - tlDragCanvasRect.left;
+
+ if (tlDrag.type === 'pan') {
+ tlOffset = Math.max(0, tlDrag.origOffset - (x - tlDrag.startX) / tlZoom);
+ drawTimeline();
+ return;
+ }
+
const dt = (x - tlDrag.startX) / tlZoom;
- const seg = segments[tlDrag.segIdx];
const MIN = 0.05;
- const { prevEnd, nextStart } = tlNeighbours(tlDrag.segIdx);
-
- if (tlDrag.type === 'move') {
- const dur = tlDrag.origEnd - tlDrag.origStart;
- const ns = Math.max(prevEnd, Math.min(nextStart - dur, tlDrag.origStart + dt));
- seg.start = Math.round(ns * 1000) / 1000;
- seg.end = Math.round((ns + dur) * 1000) / 1000;
- } else if (tlDrag.type === 'resize-start') {
- seg.start = Math.round(Math.max(prevEnd, Math.min(tlDrag.origEnd - MIN, tlDrag.origStart + dt)) * 1000) / 1000;
+ let item;
+
+ if (tlDrag.track === 'sub') {
+ item = segments[tlDrag.segIdx];
+ const { prevEnd, nextStart } = tlNeighbours(tlDrag.segIdx);
+ if (tlDrag.type === 'move') {
+ const dur = tlDrag.origEnd - tlDrag.origStart;
+ const ns = Math.max(prevEnd, Math.min(nextStart - dur, tlDrag.origStart + dt));
+ item.start = Math.round(ns * 1000) / 1000;
+ item.end = Math.round((ns + dur) * 1000) / 1000;
+ } else if (tlDrag.type === 'resize-start') {
+ item.start = Math.round(Math.max(prevEnd, Math.min(tlDrag.origEnd - MIN, tlDrag.origStart + dt)) * 1000) / 1000;
+ } else {
+ item.end = Math.round(Math.min(nextStart, Math.max(tlDrag.origStart + MIN, tlDrag.origEnd + dt)) * 1000) / 1000;
+ }
} else {
- seg.end = Math.round(Math.min(nextStart, Math.max(tlDrag.origStart + MIN, tlDrag.origEnd + dt)) * 1000) / 1000;
+ const arr = tlDrag.track === 'emoji' ? emojiItems : gifItems;
+ item = arr[tlDrag.segIdx];
+ if (!item) return;
+ if (tlDrag.type === 'move') {
+ const dur = tlDrag.origEnd - tlDrag.origStart;
+ const ns = Math.max(0, tlDrag.origStart + dt);
+ item.start = Math.round(ns * 1000) / 1000;
+ item.end = Math.round((ns + dur) * 1000) / 1000;
+ } else if (tlDrag.type === 'resize-start') {
+ item.start = Math.round(Math.max(0, Math.min(tlDrag.origEnd - MIN, tlDrag.origStart + dt)) * 1000) / 1000;
+ } else {
+ item.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();
}
@@ -1905,7 +2307,14 @@ function tlDocMove(e) { tlApplyDrag(e.clientX); }
function tlDocUp() {
document.removeEventListener('mousemove', tlDocMove);
document.removeEventListener('mouseup', tlDocUp);
- if (tlDrag) { renderSegmentsList(); tlDrag = null; }
+ if (tlDrag && tlDrag.type !== 'pan' && tlDrag.track === 'sub') {
+ const seg = segments[tlDrag.segIdx];
+ seg.words = distributeWords(seg.text, seg.start, seg.end);
+ updateTimeInputs(tlDrag.segIdx);
+ renderSegmentsList();
+ updatePreview();
+ }
+ tlDrag = null;
const canvas = document.getElementById('timeline-canvas');
if (canvas) canvas.style.cursor = 'crosshair';
drawTimeline();
@@ -1913,39 +2322,88 @@ function tlDocUp() {
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 dims = tlDims();
+ const { SUBS_Y, SUBS_H, EMOJI_Y, EMOJI_H, GIF_Y, GIF_H } = dims;
+ tlDragCanvasRect = e.currentTarget.getBoundingClientRect();
+ document.addEventListener('mousemove', tlDocMove);
+ document.addEventListener('mouseup', tlDocUp);
+ e.preventDefault();
+
+ // Ruler or waveform → seek
+ if (y < SUBS_Y) {
const vid = document.getElementById('video-player');
if (vid.style.display !== 'none') vid.currentTime = Math.max(0, tlT(x));
- drawTimeline();
+ tlDrag = { type: 'pan', startX: x, origOffset: tlOffset };
+ e.currentTarget.style.cursor = 'grabbing';
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));
+
+ if (y >= EMOJI_Y && y <= EMOJI_Y + EMOJI_H) {
+ const hit = tlGetHit(x, 'emoji', emojiItems);
+ if (hit) {
+ tlDrag = { ...hit, startX: x, origStart: emojiItems[hit.segIdx].start, origEnd: emojiItems[hit.segIdx].end };
+ e.currentTarget.style.cursor = hit.type === 'move' ? 'grabbing' : 'ew-resize';
+ } else {
+ // Add new emoji item at click position
+ const t = Math.max(0, tlT(x));
+ const item = { id: nextItemId++, start: t, end: t + 2, emojis: [], posX: 50, posY: 30, effect: 'bounceup' };
+ emojiItems.push(item);
+ drawTimeline();
+ renderReactionList();
+ openEmojiPickerForItem(item.id, e.clientX, e.clientY);
+ tlDrag = { type: 'pan', startX: x, origOffset: tlOffset };
+ e.currentTarget.style.cursor = 'grabbing';
+ }
return;
}
- tlDrag = { type: hit.type, segIdx: hit.segIdx, startX: x,
- origStart: segments[hit.segIdx].start, origEnd: segments[hit.segIdx].end };
- tlDragCanvasRect = e.currentTarget.getBoundingClientRect();
- document.addEventListener('mousemove', tlDocMove);
- document.addEventListener('mouseup', tlDocUp);
- e.currentTarget.style.cursor = hit.type === 'move' ? 'grabbing' : 'ew-resize';
- e.preventDefault();
+
+ if (y >= GIF_Y && y <= GIF_Y + GIF_H) {
+ const hit = tlGetHit(x, 'gif', gifItems);
+ if (hit) {
+ tlDrag = { ...hit, startX: x, origStart: gifItems[hit.segIdx].start, origEnd: gifItems[hit.segIdx].end };
+ e.currentTarget.style.cursor = hit.type === 'move' ? 'grabbing' : 'ew-resize';
+ } else {
+ if (!selectedGif) { alert('Välj en GIF i sökresultaten först.'); document.removeEventListener('mousemove', tlDocMove); document.removeEventListener('mouseup', tlDocUp); return; }
+ const t = Math.max(0, tlT(x));
+ gifItems.push({ id: nextItemId++, start: t, end: t + 3, gif: selectedGif, posX: 50, posY: 30, size: 50, opacity: 90, effect: 'slideup' });
+ drawTimeline();
+ renderReactionList();
+ tlDrag = { type: 'pan', startX: x, origOffset: tlOffset };
+ e.currentTarget.style.cursor = 'grabbing';
+ }
+ return;
+ }
+
+ const inSubs = y >= SUBS_Y && y <= SUBS_Y + SUBS_H;
+ const hit = inSubs ? tlGetHit(x, 'sub', segments) : null;
+ if (hit) {
+ tlDrag = { ...hit, startX: x, origStart: segments[hit.segIdx].start, origEnd: segments[hit.segIdx].end };
+ e.currentTarget.style.cursor = hit.type === 'move' ? 'grabbing' : 'ew-resize';
+ } else {
+ tlDrag = { type: 'pan', startX: x, origOffset: tlOffset };
+ e.currentTarget.style.cursor = 'grabbing';
+ }
}
function tlMouseMove(e) {
- if (tlDrag) return; // handled by document listener
+ if (tlDrag) return;
const x = e.offsetX, y = e.offsetY;
- const { SUBS_Y, SUBS_H } = tlDims();
+ const { SUBS_Y, SUBS_H, EMOJI_Y, EMOJI_H, GIF_Y, GIF_H } = tlDims();
if (y >= SUBS_Y && y <= SUBS_Y + SUBS_H) {
- const hit = tlGetHit(x);
+ const hit = tlGetHit(x, 'sub', segments);
tlHoverSeg = hit ? hit.segIdx : -1;
- e.currentTarget.style.cursor = !hit ? 'crosshair' : hit.type === 'move' ? 'grab' : 'ew-resize';
+ e.currentTarget.style.cursor = !hit ? 'grab' : hit.type === 'move' ? 'grab' : 'ew-resize';
+ } else if (y >= EMOJI_Y && y <= EMOJI_Y + EMOJI_H) {
+ tlHoverSeg = -1;
+ const hit = tlGetHit(x, 'emoji', emojiItems);
+ e.currentTarget.style.cursor = hit ? (hit.type === 'move' ? 'grab' : 'ew-resize') : 'cell';
+ } else if (y >= GIF_Y && y <= GIF_Y + GIF_H) {
+ tlHoverSeg = -1;
+ const hit = tlGetHit(x, 'gif', gifItems);
+ e.currentTarget.style.cursor = hit ? (hit.type === 'move' ? 'grab' : 'ew-resize') : 'cell';
} else {
tlHoverSeg = -1;
- e.currentTarget.style.cursor = 'crosshair';
+ e.currentTarget.style.cursor = 'grab';
}
drawTimeline();
}
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..e53c79b
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+set -e
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+VENV_DIR="$DIR/.venv"
+
+if [ ! -d "$VENV_DIR" ]; then
+ echo "Creating virtual environment..."
+ python3 -m venv "$VENV_DIR"
+fi
+
+source "$VENV_DIR/bin/activate"
+
+echo "Installing dependencies..."
+pip install --upgrade pip -q
+pip install -r "$DIR/requirements.txt"
+
+echo ""
+
+# Check ffmpeg
+FFMPEG_OK=false
+for candidate in "$(which ffmpeg 2>/dev/null)" "/opt/homebrew/bin/ffmpeg" "/usr/local/bin/ffmpeg"; do
+ [ -z "$candidate" ] && continue
+ [ -x "$candidate" ] || continue
+ if "$candidate" -buildconf 2>&1 | grep -q 'enable-libass'; then
+ echo "✓ ffmpeg with libass found at $candidate"
+ FFMPEG_OK=true
+ break
+ fi
+done
+
+if [ "$FFMPEG_OK" = false ]; then
+ echo ""
+ echo "⚠ ffmpeg with libass not found — subtitle burning will fail."
+ echo " Fix: brew install ffmpeg"
+ echo " Then re-run this script."
+fi
+
+echo ""
+echo "Done! Run ./start.sh to launch SubTok."
diff --git a/start.sh b/start.sh
index 0e4cf7f..15f960b 100755
--- a/start.sh
+++ b/start.sh
@@ -1,20 +1,18 @@
#!/bin/bash
set -e
-VENV_DIR="$(dirname "$0")/.venv"
+DIR="$(cd "$(dirname "$0")" && pwd)"
+VENV_DIR="$DIR/.venv"
if [ ! -d "$VENV_DIR" ]; then
- echo "Creating virtual environment..."
- python3 -m venv "$VENV_DIR"
+ echo "Virtual environment not found. Run ./install.sh first."
+ exit 1
fi
source "$VENV_DIR/bin/activate"
-echo "Installing dependencies..."
-pip install -r "$(dirname "$0")/requirements.txt"
-
-echo ""
echo "Starting SubTok..."
echo "Open http://localhost:8000 in your browser"
echo ""
+cd "$DIR"
python app.py