'use strict'; (() => { // ---------------------------------------------------------------- helpers const $ = (s, el = document) => el.querySelector(s); const $$ = (s, el = document) => [...el.querySelectorAll(s)]; const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); const sleep = ms => new Promise(r => setTimeout(r, ms)); function h(tag, attrs, ...kids) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v == null || v === false) continue; if (k === 'class') el.className = v; else if (k === 'style') el.style.cssText = v; else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else el.setAttribute(k, v === true ? '' : v); } for (const kid of kids.flat()) { if (kid != null && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(kid)); } return el; } function icon(name, cls = 'ic') { const ns = 'http://www.w3.org/2000/svg'; const s = document.createElementNS(ns, 'svg'); s.setAttribute('class', cls); const u = document.createElementNS(ns, 'use'); u.setAttribute('href', '#i-' + name); s.append(u); return s; } async function api(path, body) { const opt = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }; const r = await fetch(path, opt); let data = null; try { data = await r.json(); } catch { /* not JSON */ } if (!r.ok) { const e = new Error((data && data.error) || `${r.status} ${r.statusText}`); e.status = r.status; e.data = data; throw e; } return data; } function fmtTime(t, hours) { if (t == null || !isFinite(t)) return '–'; const ms = Math.round(Math.max(0, t) * 1000); const H = Math.floor(ms / 3600000), M = Math.floor(ms / 60000) % 60, sec = Math.floor(ms / 1000) % 60, f = ms % 1000; const p = (n, w = 2) => String(n).padStart(w, '0'); return (H || hours ? H + ':' + p(M) : p(M)) + ':' + p(sec) + '.' + p(f, 3); } // fmtT formats a time on the current source's scale (hours only for long videos). const fmtT = (t, m = cur()) => fmtTime(t, m && m.duration >= 3600); function fmtLen(t) { if (t == null || !isFinite(t)) return '–'; if (t < 60) return t.toFixed(t < 10 ? 2 : 1) + ' s'; t = Math.round(t * 10) / 10; const H = Math.floor(t / 3600), M = Math.floor(t / 60) % 60, sec = t - Math.floor(t / 60) * 60; return (H ? H + ':' + String(M).padStart(2, '0') : M) + ':' + sec.toFixed(1).padStart(4, '0'); } function fmtETA(s) { if (!isFinite(s) || s < 0) return ''; if (s < 60) return Math.max(1, Math.round(s)) + ' s'; if (s < 3600) return Math.floor(s / 60) + ' min ' + String(Math.round(s % 60)).padStart(2, '0') + ' s'; return Math.floor(s / 3600) + ' h ' + Math.round((s % 3600) / 60) + ' min'; } function fmtBytes(n) { if (n < 1024) return n + ' B'; const u = ['KB', 'MB', 'GB', 'TB']; let i = -1; do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); return n.toFixed(n < 10 ? 1 : 0) + ' ' + u[i]; } const fmtFps = f => (Math.round(f * 1000) / 1000) + ' fps'; // parseTime accepts "1:02:03.5", "02:03", "83.5" (seconds). function parseTime(str) { str = String(str).trim().replace(',', '.'); if (!str) return null; const parts = str.split(':'); if (parts.length > 3 || parts.some(p => !/^\d+(\.\d*)?$/.test(p))) return null; return parts.reduce((acc, p) => acc * 60 + parseFloat(p), 0); } function splitPath(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i < 0 ? ['', p] : [p.slice(0, i) || p.slice(0, 1), p.slice(i + 1)]; } function joinPath(dir, name) { if (!dir) return name; const sep = (S.info && S.info.sep) || '/'; return /[\\/]$/.test(dir) ? dir + name : dir + sep + name; } function extOf(p) { const n = splitPath(p || '')[1]; const i = n.lastIndexOf('.'); return i > 0 ? n.slice(i + 1).toLowerCase() : ''; } const stripExt = n => { const i = n.lastIndexOf('.'); return i > 0 ? n.slice(0, i) : n; }; function replaceExt(p, ext) { const [d, n] = splitPath(p); return joinPath(d, stripExt(n) + '.' + ext); } // Elements that makeEditable temporarily swaps out of the DOM. const tcCurEl = $('#tcCur'), markInEl = $('#markIn'), markOutEl = $('#markOut'); // ---------------------------------------------------------------- state const DEFAULTS = { mode: 'copy', join: true, output: '', allAudio: true, noAudio: false, vcodec: 'libx264', quality: 20, speed: 'balanced', height: 0, fps: '', acodec: 'aac', abitrate: 192, gifHeight: 480, gifFps: '15', gifDither: 'sierra2_4a', gifLoop: true, lastDir: '', volume: 1, muted: false, }; const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b']; const S = { info: null, sources: [], // probed media (see media.go Media) clips: [], // {id, src, in, out} cur: null, // source id shown in the viewer marks: {}, // source id -> {in, out} (scratch selection) sel: null, // selected clip id (being edited) kf: {}, // source id -> {status, times} play: {}, // source id -> 'direct' | 'proxy' | 'still' proxyJob: {}, // source id -> running proxy job jobs: {}, // job id -> last known job state exportJob: null, settings: { ...DEFAULTS }, time: 0, view: { start: 0, end: 1 }, playSelEnd: null, undo: [], redo: [], restored: false, }; const srcById = id => S.sources.find(m => m.id === id); const cur = () => srcById(S.cur); const fpsOf = m => (m && m.video && m.video.fps) || 30; const marksOf = id => S.marks[id] || (S.marks[id] = { in: null, out: null }); const selClip = () => S.clips.find(c => c.id === S.sel); const editingClip = () => { const c = selClip(); return c && c.src === S.cur ? c : null; }; let idSeq = 0; const newId = () => 'c' + Date.now().toString(36) + (idSeq++).toString(36); function assignColor(m) { const used = new Set(S.sources.map(s => s.color)); m.color = PALETTE.find(c => !used.has(c)) || PALETTE[S.sources.length % PALETTE.length]; } // frameFloor snaps a time to the start of the frame shown at that time. function frameFloor(t, m = cur()) { const fps = fpsOf(m); return Math.floor(t * fps + 1e-3) / fps; } // activeRange is what the In/Out controls edit: the selected clip if it // belongs to the current source, else the scratch marks. function activeRange() { const c = editingClip(); if (c) return { in: c.in, out: c.out, clip: c }; const m = cur(); if (!m) return { in: null, out: null, clip: null }; const mk = marksOf(m.id); return { in: mk.in, out: mk.out, clip: null }; } // The timeline canvas is redrawn on the next animation frame when dirty. let needDraw = true; const requestDraw = () => { needDraw = true; }; const thumbURL = (id, t, hgt) => `/api/thumb?id=${id}&t=${Math.max(0, t).toFixed(3)}&h=${hgt}`; // ---------------------------------------------------------------- undo function snapshot() { return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), sel: S.sel }; } function commit(fn) { S.undo.push(snapshot()); if (S.undo.length > 300) S.undo.shift(); S.redo.length = 0; fn(); changed(); } function restoreSnap(s) { S.clips = s.clips; S.sources = s.sources; S.sel = s.sel && S.clips.some(c => c.id === s.sel) ? s.sel : null; if (!srcById(S.cur)) { S.cur = null; if (S.sources[0]) loadSource(S.sources[0].id); else unloadViewer(); } changed(); } function undo() { if (S.undo.length) { S.redo.push(snapshot()); restoreSnap(S.undo.pop()); } } function redo() { if (S.redo.length) { S.undo.push(snapshot()); restoreSnap(S.redo.pop()); } } function changed() { renderAll(); saveSoon(); } // ---------------------------------------------------------------- toasts & confirm function toast(msg, kind = '') { const box = $('#toasts'); const el = h('div', { class: 'toast ' + kind }, msg); box.append(el); while (box.children.length > 4) box.firstChild.remove(); setTimeout(() => el.remove(), kind === 'error' ? 8000 : 3500); } function confirmDlg(text, okLabel = 'OK') { return new Promise(resolve => { const d = $('#dlgConfirm'); $('#cfText').textContent = text; $('#cfYes').textContent = okLabel; let result = false; $('#cfYes').onclick = () => { result = true; d.close(); }; $('#cfNo').onclick = () => d.close(); d.onclose = () => resolve(result); d.showModal(); $('#cfYes').focus(); }); } // ---------------------------------------------------------------- sources async function addFiles(builtin = false) { if (!S.info.ffmpegOK) return toast('ffmpeg was not found — see the message at the top of the window.', 'error'); if (!builtin && S.info.nativeDialogs) { try { const dir = S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : ''); const r = await api('/api/dialog/open', { dir }); if (r.supported) { if (r.paths && r.paths.length) { S.settings.lastDir = splitPath(r.paths[0])[0]; await addPaths(r.paths); } return; } if (r.error) toast('The file dialog could not be opened, using the built-in browser instead.', 'error'); } catch (e) { if (e.status === 409) return; } } const paths = await fileBrowser({ mode: 'open' }); if (paths && paths.length) await addPaths(paths); } async function addPaths(paths) { const results = await Promise.all(paths.map(p => api('/api/probe', { path: p }).catch(e => { toast(e.message, 'error'); return null; }))); const fresh = []; for (const m of results) { if (m && !srcById(m.id) && !fresh.some(f => f.id === m.id)) fresh.push(m); } if (fresh.length) { commit(() => { for (const m of fresh) { assignColor(m); S.sources.push(m); } }); fresh.forEach(m => ensureKeyframes(m.id)); } const target = fresh[0] || results.find(Boolean); if (target && (fresh.length || !S.cur)) loadSource(target.id); if (fresh.length > 1) toast(`Added ${fresh.length} videos`, 'ok'); } async function removeSource(id) { const m = srcById(id); if (!m) return; const n = S.clips.filter(c => c.src === id).length; if (n && !await confirmDlg(`Remove "${m.name}" and its ${n} clip${n > 1 ? 's' : ''} from the project?\n(The file itself is not touched.)`, 'Remove')) return; commit(() => { S.sources = S.sources.filter(s => s.id !== id); S.clips = S.clips.filter(c => c.src !== id); if (S.sel && !selClip()) S.sel = null; }); if (S.cur === id) { S.cur = null; if (S.sources[0]) loadSource(S.sources[0].id); else unloadViewer(); } } function addWhole(id) { const m = srcById(id); if (!m) return; commit(() => S.clips.push({ id: newId(), src: id, in: 0, out: m.duration })); if (S.cur !== id) loadSource(id); toast(`Added all of "${m.name}" as clip ${S.clips.length}. Press S at the playhead to split it.`, 'ok'); } async function newProject() { if ((S.sources.length || S.clips.length) && !await confirmDlg('Start a new, empty project?\nThis removes all sources and clips from the editor (the video files are not touched). You can undo this.', 'New project')) return; commit(() => { S.sources = []; S.clips = []; S.sel = null; }); S.marks = {}; S.settings.output = ''; S.cur = null; unloadViewer(); changed(); } async function ensureKeyframes(id) { const m = srcById(id); if (!m) return; if (!m.video) { S.kf[id] = { status: 'done', times: [] }; return; } if (S.kf[id] && S.kf[id].status !== 'error') return; S.kf[id] = { status: 'pending', times: [] }; renderSources(); for (let i = 0; ; i++) { let r; try { r = await api('/api/keyframes?id=' + id); } catch { S.kf[id] = { status: 'error', times: [] }; break; } if (r.status !== 'pending') { S.kf[id] = { status: r.status, times: r.times || [] }; break; } await sleep(Math.min(2000, 250 + i * 150)); } renderSources(); renderClips(); renderMarks(); renderTime(); requestDraw(); if ($('#dlgExport').open) updateExportView(); } // kfBefore returns the last keyframe at or before t (with a little tolerance). function kfBefore(id, t) { const a = S.kf[id] && S.kf[id].times; if (!a || !a.length) return null; let lo = 0, hi = a.length - 1, r = null; while (lo <= hi) { const mid = (lo + hi) >> 1; if (a[mid] <= t + 1e-3) { r = a[mid]; lo = mid + 1; } else hi = mid - 1; } return r; } function kfAfter(id, t) { const a = S.kf[id] && S.kf[id].times; if (!a || !a.length) return null; let lo = 0, hi = a.length - 1, r = null; while (lo <= hi) { const mid = (lo + hi) >> 1; if (a[mid] > t + 1e-3) { r = a[mid]; hi = mid - 1; } else lo = mid + 1; } return r; } // kfOffset: how much earlier a lossless export starts than the marked In. function kfOffset(srcId, tIn) { const k = kfBefore(srcId, tIn); if (k == null) return 0; const off = tIn - k; return off > 0.5 / fpsOf(srcById(srcId)) ? off : 0; } // ---------------------------------------------------------------- viewer const video = $('#video'); const still = $('#still'); let pendingSeek = null; let loadedFor = null; const playMode = m => (m && S.play[m.id]) || 'direct'; const isVideoMode = () => { const m = cur(); return !!m && playMode(m) !== 'still'; }; function loadSource(id, { time = 0, keepView = false } = {}) { const m = srcById(id); if (!m) return; const switching = S.cur !== id; S.cur = id; S.playSelEnd = null; if (S.sel && (!selClip() || selClip().src !== id)) S.sel = null; S.time = clamp(time, 0, m.duration); if (switching || !keepView) S.view = { start: 0, end: m.duration }; ensureKeyframes(id); attachMedia(); changed(); } function attachMedia() { const m = cur(); if (!m) return; video.pause(); const mode = playMode(m); const key = m.id + ':' + mode; if (mode === 'still') { if (video.getAttribute('src')) { video.removeAttribute('src'); video.load(); } loadedFor = key; updateStill(true); } else if (loadedFor !== key) { loadedFor = key; pendingSeek = S.time; video.src = `/api/media/${m.id}${mode === 'proxy' ? '?proxy=1' : ''}`; } else { seekVideo(S.time); } renderScreen(); } function unloadViewer() { video.pause(); if (video.getAttribute('src')) { video.removeAttribute('src'); video.load(); } loadedFor = null; S.time = 0; S.view = { start: 0, end: 1 }; renderAll(); } video.addEventListener('loadedmetadata', () => { const m = cur(); if (!m || !loadedFor || !loadedFor.startsWith(m.id)) return; if (m.video && !video.videoWidth) return unplayable(); if (pendingSeek != null) { video.currentTime = Math.min(pendingSeek + 1e-4, m.duration); pendingSeek = null; } renderScreen(); }); video.addEventListener('error', () => { if (video.getAttribute('src')) unplayable(); }); for (const ev of ['play', 'pause', 'ended']) video.addEventListener(ev, renderPlay); video.addEventListener('seeked', requestDraw); // unplayable switches to still frames and asks the server for an H.264 // preview copy the browser can play. function unplayable() { const m = cur(); if (!m) return; const mode = playMode(m); S.play[m.id] = 'still'; video.removeAttribute('src'); video.load(); loadedFor = m.id + ':still'; if (mode === 'direct') startProxy(m.id); else toast('The preview copy could not be played either — showing still frames.', 'error'); updateStill(true); renderScreen(); } async function startProxy(id) { try { const r = await api('/api/proxy', { id }); if (r.ready) return proxyReady(id); if (!S.proxyJob[id]) S.proxyJob[id] = { id: r.jobId, state: 'running', progress: 0 }; } catch (e) { toast('Could not create a preview: ' + e.message, 'error'); } renderScreen(); } function proxyReady(id) { S.play[id] = 'proxy'; delete S.proxyJob[id]; if (S.cur === id) { loadedFor = null; attachMedia(); } renderScreen(); } let stillTimer = 0; let stillWant = ''; function updateStill(now) { const m = cur(); if (!m || playMode(m) !== 'still' || !m.video) return; clearTimeout(stillTimer); const go = () => { const url = thumbURL(m.id, S.time, Math.min(720, m.video.displayHeight || 720)); if (stillWant === url) return; stillWant = url; const img = new Image(); img.onload = () => { if (stillWant === url) still.src = url; }; img.src = url; }; if (now) go(); else stillTimer = setTimeout(go, 60); } function seekVideo(t) { if (!isVideoMode()) return; if (video.readyState >= 1) video.currentTime = Math.min(t + 1e-4, cur().duration); else pendingSeek = t; } function seek(t, { quantize = true } = {}) { const m = cur(); if (!m) return; t = clamp(t, 0, m.duration); if (quantize) t = frameFloor(t, m); S.time = t; seekVideo(t); updateStill(); followPlayhead(true); renderTime(); requestDraw(); } function syncTime() { if (isVideoMode() && video.readyState >= 1 && !video.seeking) S.time = video.currentTime; } const seekBy = d => { syncTime(); seek(S.time + d); }; function stepFrames(n) { const m = cur(); if (!m) return; video.pause(); syncTime(); const fps = fpsOf(m); seek((Math.floor(S.time * fps + 1e-3) + n) / fps); } function gotoKey(dir) { const m = cur(); if (!m) return; const k = S.kf[m.id]; if (!k || k.status !== 'done') { return toast(k && k.status === 'error' ? 'Keyframe positions are not available for this video.' : 'Still reading keyframe positions…'); } syncTime(); const t = dir < 0 ? kfBefore(m.id, S.time - 0.5 / fpsOf(m) - 1e-3) : kfAfter(m.id, S.time); if (t != null) seek(t, { quantize: false }); } function togglePlay() { const m = cur(); if (!m || !isVideoMode()) return; if (video.paused) { S.playSelEnd = null; if (S.time >= m.duration - 0.05) seek(0); video.play().catch(() => {}); } else { video.pause(); } } function playSelection() { const m = cur(); if (!m || !isVideoMode()) return; const r = activeRange(); const a = r.in ?? 0, b = r.out ?? m.duration; if (b <= a) return; seek(a, { quantize: false }); S.playSelEnd = b; video.play().catch(() => {}); } function gotoMark(which) { const m = cur(); const r = activeRange(); if (!m || r[which] == null) return; // Out is exclusive, so show the last frame that is included. seek(which === 'in' ? r.in : Math.max(r.in ?? 0, r.out - 1 / fpsOf(m)), { quantize: which === 'out' }); } function toggleMute() { video.muted = !video.muted; S.settings.muted = video.muted; renderMute(); saveSoon(); } // ---------------------------------------------------------------- marks & clips function outAt(t) { const m = cur(); return t >= m.duration - 1 / fpsOf(m) ? m.duration : frameFloor(t, m); } function setIn(t) { const m = cur(); if (!m) return toast('Add a video first.'); syncTime(); t = clamp(t ?? frameFloor(S.time, m), 0, m.duration); const c = editingClip(); if (c) { if (t >= c.out - 1e-6) return toast('The In point must be before the Out point.'); commit(() => { c.in = t; }); return; } const mk = marksOf(m.id); mk.in = t; if (mk.out != null && mk.out <= t) mk.out = null; changed(); } function setOut(t) { const m = cur(); if (!m) return toast('Add a video first.'); syncTime(); t = clamp(t ?? outAt(S.time), 0, m.duration); const c = editingClip(); if (c) { if (t <= c.in + 1e-6) return toast('The Out point must be after the In point.'); commit(() => { c.out = t; }); return; } const mk = marksOf(m.id); mk.out = t; if (mk.in != null && mk.in >= t) mk.in = null; changed(); } function addClipOrDone() { if (editingClip()) { S.sel = null; changed(); return; } const m = cur(); if (!m) return toast('Add a video first.'); const mk = marksOf(m.id); if (mk.in == null && mk.out == null) return toast('Set an In and/or Out point first (I and O keys).'); const a = mk.in ?? 0, b = mk.out ?? m.duration; if (b - a < 0.01) return toast('The clip is empty — the Out point must be after the In point.'); commit(() => { S.clips.push({ id: newId(), src: m.id, in: a, out: b }); S.marks[m.id] = { in: null, out: null }; }); toast(`Clip ${S.clips.length} added (${fmtLen(b - a)})`, 'ok'); const list = $('#clipList'); requestAnimationFrame(() => list.scrollTo({ left: list.scrollWidth, behavior: 'smooth' })); } function selectClip(id, { seekTo = true } = {}) { const c = S.clips.find(x => x.id === id); if (!c) return; S.sel = id; if (S.cur !== c.src) loadSource(c.src, { time: c.in }); else if (seekTo) seek(c.in, { quantize: false }); const v = S.view; if (c.in < v.start || c.out > v.end) { const span = Math.max(v.end - v.start, (c.out - c.in) * 1.2); setView(c.in - (span - (c.out - c.in)) / 2, c.in - (span - (c.out - c.in)) / 2 + span); } changed(); } function split() { const m = cur(); if (!m) return; syncTime(); const t = frameFloor(S.time, m); const inside = x => x.src === m.id && x.in + 1e-3 < t && t < x.out - 1e-3; let c = editingClip(); if (!c || !inside(c)) c = S.clips.find(inside); if (!c) return toast('Put the playhead inside one of this video\'s clips to split it.\nTip: "+ Whole" adds the entire video as one clip.'); commit(() => { const i = S.clips.indexOf(c); S.clips.splice(i + 1, 0, { id: newId(), src: c.src, in: t, out: c.out }); c.out = t; S.sel = null; }); toast(`Split at ${fmtT(t)}`, 'ok'); } function deleteClip(id) { commit(() => { S.clips = S.clips.filter(c => c.id !== id); if (S.sel === id) S.sel = null; }); } function duplicateClip(id) { const i = S.clips.findIndex(c => c.id === id); if (i < 0) return; commit(() => S.clips.splice(i + 1, 0, { ...S.clips[i], id: newId() })); } function moveClipTo(id, idx) { const i = S.clips.findIndex(c => c.id === id); if (i < 0 || idx === i || idx === i + 1) return; commit(() => { const [c] = S.clips.splice(i, 1); S.clips.splice(idx > i ? idx - 1 : idx, 0, c); }); } function moveClipBy(id, d) { const i = S.clips.findIndex(c => c.id === id); const j = i + d; if (i < 0 || j < 0 || j >= S.clips.length) return; commit(() => { const [c] = S.clips.splice(i, 1); S.clips.splice(j, 0, c); }); } // ---------------------------------------------------------------- timeline const cv = $('#tlCanvas'); const ctx = cv.getContext('2d'); const RULER = 18, KFH = 9; function setView(s, e) { const m = cur(); if (!m) return; const D = m.duration, span = e - s; if (span >= D) S.view = { start: 0, end: D }; else { s = clamp(s, 0, D - span); S.view = { start: s, end: s + span }; } requestDraw(); } function zoom(factor, anchor) { const m = cur(); if (!m) return; const v = S.view, span = v.end - v.start; const minSpan = Math.min(m.duration, Math.max(0.25, 10 / fpsOf(m))); const ns = clamp(span * factor, minSpan, m.duration); const a = anchor ?? S.time; const s = a - (a - v.start) * ns / span; setView(s, s + ns); } const zoomFit = () => { const m = cur(); if (m) setView(0, m.duration); }; function followPlayhead(jump) { const m = cur(); if (!m) return; const v = S.view, span = v.end - v.start; if (span >= m.duration - 1e-6) return; if (S.time < v.start || S.time > v.end) { const s = jump ? S.time - span / 2 : S.time - span * 0.1; setView(s, s + span); } else if (!jump && S.time > v.end - span * 0.02) { setView(S.time - span * 0.1, S.time + span * 0.9); } } function niceStep(raw) { const steps = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 14400]; return steps.find(s => s >= raw) || steps[steps.length - 1]; } function rulerLabel(t, step) { const H = Math.floor(t / 3600), M = Math.floor(t / 60) % 60, sec = t % 60; const ss = step < 1 ? sec.toFixed(1).padStart(4, '0') : String(Math.round(sec) % 60).padStart(2, '0'); return (H ? H + ':' + String(M).padStart(2, '0') : M) + ':' + ss; } function hexA(hex, a) { const n = parseInt(hex.slice(1), 16); return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`; } function drawTimeline() { needDraw = false; const dpr = window.devicePixelRatio || 1; const W = cv.clientWidth, H = cv.clientHeight; if (!W || !H) return; if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) { cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0f1217'; ctx.fillRect(0, 0, W, H); const m = cur(); if (!m) return; const { start, end } = S.view; const span = end - start || 1; const X = t => (t - start) / span * W; const top = RULER, bot = H - KFH; const mono = getComputedStyle(document.documentElement).getPropertyValue('--mono'); // track ctx.fillStyle = '#151922'; ctx.fillRect(0, top, W, bot - top); if (X(m.duration) < W) { ctx.fillStyle = '#0b0d11'; ctx.fillRect(X(m.duration), 0, W - X(m.duration), H); } // ruler const step = niceStep(span / W * 95); const minor = step / 5; ctx.font = '10.5px ' + mono; ctx.textBaseline = 'top'; ctx.fillStyle = '#7d8696'; for (let i = Math.ceil(start / minor); i * minor <= end; i++) { const t = i * minor; const x = Math.round(X(t)) + 0.5; const major = i % 5 === 0; ctx.fillStyle = major ? '#4a5262' : '#2e3440'; ctx.fillRect(x - 0.5, major ? 10 : 13, 1, major ? 8 : 5); if (major) { ctx.fillStyle = '#8a93a3'; ctx.fillText(rulerLabel(t, step), x + 3, 1); } } // keyframe pre-roll (lossless mode) under each clip const copy = S.settings.mode === 'copy'; // clips of this source ctx.textBaseline = 'top'; S.clips.forEach((c, i) => { if (c.src !== m.id) return; const x1 = X(c.in), x2 = X(c.out); if (x2 < 0 || x1 > W) return; const sel = c.id === S.sel; ctx.fillStyle = hexA(m.color, sel ? 0.42 : 0.22); ctx.fillRect(x1, top + 3, x2 - x1, bot - top - 6); ctx.strokeStyle = hexA(m.color, sel ? 1 : 0.75); ctx.lineWidth = sel ? 2 : 1; ctx.strokeRect(x1 + 0.5, top + 3.5, Math.max(1, x2 - x1 - 1), bot - top - 7); if (copy) { const off = kfOffset(c.src, c.in); if (off > 0) { ctx.fillStyle = 'rgba(255,181,71,.55)'; ctx.fillRect(X(c.in - off), bot - 7, x1 - X(c.in - off), 3); } } if (x2 - x1 > 22) { ctx.fillStyle = '#fff'; ctx.font = '600 11px ' + mono; ctx.fillText('#' + (i + 1), Math.max(x1, 0) + 5, top + 7); } }); // In/Out selection const r = activeRange(); if (!r.clip && (r.in != null || r.out != null)) { const a = r.in ?? 0, b = r.out ?? m.duration; ctx.fillStyle = 'rgba(255,181,71,.15)'; ctx.fillRect(X(a), top, X(b) - X(a), bot - top); } const flag = (t, dir) => { const x = Math.round(X(t)) + 0.5; if (x < -8 || x > W + 8) return; ctx.strokeStyle = '#ffb547'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, bot); ctx.stroke(); ctx.fillStyle = '#ffb547'; ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x + 9 * dir, top); ctx.lineTo(x, top + 9); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(x, bot); ctx.lineTo(x + 9 * dir, bot); ctx.lineTo(x, bot - 9); ctx.closePath(); ctx.fill(); }; if (r.in != null) flag(r.in, 1); if (r.out != null) flag(r.out, -1); // keyframes const k = S.kf[m.id]; if (k && k.times.length) { const a = k.times; let lo = 0, hi = a.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (a[mid] < start) lo = mid + 1; else hi = mid; } ctx.fillStyle = '#6f7a8c'; let lastX = -2; for (let i = lo; i < a.length && a[i] <= end; i++) { const x = Math.round(X(a[i])); if (x === lastX) continue; lastX = x; ctx.fillRect(x, H - KFH + 2, 1, KFH - 3); } } // playhead const px = Math.round(X(S.time)) + 0.5; if (px >= -6 && px <= W + 6) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.moveTo(px - 6, 0); ctx.lineTo(px + 6, 0); ctx.lineTo(px, 8); ctx.closePath(); ctx.fill(); } } const tAt = e => { const m = cur(); const v = S.view; return clamp(v.start + e.offsetX / cv.clientWidth * (v.end - v.start), 0, m.duration); }; const xOf = t => (t - S.view.start) / (S.view.end - S.view.start) * cv.clientWidth; function handleAt(e) { if (e.offsetY < RULER - 2) return null; const r = activeRange(); const near = t => t != null && Math.abs(xOf(t) - e.offsetX) <= 6; if (near(r.out)) return 'out'; if (near(r.in)) return 'in'; return null; } let drag = null; cv.addEventListener('pointerdown', e => { const m = cur(); if (!m || e.button !== 0) return; cv.setPointerCapture(e.pointerId); hideHover(); const handle = handleAt(e); if (handle) { drag = { type: handle }; const c = editingClip(); if (c) { S.undo.push(snapshot()); S.redo.length = 0; drag.clip = c; } } else { drag = { type: 'scrub', wasPlaying: !video.paused }; video.pause(); seek(tAt(e)); } }); cv.addEventListener('pointermove', e => { const m = cur(); if (!m) return; const t = tAt(e); if (!drag) { cv.style.cursor = handleAt(e) ? 'ew-resize' : 'pointer'; return showHover(e, t); } if (drag.type === 'scrub') return seek(t); const fps = fpsOf(m); const tq = drag.type === 'out' ? outAt(t) : frameFloor(t, m); if (drag.clip) { const c = drag.clip; if (drag.type === 'in') c.in = clamp(tq, 0, c.out - 1 / fps); else c.out = clamp(tq, c.in + 1 / fps, m.duration); } else { const mk = marksOf(m.id); if (drag.type === 'in') mk.in = clamp(tq, 0, (mk.out ?? m.duration) - 1 / fps); else mk.out = clamp(tq, (mk.in ?? 0) + 1 / fps, m.duration); } seek(drag.type === 'in' ? tq : Math.max(0, tq - 1 / fps)); renderMarks(); }); const endDrag = () => { if (!drag) return; if (drag.type === 'scrub' && drag.wasPlaying) video.play().catch(() => {}); if (drag.type !== 'scrub') changed(); drag = null; }; cv.addEventListener('pointerup', endDrag); cv.addEventListener('pointercancel', endDrag); cv.addEventListener('pointerleave', hideHover); cv.addEventListener('dblclick', e => { const m = cur(); if (!m) return; const t = tAt(e); const c = [...S.clips].reverse().find(x => x.src === m.id && x.in <= t && t <= x.out); if (c) selectClip(c.id, { seekTo: false }); }); cv.addEventListener('wheel', e => { if (!cur()) return; e.preventDefault(); const v = S.view, span = v.end - v.start; if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { const d = (e.deltaX || e.deltaY) / cv.clientWidth * span; setView(v.start + d, v.end + d); } else { zoom(Math.exp(e.deltaY * 0.0015), tAt(e)); } }, { passive: false }); let hoverTimer = 0; function showHover(e, t) { const m = cur(); const tip = $('#tlHover'); tip.classList.remove('hidden'); tip.style.left = clamp(e.offsetX, 60, cv.clientWidth - 60) + 'px'; tip.querySelector('span').textContent = fmtT(t); const img = tip.querySelector('img'); if (!m.video) { img.removeAttribute('src'); return; } clearTimeout(hoverTimer); hoverTimer = setTimeout(() => { const q = Math.max((S.view.end - S.view.start) / 150, 1 / fpsOf(m)); const url = thumbURL(m.id, clamp(Math.round(t / q) * q, 0, m.duration), 72); if (img.dataset.url !== url) { img.dataset.url = url; img.src = url; } }, 90); } function hideHover() { clearTimeout(hoverTimer); $('#tlHover').classList.add('hidden'); } new ResizeObserver(requestDraw).observe($('#timeline')); function frame() { if (isVideoMode() && !video.paused && !video.seeking) { S.time = video.currentTime; if (S.playSelEnd != null && S.time >= S.playSelEnd) { const end = S.playSelEnd; S.playSelEnd = null; video.pause(); seek(Math.max(0, end - 1 / fpsOf(cur()))); } followPlayhead(false); renderTime(); drawTimeline(); } else if (needDraw) { drawTimeline(); } requestAnimationFrame(frame); } // ---------------------------------------------------------------- rendering function renderAll() { renderSources(); renderClips(); renderMarks(); renderTime(); renderPlay(); renderScreen(); renderTopbar(); requestDraw(); } function renderSources() { const ul = $('#sourceList'); ul.replaceChildren(); for (const m of S.sources) { const v = m.video; const n = S.clips.filter(c => c.src === m.id).length; const meta = [fmtLen(m.duration), v ? `${v.displayWidth}×${v.displayHeight}` : 'audio only', v ? v.codec : (m.audio[0] && m.audio[0].codec), v ? fmtFps(v.fps) : ''].filter(Boolean).join(' · '); const k = S.kf[m.id]; ul.append(h('li', { class: 'src' + (m.id === S.cur ? ' cur' : ''), 'data-id': m.id, title: m.path, style: `--c:${m.color}` }, h('div', { class: 'src-thumb' }, v ? h('img', { src: thumbURL(m.id, Math.min(m.duration * 0.1, 30), 108), alt: '', loading: 'lazy', draggable: 'false' }) : h('div', { class: 'audio-only' }, '♪')), h('div', { class: 'src-info' }, h('div', { class: 'src-name' }, m.name), h('div', { class: 'src-meta' }, meta), h('div', { class: 'src-actions' }, h('button', { class: 'chip-btn', 'data-sact': 'whole', title: 'Add the whole video as one clip' }, icon('plus'), 'Whole'), n ? h('span', { class: 'src-status' }, `${n} clip${n > 1 ? 's' : ''}`) : null, k && k.status === 'pending' ? h('span', { class: 'src-status' }, 'reading keyframes…') : null)), h('button', { class: 'icon-btn src-x', 'data-sact': 'remove', title: 'Remove from project' }, icon('x')))); } $('#sourceEmpty').classList.toggle('hidden', S.sources.length > 0); $('#srcCount').textContent = S.sources.length || ''; } function renderClips() { const list = $('#clipList'); const scroll = list.scrollLeft; list.replaceChildren(); const copy = S.settings.mode === 'copy'; let total = 0; S.clips.forEach((c, i) => { const m = srcById(c.src); if (!m) return; total += c.out - c.in; const off = copy ? kfOffset(c.src, c.in) : 0; list.append(h('div', { class: 'clip' + (c.id === S.sel ? ' sel' : ''), draggable: 'true', 'data-id': c.id, style: `--c:${m.color}`, title: m.path }, h('div', { class: 'clip-thumb' }, m.video ? h('img', { src: thumbURL(m.id, c.in, 108), alt: '', loading: 'lazy', draggable: 'false' }) : h('div', { class: 'audio-only' }, '♪'), h('span', { class: 'clip-num' }, String(i + 1)), h('span', { class: 'clip-dur' }, fmtLen(c.out - c.in)), off ? h('span', { class: 'clip-warn', title: `Lossless export starts this clip ${off.toFixed(2)} s earlier, at the keyframe at ${fmtT(c.in - off, m)}` }, icon('warn')) : null), h('div', { class: 'clip-body' }, h('div', { class: 'clip-name' }, m.name), h('div', { class: 'clip-range' }, `${fmtT(c.in, m)} → ${fmtT(c.out, m)}`)), h('div', { class: 'clip-actions' }, h('button', { 'data-cact': 'left', title: 'Move left' }, icon('left')), h('button', { 'data-cact': 'dup', title: 'Duplicate' }, icon('copy')), h('button', { 'data-cact': 'del', class: 'del', title: 'Delete (Del)' }, icon('trash')), h('button', { 'data-cact': 'right', title: 'Move right' }, icon('right'))))); }); list.scrollLeft = scroll; $('#clipEmpty').classList.toggle('hidden', S.clips.length > 0); $('#clipCount').textContent = S.clips.length || ''; $('#clipTotal').textContent = S.clips.length ? 'total ' + fmtLen(total) : ''; $$('#modeSeg button').forEach(b => b.classList.toggle('on', b.dataset.mode === S.settings.mode)); } function renderMarks() { const m = cur(); const r = activeRange(); markInEl.textContent = m && r.in != null ? fmtT(r.in) : '–'; markOutEl.textContent = m && r.out != null ? fmtT(r.out) : '–'; $('#markLen').textContent = m && (r.in != null || r.out != null) ? fmtLen((r.out ?? m.duration) - (r.in ?? 0)) : '–'; const btn = $('#btnAddClip'); btn.querySelector('span').textContent = r.clip ? 'Done' : 'Add clip'; btn.querySelector('use').setAttribute('href', r.clip ? '#i-check' : '#i-plus'); btn.title = r.clip ? 'Finish editing this clip (Enter)' : 'Add the In→Out range as a clip (Enter)'; const rows = []; if (r.clip) { rows.push(h('div', { class: 'hint-row edit' }, icon('cut'), `Editing clip ${S.clips.indexOf(r.clip) + 1}: Set In / Set Out or drag the orange handles to trim it.`, h('button', { class: 'chip-btn', 'data-hact': 'done' }, 'Done (Enter)'))); } if (m && S.settings.mode === 'copy' && r.in != null) { const off = kfOffset(m.id, r.in); if (off > 0) { const next = kfAfter(m.id, r.in); rows.push(h('div', { class: 'hint-row warn' }, icon('warn'), `Lossless: In is not on a keyframe, so the export starts ${off.toFixed(2)} s earlier (at ${fmtT(r.in - off)}).`, h('button', { class: 'chip-btn', 'data-hact': 'snapPrev' }, 'Snap In to previous keyframe'), next != null && next < (r.out ?? m.duration) ? h('button', { class: 'chip-btn', 'data-hact': 'snapNext' }, 'Snap to next keyframe') : null)); } } const box = $('#markHint'); box.replaceChildren(...rows); box.classList.toggle('hidden', rows.length === 0); } function renderTime() { const m = cur(); tcCurEl.textContent = fmtT(m ? S.time : 0); $('#tcDur').textContent = fmtT(m ? m.duration : 0); let info = ''; if (m && m.video) { const fps = fpsOf(m); info = 'frame ' + Math.floor(S.time * fps + 1e-3); const k = kfBefore(m.id, S.time); if (k != null && S.time - k < 0.5 / fps) info += ' · keyframe'; } $('#tcFrame').textContent = info; } function renderPlay() { $('#btnPlay use').setAttribute('href', video.paused ? '#i-play' : '#i-pause'); } function renderMute() { $('#btnMute use').setAttribute('href', video.muted ? '#i-mute' : '#i-vol'); } function renderScreen() { const m = cur(); $('#noSource').classList.toggle('hidden', !!m); const mode = playMode(m); video.classList.toggle('hidden', !m || mode === 'still'); still.classList.toggle('hidden', !m || mode !== 'still'); let msg = ''; let center = false; if (m && !m.video) { msg = '♪ Audio only'; center = true; } if (m && mode === 'still') { const j = S.proxyJob[m.id]; msg = j ? `This format can't be played by the browser directly.\nCreating a preview copy… ${Math.round((j.progress || 0) * 100)}% (you can already scrub and cut)` : 'Preview not available — showing still frames.'; } const el = $('#screenMsg'); el.textContent = msg; el.style.whiteSpace = 'pre-line'; el.classList.toggle('center', center); el.classList.toggle('hidden', !msg); } function renderTopbar() { $('#btnUndo').disabled = !S.undo.length; $('#btnRedo').disabled = !S.redo.length; $('#btnExport').disabled = !S.clips.length && !(S.exportJob && S.exportJob.state === 'running'); const j = S.exportJob; const pill = $('#jobPill'); const running = j && j.state === 'running'; pill.classList.toggle('hidden', !running); if (running) { const p = Math.floor((j.progress || 0) * 100); pill.textContent = `Exporting… ${p}%`; pill.style.setProperty('--p', p + '%'); } } // ---------------------------------------------------------------- editable time fields function makeEditable(el, getValue, onCommit) { el.addEventListener('click', () => { if (!cur()) return; const input = h('input', { class: 'tc-input', value: getValue(), spellcheck: 'false' }); el.replaceWith(input); input.focus(); input.select(); let done = false; const finish = ok => { if (done) return; done = true; const v = parseTime(input.value); input.replaceWith(el); if (ok) { if (v == null) toast('Type a time like 1:23.5 or 83.5'); else onCommit(v); } renderAll(); }; input.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') finish(true); if (e.key === 'Escape') finish(false); }); input.addEventListener('blur', () => finish(true)); }); } makeEditable(tcCurEl, () => fmtT(S.time), v => { video.pause(); seek(v); }); makeEditable(markInEl, () => fmtT(activeRange().in ?? S.time), v => setIn(frameFloor(clamp(v, 0, cur().duration)))); makeEditable(markOutEl, () => fmtT(activeRange().out ?? S.time), v => setOut(outAt(clamp(v, 0, cur().duration)))); // ---------------------------------------------------------------- export dialog const dlgEx = $('#dlgExport'); function copyContainerChoices() { const exts = ['mp4', 'mkv', 'mov', 'webm']; for (const c of S.clips) { const e = extOf(srcById(c.src).path); if (S.info.copyExts.includes(e) && !exts.includes(e)) exts.push(e); } return exts; } function exportExts(mode) { if (mode === 'copy') return copyContainerChoices(); if (mode === 'gif') return ['gif']; return S.info.containers.map(c => c.ext); } function firstClipSource() { return srcById(S.clips[0] && S.clips[0].src) || cur(); } function defaultOutput() { const first = firstClipSource(); if (!first) return ''; const [dir, name] = splitPath(first.path); return joinPath(dir, stripExt(name) + '_edit.' + defaultExt()); } function defaultExt() { if (S.settings.mode === 'gif') return 'gif'; if (S.settings.mode !== 'copy') return 'mp4'; const first = firstClipSource(); const e = first ? extOf(first.path) : ''; return S.info.copyExts.includes(e) ? e : 'mkv'; } function openExport() { if (!S.clips.length && !(S.exportJob && S.exportJob.state === 'running')) return toast('Add some clips first.'); if (!S.settings.output) S.settings.output = defaultOutput(); fillExport(); dlgEx.showModal(); refreshCodecs(); } async function refreshCodecs() { for (let i = 0; i < 60 && !S.info.codecsReady; i++) { await sleep(500); try { S.info = await api('/api/info'); } catch { /* ignore */ } } if (dlgEx.open) fillExport(); } function setOptions(sel, items, value) { sel.replaceChildren(...items.map(([v, label]) => h('option', { value: v }, label))); sel.value = value; } function fillExport() { const st = S.settings; const info = S.info; $$('input[name=mode]').forEach(r => { r.checked = r.value === st.mode; }); $$('input[name=join]').forEach(r => { r.checked = (r.value === '1') === st.join; }); const exts = exportExts(st.mode); if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, exts.includes(defaultExt()) ? defaultExt() : exts[0]); setOptions($('#exContainer'), exts.map(e => [e, '.' + e]), extOf(st.output)); if (document.activeElement !== $('#exOutput')) $('#exOutput').value = st.output; $('#exAllAudio').checked = st.allAudio; $('#exNoAudioCopy').checked = st.noAudio; $('#exAllAudioRow').classList.toggle('hidden', !S.clips.some(c => srcById(c.src).audio.length > 1)); const ct = info.containers.find(c => c.ext === extOf(st.output)); const vcs = (info.codecs || []).filter(c => c.kind === 'video' && (!ct || ct.video.includes(c.family))); if (vcs.length && !vcs.some(c => c.name === st.vcodec)) { st.vcodec = vcs[0].name; st.quality = vcs[0].defQ; } setOptions($('#exVCodec'), vcs.length ? vcs.map(c => [c.name, c.label]) : [['', info.codecsReady ? 'No encoder available' : 'Checking encoders…']], st.vcodec); const vc = vcs.find(c => c.name === st.vcodec); const q = $('#exQuality'); q.max = vc ? vc.maxQ : 51; q.value = st.quality; $('#exSpeed').value = st.speed; $('#exHeight').value = String(st.height); $('#exFPS').value = st.fps; const acs = (info.codecs || []).filter(c => c.kind === 'audio' && (!ct || ct.audio.includes(c.family))); if (st.acodec !== 'none' && acs.length && !acs.some(c => c.name === st.acodec)) st.acodec = acs[0].name; setOptions($('#exACodec'), [...acs.map(c => [c.name, c.label]), ['none', 'No audio']], st.acodec); $('#exABitrate').value = String(st.abitrate); $('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac'; $('#exGifHeight').value = String(st.gifHeight); $('#exGifFPS').value = st.gifFps; $('#exGifDither').value = st.gifDither; $('#exGifLoop').value = st.gifLoop ? '1' : '0'; updateExportView(); } function qualityText(q, vc) { if (!vc) return ''; const rel = q - vc.defQ; const t = rel <= -6 ? 'near lossless, very large file' : rel <= -3 ? 'very high quality' : rel < 0 ? 'high quality' : rel === 0 ? 'recommended balance' : rel <= 4 ? 'smaller file' : rel <= 9 ? 'small file, visible loss' : 'very small, low quality'; return `${q} — ${t}`; } function outSize(m, height) { let w = m.video.displayWidth, hh = m.video.displayHeight; if (height > 0 && height !== hh) { w = w * height / hh; hh = height; } const even = n => { n = Math.round(n); return n % 2 ? n + 1 : n; }; return [even(w), even(hh)]; } function formatSig(m, allAudio) { const v = m.video; const parts = [v ? `${v.codec} ${v.width}×${v.height} ${v.pixFmt || ''}`.trim() : 'no video']; const audio = allAudio ? m.audio : m.audio.slice(0, 1); for (const a of audio) parts.push(`${a.codec} ${a.sampleRate} Hz ${a.channels}ch`); return parts.join(', '); } function updateExportView() { const st = S.settings; const copy = st.mode === 'copy', gif = st.mode === 'gif'; const n = S.clips.length; const total = S.clips.reduce((s, c) => s + c.out - c.in, 0); $('#exCopyOpts').classList.toggle('hidden', !copy); $('#exEncOpts').classList.toggle('hidden', st.mode !== 'encode'); $('#exGifOpts').classList.toggle('hidden', !gif); const result = st.join || n === 1 ? (gif ? 'one animated GIF' : 'one video file') : n + (gif ? ' separate GIFs' : ' separate files'); $('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${result}`; let outHint = ''; if (!st.join && n > 1 && st.output) { const [, name] = splitPath(st.output); const w = Math.max(2, String(n).length); const num = i => String(i).padStart(w, '0'); outHint = `Files are named ${stripExt(name)}_${num(1)}.${extOf(name)} … ${stripExt(name)}_${num(n)}.${extOf(name)}`; } $('#exOutHint').textContent = outHint; const vc = (S.info.codecs || []).find(c => c.name === st.vcodec); $('#exQLabel').textContent = qualityText(+st.quality, vc); let encHint = ''; const firstV = S.clips.map(c => srcById(c.src)).find(m => m && m.video); if (!copy && firstV) { const [w, hh] = outSize(firstV, gif ? +st.gifHeight : +st.height); const fps = gif ? st.gifFps + ' fps, no sound' : st.fps ? $('#exFPS').selectedOptions[0].textContent + ' fps' : fmtFps(firstV.video.fps); encHint = `Output: ${w}×${hh}, ${fps}`; if (st.join) { const shapes = new Set(S.clips.map(c => srcById(c.src)).filter(m => m.video).map(m => (m.video.displayWidth / m.video.displayHeight).toFixed(3))); if (shapes.size > 1) encHint += ' · clips with a different shape get black bars'; } } $('#exEncHint').textContent = gif ? '' : encHint; $('#exGifHint').textContent = gif ? encHint : ''; const warns = []; if (copy) { const offs = S.clips.map(c => kfOffset(c.src, c.in)).filter(o => o > 0); if (offs.length) { warns.push(`${offs.length} clip${offs.length > 1 ? 's don\'t' : ' doesn\'t'} start on a keyframe and will begin up to ${Math.max(...offs).toFixed(2)} s earlier than marked. Choose Re-encode for frame-exact cuts.`); } if (st.join && n > 1) { const seen = new Map(); for (const c of S.clips) { const m = srcById(c.src); seen.set(formatSig(m, st.allAudio && !st.noAudio), m.name); } if (seen.size > 1) { warns.push('The source videos have different formats, so they can\'t be joined losslessly:\n' + [...seen].map(([sig, name]) => `• ${name}: ${sig}`).join('\n') + '\nChoose Re-encode, or save each clip separately.'); } } if (S.clips.some(c => S.kf[c.src] && S.kf[c.src].status === 'pending')) warns.push('Still reading keyframe positions…'); } else if (gif) { const longest = st.join ? total : Math.max(...S.clips.map(c => c.out - c.in)); if (longest > 30) { warns.push(`${st.join || n === 1 ? 'The GIF' : 'The longest GIF'} will be ${fmtLen(longest)} long. GIF files get large quickly; a lower resolution or frame rate keeps them smaller.`); } } else if (!S.info.codecsReady) { warns.push('Checking which encoders (including GPU encoders) are available…'); } $('#exWarn').textContent = warns.join('\n\n'); $('#exWarn').classList.toggle('hidden', !warns.length); const running = !!(S.exportJob && S.exportJob.state === 'running'); $('#exGo').disabled = running || !n; $('#exCancel').classList.toggle('hidden', !running); $$('#exportForm input, #exportForm select, #exBrowse').forEach(el => { el.disabled = running; }); if (!running) $('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac'; } function renderExportProgress() { const j = S.exportJob; const prog = $('#exProgress'), res = $('#exResult'); const running = !!(j && j.state === 'running'); prog.classList.toggle('hidden', !running); res.classList.toggle('hidden', !j || running); if (running) { $('#exBar').style.width = ((j.progress || 0) * 100).toFixed(1) + '%'; $('#exPText').textContent = `${j.step || 'Working'}… ${Math.floor((j.progress || 0) * 100)}%${j.speed ? ' · ' + j.speed : ''}`; $('#exPEta').textContent = j.eta > 0 && j.progress > 0.02 ? `about ${fmtETA(j.eta)} left` : ''; } else if (j) { res.replaceChildren(); if (j.state === 'done') { const outs = j.outputs || []; res.append(h('div', { class: 'notice ok' }, h('b', {}, `Export finished in ${fmtETA(j.elapsed)} — ${outs.length} file${outs.length !== 1 ? 's' : ''} saved`), h('div', { class: 'paths' }, ...outs.map(o => h('div', {}, o))), outs.length ? h('button', { class: 'btn', onclick: () => api('/api/reveal', { path: outs[0] }).catch(e => toast(e.message, 'error')) }, icon('folder'), 'Show in folder') : null)); } else if (j.state === 'error') { res.append(h('div', { class: 'notice error' }, 'Export failed:\n' + j.error)); } else if (j.state === 'canceled') { res.append(h('div', { class: 'notice warn' }, 'Export canceled.')); } requestAnimationFrame(() => res.scrollIntoView({ block: 'nearest', behavior: 'smooth' })); } updateExportView(); } async function startExport(overwrite = false) { const st = S.settings; st.output = $('#exOutput').value.trim(); if (!st.output) return toast('Choose where to save the export.', 'error'); const gif = st.mode === 'gif'; const body = { clips: S.clips.map(c => ({ src: c.src, in: c.in, out: c.out })), mode: st.mode, join: st.join, output: st.output, overwrite, allAudio: st.allAudio, noAudio: st.mode === 'copy' ? st.noAudio : st.acodec === 'none', vcodec: st.vcodec, quality: +st.quality, speed: st.speed, height: gif ? +st.gifHeight : +st.height, fps: gif ? st.gifFps : st.fps, acodec: st.acodec, abitrate: +st.abitrate, dither: st.gifDither, loop: st.gifLoop, }; saveSoon(); try { const r = await api('/api/export', body); const known = S.jobs[r.jobId]; S.exportJob = known || { id: r.jobId, state: 'running', progress: 0, step: 'Starting', outputs: [] }; if (known && known.state !== 'running') exportFinished(known); } catch (e) { if (e.status === 409 && e.data && e.data.exists) { const ex = e.data.exists; const names = ex.slice(0, 4).map(p => splitPath(p)[1]).join('\n') + (ex.length > 4 ? '\n…' : ''); if (await confirmDlg(`${ex.length === 1 ? 'This file already exists' : ex.length + ' files already exist'}:\n${names}\n\nReplace ${ex.length === 1 ? 'it' : 'them'}?`, 'Replace')) { return startExport(true); } return; } S.exportJob = { id: 'local', state: 'error', error: e.message, outputs: [] }; } renderExportProgress(); renderTopbar(); } function exportFinished(j) { if (dlgEx.open) return; // the dialog shows the result if (j.state === 'done') toast(`Export finished — ${(j.outputs || []).length} file(s) saved`, 'ok'); else if (j.state === 'error') toast('Export failed — open Export for details.', 'error'); } async function browseOutput() { const st = S.settings; st.output = $('#exOutput').value.trim() || defaultOutput(); if (S.info.nativeDialogs) { try { const r = await api('/api/dialog/save', { path: st.output }); if (r.supported) { if (r.path) { st.output = r.path; fillExport(); saveSoon(); } return; } } catch (e) { if (e.status === 409) return; } } const p = await fileBrowser({ mode: 'save', path: st.output }); if (p) { st.output = p; fillExport(); saveSoon(); } } function bindExport() { const st = () => S.settings; const refill = () => { fillExport(); saveSoon(); }; $$('input[name=mode]').forEach(r => r.addEventListener('change', () => { setMode(r.value); })); $$('input[name=join]').forEach(r => r.addEventListener('change', () => { st().join = r.value === '1'; refill(); })); $('#exOutput').addEventListener('input', e => { st().output = e.target.value; updateExportView(); saveSoon(); }); $('#exOutput').addEventListener('change', e => { st().output = e.target.value.trim(); refill(); }); $('#exContainer').addEventListener('change', e => { st().output = replaceExt(st().output || defaultOutput(), e.target.value); refill(); }); $('#exBrowse').addEventListener('click', browseOutput); $('#exAllAudio').addEventListener('change', e => { st().allAudio = e.target.checked; refill(); }); $('#exNoAudioCopy').addEventListener('change', e => { st().noAudio = e.target.checked; refill(); }); $('#exVCodec').addEventListener('change', e => { st().vcodec = e.target.value; const vc = (S.info.codecs || []).find(c => c.name === e.target.value); if (vc) st().quality = vc.defQ; refill(); }); $('#exQuality').addEventListener('input', e => { st().quality = +e.target.value; updateExportView(); saveSoon(); }); $('#exSpeed').addEventListener('change', e => { st().speed = e.target.value; refill(); }); $('#exHeight').addEventListener('change', e => { st().height = +e.target.value; refill(); }); $('#exFPS').addEventListener('change', e => { st().fps = e.target.value; refill(); }); $('#exACodec').addEventListener('change', e => { st().acodec = e.target.value; refill(); }); $('#exABitrate').addEventListener('change', e => { st().abitrate = +e.target.value; refill(); }); $('#exGifHeight').addEventListener('change', e => { st().gifHeight = +e.target.value; refill(); }); $('#exGifFPS').addEventListener('change', e => { st().gifFps = e.target.value; refill(); }); $('#exGifDither').addEventListener('change', e => { st().gifDither = e.target.value; refill(); }); $('#exGifLoop').addEventListener('change', e => { st().gifLoop = e.target.value === '1'; refill(); }); $('#exGo').addEventListener('click', () => startExport(false)); $('#exCancel').addEventListener('click', () => { if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {}); }); dlgEx.addEventListener('close', () => { // A finished result has been seen once the dialog is closed. if (S.exportJob && S.exportJob.state !== 'running') { S.exportJob = null; renderExportProgress(); } }); } function setMode(mode) { const st = S.settings; if (st.mode === mode) return; st.mode = mode; const exts = exportExts(mode); if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, defaultExt()); if (dlgEx.open) fillExport(); changed(); } // ---------------------------------------------------------------- built-in file browser const dlgF = $('#dlgFiles'); let fb = null; function fileBrowser({ mode = 'open', path = '' } = {}) { return new Promise(resolve => { fb = { mode, resolve, sel: new Set(), path: '', entries: [], parent: '' }; $('#fbTitle').textContent = mode === 'open' ? 'Add videos' : 'Export as'; $('#fbOk').textContent = mode === 'open' ? 'Add' : 'Save here'; $('#fbSaveRow').classList.toggle('hidden', mode !== 'save'); let dir = S.settings.lastDir || ''; if (mode === 'save' && path) { const [d, n] = splitPath(path); dir = d; $('#fbName').value = n; } $('#fbList').replaceChildren(h('li', { class: 'fb-empty' }, 'Loading…')); $('#fbOk').disabled = mode === 'open'; dlgF.showModal(); fbLoad(dir); }); } let fbSeq = 0; async function fbLoad(path) { if (!fb) return; const seq = ++fbSeq; try { const r = await api('/api/fs/list?path=' + encodeURIComponent(path) + ($('#fbAll').checked ? '&all=1' : '')); if (!fb || seq !== fbSeq) return; // closed, or a newer listing was requested Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent }); fb.sel.clear(); $('#fbPath').value = r.path; // The deepest root containing the folder (on Linux, / contains the mounted drives too). const P = r.path.toUpperCase(), sep = S.info.sep || '/'; const inRoot = x => { const X = x.toUpperCase(); return P === X || P.startsWith(X.replace(/[\\/]$/, '') + sep); }; const root = r.roots.filter(inRoot).sort((a, b) => b.length - a.length)[0] || r.roots[0]; setOptions($('#fbRoots'), r.roots.map(x => [x, x]), root); $('#fbUp').disabled = !r.parent; fbRender(); } catch (e) { if (fb && seq === fbSeq) toast(e.message, 'error'); } } function fbRender() { const ul = $('#fbList'); ul.replaceChildren(); if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, fb.mode === 'open' ? 'No videos in this folder' : 'This folder is empty')); for (const en of fb.entries) { ul.append(h('li', { class: (en.dir ? 'dir' : 'file') + (fb.sel.has(en.path) ? ' sel' : ''), 'data-path': en.path, 'data-dir': en.dir ? '1' : null }, icon(en.dir ? 'folder' : 'film'), h('span', { class: 'fb-name' }, en.name), en.dir ? null : h('span', { class: 'fb-size' }, fmtBytes(en.size)))); } const n = fb.sel.size; $('#fbSelInfo').textContent = fb.mode === 'open' && n ? `${n} selected` : ''; $('#fbOk').disabled = fb.mode === 'open' && !n; } function fbFinish() { if (!fb) return; let result; if (fb.mode === 'open') { result = [...fb.sel]; } else { const name = $('#fbName').value.trim(); if (!name) return toast('Type a file name.'); result = joinPath(fb.path, name); } S.settings.lastDir = fb.path; const f = fb; fb = null; dlgF.close(); f.resolve(result); } function bindFileBrowser() { $('#fbList').addEventListener('click', e => { const li = e.target.closest('li[data-path]'); if (!li || !fb) return; const p = li.dataset.path; if (li.dataset.dir) return fbLoad(p); if (fb.mode === 'open') { if (fb.sel.has(p)) fb.sel.delete(p); else fb.sel.add(p); } else { $('#fbName').value = splitPath(p)[1]; } fbRender(); }); $('#fbList').addEventListener('dblclick', e => { const li = e.target.closest('li[data-path]'); if (!li || li.dataset.dir || !fb) return; if (fb.mode === 'open') fb.sel.add(li.dataset.path); else $('#fbName').value = splitPath(li.dataset.path)[1]; fbFinish(); }); $('#fbOk').addEventListener('click', fbFinish); $('#fbUp').addEventListener('click', () => fb && fb.parent && fbLoad(fb.parent)); $('#fbRoots').addEventListener('change', e => fbLoad(e.target.value)); $('#fbAll').addEventListener('change', () => fb && fbLoad(fb.path)); $('#fbPath').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fbLoad(e.target.value); } }); $('#fbName').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fbFinish(); } }); dlgF.addEventListener('close', () => { if (fb) { const f = fb; fb = null; f.resolve(null); } }); } // ---------------------------------------------------------------- events from the server let offlineTimer = 0; function connectEvents() { const es = new EventSource('/api/events'); es.addEventListener('job', e => onJob(JSON.parse(e.data))); es.onopen = () => { clearTimeout(offlineTimer); $('#offline').classList.add('hidden'); }; es.onerror = () => { clearTimeout(offlineTimer); offlineTimer = setTimeout(() => { if (es.readyState !== EventSource.OPEN) $('#offline').classList.remove('hidden'); }, 2500); }; } function onJob(j) { S.jobs[j.id] = j; if (j.kind === 'proxy') { if (j.state === 'running') S.proxyJob[j.sourceId] = j; else if (j.state === 'done') proxyReady(j.sourceId); else { delete S.proxyJob[j.sourceId]; if (j.state === 'error' && j.sourceId === S.cur) toast('Could not create a preview copy:\n' + j.error, 'error'); } if (j.sourceId === S.cur) renderScreen(); return; } if (j.kind === 'export') { const prev = S.exportJob; if (j.state === 'running' || (prev && prev.id === j.id)) { const finishedNow = prev && prev.id === j.id && prev.state === 'running' && j.state !== 'running'; S.exportJob = j; if (finishedNow) exportFinished(j); } if (dlgEx.open) renderExportProgress(); renderTopbar(); } } // ---------------------------------------------------------------- session autosave let saveTimer = 0; function saveSoon() { if (!S.restored) return; clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700); } function saveNow() { if (!S.restored) return; clearTimeout(saveTimer); const marks = {}; for (const [id, mk] of Object.entries(S.marks)) { const m = srcById(id); if (m && (mk.in != null || mk.out != null)) marks[m.path] = mk; } const data = { version: 1, sources: S.sources.map(m => m.path), clips: S.clips.map(c => ({ path: srcById(c.src).path, in: c.in, out: c.out })), current: cur() ? cur().path : null, time: S.time, marks, settings: S.settings, }; fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), keepalive: true }).catch(() => {}); } async function restoreSession() { let s = {}; try { s = await api('/api/session'); } catch { /* fresh start */ } S.settings = { ...DEFAULTS, ...(s.settings || {}) }; if (Array.isArray(s.sources) && s.sources.length && S.info.ffmpegOK) { const results = await Promise.all(s.sources.map(p => api('/api/probe', { path: p }).catch(() => null))); const byPath = {}; const missing = []; results.forEach((m, i) => { if (!m) { missing.push(s.sources[i]); return; } if (!srcById(m.id)) { assignColor(m); S.sources.push(m); } byPath[s.sources[i]] = m.id; }); S.clips = (s.clips || []).filter(c => byPath[c.path] && c.out > c.in) .map(c => ({ id: newId(), src: byPath[c.path], in: +c.in, out: +c.out })); for (const [p, mk] of Object.entries(s.marks || {})) { if (byPath[p]) S.marks[byPath[p]] = { in: mk.in ?? null, out: mk.out ?? null }; } if (missing.length) { toast(`${missing.length} video${missing.length > 1 ? 's' : ''} from your last session could not be found:\n` + missing.map(p => splitPath(p)[1]).join('\n'), 'error'); } S.sources.forEach(m => ensureKeyframes(m.id)); const curId = byPath[s.current] || (S.sources[0] && S.sources[0].id); if (curId) loadSource(curId, { time: s.time || 0 }); } S.restored = true; } // ---------------------------------------------------------------- input bindings const ACTIONS = { start: () => seek(0), end: () => { const m = cur(); if (m) seek(m.duration); }, prevKey: () => gotoKey(-1), nextKey: () => gotoKey(1), back1s: () => seekBy(-1), fwd1s: () => seekBy(1), prevFrame: () => stepFrames(-1), nextFrame: () => stepFrames(1), play: togglePlay, mute: toggleMute, zoomIn: () => zoom(0.5), zoomOut: () => zoom(2), zoomFit, setIn: () => setIn(), setOut: () => setOut(), split, playSel: playSelection, addClip: addClipOrDone, }; function bindUI() { document.addEventListener('click', e => { const b = e.target.closest('[data-act]'); if (b && ACTIONS[b.dataset.act]) { ACTIONS[b.dataset.act](); b.blur(); } }); $$('[data-close]').forEach(b => b.addEventListener('click', () => b.closest('dialog').close())); $('#btnAdd').addEventListener('click', () => addFiles(false)); $('#btnBrowse').addEventListener('click', () => addFiles(true)); $('#btnUndo').addEventListener('click', undo); $('#btnRedo').addEventListener('click', redo); $('#btnNew').addEventListener('click', newProject); $('#btnHelp').addEventListener('click', () => $('#dlgHelp').showModal()); $('#btnExport').addEventListener('click', openExport); $('#jobPill').addEventListener('click', openExport); $('#volume').addEventListener('input', e => { video.volume = +e.target.value; video.muted = false; S.settings.volume = video.volume; S.settings.muted = false; renderMute(); saveSoon(); }); $$('#modeSeg button').forEach(b => b.addEventListener('click', () => { setMode(b.dataset.mode); b.blur(); })); $('#sourceList').addEventListener('click', e => { const li = e.target.closest('.src'); if (!li) return; const act = e.target.closest('[data-sact]'); if (act && act.dataset.sact === 'remove') return removeSource(li.dataset.id); if (act && act.dataset.sact === 'whole') return addWhole(li.dataset.id); if (li.dataset.id !== S.cur) loadSource(li.dataset.id); }); $('#markHint').addEventListener('click', e => { const a = e.target.closest('[data-hact]'); const m = cur(); if (!a || !m) return; const r = activeRange(); if (a.dataset.hact === 'done') { S.sel = null; changed(); } if (a.dataset.hact === 'snapPrev') { const k = kfBefore(m.id, r.in); if (k != null) { setIn(k); seek(k, { quantize: false }); } } if (a.dataset.hact === 'snapNext') { const k = kfAfter(m.id, r.in); if (k != null) { setIn(k); seek(k, { quantize: false }); } } }); // clip strip: click / actions / drag to reorder const list = $('#clipList'); let dragId = null, dropIndex = null; const clearDrop = () => $$('.drop-before, .drop-after', list).forEach(el => el.classList.remove('drop-before', 'drop-after')); list.addEventListener('click', e => { const card = e.target.closest('.clip'); if (!card) return; const id = card.dataset.id; const act = e.target.closest('[data-cact]'); if (act) { ({ del: deleteClip, dup: duplicateClip, left: x => moveClipBy(x, -1), right: x => moveClipBy(x, 1) })[act.dataset.cact](id); return; } selectClip(id); }); list.addEventListener('dragstart', e => { const card = e.target.closest('.clip'); if (!card) return; dragId = card.dataset.id; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragId); }); list.addEventListener('dragover', e => { if (!dragId) return; e.preventDefault(); clearDrop(); const card = e.target.closest('.clip'); if (!card) { const last = list.lastElementChild; if (last) last.classList.add('drop-after'); dropIndex = S.clips.length; return; } const r = card.getBoundingClientRect(); const after = e.clientX > r.left + r.width / 2; card.classList.add(after ? 'drop-after' : 'drop-before'); const i = S.clips.findIndex(c => c.id === card.dataset.id); dropIndex = after ? i + 1 : i; }); list.addEventListener('drop', e => { e.preventDefault(); if (dragId != null && dropIndex != null) moveClipTo(dragId, dropIndex); }); list.addEventListener('dragend', () => { dragId = null; dropIndex = null; clearDrop(); $$('.dragging', list).forEach(el => el.classList.remove('dragging')); }); // Files dropped from Explorer have no usable path in a browser window. window.addEventListener('dragover', e => { if (!dragId) e.preventDefault(); }); window.addEventListener('drop', e => { if (dragId) return; e.preventDefault(); if (e.dataTransfer && e.dataTransfer.files.length) toast('Dropping files isn\'t supported here — use "Add videos" instead.'); }); document.addEventListener('keydown', onKey); window.addEventListener('beforeunload', saveNow); } function onKey(e) { if (e.target.closest('input, select, textarea') || document.querySelector('dialog[open]')) return; const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; const ctrl = e.ctrlKey || e.metaKey; let handled = true; if (ctrl && k === 'z') { if (e.shiftKey) redo(); else undo(); } else if (ctrl && k === 'y') redo(); else if (ctrl && (k === 'ArrowLeft' || k === 'ArrowRight')) seekBy(k === 'ArrowLeft' ? -10 : 10); else if (ctrl || e.altKey) handled = false; else { switch (k) { case ' ': case 'k': togglePlay(); break; case 'ArrowLeft': if (e.shiftKey) seekBy(-1); else stepFrames(-1); break; case 'ArrowRight': if (e.shiftKey) seekBy(1); else stepFrames(1); break; case 'ArrowUp': gotoKey(-1); break; case 'ArrowDown': gotoKey(1); break; case 'Home': seek(0); break; case 'End': ACTIONS.end(); break; case 'i': if (e.shiftKey) gotoMark('in'); else setIn(); break; case 'o': if (e.shiftKey) gotoMark('out'); else setOut(); break; case 'Enter': addClipOrDone(); break; case 'p': playSelection(); break; case 's': split(); break; case 'm': toggleMute(); break; case 'Delete': case 'Backspace': if (S.sel) deleteClip(S.sel); break; case 'Escape': if (S.sel) { S.sel = null; changed(); } break; case '+': case '=': zoom(0.5); break; case '-': case '_': zoom(2); break; case '0': zoomFit(); break; default: handled = false; } } if (handled) e.preventDefault(); } // ---------------------------------------------------------------- start async function init() { try { S.info = await api('/api/info'); } catch { $('#offline').classList.remove('hidden'); return; } if (!S.info.ffmpegOK) { const win = S.info.os === 'windows'; const b = $('#banner'); b.replaceChildren(h('b', {}, 'ffmpeg was not found. '), win ? 'Download a Windows build (for example "ffmpeg-release-essentials.zip" from ' : 'Install ffmpeg with your package manager (or put ffmpeg and ffprobe from ', win ? h('a', { href: 'https://www.gyan.dev/ffmpeg/builds/', target: '_blank', rel: 'noopener' }, 'gyan.dev/ffmpeg/builds') : h('a', { href: 'https://ffmpeg.org/download.html', target: '_blank', rel: 'noopener' }, 'ffmpeg.org/download.html'), win ? '), copy ffmpeg.exe and ffprobe.exe from its bin folder next to vibedit.exe, then restart VibEdit.' : ' next to vibedit) and restart VibEdit.'); b.classList.remove('hidden'); } $('#btnBrowse').classList.toggle('hidden', !S.info.nativeDialogs); $('#helpVersion').textContent = `VibEdit ${S.info.version} · ${S.info.ffmpegVersion || 'ffmpeg not found'}`; bindUI(); bindExport(); bindFileBrowser(); connectEvents(); await restoreSession(); video.volume = clamp(+S.settings.volume || 0, 0, 1); video.muted = !!S.settings.muted; $('#volume').value = video.volume; renderMute(); renderAll(); requestAnimationFrame(frame); } window.vibedit = { S, api }; // handy for debugging from the console init(); })();