162 lines
6.1 KiB
JavaScript
162 lines
6.1 KiB
JavaScript
/* ============================================================================
|
|
* video-edit.js — pure helpers for the "Edit & download" custom-video feature
|
|
*
|
|
* The video editor lets a user mark one or more CUT ranges (parts to delete)
|
|
* on a source video before saving it offline. Everything here is pure maths on
|
|
* {start,end} second ranges so it can be unit-tested with `node --test` and
|
|
* reused identically by the browser (window.VideoEdit) and, conceptually, by
|
|
* the server when it validates the same ?keep= parameter.
|
|
*
|
|
* Vocabulary:
|
|
* cut — a [start,end] span the user wants REMOVED from the final video.
|
|
* keep — a [start,end] span that SURVIVES into the final video. The keep
|
|
* list is the complement of the (merged, clamped) cut list over
|
|
* [0,duration].
|
|
*
|
|
* The wire format for the server is a compact string of keep segments:
|
|
* "12.5-40,95-130.2" → keep 12.5s‥40s and 95s‥130.2s, drop everything else.
|
|
* ========================================================================== */
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
// Round to milliseconds so float noise from the <video> clock doesn't leak
|
|
// into filenames / ffmpeg args, while still being precise enough for frames.
|
|
function round3(n) { return Math.round(n * 1000) / 1000; }
|
|
|
|
// Parse "H:MM:SS(.mmm)", "MM:SS(.mmm)" or a bare seconds number to seconds.
|
|
// Returns null when the input can't be understood.
|
|
function parseTime(input) {
|
|
if (typeof input === 'number' && isFinite(input)) return input < 0 ? null : round3(input);
|
|
if (typeof input !== 'string') return null;
|
|
const s = input.trim();
|
|
if (!s) return null;
|
|
// Bare number of seconds (may be fractional).
|
|
if (/^\d+(\.\d+)?$/.test(s)) return round3(parseFloat(s));
|
|
// Colon-separated clock. 1-3 components (ss, mm:ss, hh:mm:ss).
|
|
const parts = s.split(':');
|
|
if (parts.length < 2 || parts.length > 3) return null;
|
|
let total = 0;
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const p = parts[i];
|
|
if (!/^\d+(\.\d+)?$/.test(p)) return null;
|
|
const val = parseFloat(p);
|
|
// Only the leading component may exceed 59.
|
|
if (i > 0 && val >= 60) return null;
|
|
total = total * 60 + val;
|
|
}
|
|
return round3(total);
|
|
}
|
|
|
|
// Format seconds → "M:SS" or "H:MM:SS", mirroring app.js fmtTime but kept
|
|
// local so this module has no dependencies. Fractions are dropped for
|
|
// display (labels), never for the maths.
|
|
function fmtTime(sec) {
|
|
sec = Math.max(0, Math.floor(sec || 0));
|
|
const h = Math.floor(sec / 3600);
|
|
const m = Math.floor((sec % 3600) / 60);
|
|
const s = sec % 60;
|
|
const mm = h ? String(m).padStart(2, '0') : String(m);
|
|
const ss = String(s).padStart(2, '0');
|
|
return (h ? h + ':' : '') + mm + ':' + ss;
|
|
}
|
|
|
|
// Normalise a raw list of cut ranges: coerce to numbers, drop invalid /
|
|
// zero-length spans, clamp to [0,duration], sort, and merge overlaps so the
|
|
// downstream complement is clean. Never mutates the input.
|
|
function normalizeCuts(cuts, duration) {
|
|
const dur = isFinite(duration) && duration > 0 ? round3(duration) : Infinity;
|
|
const clean = [];
|
|
for (const c of cuts || []) {
|
|
if (!c) continue;
|
|
let a = Number(c.start);
|
|
let b = Number(c.end);
|
|
if (!isFinite(a) || !isFinite(b)) continue;
|
|
if (b < a) { const t = a; a = b; b = t; } // tolerate reversed input
|
|
a = round3(Math.max(0, a));
|
|
b = round3(Math.min(dur, b));
|
|
if (b - a <= 0) continue; // zero-length or fully out of range
|
|
clean.push({ start: a, end: b });
|
|
}
|
|
clean.sort((x, y) => x.start - y.start);
|
|
const merged = [];
|
|
for (const c of clean) {
|
|
const last = merged[merged.length - 1];
|
|
if (last && c.start <= last.end) {
|
|
last.end = Math.max(last.end, c.end);
|
|
} else {
|
|
merged.push({ start: c.start, end: c.end });
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
// Complement of the cut list over [0,duration] → the keep segments.
|
|
// With no cuts the whole video is kept. Requires a finite positive duration.
|
|
function invertCuts(cuts, duration) {
|
|
if (!isFinite(duration) || duration <= 0) return [];
|
|
const dur = round3(duration);
|
|
const merged = normalizeCuts(cuts, dur);
|
|
const keep = [];
|
|
let cursor = 0;
|
|
for (const c of merged) {
|
|
if (c.start > cursor) keep.push({ start: round3(cursor), end: round3(c.start) });
|
|
cursor = Math.max(cursor, c.end);
|
|
}
|
|
if (cursor < dur) keep.push({ start: round3(cursor), end: dur });
|
|
// Drop any degenerate zero-length keeps that rounding could produce.
|
|
return keep.filter((k) => k.end - k.start > 0.001);
|
|
}
|
|
|
|
// Total surviving duration for a keep list.
|
|
function keepDuration(keep) {
|
|
return round3((keep || []).reduce((s, k) => s + (k.end - k.start), 0));
|
|
}
|
|
|
|
// Serialise keep segments to the compact wire string "s-e,s-e".
|
|
function keepToParam(keep) {
|
|
return (keep || []).map((k) => round3(k.start) + '-' + round3(k.end)).join(',');
|
|
}
|
|
|
|
// Parse the wire string back into keep segments. Invalid tokens are skipped;
|
|
// returns [] on empty/garbage input. Used by the server to validate ?keep=.
|
|
function parseKeepParam(str) {
|
|
if (typeof str !== 'string') return [];
|
|
const out = [];
|
|
for (const tok of str.split(',')) {
|
|
const t = tok.trim();
|
|
if (!t) continue;
|
|
const m = t.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/);
|
|
if (!m) continue;
|
|
const a = parseFloat(m[1]);
|
|
const b = parseFloat(m[2]);
|
|
if (!isFinite(a) || !isFinite(b) || b <= a) continue;
|
|
out.push({ start: round3(a), end: round3(b) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// True when the cut list actually changes the video (i.e. there is at least
|
|
// one real cut inside [0,duration]). A no-op edit should just save normally.
|
|
function hasEdits(cuts, duration) {
|
|
return normalizeCuts(cuts, duration).length > 0;
|
|
}
|
|
|
|
const VideoEdit = {
|
|
round3,
|
|
parseTime,
|
|
fmtTime,
|
|
normalizeCuts,
|
|
invertCuts,
|
|
keepDuration,
|
|
keepToParam,
|
|
parseKeepParam,
|
|
hasEdits,
|
|
};
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = VideoEdit;
|
|
} else {
|
|
root.VideoEdit = VideoEdit;
|
|
}
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|