Changeset 7ecbefc in Klonkt


Ignore:
Timestamp:
06/30/2026 05:56:01 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
1c33804
Parents:
857a06f
Message:

fix(video-cover): composite partial animated-WebP frames (fixes torn/tiled MP4)

node-webpmux getFrameData(i) returns only frame i's own sub-region, NOT the composited canvas.
Real tool-made animated WebPs use partial frames of varying size, so concatenating them at a fixed
-s WxH desynced ffmpeg's rawvideo stream -> a torn/tiled video (and a garbled "GIF" on Mastodon).
Now composite each sub-region onto a persistent WxH canvas (honoring blend + dispose) and emit full
frames. Verified visually: a partial-frame webp now converts to a clean, correctly-framed MP4.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/VideoCoverService.js

    r857a06f r7ecbefc  
    2222function ensureLib() { if (!_lib) _lib = WebP.Image.initLib(); return _lib; }
    2323
     24// node-webpmux's getFrameData(i) returns ONLY frame i's own sub-region (x,y,width,height) — it does
     25// 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.
     28async function compositeFrames(img) {
     29  const W = img.width, H = img.height, frames = img.anim.frames;
     30  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);
     39      }
     40    }
     41    const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
     42    const fx = fr.x, fy = fr.y, fw = fr.width, fh = fr.height, blend = fr.blend;
     43    if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
     44      data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
     45    } else {
     46      for (let row = 0; row < fh; row++) {
     47        const cy = fy + row; if (cy < 0 || cy >= H) continue;
     48        for (let col = 0; col < fw; col++) {
     49          const cx = fx + col; if (cx < 0 || cx >= W) continue;
     50          const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
     51          if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
     52          else if (sa !== 0) { // alpha-over the existing canvas pixel
     53            const a = sa / 255, ia = 1 - a;
     54            canvas[d]     = (data[s]     * a + canvas[d]     * ia) | 0;
     55            canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
     56            canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
     57            canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
     58          }
     59        }
     60      }
     61    }
     62    out.push(Buffer.from(canvas)); // snapshot the full composited canvas
     63    prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
     64  }
     65  return Buffer.concat(out);
     66}
     67
    2468// True if the file is an animated WebP (a VP8X chunk with the animation flag set).
    2569export function isAnimatedWebp(filePath) {
     
    4488    await img.load(srcPath);
    4589    if (!img.hasAnim || !img.anim || !Array.isArray(img.anim.frames) || img.anim.frames.length < 2) return null;
    46     const W = img.width, H = img.height, n = img.anim.frames.length;
     90    const W = img.width, H = img.height;
    4791    const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
    48     // getFrameData(i) returns the FULL-canvas RGBA (W*H*4) for frame i (already composited).
    49     const bufs = [];
    50     for (let i = 0; i < n; i++) bufs.push(Buffer.from(await img.getFrameData(i)));
    5192    await fs.promises.mkdir(outDir, { recursive: true });
    5293    rawPath = path.join(outDir, baseName + '.rgba.tmp');
    53     await fs.promises.writeFile(rawPath, Buffer.concat(bufs));
     94    await fs.promises.writeFile(rawPath, await compositeFrames(img)); // full composited W×H frames
    5495    const videoPath = path.join(outDir, baseName + '.mp4');
    5596    const posterPath = path.join(outDir, baseName + '.jpg');
Note: See TracChangeset for help on using the changeset viewer.