commits
tags
<!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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Bangers&family=Bebas+Neue&family=Boogaloo&family=Cabin+Sketch:wght@700&family=Cinzel:wght@900&family=Fredoka+One&family=Lilita+One&family=Lobster&family=Montserrat:wght@900&family=Oswald:wght@700&family=Pacifico&family=Permanent+Marker&family=Playfair+Display:ital,wght@0,900;1,900&family=Press+Start+2P&family=Righteous&family=Russo+One&family=Teko:wght@700&family=Titan+One&display=swap" rel="stylesheet">
<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; min-height: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 24px; background: #050508; position: relative; overflow: hidden;
}
.video-wrapper { position: relative; max-width: 100%; max-height: 100%; display: flex; align-items: center; justify-content: center; }
video { max-width: 100%; max-height: 100%; 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: 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); }
.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; }
/* Panel tabs */
.panel-tabs { display:flex; gap:2px; }
.panel-tab { background:none; border:none; border-bottom:2px solid transparent; color:var(--muted); cursor:pointer; font-size:12px; font-weight:600; padding:4px 10px; transition:all 0.15s; white-space:nowrap; }
.panel-tab:hover { color:var(--text); }
.panel-tab.active { color:var(--text); border-bottom-color:var(--accent); }
#reaction-panel { flex:1; overflow-y:auto; padding:8px 10px; flex-direction:column; gap:6px; }
/* 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; }
.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; }
/* Subtitle import zone */
.sub-upload-zone {
border: 1px dashed var(--border); border-radius: var(--radius);
padding: 14px 12px; text-align: center; cursor: pointer;
background: var(--surface2); transition: all 0.2s; font-size: 12px;
color: var(--muted);
}
.sub-upload-zone:hover { border-color: var(--accent2); color: var(--accent2); }
.sub-upload-zone input { display: none; }
/* Edit mode */
.segments-header .edit-toggle-btn {
padding: 3px 10px; font-size: 11px; font-weight: 700; border-radius: 6px;
border: 1px solid var(--border); background: var(--surface2); color: var(--muted);
cursor: pointer; transition: all 0.15s;
}
.segments-header .edit-toggle-btn:hover { border-color: var(--accent2); color: var(--accent2); }
.segments-header .edit-toggle-btn.active { background: var(--accent); border-color: var(--accent); color: white; }
.seg-edit-time-row { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
.seg-edit-time-row input {
width: 70px; background: var(--surface); border: 1px solid var(--border);
color: var(--accent2); border-radius: 6px; padding: 3px 6px; font-size: 11px;
font-family: monospace; text-align: center; outline: none;
}
.seg-edit-time-row input:focus { border-color: var(--accent2); }
.seg-edit-time-row span { color: var(--muted); font-size: 11px; }
.seg-edit-delete-btn {
margin-left: auto; background: none; border: 1px solid var(--border);
color: var(--muted); border-radius: 6px; padding: 2px 7px; font-size: 13px;
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
}
.seg-edit-delete-btn:hover { border-color: var(--accent); color: var(--accent); }
textarea.seg-edit-text {
width: 100%; background: var(--surface); border: 1px solid var(--border);
color: var(--text); border-radius: 6px; padding: 6px 8px; font-size: 13px;
resize: vertical; outline: none; font-family: inherit; min-height: 52px; line-height: 1.4;
}
textarea.seg-edit-text:focus { border-color: var(--accent2); }
.seg-add-btn {
display: block; width: 100%; margin-top: 4px; padding: 4px;
background: none; border: 1px dashed var(--border); color: var(--muted);
border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 0.15s;
}
.seg-add-btn:hover { border-color: var(--accent2); color: var(--accent2); }
/* Timeline */
.timeline-bar {
border-top: 1px solid var(--border); background: #060609;
display: flex; flex-direction: column; flex-shrink: 0; height: 210px;
}
.timeline-header {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 14px; border-bottom: 1px solid var(--border); height: 28px; flex-shrink: 0;
}
.timeline-zoom-controls { display: flex; align-items: center; gap: 5px; }
.tl-btn {
background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
border-radius: 5px; padding: 1px 8px; font-size: 14px; cursor: pointer;
line-height: 1.5; transition: all 0.15s;
}
.tl-btn:hover { border-color: var(--accent2); color: var(--accent2); }
#timeline-canvas { flex: 1; width: 100%; display: block; cursor: crosshair; }
/* Segment score bar */
.seg-score-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.seg-score-bar-bg { flex: 1; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; cursor: pointer; }
.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 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>Debian: sudo apt install ffmpeg libass-dev / Mac: 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 -->
<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>
<!-- Import subtitles -->
<div class="section">
<div class="section-title">Import Subtitles</div>
<div class="sub-upload-zone" onclick="document.getElementById('sub-file-input').click()">
📄 Upload SRT or ASS file
<input type="file" id="sub-file-input" accept=".srt,.ass,.ssa">
</div>
</div>
<!-- Whisper settings -->
<div class="section">
<div class="section-title">Whisper</div>
<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="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>
<option value="Anton">Anton</option>
<option value="Bebas Neue">Bebas Neue</option>
<option value="Bangers">Bangers</option>
<option value="Lilita One">Lilita One</option>
<option value="Titan One">Titan One</option>
</optgroup>
<optgroup label="── Kondenserad ──">
<option value="Oswald">Oswald</option>
<option value="Teko">Teko</option>
<option value="Montserrat">Montserrat</option>
<option value="Russo One">Russo One</option>
</optgroup>
<optgroup label="── Lekfull / Rund ──">
<option value="Fredoka One">Fredoka One</option>
<option value="Righteous">Righteous</option>
<option value="Boogaloo">Boogaloo</option>
<option value="Arial Rounded MT Bold">Arial Rounded</option>
</optgroup>
<optgroup label="── Skript / Handstil ──">
<option value="Pacifico">Pacifico</option>
<option value="Lobster">Lobster</option>
<option value="Permanent Marker">Permanent Marker</option>
<option value="Cabin Sketch">Cabin Sketch</option>
</optgroup>
<optgroup label="── Elegant / Serif ──">
<option value="Playfair Display">Playfair Display</option>
<option value="Cinzel">Cinzel</option>
<option value="Georgia">Georgia</option>
<option value="Helvetica Neue">Helvetica Neue</option>
</optgroup>
<optgroup label="── Special ──">
<option value="Press Start 2P">Press Start 2P</option>
</optgroup>
</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>Vertical: <span id="pos-val">85</span>%</label>
<input type="range" id="position" min="10" max="98" value="85" oninput="document.getElementById('pos-val').textContent=this.value;updatePreview()">
<label>Horizontal: <span id="pos-x-val">50</span>%</label>
<input type="range" id="position-x" min="5" max="95" value="50" oninput="document.getElementById('pos-x-val').textContent=this.value;updatePreview()">
<div class="toggle-row">
<span class="toggle-label">Uppercase</span>
<div class="toggle on" id="uppercase-toggle" onclick="this.classList.toggle('on');updatePreview()"></div>
</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">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()">
<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>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>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">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 (gratis på developers.giphy.com)" title="Din Giphy API-nyckel">
</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 / Reactions panel -->
<div class="segments-panel">
<div class="segments-header">
<div class="panel-tabs">
<button type="button" class="panel-tab active" id="tab-transcript" onclick="switchPanelTab('transcript')">Transcript <span id="lang-badge"></span></button>
<button type="button" class="panel-tab" id="tab-reactions" onclick="switchPanelTab('reactions')" style="display:none">Emoji & GIF</button>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="seg-count" id="seg-count">0 segments</span>
<button type="button" class="edit-toggle-btn" id="edit-mode-btn" onclick="toggleEditMode()">Edit</button>
</div>
</div>
<div class="segments-list" id="segments-list">
<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:13px;">
Transcribe a video or import subtitles
</div>
</div>
<div id="reaction-panel" style="display:none;flex:1;overflow-y:auto;padding:8px 10px;display:none;flex-direction:column;gap:6px">
<div style="display:flex;gap:6px;padding-bottom:6px;border-bottom:1px solid var(--border);margin-bottom:4px">
<button type="button" class="tl-btn" onclick="addEmojiItemManual()">+ Emoji</button>
<button type="button" class="tl-btn" onclick="addGifItemManual()">+ GIF</button>
</div>
<div id="reaction-list"></div>
</div>
</div>
</div>
<!-- Timeline (shown in edit mode) -->
<div class="timeline-bar" id="timeline-bar" style="display:none">
<div class="timeline-header">
<span style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px">Timeline</span>
<div class="timeline-zoom-controls">
<button type="button" class="tl-btn" onclick="zoomTimeline(-1)" title="Zoom out">−</button>
<span id="tl-zoom-label" style="font-size:11px;color:var(--muted);min-width:30px;text-align:center">1×</span>
<button type="button" class="tl-btn" onclick="zoomTimeline(1)" title="Zoom in">+</button>
<button type="button" class="tl-btn" onclick="fitTimeline()" style="font-size:10px;padding:1px 8px" title="Fit all">Fit</button>
</div>
</div>
<canvas id="timeline-canvas"></canvas>
</div>
<!-- Export bar -->
<div class="export-bar" id="export-bar">
<span style="font-size:12px;color:var(--muted);flex:1">Export</span>
<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();
let editMode = false;
// Timeline state
let waveformData = null; // { peaks: Float32Array, duration, numPeaks }
let tlZoom = 100; // px/sec
let tlOffset = 0; // seconds at left edge
let tlDrag = null; // active drag: { type, segIdx, startX, origStart, origEnd }
let tlHoverSeg = -1;
let tlRafId = null;
// 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 => {
const f = opt.value;
if (f) opt.style.fontFamily = `"${f}", sans-serif`;
});
});
// ── File handling ──────────────────────────────────────────────
document.getElementById('file-input').addEventListener('change', e => {
const f = e.target.files[0];
if (f) loadVideo(f);
});
document.getElementById('sub-file-input').addEventListener('change', e => {
const f = e.target.files[0];
if (f) importSubtitles(f);
e.target.value = '';
});
async function importSubtitles(f) {
const zone = document.querySelector('.sub-upload-zone');
zone.textContent = '⏳ Importing…';
const fd = new FormData();
fd.append('file', f);
try {
const res = await fetch('/import/subtitles', { method: 'POST', body: fd });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
segments = data.segments;
segmentEmojis = {};
segmentGifs = {};
highlightScores = [];
highlightSelected = new Set();
renderSegmentsList();
refreshGifTargetDropdown();
document.getElementById('hl-local-btn').disabled = false;
enableExports();
showLangBadge(data.language !== 'unknown' ? data.language : '');
setStatus(`Imported ${segments.length} subtitles from ${f.name}`);
} catch (err) {
setStatus('Import error: ' + err.message);
alert('Import failed: ' + err.message);
}
zone.innerHTML = '📄 Upload SRT or ASS file';
}
function handleDrop(e) {
e.preventDefault();
document.getElementById('upload-zone').classList.remove('drag');
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);
});
vid.addEventListener('play', () => { if (editMode) startTlRaf(); });
vid.addEventListener('pause', () => stopTlRaf());
vid.addEventListener('ended', () => stopTlRaf());
window.addEventListener('resize', () => {
resizeCanvas();
updateCropOverlay();
if (document.getElementById('timeline-bar').style.display !== 'none') {
const bar = document.getElementById('timeline-bar');
const canvas = document.getElementById('timeline-canvas');
canvas.width = bar.clientWidth;
canvas.height = bar.clientHeight - 28;
drawTimeline();
}
});
document.getElementById('crop-export-btn').disabled = false;
generateWaveform(f);
}
function resizeCanvas() {
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 = '';
if (!segments.length) {
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:13px;">Transcribe a video or import subtitles</div>';
return;
}
segments.forEach((seg, i) => {
const card = document.createElement('div');
card.className = 'segment-card';
card.id = `seg-${i}`;
if (editMode) {
card.innerHTML = `
<div class="seg-edit-time-row">
<input type="text" value="${fmt(seg.start)}" title="Start time (m:ss.s)"
onblur="updateSegmentTime(${i},'start',this.value)"
onkeydown="if(event.key==='Enter')this.blur()">
<span>→</span>
<input type="text" value="${fmt(seg.end)}" title="End time (m:ss.s)"
onblur="updateSegmentTime(${i},'end',this.value)"
onkeydown="if(event.key==='Enter')this.blur()">
<button type="button" class="seg-edit-delete-btn" onclick="deleteSegment(${i})" title="Delete segment">🗑</button>
</div>
<textarea class="seg-edit-text" oninput="updateSegmentText(${i},this.value)">${seg.text}</textarea>
<button type="button" class="seg-add-btn" onclick="addSegmentAfter(${i})">+ Add segment after</button>`;
list.appendChild(card);
return;
}
const ts = `${fmt(seg.start)} → ${fmt(seg.end)}`;
const wordsHtml = (seg.words || []).map(w =>
`<span class="seg-word" data-start="${w.start}" data-end="${w.end}">${w.word.trim()}</span>`
).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);
});
}
// ── Edit mode ─────────────────────────────────────────────────
function toggleEditMode() {
editMode = !editMode;
const btn = document.getElementById('edit-mode-btn');
btn.classList.toggle('active', editMode);
btn.textContent = editMode ? 'Done' : 'Edit';
const bar = document.getElementById('timeline-bar');
bar.style.display = editMode ? 'flex' : 'none';
document.getElementById('tab-reactions').style.display = editMode ? '' : 'none';
if (editMode) {
requestAnimationFrame(() => initTimeline());
const vid = document.getElementById('video-player');
if (vid.style.display !== 'none' && !vid.paused) startTlRaf();
renderReactionList();
} else {
stopTlRaf();
switchPanelTab('transcript');
}
renderSegmentsList();
}
function switchPanelTab(tab) {
const isTranscript = tab === 'transcript';
document.getElementById('segments-list').style.display = isTranscript ? '' : 'none';
document.getElementById('reaction-panel').style.display = isTranscript ? 'none' : 'flex';
document.getElementById('tab-transcript').classList.toggle('active', isTranscript);
document.getElementById('tab-reactions').classList.toggle('active', !isTranscript);
if (!isTranscript) renderReactionList();
}
function parseFmt(str) {
const parts = str.trim().split(':');
if (parts.length !== 2) return NaN;
return parseInt(parts[0], 10) * 60 + parseFloat(parts[1]);
}
function distributeWords(text, start, end) {
const words = text.trim().split(/\s+/).filter(Boolean);
if (!words.length) return [];
const dur = (end - start) / words.length;
return words.map((w, i) => ({
word: w,
start: Math.round((start + i * dur) * 1000) / 1000,
end: Math.round((start + (i + 1) * dur) * 1000) / 1000,
}));
}
function updateSegmentText(i, text) {
segments[i].text = text;
segments[i].words = distributeWords(text, segments[i].start, segments[i].end);
updatePreview();
drawTimeline();
}
function updateSegmentTime(i, field, value) {
const t = parseFmt(value);
if (isNaN(t) || t < 0) return;
segments[i][field] = Math.round(t * 1000) / 1000;
segments[i].words = distributeWords(segments[i].text, segments[i].start, segments[i].end);
// Refresh just the time input to show normalised value
const card = document.getElementById(`seg-${i}`);
if (card) {
const inputs = card.querySelectorAll('.seg-edit-time-row input');
if (field === 'start' && inputs[0]) inputs[0].value = fmt(segments[i].start);
if (field === 'end' && inputs[1]) inputs[1].value = fmt(segments[i].end);
}
updatePreview();
drawTimeline();
}
function deleteSegment(i) {
segments.splice(i, 1);
// Re-index ids
segments.forEach((s, idx) => { s.id = idx; });
// Shift emoji/gif maps
const newEmojis = {}, newGifs = {};
Object.entries(segmentEmojis).forEach(([k, v]) => { const n = parseInt(k); if (n < i) newEmojis[n] = v; else if (n > i) newEmojis[n-1] = v; });
Object.entries(segmentGifs).forEach(([k, v]) => { const n = parseInt(k); if (n < i) newGifs[n] = v; else if (n > i) newGifs[n-1] = v; });
segmentEmojis = newEmojis;
segmentGifs = newGifs;
highlightScores = [];
highlightSelected = new Set();
renderSegmentsList();
refreshGifTargetDropdown();
updatePreview();
}
function addSegmentAfter(i) {
const prevEnd = segments[i].end;
const nextStart = segments[i + 1]?.start ?? prevEnd + 3;
const newSeg = {
id: i + 1,
start: Math.round(prevEnd * 1000) / 1000,
end: Math.round(Math.min(prevEnd + 2, nextStart) * 1000) / 1000,
text: '',
words: [],
};
segments.splice(i + 1, 0, newSeg);
segments.forEach((s, idx) => { s.id = idx; });
// Shift emoji/gif maps for indices > i
const newEmojis = {}, newGifs = {};
Object.entries(segmentEmojis).forEach(([k, v]) => { const n = parseInt(k); newEmojis[n <= i ? n : n + 1] = v; });
Object.entries(segmentGifs).forEach(([k, v]) => { const n = parseInt(k); newGifs[n <= i ? n : n + 1] = v; });
segmentEmojis = newEmojis;
segmentGifs = newGifs;
highlightScores = [];
highlightSelected = new Set();
renderSegmentsList();
refreshGifTargetDropdown();
// Focus the new card's textarea
setTimeout(() => {
const card = document.getElementById(`seg-${i + 1}`);
if (card) { card.querySelector('textarea')?.focus(); card.scrollIntoView({ block: 'nearest' }); }
}, 50);
}
function fmt(s) {
const m = Math.floor(s/60), sec = (s%60).toFixed(1).padStart(4,'0');
return `${m}:${sec}`;
}
// ── 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,
positionX: parseInt(document.getElementById('position-x').value) / 100,
uppercase: document.getElementById('uppercase-toggle').classList.contains('on'),
highlightEnabled: document.getElementById('highlight-toggle').classList.contains('on'),
wordsPerChunk: parseInt(document.getElementById('words-per-chunk').value),
};
}
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 * s.positionX;
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;
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';
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; }
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 = item.effect ?? 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; }
// Use item's own position, not the subtitle position
const ey2 = canvas.height * ((item.posY ?? 30) / 100);
const cx2 = canvas.width * ((item.posX ?? 50) / 100);
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);
}
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: s.fontSize,
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),
pos_x_frac: s.positionX,
pos_y_frac: s.position,
};
}
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');
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">
</div>`
).join('');
grid._data = data.results;
}
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;
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');
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;
}
if (!gif?.mp4) { overlay.innerHTML = ''; return; }
const vw = vid.offsetWidth, vh = vid.offsetHeight;
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 ──────────────────────────────────────────────────────
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 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}" oninput="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}" oninput="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)}" oninput="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)}" oninput="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}" oninput="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}" oninput="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);
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();
}
// ── Timeline ──────────────────────────────────────────────────
async function generateWaveform(file) {
try {
const ab = await file.arrayBuffer();
const ac = new (window.AudioContext || window.webkitAudioContext)();
const buf = await ac.decodeAudioData(ab);
const data = buf.getChannelData(0);
const dur = buf.duration;
const numPeaks = Math.min(8192, Math.ceil(dur * 100));
const blockSize = Math.floor(data.length / numPeaks);
const peaks = new Float32Array(numPeaks);
for (let i = 0; i < numPeaks; i++) {
let max = 0;
const base = i * blockSize;
for (let j = 0; j < blockSize; j++) {
const abs = Math.abs(data[base + j] || 0);
if (abs > max) max = abs;
}
peaks[i] = max;
}
waveformData = { peaks, duration: dur, numPeaks };
ac.close();
if (document.getElementById('timeline-bar').style.display !== 'none') drawTimeline();
} catch (e) {
console.warn('Waveform failed:', e);
}
}
function tlTotalDuration() {
const vid = document.getElementById('video-player');
if (vid.style.display !== 'none' && vid.duration > 0) return vid.duration;
if (segments.length) return Math.max(...segments.map(s => s.end)) + 1;
return 60;
}
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');
const canvas = document.getElementById('timeline-canvas');
canvas.width = bar.clientWidth;
canvas.height = bar.clientHeight - 28;
canvas.onmousedown = tlMouseDown;
canvas.onmousemove = tlMouseMove;
canvas.onmouseleave = tlMouseLeave;
canvas.addEventListener('wheel', tlWheel, { passive: false });
fitTimeline();
}
function fitTimeline() {
const canvas = document.getElementById('timeline-canvas');
if (!canvas) return;
const W = canvas.width || 800;
const dur = tlTotalDuration();
tlZoom = dur > 0 ? Math.max(10, (W - 20) / dur) : 100;
tlOffset = 0;
updateTlZoomLabel();
drawTimeline();
}
function zoomTimeline(dir) {
const canvas = document.getElementById('timeline-canvas');
const W = canvas.width;
const centerT = tlOffset + W / 2 / tlZoom;
tlZoom = Math.max(10, Math.min(3000, tlZoom * (dir > 0 ? 1.6 : 0.625)));
tlOffset = Math.max(0, centerT - W / 2 / tlZoom);
updateTlZoomLabel();
drawTimeline();
}
function updateTlZoomLabel() {
const z = tlZoom / 100;
const lbl = document.getElementById('tl-zoom-label');
if (lbl) lbl.textContent = z >= 10 ? `${Math.round(z)}×` : z >= 1 ? `${z.toFixed(1)}×` : `${z.toFixed(2)}×`;
}
function tlDims() {
const canvas = document.getElementById('timeline-canvas');
const W = canvas.width, H = canvas.height;
const RULER = 20, WAVE = 54;
const SUBS_Y = RULER + WAVE + 3;
const SUBS_H = 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; }
function tlT(x) { return tlOffset + x / tlZoom; }
function drawTimeline() {
const canvas = document.getElementById('timeline-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const { W, H, RULER, WAVE, SUBS_Y, SUBS_H } = tlDims();
if (!W || !H) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#060609';
ctx.fillRect(0, 0, W, H);
// ── Ruler ──
ctx.fillStyle = '#10101a';
ctx.fillRect(0, 0, W, RULER);
const tick = tlNiceTick(tlZoom);
const t0 = Math.floor(tlOffset / tick) * tick;
ctx.font = '9px monospace';
ctx.textBaseline = 'middle';
for (let t = t0; t < tlOffset + W / tlZoom + tick; t += tick) {
const x = Math.round(tlX(t));
if (x < -30 || x > W + 10) continue;
ctx.fillStyle = '#333';
ctx.fillRect(x, RULER - 5, 1, 5);
ctx.fillStyle = '#666';
ctx.textAlign = 'left';
ctx.fillText(tlFmtTime(t), x + 2, RULER / 2);
}
ctx.fillStyle = '#222';
ctx.fillRect(0, RULER, W, 1);
// ── Waveform ──
ctx.fillStyle = '#0c0c16';
ctx.fillRect(0, RULER, W, WAVE);
if (waveformData) {
const { peaks, duration, numPeaks } = waveformData;
const yCtr = RULER + WAVE / 2;
ctx.fillStyle = '#1e6e6e';
ctx.fillRect(0, yCtr, W, 1);
for (let px = 0; px < W; px++) {
const t = tlT(px);
if (t < 0 || t > duration) continue;
const idx = Math.min(numPeaks - 1, Math.floor((t / duration) * numPeaks));
const amp = Math.round(peaks[idx] * (WAVE / 2 - 2));
if (amp < 1) continue;
ctx.fillStyle = '#25f4ee60';
ctx.fillRect(px, yCtr - amp, 1, amp * 2);
}
}
// ── Subtitle blocks ──
ctx.fillStyle = '#111118';
ctx.fillRect(0, SUBS_Y - 3, W, 3);
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, SUBS_Y, W, SUBS_H);
segments.forEach((seg, i) => {
const x1 = tlX(seg.start), x2 = tlX(seg.end);
if (x2 < -20 || x1 > W + 20) return;
const bw = x2 - x1;
if (bw < 1) return;
const isDragging = tlDrag?.segIdx === i;
const isHovered = tlHoverSeg === i;
const isDragStart = isDragging && tlDrag.type === 'resize-start';
const isDragEnd = isDragging && tlDrag.type === 'resize-end';
const isDragMove = isDragging && tlDrag.type === 'move';
const y1 = SUBS_Y + 3, y2 = SUBS_Y + SUBS_H - 3, midY = SUBS_Y + SUBS_H / 2;
const vx1 = Math.max(0, x1), vx2 = Math.min(W, x2);
// Subtle background
ctx.fillStyle = isDragging ? 'rgba(254,44,85,.14)' : isHovered ? 'rgba(254,44,85,.09)' : 'rgba(254,44,85,.05)';
ctx.fillRect(vx1, y1, vx2 - vx1, y2 - y1);
// Top + bottom lines (only visible portion)
ctx.strokeStyle = isDragMove ? '#25f4ee' : isHovered ? '#ff607a' : '#fe2c55';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(vx1, y1); ctx.lineTo(vx2, y1);
ctx.moveTo(vx1, y2); ctx.lineTo(vx2, y2);
ctx.stroke();
// Left edge + arrow
if (x1 >= -1 && x1 <= W + 1) {
ctx.strokeStyle = isDragStart ? '#25f4ee' : isHovered ? '#ff607a' : '#fe2c55';
ctx.lineWidth = isDragStart ? 2.5 : 1.5;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x1, y2); ctx.stroke();
if (bw > 22) {
ctx.fillStyle = isDragStart ? '#25f4ee' : '#fe2c55';
ctx.font = '10px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('◀', x1 + 3, midY);
}
}
// Right edge + arrow
if (x2 >= -1 && x2 <= W + 1) {
ctx.strokeStyle = isDragEnd ? '#25f4ee' : isHovered ? '#ff607a' : '#fe2c55';
ctx.lineWidth = isDragEnd ? 2.5 : 1.5;
ctx.beginPath(); ctx.moveTo(x2, y1); ctx.lineTo(x2, y2); ctx.stroke();
if (bw > 22) {
ctx.fillStyle = isDragEnd ? '#25f4ee' : '#fe2c55';
ctx.font = '10px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText('▶', x2 - 3, midY);
}
}
// Text label
if (bw > 42) {
ctx.save();
const tx = Math.max(0, x1) + 16, tw = Math.min(W, x2) - tx - 16;
if (tw > 8) {
ctx.beginPath(); ctx.rect(tx, y1, tw, y2 - y1); ctx.clip();
ctx.fillStyle = isDragging ? 'rgba(37,244,238,.9)' : 'rgba(255,255,255,.75)';
ctx.font = '10px Inter, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(seg.text, tx + tw / 2, midY);
}
ctx.restore();
}
});
// ── 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;
const phX = Math.round(tlX(ph));
if (phX >= 0 && phX <= W) {
ctx.strokeStyle = '#fe2c55';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(phX, 0); ctx.lineTo(phX, H); ctx.stroke();
ctx.fillStyle = '#fe2c55';
ctx.beginPath(); ctx.moveTo(phX - 5, 0); ctx.lineTo(phX + 5, 0); ctx.lineTo(phX, 8); ctx.closePath(); ctx.fill();
}
}
function tlRoundRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function tlNiceTick(pxPerSec) {
const sec = 80 / pxPerSec;
for (const n of [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]) {
if (n >= sec) return n;
}
return 600;
}
function tlFmtTime(s) {
if (s < 0) return '';
return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
}
function tlGetHit(x, 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 { 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;
}
// Sort segments by start to find neighbours for clamping
function tlNeighbours(segIdx) {
const sorted = segments
.map((s, i) => ({ start: s.start, end: s.end, i }))
.sort((a, b) => a.start - b.start);
const pos = sorted.findIndex(s => s.i === segIdx);
return {
prevEnd: pos > 0 ? sorted[pos - 1].end : 0,
nextStart: pos < sorted.length - 1 ? sorted[pos + 1].start : Infinity,
};
}
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 MIN = 0.05;
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 {
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;
}
}
drawTimeline();
}
function tlDocMove(e) { tlApplyDrag(e.clientX); }
function tlDocUp() {
document.removeEventListener('mousemove', tlDocMove);
document.removeEventListener('mouseup', tlDocUp);
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();
}
function tlMouseDown(e) {
const x = e.offsetX, y = e.offsetY;
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));
tlDrag = { type: 'pan', startX: x, origOffset: tlOffset };
e.currentTarget.style.cursor = 'grabbing';
return;
}
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;
}
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;
const x = e.offsetX, y = e.offsetY;
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, 'sub', segments);
tlHoverSeg = hit ? hit.segIdx : -1;
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 = 'grab';
}
drawTimeline();
}
function tlMouseLeave() {
if (tlDrag) return; // drag continues outside canvas via document listener
tlHoverSeg = -1;
drawTimeline();
}
function tlWheel(e) {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const curT = tlT(e.offsetX);
tlZoom = Math.max(10, Math.min(3000, tlZoom * (e.deltaY < 0 ? 1.12 : 0.89)));
tlOffset = Math.max(0, curT - e.offsetX / tlZoom);
updateTlZoomLabel();
} else {
tlOffset = Math.max(0, tlOffset + e.deltaY / tlZoom);
}
drawTimeline();
}
function startTlRaf() {
if (tlRafId) return;
const loop = () => { drawTimeline(); tlRafId = requestAnimationFrame(loop); };
tlRafId = requestAnimationFrame(loop);
}
function stopTlRaf() {
if (tlRafId) { cancelAnimationFrame(tlRafId); tlRafId = null; }
drawTimeline();
}
// ── Emoji reactions ───────────────────────────────────────────
function refreshEmojiRow(segIdx) {
const row = document.getElementById(`emoji-row-${segIdx}`);
if (!row) return;
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>