feat: Add support for editing video

Add an Edit
This commit is contained in:
Claude Worker
2026-07-18 16:43:15 +00:00
parent 94dba16ff6
commit 8c0776f97d
7 changed files with 834 additions and 21 deletions

View File

@@ -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_<src>_<uid>). 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 doesnt 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 = `
<p class="ve-intro">Mark the parts to <strong>remove</strong>. Everything else is kept and saved as a new offline video.</p>
<div class="ve-add-row">
<label>From <input type="text" class="ve-from" placeholder="0:30" inputmode="numeric" /></label>
<label>To <input type="text" class="ve-to" placeholder="1:15" inputmode="numeric" /></label>
<button type="button" class="btn ve-add">Add cut</button>
</div>
<div class="ve-error" hidden></div>
<div class="ve-cuts"></div>
<label class="ve-title-row">Name <input type="text" class="ve-title" maxlength="120" /></label>
<div class="ve-summary"></div>`;
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 = `<span>✂ ${VideoEdit.fmtTime(cut.start)} ${VideoEdit.fmtTime(cut.end)}</span>`;
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: <strong>${VideoEdit.fmtTime(finalLen)}</strong> 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 isnt 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 = `
<div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" /></div>
<div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}</div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel saved-size">${fmtBytes(it.size)}</div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
</div>
<button class="card-del" title="Delete saved file">✕</button>`;
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,9 +2720,17 @@ 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);
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();
@@ -2458,8 +2744,18 @@ function openCardMenu(video) {
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 || {},

View File

@@ -181,6 +181,7 @@
<div class="np-actions">
<button id="queueBtn" class="np-btn" title="Add to the temporary queue"> Queue</button>
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
<button id="editBtn" class="np-btn" title="Cut parts out and save a custom edited copy">✂ Edit &amp; download</button>
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist"> Playlist</button>
</div>
</div>
@@ -309,6 +310,7 @@
<script src="fingerprint.js"></script>
<script src="opfs.js"></script>
<script src="video-edit.js"></script>
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="app.js"></script>

View File

@@ -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; }

161
frontend/video-edit.js Normal file
View File

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

View File

@@ -0,0 +1,89 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const VE = require('./video-edit');
test('parseTime: bare seconds, clock forms, fractions', () => {
assert.strictEqual(VE.parseTime('90'), 90);
assert.strictEqual(VE.parseTime('1:30'), 90);
assert.strictEqual(VE.parseTime('1:00:00'), 3600);
assert.strictEqual(VE.parseTime('0:05.5'), 5.5);
assert.strictEqual(VE.parseTime(42), 42);
});
test('parseTime: rejects garbage and negatives', () => {
assert.strictEqual(VE.parseTime(''), null);
assert.strictEqual(VE.parseTime('abc'), null);
assert.strictEqual(VE.parseTime('1:99'), null); // seconds field out of range
assert.strictEqual(VE.parseTime('-3'), null);
assert.strictEqual(VE.parseTime('1:2:3:4'), null);
});
test('normalizeCuts: clamps, drops zero-length, merges overlaps', () => {
const cuts = [
{ start: -5, end: 10 }, // clamps to 0-10
{ start: 8, end: 15 }, // overlaps → merge to 0-15
{ start: 40, end: 40 }, // zero-length → dropped
{ start: 200, end: 300 }, // clamps end to duration 120 → 120-120 dropped? start>dur
{ start: 50, end: 60 },
];
const out = VE.normalizeCuts(cuts, 120);
assert.deepStrictEqual(out, [
{ start: 0, end: 15 },
{ start: 50, end: 60 },
]);
});
test('normalizeCuts: reversed range tolerated', () => {
assert.deepStrictEqual(VE.normalizeCuts([{ start: 30, end: 10 }], 60), [{ start: 10, end: 30 }]);
});
test('invertCuts: complement over [0,duration]', () => {
const keep = VE.invertCuts([{ start: 10, end: 20 }], 60);
assert.deepStrictEqual(keep, [{ start: 0, end: 10 }, { start: 20, end: 60 }]);
});
test('invertCuts: cut at the very start and end', () => {
const keep = VE.invertCuts([{ start: 0, end: 5 }, { start: 55, end: 60 }], 60);
assert.deepStrictEqual(keep, [{ start: 5, end: 55 }]);
});
test('invertCuts: no cuts keeps whole video', () => {
assert.deepStrictEqual(VE.invertCuts([], 60), [{ start: 0, end: 60 }]);
});
test('invertCuts: cutting the entire video yields no keep segments', () => {
assert.deepStrictEqual(VE.invertCuts([{ start: 0, end: 60 }], 60), []);
});
test('invertCuts: needs a finite positive duration', () => {
assert.deepStrictEqual(VE.invertCuts([{ start: 1, end: 2 }], 0), []);
assert.deepStrictEqual(VE.invertCuts([{ start: 1, end: 2 }], Infinity), []);
});
test('keepDuration sums surviving spans', () => {
const keep = VE.invertCuts([{ start: 10, end: 20 }], 60);
assert.strictEqual(VE.keepDuration(keep), 50);
});
test('keepToParam / parseKeepParam round-trip', () => {
const keep = [{ start: 12.5, end: 40 }, { start: 95, end: 130.2 }];
const param = VE.keepToParam(keep);
assert.strictEqual(param, '12.5-40,95-130.2');
assert.deepStrictEqual(VE.parseKeepParam(param), keep);
});
test('parseKeepParam skips malformed / non-increasing tokens', () => {
assert.deepStrictEqual(VE.parseKeepParam('0-10,bad,20-15,30-40'), [
{ start: 0, end: 10 },
{ start: 30, end: 40 },
]);
assert.deepStrictEqual(VE.parseKeepParam(''), []);
assert.deepStrictEqual(VE.parseKeepParam(null), []);
});
test('hasEdits reflects whether a real cut exists', () => {
assert.strictEqual(VE.hasEdits([], 60), false);
assert.strictEqual(VE.hasEdits([{ start: 0, end: 0 }], 60), false);
assert.strictEqual(VE.hasEdits([{ start: 5, end: 10 }], 60), true);
});

View File

@@ -33,6 +33,7 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
const PORT = parseInt(process.env.PORT || '3000', 10);
const APP_VERSION = process.env.APP_VERSION || '1.0.0';
const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
// ----------------------------------------------------------------------------
// BUILD_TAG — must be DETERMINISTIC across restarts of identical code.
@@ -115,6 +116,68 @@ function runYtdlp(args) {
});
}
// Run ffmpeg the same way — async spawn so a multi-minute trim/concat never
// blocks Bun's event loop. Rejects on non-zero exit with ffmpeg's stderr tail.
function runFfmpeg(args) {
return new Promise((resolve, reject) => {
const child = spawn(FFMPEG, args, { stdio: ['ignore', 'ignore', 'pipe'] });
let err = '';
child.stderr.setEncoding('utf8');
// ffmpeg is extremely chatty on stderr; keep only the tail so an error
// message stays useful without buffering the whole progress log.
child.stderr.on('data', (d) => { err = (err + d).slice(-4000); });
child.on('error', (e) => reject(new Error('ffmpeg not found: ' + e.message)));
child.on('close', (code) => {
if (code !== 0) reject(new Error(err.trim() || 'ffmpeg exited with code ' + code));
else resolve();
});
});
}
// Parse the compact "s-e,s-e" keep-segment string (see frontend/video-edit.js)
// into an array of {start,end} second ranges. Skips malformed / non-increasing
// tokens; returns [] on empty or all-garbage input. Kept in lockstep with the
// frontend parseKeepParam so both ends agree on the wire format.
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: a, end: b });
}
return out;
}
// Build an ffmpeg filter_complex that trims `src` to the keep segments and
// concatenates them back into a single continuous stream. Re-encodes (the cut
// points rarely fall on keyframes, so stream-copy would glitch), producing one
// clean mp4. Returns the ffmpeg argv (input already appended by the caller).
function buildTrimArgs(keep) {
const parts = [];
keep.forEach((k, i) => {
parts.push(
`[0:v]trim=start=${k.start}:end=${k.end},setpts=PTS-STARTPTS[v${i}]`,
`[0:a]atrim=start=${k.start}:end=${k.end},asetpts=PTS-STARTPTS[a${i}]`,
);
});
const concatInputs = keep.map((_, i) => `[v${i}][a${i}]`).join('');
const filter = parts.join(';') + ';' +
`${concatInputs}concat=n=${keep.length}:v=1:a=1[outv][outa]`;
return [
'-filter_complex', filter,
'-map', '[outv]', '-map', '[outa]',
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
];
}
// Helpers to pick the right field from a yt-dlp JSON record
function pick(obj, ...keys) {
for (const k of keys) {
@@ -356,6 +419,58 @@ async function ytdlpDownloadResponse(videoId, fp, formatArgs) {
});
}
// "Edit & download": fetch the source with yt-dlp (muxed up to 720p, same as
// the mux path), then run ffmpeg to KEEP only the requested segments and
// concatenate them into one continuous mp4 — the user's custom cut. The result
// is streamed to the browser exactly like a normal save, so OPFS stores it
// under the caller-chosen custom id. Every temp file is swept afterwards.
async function ytdlpEditedDownloadResponse(videoId, fp, keep) {
const tmpBase = `ytp-edit-${videoId}-${Date.now()}`;
const srcTmp = `${tmpdir()}/${tmpBase}.src.mp4`;
const outTmp = `${tmpdir()}/${tmpBase}.out.mp4`;
let size, fd;
try {
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams.
await runYtdlp([
`https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
'--merge-output-format', 'mp4',
'-N', '4',
'-o', srcTmp,
]);
// 2) Trim + concat the keep segments into the final custom video.
await runFfmpeg([
'-y', '-hide_banner', '-loglevel', 'error',
'-i', srcTmp,
...buildTrimArgs(keep),
outTmp,
]);
size = statSync(outTmp).size;
fd = openSync(outTmp, 'r');
} finally {
for (const name of readdirSync(tmpdir())) {
if (name.startsWith(tmpBase)) {
try { unlinkSync(`${tmpdir()}/${name}`); } catch { /* already gone */ }
}
}
}
const stream = createReadStream('', { fd });
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
return new Response(Readable.toWeb(stream), {
status: 200,
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${videoId}-edited.mp4"`,
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
});
}
// GET /api/download/:videoId
// Downloads the video server-side via yt-dlp and streams the finished file
// to the browser so OPFS can store it. The browser never contacts YouTube
@@ -365,6 +480,20 @@ app.get('/api/download/:videoId', async (c) => {
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
const fp = c.req.query('fp');
// ?edit=1&keep=s-e,s-e — "Edit & download" path: download the source, then
// ffmpeg-trim it to the requested keep segments and stream the custom cut.
// Requires ffmpeg; there is no progressive fallback because the whole point
// is the server-side edit. Invalid/empty keep params are rejected up front.
if (c.req.query('edit') === '1') {
const keep = parseKeepParam(c.req.query('keep') || '');
if (!keep.length) return c.json({ ok: false, error: 'missing or invalid keep segments' }, 400);
try {
return await ytdlpEditedDownloadResponse(videoId, fp, keep);
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
}
// ?mux=1 — "Save before playing" path: bestvideo up to 720p PLUS bestaudio
// compiled into one mp4 with ffmpeg on the server. Falls back to the
// progressive single-file save below when ffmpeg is missing or the merge

View File

@@ -0,0 +1,72 @@
/**
* Smoke test for the "Edit & download" custom-video feature (Task #68).
*
* The editor lets a user cut parts out of a video and save the result as a
* custom offline video that can be added to playlists like any other. This
* spec runs against the static frontend (no backend), so it exercises the
* pieces that don't need the yt-dlp/ffmpeg server round-trip:
* • video-edit.js loads and exposes window.VideoEdit with correct maths.
* • The now-playing "Edit & download" button (#editBtn) is present.
* • The editor modal opens, accepts a cut, and reports the right final
* length via the shared keep-segment maths.
*/
const { test, expect } = require('@playwright/test');
test.describe('Video editor — Edit & download', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.app', { state: 'attached' });
});
test('exposes window.VideoEdit with correct cut/keep maths', async ({ page }) => {
const ok = await page.evaluate(() => typeof window.VideoEdit === 'object' && window.VideoEdit !== null);
expect(ok).toBe(true);
const keep = await page.evaluate(() =>
window.VideoEdit.invertCuts([{ start: 10, end: 20 }], 60));
expect(keep).toEqual([{ start: 0, end: 10 }, { start: 20, end: 60 }]);
const param = await page.evaluate(() =>
window.VideoEdit.keepToParam(window.VideoEdit.invertCuts([{ start: 10, end: 20 }], 60)));
expect(param).toBe('0-10,20-60');
const finalLen = await page.evaluate(() =>
window.VideoEdit.keepDuration(window.VideoEdit.invertCuts([{ start: 10, end: 20 }], 60)));
expect(finalLen).toBe(50);
});
test('renders the now-playing Edit & download button', async ({ page }) => {
const btn = page.locator('#editBtn');
await expect(btn).toHaveCount(1);
await expect(btn).toContainText('Edit');
});
test('editor modal opens for a source video and computes the final length', async ({ page }) => {
// Drive openVideoEditor directly with a fake source that has a known
// duration, so the test doesn't depend on network playback. OPFS may be
// absent in the test browser; if so the editor toasts and returns — assert
// whichever path this browser takes so the test is deterministic.
const supported = await page.evaluate(() =>
!!(window.OPFS && window.OPFS.isSupported && window.OPFS.isSupported()));
const opened = await page.evaluate(() => {
window.openVideoEditor({ id: 'testsource1', title: 'Sample', duration: 100, thumbnail: '' });
const modal = document.getElementById('modal');
return modal && !modal.classList.contains('hidden');
});
if (!supported) {
// Without OPFS the editor declines to open — that's the correct guard.
expect(opened).toBe(false);
return;
}
expect(opened).toBe(true);
// Add a cut 0:100:40 → final length should be 100 - 30 = 70s = "1:10".
await page.fill('.video-editor .ve-from', '0:10');
await page.fill('.video-editor .ve-to', '0:40');
await page.click('.video-editor .ve-add');
await expect(page.locator('.video-editor .ve-cut')).toHaveCount(1);
await expect(page.locator('.video-editor .ve-summary')).toContainText('1:10');
});
});