feat: Add support for editing video
Add an Edit
This commit is contained in:
129
server/server.js
129
server/server.js
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user