diff --git a/server/media-cache.js b/server/media-cache.js index f92aafe..e945f12 100644 --- a/server/media-cache.js +++ b/server/media-cache.js @@ -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; diff --git a/server/media-cache.test.js b/server/media-cache.test.js index 7481436..e484be0 100644 --- a/server/media-cache.test.js +++ b/server/media-cache.test.js @@ -36,6 +36,11 @@ beforeAll(async () => { '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', fx('noaudio.mp4')); ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10', '-c:v', 'mpeg4', '-c:a', 'aac', '-shortest', fx('mpeg4.mp4')); + // Album-art upload: one picture with light grain for 40 s, at YouTube-like bitrate. + ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=1:duration=1', '-frames:v', '1', fx('cover.png')); + ff('-loop', '1', '-framerate', '25', '-i', fx('cover.png'), '-f', 'lavfi', '-i', 'sine=frequency=330:duration=40', + '-vf', 'noise=alls=4:allf=t', '-t', '40', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '600k', '-g', '125', + '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', fx('still40.mp4')); // HEVC as the compression lane writes it (hvc1) and as Safari rejects it (hev1). ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10', '-c:v', 'libx265', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-tag:v', 'hvc1', '-x265-params', 'log-level=error', @@ -54,7 +59,7 @@ beforeAll(async () => { ff('-i', fx('good.mp4'), '-c', 'copy', '-movflags', '+faststart', fx('good-fs.mp4')); const fs = readFileSync(fx('good-fs.mp4')); writeFileSync(fx('truncated-fs.mp4'), fs.subarray(0, Math.floor(fs.length * 0.6))); -}); +}, 30000); afterAll(() => rmSync(root, { recursive: true, force: true })); describe('validateMedia', () => { @@ -396,6 +401,44 @@ describe('media cache jobs', () => { expect(lines.some((l) => /sample predicts/.test(l))).toBe(false); }, 90000); + test('still-picture lane: one looped frame in H.264, far smaller, still verifies', async () => { + await clearDb(); + const lines = []; + const { cache } = makeCache({ fixture: 'still40.mp4', duration: 40, log: { info: (m) => lines.push(m), warn: (m) => lines.push(m) }, + transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 } }); + await cache.init(); + const first = await cache.ensureCached('stilAAAAAA1', { priority: HIGH }); + expect(await until(async () => (await dbmod.getMedia('stilAAAAAA1')).optimized === OPT_REV, 60000)).toBe(true); + const after = await dbmod.getMedia('stilAAAAAA1'); + expect(after.gen).toBeGreaterThan(first.gen); + expect(after.vcodec).toBe('h264'); + expect(after.size).toBeLessThan(first.size * 0.6); + expect(lines.some((l) => /\(still\)/.test(l))).toBe(true); + expect(await cache.verify('stilAAAAAA1')).toBe(true); + }, 90000); + + test('a moving copy already done by the HEVC lane is not re-encoded for rev 3', async () => { + await clearDb(); + const seed = makeCache({ fixture: 'fat64.mp4', duration: 64, + transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 } }); + await seed.cache.init(); + await seed.cache.ensureCached('rev2AAAAAA1', { priority: HIGH }); + await until(async () => (await dbmod.getMedia('rev2AAAAAA1')).optimized === OPT_REV, 60000); + // Pretend it was evaluated by rev 2 and kept: rev 3 must only run the still check. + await dbmod.upsertMedia('rev2AAAAAA1', { optimized: 2 }); + const before = await dbmod.getMedia('rev2AAAAAA1'); + // A fresh instance over the same dir + DB, as after a deploy. + const again = createMediaCache({ + dir: seed.dir, db: { getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia, + listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia, mediaStats: dbmod.mediaStats }, + log: silentLog, getInfo: async () => ({ duration: 64 }), download: async () => { throw new Error('no download expected'); }, + transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 }, freeBytes: () => 100 * 1024 ** 3, + }); + await again.init(); + expect(await until(async () => (await dbmod.getMedia('rev2AAAAAA1')).optimized === OPT_REV, 30000)).toBe(true); + expect((await dbmod.getMedia('rev2AAAAAA1')).gen).toBe(before.gen); + }, 120000); + test('copies evaluated by an older lane revision are re-queued at boot', async () => { await clearDb(); const seed = makeCache();