Changeset f2943c1 in Klonkt
- Timestamp:
- 06/30/2026 07:50:05 PM (2 months ago)
- Branches:
- main
- Children:
- 65a0697
- Parents:
- 01e3678
- Location:
- src
- Files:
-
- 2 edited
-
routes/admin-media.js (modified) (3 diffs)
-
services/VideoCoverService.js (modified) (9 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/admin-media.js
r01e3678 rf2943c1 48 48 function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } } 49 49 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. 52 function imageEntries(siteId) { 53 const used = usageMap(siteId); 54 54 let all = []; 55 55 try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ } 56 56 const present = new Set(all); 57 const items =all57 return all 58 58 .filter(f => IMG_EXT.test(f) && !isSibling(f)) 59 59 .map(f => { … … 62 62 const hasVideo = present.has(mp4); 63 63 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 68 router.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 })) 73 80 .sort((a, b) => b._mtime - a._mtime); // newest first 74 81 renderPage(req, res, 'pages/admin-media', { … … 101 108 const site = res.locals.site; 102 109 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);107 110 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`]) { 114 114 try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ } 115 115 } -
src/services/VideoCoverService.js
r01e3678 rf2943c1 5 5 * ffmpeg-static can't DECODE an animated WebP, so we decode it with node-webpmux (pure JS/WASM, 6 6 * 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 a8 * JPG poster frame. A real uploaded video goes straight through ffmpeg. Both are best-effort: on9 * any failurewe 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. 10 10 */ 11 11 import { execFile } from 'child_process'; … … 17 17 18 18 const execFileP = promisify(execFile); 19 const MAX_SECONDS = 60; // cap a cover loop at one minute 19 const MAX_SECONDS = 60; // cap a cover loop at one minute 20 const MAX_FRAMES = 600; // skip a pathological animated WebP (keep the still image) 21 const MAX_WORK = 250_000_000; // W*H*frames cap — bounds compositor memory churn + CPU/time 20 22 21 23 let _lib = null; … … 24 26 // node-webpmux's getFrameData(i) returns ONLY frame i's own sub-region (x,y,width,height) — it does 25 27 // 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. 31 async function compositeFramesToFile(img, rawPath) { 29 32 const W = img.width, H = img.height, frames = img.anim.frames; 30 33 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 } 39 44 } 40 }41 const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region42 // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame43 // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel44 // 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 pixel56 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 } 61 66 } 62 67 } 63 68 } 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 }; 64 71 } 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); } 69 73 } 70 74 … … 82 86 } 83 87 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). 85 89 export async function animatedWebpToVideo(srcPath, outDir, baseName) { 86 90 let rawPath = null; … … 91 95 await img.load(srcPath); 92 96 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 } 94 103 const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100)))); 95 104 await fs.promises.mkdir(outDir, { recursive: true }); 96 105 rawPath = path.join(outDir, baseName + '.rgba.tmp'); 97 await fs.promises.writeFile(rawPath, await compositeFrames(img)); // full composited W×H frames106 await compositeFramesToFile(img, rawPath); // streams full composited W×H frames to disk 98 107 const videoPath = path.join(outDir, baseName + '.mp4'); 99 const posterPath = path.join(outDir, baseName + '.jpg');100 108 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error', 101 109 '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', rawPath, … … 103 111 '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath], 104 112 { 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 }; 109 114 } catch (e) { 110 115 console.warn('[videocover] animated webp → mp4 failed:', e.message); … … 115 120 } 116 121 117 // An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s) + JPG poster. ffmpeg decodes118 // 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. 119 124 export async function videoToLoop(srcPath, outDir, baseName) { 120 125 try { … … 122 127 await fs.promises.mkdir(outDir, { recursive: true }); 123 128 const videoPath = path.join(outDir, baseName + '.mp4'); 124 const posterPath = path.join(outDir, baseName + '.jpg');125 129 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error', 126 130 '-i', srcPath, '-t', String(MAX_SECONDS), … … 128 132 '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath], 129 133 { 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 }; 133 135 } catch (e) { 134 136 console.warn('[videocover] video → loop failed:', e.message);
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)