Media cache: still-picture lane re-encodes album-art videos as one looped frame in H.264

This commit is contained in:
Jonathan Sykes
2026-10-16 18:18:42 +08:00
parent a2ab53ea8a
commit fcbc9344ba
2 changed files with 122 additions and 24 deletions

View File

@@ -31,8 +31,9 @@ export const LOW = 1; // auto-cache after a play
// Compression-lane revision. `optimized` stores the revision a copy was last
// evaluated with; bumping this re-queues every cached copy once (rev 1 was
// x264, which never beat YouTube's own encode; rev 2 is HEVC).
export const OPT_REV = 2;
// x264, which never beat YouTube's own encode; rev 2 is HEVC; rev 3 adds the
// still-picture lane, so rev-2 copies are re-checked for it once).
export const OPT_REV = 3;
const ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
export const isMediaId = (id) => typeof id === 'string' && ID_RE.test(id);
@@ -45,6 +46,13 @@ const EST_BYTES_PER_SEC = 250 * 1024; // ~2 Mbps: pre-compression 720p + 128k
const MIN_SAVING = 0.15; // keep a re-encode only if ≥15% smaller
const SAMPLE_SECONDS = 10; // per sample window (two windows)
const SAMPLE_MIN_DURATION = 60; // shorter videos are cheap enough to encode whole
// Still-picture lane: album-art uploads ("Provided to YouTube", fan uploads)
// show one image for the whole song. Measured: largest keyframe-to-keyframe
// scene score 0.003 on a Topic track vs 0.99 on a lyric video, so 0.01 is a
// safe cut. One frame looped at 1 fps costs ~16 kbps instead of ~335-1750.
const STILL_MAX_SCENE = 0.01;
const STILL_MIN_KEYFRAMES = 3;
const STILL_MIN_DURATION = 30;
// x265 tuning from the codec benchmark: stronger adaptive quantisation and
// psy settings, no SAO blur, lighter deblock, longer lookahead.
const X265_TUNING = 'aq-mode=3:aq-strength=0.8:psy-rd=1.5:psy-rdoq=1.0:no-sao=1:deblock=-1,-1:'
@@ -420,6 +428,10 @@ export function createMediaCache({
};
// Very long videos would tie the lane up for hours on 2 niced threads.
if (transcode.maxSeconds && row.duration > transcode.maxSeconds) { await markDone(); return; }
const still = row.duration >= STILL_MIN_DURATION && await isStill(src).catch(() => false);
if (still) return optimizeStill(id, row, src, markDone);
// Rev-2 copies already went through the HEVC lane; only the still check was new.
if ((row.optimized || 0) >= 2) { await markDone(); return; }
const hevc = transcode.codec !== 'h264';
const prefix = `${id}-${now()}-${hevc ? 'x265' : 'x264'}`;
const out = join(tmpDir, `${prefix}.mp4`);
@@ -455,27 +467,7 @@ export function createMediaCache({
if (r.code !== 0) throw new Error(`${hevc ? 'x265' : 'x264'} encode failed: ` + tail(r.stderr));
const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: [hevc ? 'hevc' : 'h264'] });
const cur = await db.getMedia(id);
if (!cur || cur.status !== 'ready' || cur.gen !== row.gen) return; // replaced or evicted meanwhile
const oldSize = sizeOf(src);
if (probe.size > oldSize * (1 - MIN_SAVING)) {
await db.upsertMedia(id, { optimized: OPT_REV });
log.info?.(`[media] kept original ${id}: ${probe.vcodec} ${fmtMB(probe.size)} vs ${fmtMB(oldSize)}`);
return;
}
const sha256 = await hashFile(out);
const gen = row.gen + 1;
renameSync(out, fileFor(id, gen));
const oldAudio = fileFor(id, row.gen, 'm4a');
const newAudio = fileFor(id, gen, 'm4a');
try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); }
await db.upsertMedia(id, {
gen, size: probe.size + sizeOf(newAudio), height: probe.height, vcodec: probe.vcodec,
duration: probe.duration, optimized: OPT_REV, updated_at: now(), sha256,
});
notifyReady(id, gen, sha256, probe, cur.meta);
removeFiles(id, gen, OLD_GEN_GRACE_MS);
log.info?.(`[media] optimized ${id}: ${fmtMB(oldSize)} → ${fmtMB(probe.size)} (${probe.vcodec})`);
await commitOptimized(id, row, src, out, probe, probe.vcodec);
} catch (err) {
await markDone().catch(() => {}); // don't retry a source the encoder can't improve
throw err;
@@ -487,6 +479,69 @@ export function createMediaCache({
// Encode two short windows (at 25% and 60% of the video) and compare them
// with the source's own video bytes over exactly the same timestamps.
// Returns encoded/source (below 1 = smaller).
async function commitOptimized(id, row, src, out, probe, label) {
const cur = await db.getMedia(id);
if (!cur || cur.status !== 'ready' || cur.gen !== row.gen) return; // replaced or evicted meanwhile
const oldSize = sizeOf(src);
if (probe.size > oldSize * (1 - MIN_SAVING)) {
await db.upsertMedia(id, { optimized: OPT_REV });
log.info?.(`[media] kept original ${id}: ${label} ${fmtMB(probe.size)} vs ${fmtMB(oldSize)}`);
return;
}
const sha256 = await hashFile(out);
const gen = row.gen + 1;
renameSync(out, fileFor(id, gen));
const oldAudio = fileFor(id, row.gen, 'm4a');
const newAudio = fileFor(id, gen, 'm4a');
try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); }
await db.upsertMedia(id, {
gen, size: probe.size + sizeOf(newAudio), height: probe.height, vcodec: probe.vcodec,
duration: probe.duration, optimized: OPT_REV, updated_at: now(), sha256,
});
notifyReady(id, gen, sha256, probe, cur.meta);
removeFiles(id, gen, OLD_GEN_GRACE_MS);
log.info?.(`[media] optimized ${id} (${label}): ${fmtMB(oldSize)} → ${fmtMB(probe.size)} (${probe.vcodec})`);
}
// Keyframes only: ~1 s for a 5-minute video. Every keyframe-to-keyframe
// scene score must stay under STILL_MAX_SCENE.
async function isStill(src) {
const r = await run(ffmpeg, ['-hide_banner', '-nostdin', '-skip_frame', 'nokey', '-i', src, '-an',
'-vf', "scale=160:-2,select='gte(scene\\,0)',metadata=print:key=lavfi.scene_score", '-f', 'null', '-']);
if (r.code !== 0) return false;
const scores = [...String(r.stderr).matchAll(/scene_score=([\d.]+)/g)].map((m) => Number(m[1]));
return scores.length + 1 >= STILL_MIN_KEYFRAMES && scores.every((x) => x <= STILL_MAX_SCENE);
}
// One frame from the middle, looped at 1 fps, audio copied. H.264 so every
// client plays it from the cache (HEVC is only served to clients that ask).
async function optimizeStill(id, row, src, markDone) {
const prefix = `${id}-${now()}-still`;
const frame = join(tmpDir, `${prefix}.png`);
const out = join(tmpDir, `${prefix}.mp4`);
const threads = String(transcode.threads);
const niced = (args) => (nice ? run(nice, ['-n', '19', ffmpeg, ...args]) : run(ffmpeg, args));
try {
const at = String(Math.min(30, Math.floor(row.duration / 2)));
const f = await niced(['-v', 'error', '-nostdin', '-y', '-ss', at, '-i', src, '-frames:v', '1',
'-vf', 'scale=-2:min(720\\,ih)', frame]);
if (f.code !== 0) throw new Error('still frame grab failed: ' + tail(f.stderr));
const r = await niced(['-v', 'error', '-nostdin', '-y', '-loop', '1', '-framerate', '1', '-i', frame, '-i', src,
'-map', '0:v', '-map', '1:a:0', '-t', String(row.duration), '-shortest',
'-c:v', 'libx264', '-preset', 'medium', '-crf', String(transcode.crf), '-tune', 'stillimage',
'-profile:v', 'high', '-level:v', '4.0', '-pix_fmt', 'yuv420p', '-g', '600', '-threads', threads,
'-c:a', 'copy', '-movflags', '+faststart', '-f', 'mp4', out]);
if (r.code !== 0) throw new Error('still encode failed: ' + tail(r.stderr));
const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: ['h264'] });
await commitOptimized(id, row, src, out, probe, 'still');
} catch (err) {
await markDone().catch(() => {});
throw err;
} finally {
sweepTmp(prefix);
}
}
async function sampleRatio(src, duration, videoArgs, prefix) {
let srcBytes = 0;
let encBytes = 0;