From 8c0776f97dc51f72ec380df383b6ff289b55b4b9 Mon Sep 17 00:00:00 2001 From: Claude Worker Date: Sat, 18 Jul 2026 16:43:15 +0000 Subject: [PATCH] feat: Add support for editing video Add an Edit --- frontend/app.js | 350 +++++++++++++++++++++++++++++-- frontend/index.html | 2 + frontend/styles.css | 52 +++++ frontend/video-edit.js | 161 ++++++++++++++ frontend/video-edit.test.js | 89 ++++++++ server/server.js | 129 ++++++++++++ tests/video-editor.smoke.spec.js | 72 +++++++ 7 files changed, 834 insertions(+), 21 deletions(-) create mode 100644 frontend/video-edit.js create mode 100644 frontend/video-edit.test.js create mode 100644 tests/video-editor.smoke.spec.js diff --git a/frontend/app.js b/frontend/app.js index 910f2fb..c503834 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -113,6 +113,43 @@ async function opfsDownload(videoId, { mux = false } = {}) { } } +// "Edit & download": ask the server to trim `sourceId` to the given keep +// segments and store the resulting custom cut in OPFS under `customId` (which +// is NOT a real YouTube id — it's edit__). Same worker-first / +// main-thread-fallback strategy as opfsDownload, but the URL targets the +// source video with ?edit=1&keep=… while the file is written under customId. +async function opfsDownloadEdited(customId, sourceId, keepParam) { + if (!window.OPFS || !window.OPFS.isSupported()) { + return { ok: false, error: 'OPFS not supported in this browser' }; + } + const fp = window.getFingerprint ? window.getFingerprint() : ''; + const params = new URLSearchParams(); + params.set('edit', '1'); + params.set('keep', keepParam); + if (fp) params.set('fp', fp); + const url = `/api/download/${encodeURIComponent(sourceId)}?${params.toString()}`; + + let workerError = null; + if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { + const w = await window.OPFS.downloadVideo(customId, url); + if (w.ok) return { ok: true, cached: true }; + workerError = w.error || null; + } + try { + const res = await fetch(url); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + return { ok: false, error: j.error || `HTTP ${res.status}` }; + } + const ct = res.headers.get('content-type') || 'video/mp4'; + const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4'; + await window.OPFS.writeFromResponse(customId, ext, res); + return { ok: true, cached: true }; + } catch (err) { + return { ok: false, error: workerError || err.message }; + } +} + async function opfsStatus(videoId) { if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, cached: false }; try { @@ -170,6 +207,11 @@ const API = { cacheDownload: (videoId, opts) => WEB ? opfsDownload(sanitizeId(videoId), opts) : call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }), + // Download a server-edited cut of `sourceId` into the cache under a custom + // id. WEB (OPFS) only — the native shells have no ffmpeg edit pipeline. + cacheDownloadEdited: (customId, sourceId, keepParam) => WEB + ? opfsDownloadEdited(sanitizeId(customId), sanitizeId(sourceId), keepParam) + : Promise.resolve({ ok: false, error: 'Editing is only available in the web app' }), cacheStatus: (videoId) => WEB ? opfsStatus(sanitizeId(videoId)) : call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }), @@ -206,7 +248,7 @@ const DEFAULT_SETTINGS = { autoBackupEnabled: false, autoBackupIntervalDays: 7, }; -let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null }; +let data = { playlists: [], history: [], queue: [], customVideos: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null }; let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let searchResults = []; let channelData = { name: '', url: '', key: '', results: [], loading: false }; @@ -282,6 +324,7 @@ const els = { newPlaylistBtn: $('newPlaylistBtn'), audioOnlyToggle: $('audioOnlyToggle'), saveBtn: $('saveBtn'), + editBtn: $('editBtn'), addPlaylistBtn: $('addPlaylistBtn'), }; @@ -302,6 +345,7 @@ function profilePayload() { return { playlists: data.playlists, history: data.history, + customVideos: data.customVideos, settings: data.settings, resumePositions: data.resumePositions, rememberPos: data.rememberPos, @@ -340,6 +384,7 @@ function applyProfileData(name, payload, updatedAt) { payload = payload || {}; if (Array.isArray(payload.playlists)) data.playlists = payload.playlists; if (Array.isArray(payload.history)) data.history = payload.history; + if (Array.isArray(payload.customVideos)) data.customVideos = payload.customVideos; if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions; if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos; if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount; @@ -534,6 +579,10 @@ async function refreshCachedIds() { // Download a video into the permanent offline cache. Safe to call repeatedly. async function preload(video, { quiet = false, mux = false } = {}) { const id = video.id; + // Custom (edited) videos have no YouTube source to (re)download — their + // media is produced once by the editor. Never route them through the normal + // cache-download path (a fake edit_… id would 404 on /api/download). + if (video.custom) return; if (!id || cachedIds.has(id) || downloading.has(id)) return; downloading.add(id); downloadMeta.set(id, slim(video)); @@ -599,6 +648,203 @@ function markCardCacheState(id, state) { }); } +// ============================================================================ +// Video editor — cut parts out of a video and save a custom offline copy +// +// The editor works on a SOURCE video (any card / the now-playing video). The +// user marks one or more CUT ranges; everything outside those ranges survives. +// On confirm we compute the keep segments (VideoEdit.invertCuts), ask the +// server to trim+concat the source into one continuous mp4 (?edit=1&keep=…), +// store it in OPFS under a fresh custom id, and register a custom video object +// in data.customVideos so it plays offline and can be added to playlists just +// like a normal video. +// ============================================================================ + +// Make a stable-ish unique id for a custom cut. Not a YouTube id — the +// `edit_` prefix is how the rest of the app recognises an offline-only video. +function customVideoId(sourceId) { + return 'edit_' + sanitizeId(sourceId) + '_' + uid(); +} + +// Kick off the server-side edit + OPFS save for a custom video object, driving +// the same download/cache UI state (badges, toasts) as a normal save. +async function downloadEdited(customVideo) { + const id = customVideo.id; + if (!id || cachedIds.has(id) || downloading.has(id)) return; + downloading.add(id); + downloadMeta.set(id, slim(customVideo)); + markCardCacheState(id, 'downloading'); + if (view.type === 'downloads') renderList(); + updateDownloadBadge(); + toast(`Rendering “${customVideo.title}”…`); + try { + const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep); + if (res && res.ok && res.cached) { + cachedIds.add(id); + // Only persist the custom video once its media is actually stored, so a + // failed render never leaves a dangling entry the user can't play. + if (!(data.customVideos || []).some((v) => v.id === id)) { + data.customVideos = data.customVideos || []; + data.customVideos.push(customVideo); + persist(); + } + toast(`Saved edited “${customVideo.title}” ✓`); + if (view.type === 'downloads' || view.type === 'saved') renderList(); + } else { + toast('⚠ ' + ((res && res.error) || 'Could not render edited video')); + } + } catch (e) { + toast('⚠ ' + (e && e.message ? e.message : 'Editing failed')); + } finally { + downloading.delete(id); + downloadMeta.delete(id); + markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); + updateDownloadBadge(); + if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList(); + } +} + +// Remove a custom (edited) video entirely: its cached media file, its cache +// membership, its registry entry, and any playlist references. Unlike a normal +// "remove from cache", the media can't be re-fetched, so this is a true delete. +async function deleteCustomVideo(id) { + try { await API.cacheDelete(id); } catch { /* best-effort */ } + cachedIds.delete(id); + data.customVideos = (data.customVideos || []).filter((v) => v.id !== id); + data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => x.id !== id); }); + data.queue = (data.queue || []).filter((x) => x.id !== id); + persist(); + markCardCacheState(id, 'none'); + if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); + toast('Deleted edited video'); + if (view.type === 'saved' || view.type === 'downloads' || view.type === 'playlist') renderList(); +} + +// Open the editor modal for a source video. `duration` seconds is needed to +// compute keep segments; we take it from the live player when the video is +// currently playing, else from the card metadata. +function openVideoEditor(source) { + if (!(WEB && window.OPFS && window.OPFS.isSupported())) { + toast('⚠ Editing needs offline storage, which this browser doesn’t support'); + return; + } + // Prefer the precise live duration when editing the now-playing video. + let duration = 0; + if (current && current.meta && current.meta.id === source.id && Player.master && Player.master.duration) { + duration = Player.master.duration; + } + if (!duration) duration = Number(source.duration) || 0; + if (!duration || !isFinite(duration)) { + toast('⚠ Play the video first so its length is known, then edit'); + return; + } + + const cuts = []; // [{start,end}] the user is removing + + const body = document.createElement('div'); + body.className = 'video-editor'; + body.innerHTML = ` +

Mark the parts to remove. Everything else is kept and saved as a new offline video.

+
+ + + +
+ +
+ +
`; + + const fromEl = body.querySelector('.ve-from'); + const toEl = body.querySelector('.ve-to'); + const addBtn = body.querySelector('.ve-add'); + const errEl = body.querySelector('.ve-error'); + const cutsEl = body.querySelector('.ve-cuts'); + const titleEl = body.querySelector('.ve-title'); + const sumEl = body.querySelector('.ve-summary'); + titleEl.value = (source.title || 'Video') + ' (edit)'; + + function showErr(msg) { + errEl.textContent = msg; + errEl.hidden = !msg; + } + + function refresh() { + cutsEl.innerHTML = ''; + const sorted = cuts.slice().sort((a, b) => a.start - b.start); + sorted.forEach((cut) => { + const row = document.createElement('div'); + row.className = 've-cut'; + row.innerHTML = `✂ ${VideoEdit.fmtTime(cut.start)} – ${VideoEdit.fmtTime(cut.end)}`; + const del = document.createElement('button'); + del.className = 've-cut-del'; + del.type = 'button'; + del.textContent = '✕'; + del.title = 'Remove this cut'; + del.onclick = () => { + const i = cuts.indexOf(cut); + if (i > -1) cuts.splice(i, 1); + refresh(); + }; + row.appendChild(del); + cutsEl.appendChild(row); + }); + const keep = VideoEdit.invertCuts(cuts, duration); + const finalLen = VideoEdit.keepDuration(keep); + sumEl.innerHTML = cuts.length + ? `Final length: ${VideoEdit.fmtTime(finalLen)} of ${VideoEdit.fmtTime(duration)}` + : `No cuts yet — the whole ${VideoEdit.fmtTime(duration)} video would be saved.`; + } + + addBtn.onclick = () => { + showErr(''); + const a = VideoEdit.parseTime(fromEl.value); + const b = VideoEdit.parseTime(toEl.value); + if (a === null || b === null) { showErr('Enter valid times, e.g. 0:30 and 1:15.'); return; } + if (b <= a) { showErr('“To” must be after “From”.'); return; } + if (a >= duration) { showErr(`Times must be within the video (0 – ${VideoEdit.fmtTime(duration)}).`); return; } + cuts.push({ start: a, end: Math.min(b, duration) }); + fromEl.value = ''; + toEl.value = ''; + fromEl.focus(); + refresh(); + }; + + refresh(); + + showModal('Edit & download', body, [ + { label: 'Cancel', onClick: closeModal }, + { + label: 'Save edited copy', primary: true, onClick: () => { + if (!VideoEdit.hasEdits(cuts, duration)) { + showErr('Add at least one cut, or use ⬇ Save for the full video.'); + return; + } + const keep = VideoEdit.invertCuts(cuts, duration); + if (!keep.length) { + showErr('That would remove the entire video — leave something to keep.'); + return; + } + const custom = { + id: customVideoId(source.id), + title: (titleEl.value || '').trim() || ((source.title || 'Video') + ' (edit)'), + channel: source.channel || '', + channelId: source.channelId || '', + channelUrl: source.channelUrl || '', + thumbnail: source.thumbnail || '', + duration: Math.round(VideoEdit.keepDuration(keep)), + custom: true, + sourceId: source.id, + keep: VideoEdit.keepToParam(keep), + }; + closeModal(); + downloadEdited(custom); + }, + }, + ]); + setTimeout(() => fromEl.focus(), 50); +} + // ============================================================================ // Player engine — single video, or video+audio synced (adaptive), or audio-only // ============================================================================ @@ -663,6 +909,14 @@ const Player = { } } + // Custom (edited) videos exist ONLY in the offline cache — there is no + // YouTube stream to fall back to. If the cached file is missing (e.g. + // cleared, or synced from another device that never had the media), + // surface a clear error instead of trying to stream a fake video id. + if (videoObj.custom) { + throw new Error('This edited video isn’t available offline on this device.'); + } + // Opt-in "Save before playing" (Settings → Playback, off by default): // download the server-compiled single file into the offline cache // first, then play the local copy — one data stream instead of the @@ -1186,6 +1440,11 @@ function updatePlayBtn() { function updateNowPlayingActions() { if (!current || !current.meta) return; const id = current.meta.id; + // Custom (edited) videos are already a saved-offline cut of a source video; + // re-saving or re-editing them makes no sense, so hide those actions. + const isCustom = !!current.meta.custom; + if (els.editBtn) els.editBtn.hidden = isCustom; + if (els.saveBtn) els.saveBtn.hidden = isCustom; const btn = els.saveBtn; if (!btn) return; if (removing.has(id)) { @@ -1754,11 +2013,20 @@ async function renderSaved() { const clearAll = document.createElement('button'); clearAll.textContent = 'Clear all'; clearAll.onclick = () => { - showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Playlist entries stay and re-download on demand.'), [ + showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [ { label: 'Cancel', onClick: closeModal }, { label: 'Clear all', danger: true, onClick: async () => { try { await API.cacheClear(); } catch {} cachedIds.clear(); + // Custom cuts live only in the cache — clearing it destroys their media, + // so drop their registry entries and playlist references too. + const customIds = new Set((data.customVideos || []).map((v) => v.id)); + if (customIds.size) { + data.customVideos = []; + data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => !customIds.has(x.id)); }); + data.queue = (data.queue || []).filter((x) => !customIds.has(x.id)); + persist(); + } closeModal(); toast('Cache cleared'); if (current) updateNowPlayingActions(); @@ -1775,15 +2043,20 @@ async function renderSaved() { c.appendChild(summary); items.forEach((it) => { - const v = videoById(it.id) || { id: it.id, title: videoTitleById(it.id), thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` }; + const known = videoById(it.id); + // A custom (edited) id has no YouTube thumbnail; fall back to its source's + // thumbnail when we know it, else a neutral placeholder. + const isCustom = (known && known.custom) || String(it.id).startsWith('edit_'); + const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`; + const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb }; const row = document.createElement('div'); - row.className = 'card saved-card'; + row.className = 'card saved-card' + (isCustom ? ' custom' : ''); row.dataset.id = it.id; row.innerHTML = ` -
+
${isCustom ? '' : ''}
-
${fmtBytes(it.size)}
+
${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
`; row.querySelector('.card-title').textContent = v.title || it.id; @@ -1793,6 +2066,7 @@ async function renderSaved() { }); row.querySelector('.card-del').addEventListener('click', async (e) => { e.stopPropagation(); + if (isCustom) { await deleteCustomVideo(it.id); renderSaved(); return; } try { await API.cacheDelete(it.id); } catch {} cachedIds.delete(it.id); if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions(); @@ -1867,6 +2141,10 @@ function videoById(id) { if (q) return q; const h = data.history.find((x) => x.id === id); if (h) return h; + // Custom (edited) videos live only in data.customVideos — their media is in + // the offline cache, never on YouTube, so nothing else references them. + const cv = (data.customVideos || []).find((x) => x.id === id); + if (cv) return cv; return null; } function videoTitleById(id) { @@ -2442,24 +2720,42 @@ function openCardMenu(video) { queueBtn.onclick = () => { addToQueue(video); closeModal(); }; body.appendChild(queueBtn); - // Quick: save / remove offline + const isCustom = !!video.custom; + + // Quick: save / remove offline. For a custom (edited) video there's no + // source to re-download, so "remove" deletes the edit entirely. const saveBtn = document.createElement('button'); const isSaved = cachedIds.has(video.id); - saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline'; - saveBtn.onclick = async () => { - closeModal(); - if (cachedIds.has(video.id)) { - try { await API.cacheDelete(video.id); } catch {} - cachedIds.delete(video.id); - markCardCacheState(video.id, 'none'); - if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions(); - toast('Removed from offline cache'); - } else { - preload(video); - } - }; + if (isCustom) { + saveBtn.textContent = '🗑 Delete edited video'; + saveBtn.className = 'danger'; + saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); }; + } else { + saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline'; + saveBtn.onclick = async () => { + closeModal(); + if (cachedIds.has(video.id)) { + try { await API.cacheDelete(video.id); } catch {} + cachedIds.delete(video.id); + markCardCacheState(video.id, 'none'); + if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions(); + toast('Removed from offline cache'); + } else { + preload(video); + } + }; + } body.appendChild(saveBtn); + // Quick: edit & download (only for real source videos — a custom cut can't + // be re-cut server-side because its media lives only in the browser cache). + if (!isCustom) { + const editBtn = document.createElement('button'); + editBtn.textContent = '✂ Edit & download'; + editBtn.onclick = () => { closeModal(); openVideoEditor(video); }; + body.appendChild(editBtn); + } + const divider = document.createElement('div'); divider.className = 'modal-divider'; divider.textContent = 'Playlists'; @@ -2519,11 +2815,19 @@ function openCardMenu(video) { } function slim(v) { - return { + const s = { id: v.id, title: v.title, channel: v.channel, channelId: v.channelId || '', channelUrl: v.channelUrl || '', duration: v.duration, thumbnail: v.thumbnail, }; + // Preserve the markers that make a custom (edited) video self-contained, so + // a slimmed copy sitting in a playlist still knows it's an offline-only cut. + if (v.custom) { + s.custom = true; + s.sourceId = v.sourceId || ''; + if (v.keep) s.keep = v.keep; + } + return s; } function newPlaylist(addVideo) { @@ -2942,6 +3246,9 @@ document.querySelectorAll('.chip').forEach((c) => { els.addPlaylistBtn.addEventListener('click', () => { if (current && current.meta) openCardMenu(current.meta); }); + if (els.editBtn) els.editBtn.addEventListener('click', () => { + if (current && current.meta) openVideoEditor(current.meta); + }); els.queueBtn.addEventListener('click', () => { if (current && current.meta) addToQueue(current.meta); }); @@ -3582,6 +3889,7 @@ async function boot() { playlists: loaded.playlists || [], history: loaded.history || [], queue: loaded.queue || [], + customVideos: loaded.customVideos || [], resumePositions: loaded.resumePositions || {}, rememberPos: loaded.rememberPos || {}, playCount: loaded.playCount || {}, diff --git a/frontend/index.html b/frontend/index.html index 023b209..6507d38 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -181,6 +181,7 @@
+
@@ -309,6 +310,7 @@ + diff --git a/frontend/styles.css b/frontend/styles.css index 572f23c..b6c7829 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -2207,3 +2207,55 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } padding-right: calc(14px + env(safe-area-inset-right, 0px)); } } + +/* ============================================================================ + * Video editor modal (Edit & download) + edited-video badges + * ========================================================================== */ +.video-editor { display: flex; flex-direction: column; gap: 12px; } +.video-editor .ve-intro { margin: 0; color: var(--text-2); font-size: 13px; line-height: 1.5; } +.video-editor .ve-add-row { + display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; +} +.video-editor .ve-add-row label { + display: flex; flex-direction: column; gap: 4px; + font-size: 12px; color: var(--text-2); flex: 1 1 90px; +} +.video-editor .ve-add-row input { + background: var(--bg-2); border: 1px solid var(--line); color: var(--text); + border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px; width: 100%; +} +.video-editor .ve-add-row input:focus { outline: none; border-color: var(--accent); } +.video-editor .ve-add { flex: 0 0 auto; } +.video-editor .ve-error { + color: var(--accent-bright); font-size: 12.5px; margin: -4px 0 0; +} +.video-editor .ve-cuts { display: flex; flex-direction: column; gap: 6px; } +.video-editor .ve-cut { + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-sm); padding: 7px 10px; font-size: 13.5px; +} +.video-editor .ve-cut-del { + background: transparent; border: none; color: var(--text-2); + cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 6px; +} +.video-editor .ve-cut-del:hover { color: #fff; background: var(--accent); } +.video-editor .ve-title-row { + display: flex; flex-direction: column; gap: 4px; + font-size: 12px; color: var(--text-2); +} +.video-editor .ve-title { + background: var(--bg-2); border: 1px solid var(--line); color: var(--text); + border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px; +} +.video-editor .ve-title:focus { outline: none; border-color: var(--accent); } +.video-editor .ve-summary { color: var(--text); font-size: 13.5px; } + +/* Badge marking an edited (custom) copy in the Saved list and on cards. */ +.edit-badge { + position: absolute; top: 4px; left: 4px; + background: var(--accent); color: #fff; font-size: 11px; + line-height: 1; padding: 3px 5px; border-radius: 6px; + box-shadow: 0 2px 6px -2px var(--accent-glow); +} +.saved-card.custom .thumb { position: relative; } diff --git a/frontend/video-edit.js b/frontend/video-edit.js new file mode 100644 index 0000000..a1d3f67 --- /dev/null +++ b/frontend/video-edit.js @@ -0,0 +1,161 @@ +/* ============================================================================ + * 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