Files
ytplayer/frontend/video-edit.js
Claude Worker 8c0776f97d feat: Add support for editing video
Add an Edit
2026-07-18 16:43:15 +00:00

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);