Changeset f2943c1 in Klonkt


Ignore:
Timestamp:
06/30/2026 07:50:05 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
65a0697
Parents:
01e3678
Message:

refactor(video-cover, media): stream frames + size cap, drop unused poster, de-dup media listing

  • VideoCoverService: composite frames straight to disk (one canvas in memory, not all N) + guard an oversized cover (MAX_FRAMES / W*H*frames) so it keeps the still image instead of spiking memory/CPU; drop the never-used JPG poster (the iOS swap uses the WebP as the video poster).
  • admin-media: extract imageEntries() shared by the list view and the cleanup route (was duplicated).

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/admin-media.js

    r01e3678 rf2943c1  
    4848function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
    4949
    50 router.get('/', requireGod, (req, res) => {
    51   const site = res.locals.site;
    52   if (!site) return res.status(404).send('Site required');
    53   const used = usageMap(site.id);
     50// All non-sibling images, each with its loop-MP4 sibling + how many posts use it. Shared by the
     51// list view and the cleanup route so the readdir/filter/usage logic lives in one place.
     52function imageEntries(siteId) {
     53  const used = usageMap(siteId);
    5454  let all = [];
    5555  try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
    5656  const present = new Set(all);
    57   const items = all
     57  return all
    5858    .filter(f => IMG_EXT.test(f) && !isSibling(f))
    5959    .map(f => {
     
    6262      const hasVideo = present.has(mp4);
    6363      const ids = new Set([...(used.get(f) || []), ...(hasVideo ? (used.get(mp4) || []) : [])]);
    64       return {
    65         file: f,
    66         url: `/media/post-images/${f}`,
    67         kb: Math.round((statSize(f) + (hasVideo ? statSize(mp4) : 0)) / 1024),
    68         hasVideo,
    69         usedCount: ids.size,
    70         _mtime: statMtime(f),
    71       };
    72     })
     64      return { file: f, stem, mp4, hasVideo, usedCount: ids.size };
     65    });
     66}
     67
     68router.get('/', requireGod, (req, res) => {
     69  const site = res.locals.site;
     70  if (!site) return res.status(404).send('Site required');
     71  const items = imageEntries(site.id)
     72    .map(e => ({
     73      file: e.file,
     74      url: `/media/post-images/${e.file}`,
     75      kb: Math.round((statSize(e.file) + (e.hasVideo ? statSize(e.mp4) : 0)) / 1024),
     76      hasVideo: e.hasVideo,
     77      usedCount: e.usedCount,
     78      _mtime: statMtime(e.file),
     79    }))
    7380    .sort((a, b) => b._mtime - a._mtime); // newest first
    7481  renderPage(req, res, 'pages/admin-media', {
     
    101108  const site = res.locals.site;
    102109  if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
    103   const used = usageMap(site.id);
    104   let all = [];
    105   try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* */ }
    106   const present = new Set(all);
    107110  let removed = 0;
    108   for (const f of all.filter(x => IMG_EXT.test(x) && !isSibling(x))) {
    109     const stem = f.replace(/\.[^.]+$/, '');
    110     const mp4 = `${stem}-v.mp4`;
    111     const ids = new Set([...(used.get(f) || []), ...(present.has(mp4) ? (used.get(mp4) || []) : [])]);
    112     if (ids.size) continue; // still in use
    113     for (const name of [f, mp4, `${stem}-v.jpg`]) {
     111  for (const e of imageEntries(site.id)) {
     112    if (e.usedCount) continue; // still in use
     113    for (const name of [e.file, e.mp4, `${e.stem}-v.jpg`]) {
    114114      try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
    115115    }
  • src/services/VideoCoverService.js

    r01e3678 rf2943c1  
    55 * ffmpeg-static can't DECODE an animated WebP, so we decode it with node-webpmux (pure JS/WASM,
    66 * NO native deps → installs on every platform, never breaks `npm ci`) into RGBA frames, then
    7  * encode with the bundled ffmpeg-static into an H.264 MP4 (yuv420p + faststart + no audio) plus a
    8  * JPG poster frame. A real uploaded video goes straight through ffmpeg. Both are best-effort: on
    9  * any failure we return null and the caller keeps the still image.
     7 * encode with the bundled ffmpeg-static into an H.264 MP4 (yuv420p + faststart + no audio). A real
     8 * uploaded video goes straight through ffmpeg. Both are best-effort: on any failure (or an oversized
     9 * input) we return null and the caller keeps the still image.
    1010 */
    1111import { execFile } from 'child_process';
     
    1717
    1818const execFileP = promisify(execFile);
    19 const MAX_SECONDS = 60; // cap a cover loop at one minute
     19const MAX_SECONDS = 60;            // cap a cover loop at one minute
     20const MAX_FRAMES = 600;            // skip a pathological animated WebP (keep the still image)
     21const MAX_WORK = 250_000_000;      // W*H*frames cap — bounds compositor memory churn + CPU/time
    2022
    2123let _lib = null;
     
    2426// node-webpmux's getFrameData(i) returns ONLY frame i's own sub-region (x,y,width,height) — it does
    2527// NOT composite onto the canvas. Real (tool-made) animated WebPs use partial frames of varying size,
    26 // so we composite each onto a persistent W×H canvas (honoring blend + dispose) and emit consistent
    27 // full frames. Feeding ffmpeg the raw varying-size sub-regions desyncs the stream → a torn/tiled video.
    28 async function compositeFrames(img) {
     28// so we composite each onto a persistent W×H canvas (honoring blend + dispose) and STREAM consistent
     29// full frames straight to `rawPath` — one canvas in memory, not all N frames. Feeding ffmpeg the raw
     30// varying-size sub-regions desyncs the stream → a torn/tiled video.
     31async function compositeFramesToFile(img, rawPath) {
    2932  const W = img.width, H = img.height, frames = img.anim.frames;
    3033  const canvas = Buffer.alloc(W * H * 4); // transparent black
    31   const out = [];
    32   let prev = null; // previous frame's rect + dispose
    33   for (let i = 0; i < frames.length; i++) {
    34     const fr = frames[i];
    35     if (prev && prev.dispose) { // dispose-to-background: clear the previous frame's rect first
    36       for (let row = 0; row < prev.h; row++) {
    37         const y = prev.y + row; if (y < 0 || y >= H) continue;
    38         canvas.fill(0, (y * W + prev.x) * 4, (y * W + prev.x + prev.w) * 4);
     34  const fd = fs.openSync(rawPath, 'w');
     35  try {
     36    let prev = null; // previous frame's rect + dispose
     37    for (let i = 0; i < frames.length; i++) {
     38      const fr = frames[i];
     39      if (prev && prev.dispose) { // dispose-to-background: clear the previous frame's rect first
     40        for (let row = 0; row < prev.h; row++) {
     41          const y = prev.y + row; if (y < 0 || y >= H) continue;
     42          canvas.fill(0, (y * W + prev.x) * 4, (y * W + prev.x + prev.w) * 4);
     43        }
    3944      }
    40     }
    41     const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
    42     // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame
    43     // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel
    44     // position — else partial frames land at half-offset and ghost over the base. width/height are fine.
    45     const fx = fr.x * 2, fy = fr.y * 2, fw = fr.width, fh = fr.height, blend = fr.blend;
    46     if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
    47       data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
    48     } else {
    49       for (let row = 0; row < fh; row++) {
    50         const cy = fy + row; if (cy < 0 || cy >= H) continue;
    51         for (let col = 0; col < fw; col++) {
    52           const cx = fx + col; if (cx < 0 || cx >= W) continue;
    53           const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
    54           if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
    55           else if (sa !== 0) { // alpha-over the existing canvas pixel
    56             const a = sa / 255, ia = 1 - a;
    57             canvas[d]     = (data[s]     * a + canvas[d]    * ia) | 0;
    58             canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
    59             canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
    60             canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
     45      const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
     46      // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame
     47      // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel
     48      // position — else partial frames land at half-offset and ghost over the base. width/height are fine.
     49      const fx = fr.x * 2, fy = fr.y * 2, fw = fr.width, fh = fr.height, blend = fr.blend;
     50      if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
     51        data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
     52      } else {
     53        for (let row = 0; row < fh; row++) {
     54          const cy = fy + row; if (cy < 0 || cy >= H) continue;
     55          for (let col = 0; col < fw; col++) {
     56            const cx = fx + col; if (cx < 0 || cx >= W) continue;
     57            const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
     58            if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
     59            else if (sa !== 0) { // alpha-over the existing canvas pixel
     60              const a = sa / 255, ia = 1 - a;
     61              canvas[d]     = (data[s]     * a + canvas[d]     * ia) | 0;
     62              canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
     63              canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
     64              canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
     65            }
    6166          }
    6267        }
    6368      }
     69      fs.writeSync(fd, canvas); // stream the full composited canvas (bounds memory to one frame)
     70      prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
    6471    }
    65     out.push(Buffer.from(canvas)); // snapshot the full composited canvas
    66     prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
    67   }
    68   return Buffer.concat(out);
     72  } finally { fs.closeSync(fd); }
    6973}
    7074
     
    8286}
    8387
    84 // Animated WebP → muted loop MP4 + JPG poster. Returns { videoPath, posterPath } or null.
     88// Animated WebP → muted loop MP4. Returns { videoPath } or null (caller keeps the still image).
    8589export async function animatedWebpToVideo(srcPath, outDir, baseName) {
    8690  let rawPath = null;
     
    9195    await img.load(srcPath);
    9296    if (!img.hasAnim || !img.anim || !Array.isArray(img.anim.frames) || img.anim.frames.length < 2) return null;
    93     const W = img.width, H = img.height;
     97    const W = img.width, H = img.height, n = img.anim.frames.length;
     98    // Guard a pathologically large cover (memory/CPU): keep the still image instead of converting.
     99    if (!W || !H || n > MAX_FRAMES || W * H * n > MAX_WORK) {
     100      console.warn(`[videocover] cover too large to convert (${W}x${H}, ${n} frames) — keeping the still image`);
     101      return null;
     102    }
    94103    const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
    95104    await fs.promises.mkdir(outDir, { recursive: true });
    96105    rawPath = path.join(outDir, baseName + '.rgba.tmp');
    97     await fs.promises.writeFile(rawPath, await compositeFrames(img)); // full composited W×H frames
     106    await compositeFramesToFile(img, rawPath); // streams full composited W×H frames to disk
    98107    const videoPath = path.join(outDir, baseName + '.mp4');
    99     const posterPath = path.join(outDir, baseName + '.jpg');
    100108    await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
    101109      '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', rawPath,
     
    103111      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
    104112      { timeout: 60000 });
    105     await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
    106       '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-i', rawPath,
    107       '-frames:v', '1', '-y', posterPath], { timeout: 30000 });
    108     return { videoPath, posterPath };
     113    return { videoPath };
    109114  } catch (e) {
    110115    console.warn('[videocover] animated webp → mp4 failed:', e.message);
     
    115120}
    116121
    117 // An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s) + JPG poster. ffmpeg decodes
    118 // every video format, so no node-webpmux here. Returns { videoPath, posterPath } or null.
     122// An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s). ffmpeg decodes every video format,
     123// so no node-webpmux here. Returns { videoPath } or null.
    119124export async function videoToLoop(srcPath, outDir, baseName) {
    120125  try {
     
    122127    await fs.promises.mkdir(outDir, { recursive: true });
    123128    const videoPath = path.join(outDir, baseName + '.mp4');
    124     const posterPath = path.join(outDir, baseName + '.jpg');
    125129    await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
    126130      '-i', srcPath, '-t', String(MAX_SECONDS),
     
    128132      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
    129133      { timeout: 120000 });
    130     await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
    131       '-i', videoPath, '-frames:v', '1', '-y', posterPath], { timeout: 30000 });
    132     return { videoPath, posterPath };
     134    return { videoPath };
    133135  } catch (e) {
    134136    console.warn('[videocover] video → loop failed:', e.message);
Note: See TracChangeset for help on using the changeset viewer.