Files
vibedit/web/app.js
2026-11-06 17:57:01 +00:00

2059 lines
77 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
burnCaptions: true, captionSize: 'medium', captionStyle: 'outline',
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}
captions: {}, // source id -> {path, name, cues: [{start, end, text}]} (a loaded .srt file)
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(), captions: { ...S.captions }, 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.captions = s.captions;
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
// pickFiles asks for files to open, with the native dialog when there is one
// and else with the built-in browser. kind is 'video' or 'captions'.
async function pickFiles({ kind = 'video', dir = '', builtin = false } = {}) {
if (!builtin && S.info.nativeDialogs) {
try {
const r = await api('/api/dialog/open', { dir, kind });
if (r.supported) return r.paths || [];
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 [];
}
}
return await fileBrowser({ mode: 'open', kind, dir }) || [];
}
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');
const paths = await pickFiles({ dir: S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : ''), builtin });
if (!paths.length) return;
S.settings.lastDir = splitPath(paths[0])[0];
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();
}
}
async function loadCaptions(id) {
const m = srcById(id);
if (!m) return;
const old = S.captions[id];
const [path] = await pickFiles({ kind: 'captions', dir: splitPath(old ? old.path : m.path)[0] });
if (!path || !srcById(id)) return;
try {
const c = await api('/api/captions', { path });
commit(() => { S.captions[id] = c; });
if (S.cur !== id) loadSource(id);
toast(`Loaded ${c.cues.length} caption${c.cues.length !== 1 ? 's' : ''} from "${c.name}"`, 'ok');
} catch (e) {
toast(e.message, 'error');
}
}
function removeCaptions(id) {
commit(() => { delete S.captions[id]; });
}
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.captions = {}; 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];
const cap = S.captions[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'),
cap ? h('button', { class: 'chip-btn on', 'data-sact': 'captions', title: `Captions: ${cap.name} (${cap.cues.length})\nClick to load a different .srt file.` }, 'CC',
h('span', { class: 'chip-x', 'data-sact': 'nocaptions', title: 'Remove the captions' }, icon('x')))
: h('button', { class: 'chip-btn', 'data-sact': 'captions', title: 'Load captions (.srt) for this video' }, icon('plus'), 'CC'),
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() {
renderCaptions();
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.';
}
layoutCaptions();
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 + '%');
}
}
// ---------------------------------------------------------------- captions preview
// The viewer shows the current source's captions the way an export burns them
// in (captionFilter in captions.go). Sizes are in 1/288ths of the picture's
// shorter side and match captionSizes there; libass makes each line as tall as
// the font size, which for Arial is 1.117 em.
const CAPTION_SIZES = { small: 12, medium: 16, large: 21 };
const capEl = $('#captions');
let capShown = null;
function renderCaptions() {
const m = cur();
const c = m && S.captions[m.id];
const t = S.time + 1e-3;
const cues = c ? c.cues.filter(q => q.start <= t && t < q.end) : [];
const key = cues.map(q => q.text).join('\0');
if (key === capShown) return;
capShown = key;
capEl.replaceChildren(...cues.map(q => h('div', {}, captionNodes(q.text))));
capEl.classList.toggle('hidden', !cues.length);
}
// layoutCaptions places the captions over the picture inside the viewer.
function layoutCaptions() {
const scr = $('#screen');
const W = scr.clientWidth, H = scr.clientHeight;
let w = W, hh = H;
const v = cur() && cur().video;
if (v && v.displayWidth && v.displayHeight) {
const a = v.displayWidth / v.displayHeight;
if (W / H > a) w = H * a; else hh = W / a;
}
const u = Math.min(w, hh) / 288;
const size = (CAPTION_SIZES[S.settings.captionSize] || CAPTION_SIZES.medium) * u;
const margin = 10 * u; // ffmpeg's default MarginL/R/V
Object.assign(capEl.style, {
left: (W - w) / 2 + margin + 'px', width: Math.max(0, w - 2 * margin) + 'px', bottom: (H - hh) / 2 + margin + 'px',
fontSize: size / 1.117 + 'px', lineHeight: size + 'px',
});
capEl.style.setProperty('--u', u + 'px');
capEl.classList.toggle('box', S.settings.captionStyle === 'box');
}
new ResizeObserver(layoutCaptions).observe($('#screen'));
// captionNodes keeps the <i>, <b> and <u> tags of SRT caption text and drops
// other tags and {\...} override codes.
function captionNodes(text) {
const root = h('span');
let el = root;
for (const part of text.replace(/\{\\[^}]*\}/g, '').split(/(<\/?[a-z]+[^>]*>)/i)) {
const tag = /^<(\/?)([a-z]+)[^>]*>$/i.exec(part);
if (!tag) el.append(part);
else if (!['i', 'b', 'u'].includes(tag[2].toLowerCase())) continue;
else if (!tag[1]) el = el.appendChild(h(tag[2].toLowerCase()));
else if (el !== root) el = el.parentNode;
}
return root;
}
// ---------------------------------------------------------------- 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(); }
// willBurnCaptions tells whether the export draws captions into the picture.
const willBurnCaptions = () => S.settings.mode !== 'copy' && S.settings.burnCaptions && S.info.captionsOK &&
S.clips.some(c => S.captions[c.src]);
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';
$('#exCapSize').value = st.captionSize;
$('#exCapStyle').value = st.captionStyle;
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}${willBurnCaptions() ? ' with captions' : ''}`;
const capFiles = [...new Set(S.clips.map(c => S.captions[c.src]).filter(Boolean))];
const capOK = !copy && S.info.captionsOK;
let capHint = '';
if (copy) capHint = 'Lossless export copies the picture unchanged, so it can\'t add captions. Choose Re-encode or GIF to burn them in.';
else if (!S.info.codecsReady) capHint = 'Checking whether ffmpeg can draw captions…';
else if (!S.info.captionsOK) capHint = 'This ffmpeg can\'t draw captions: it was built without libass (the subtitles filter).';
else if (st.burnCaptions) {
const none = S.clips.filter(c => !S.captions[c.src]).length;
capHint = 'From ' + capFiles.map(c => c.name).join(', ') +
(none ? ` · ${none} clip${none > 1 ? 's come' : ' comes'} from a video without captions` : '');
}
$('#exCapOpts').classList.toggle('hidden', !capFiles.length);
$('#exCaptions').checked = capOK && st.burnCaptions;
$('#exCapHint').textContent = capHint;
$('#exCapLook').classList.toggle('hidden', !capOK || !st.burnCaptions);
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';
$('#exCaptions').disabled = !capOK;
}
}
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,
captions: {}, captionSize: st.captionSize, captionStyle: st.captionStyle,
};
if (willBurnCaptions()) {
for (const c of S.clips) if (S.captions[c.src]) body.captions[c.src] = S.captions[c.src].path;
}
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(); });
$('#exCaptions').addEventListener('change', e => { st().burnCaptions = e.target.checked; refill(); });
$('#exCapSize').addEventListener('change', e => { st().captionSize = e.target.value; refill(); layoutCaptions(); });
$('#exCapStyle').addEventListener('change', e => { st().captionStyle = e.target.value; refill(); layoutCaptions(); });
$('#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;
// fileBrowser picks files to open (of a kind: 'video' or 'captions') or the
// path to save an export to.
function fileBrowser({ mode = 'open', kind = 'video', dir = '', path = '' } = {}) {
return new Promise(resolve => {
const caps = kind === 'captions';
fb = { mode, kind, resolve, sel: new Set(), path: '', entries: [], parent: '' };
$('#fbTitle').textContent = mode === 'save' ? 'Export as' : caps ? 'Load captions' : 'Add videos';
$('#fbOk').textContent = mode === 'save' ? 'Save here' : caps ? 'Load' : 'Add';
$('#fbSaveRow').classList.toggle('hidden', mode !== 'save');
dir = 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) + '&kind=' + fb.kind + ($('#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();
const none = fb.mode !== 'open' ? 'This folder is empty' : fb.kind === 'captions' ? 'No .srt files in this folder' : 'No videos in this folder';
if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, none));
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);
}
if (fb.kind !== 'captions') 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') {
const had = fb.sel.has(p);
if (fb.kind === 'captions') fb.sel.clear(); // one caption file at a time
if (had) 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') {
if (fb.kind === 'captions') fb.sel.clear();
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,
captions: Object.fromEntries(S.sources.filter(m => S.captions[m.id]).map(m => [m.path, S.captions[m.id].path])),
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 };
}
const caps = Object.entries(s.captions || {}).filter(([p]) => byPath[p]);
const loaded = await Promise.all(caps.map(([, srt]) => api('/api/captions', { path: srt }).catch(() => null)));
const lost = [];
caps.forEach(([p, srt], i) => { if (loaded[i]) S.captions[byPath[p]] = loaded[i]; else lost.push(srt); });
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');
}
if (lost.length) {
toast('Captions from your last session could not be loaded:\n' + lost.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 (act && act.dataset.sact === 'captions') return loadCaptions(li.dataset.id);
if (act && act.dataset.sact === 'nocaptions') return removeCaptions(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();
})();