From a1be7792a28b19b8a77fb1855edc21e556719440 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Wed, 13 May 2026 14:07:55 +0200 Subject: [PATCH] #9 - adding first stage of dynamic thumbnails on the main page --- config_example.json | 1 + debug/thumbnailer.mjs | 9 ++++-- public/s/css/f0ckm.css | 7 +++++ public/s/js/f0ckm.js | 1 + public/s/js/scroller.js | 26 +++++++++++++++- scripts/regen.mjs | 5 +++- src/inc/queue.mjs | 54 +++++++++++++++++++++++++--------- src/inc/routeinc/f0cklib.mjs | 21 ++++++++++++- src/index.mjs | 1 + src/rethumb_handler.mjs | 3 +- src/upload_handler.mjs | 9 +++--- views/index-partial.html | 2 +- views/snippets/items-grid.html | 2 +- 13 files changed, 114 insertions(+), 27 deletions(-) diff --git a/config_example.json b/config_example.json index 8007146..b349bd1 100644 --- a/config_example.json +++ b/config_example.json @@ -68,6 +68,7 @@ "bypass_duplicate_check": true, "shitpost_mode": false, "protect_files": false, + "enable_dynamic_thumbs": false, "allowed_comment_images": [ "i.imgur.com", "tenor.com", diff --git a/debug/thumbnailer.mjs b/debug/thumbnailer.mjs index 251c42e..1f5e5d5 100644 --- a/debug/thumbnailer.mjs +++ b/debug/thumbnailer.mjs @@ -15,6 +15,10 @@ const exec = cmd => new Promise((resolve, reject) => { const _args = process.argv.slice(2); const _itemid = +_args[0] || 0; +// Thumb size: 512px for high-quality dynamic thumbnails +const THUMB_SIZE = 512; +const THUMB_SPEC = `${THUMB_SIZE}x${THUMB_SIZE}`; + // Ensure temp and output directories exist if (!fs.existsSync('./tmp')) fs.mkdirSync('./tmp', { recursive: true }); if (!fs.existsSync('./public/t')) fs.mkdirSync('./public/t', { recursive: true }); @@ -36,7 +40,7 @@ for(let item of items) { if(mime.startsWith('video/') || mime == 'image/gif') { const seeks = ['20%', '40%', '60%', '80%']; for (const seek of seeks) { - await exec(`ffmpegthumbnailer -i./public/b/${filename} -s1024 -t ${seek} -o./tmp/${itemid}.png`); + await exec(`ffmpegthumbnailer -i./public/b/${filename} -s${THUMB_SIZE} -t ${seek} -o./tmp/${itemid}.png`); try { const { stdout } = await exec(`magick "./tmp/${itemid}.png" -colorspace Gray -format "%[fx:mean]" info:`); if (parseFloat(stdout.trim()) > 0.05) break; @@ -72,12 +76,11 @@ for(let item of items) { } } - await exec(`magick "./tmp/${itemid}.png" -resize "128x128^" -gravity center -crop 128x128+0+0 +repage ./public/t/${itemid}.webp`); + await exec(`magick "./tmp/${itemid}.png" -resize "${THUMB_SPEC}^" -gravity center -crop ${THUMB_SPEC}+0+0 +repage ./public/t/${itemid}.webp`); await fs.promises.unlink(`./tmp/${itemid}.png`).catch(err => {}); await fs.promises.unlink(`./tmp/${itemid}.jpg`).catch(err => {}); } catch(err) { console.error(`Failed to generate thumbnail for ${itemid}:`, err.message); - // await exec(`magick ./mugge.png ./public/t/${itemid}.webp`); } console.log(`current: ${itemid} (${count} / ${total})`); count++; diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 2f542af..d56f4a3 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -9290,6 +9290,13 @@ div.posts>a.thumb.has-notif p::after { overflow: hidden; } +/* ===== Dynamic Thumbnail Sizes ===== */ +/* Size tier 2: 2×2 grid cells */ +div.posts > a.thumb[data-size="2"] { + grid-column: span 2; + grid-row: span 2; +} + .thumb>.preview-video { position: absolute; top: 0; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 9379e3d..0673d90 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -6518,6 +6518,7 @@ class NotificationSystem { thumb.dataset.ext = data.mime.split('/')[1].replace('youtube', 'yt').toUpperCase(); thumb.dataset.mode = mode; thumb.dataset.bg = `/t/${data.id}.webp`; + thumb.dataset.size = '1'; // New items start with no contributions → tier 1 thumb.style.backgroundImage = `url('/t/${data.id}.webp')`; thumb.style.opacity = '0'; thumb.style.transform = 'scale(0.9)'; diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js index 95b4141..82c075b 100644 --- a/public/s/js/scroller.js +++ b/public/s/js/scroller.js @@ -2701,7 +2701,31 @@ const el = document.createElement('div'); el.className = 'scroll-tag-sugg-item'; el.dataset.idx = i; el.innerHTML = `${esc(r.tag)}${r.uses ?? ''}`; - el.addEventListener('mousedown', e => { e.preventDefault(); addTagInput.value = r.tag; closeSugg(); }); + + el.addEventListener('mouseup', (ev) => { + const sel = window.getSelection?.()?.toString().trim(); + if (!sel || sel === r.tag) return; + el.addEventListener('click', (e) => e.stopImmediatePropagation(), { once: true, capture: true }); + ev.stopPropagation(); + window._showSelTagPopover?.(sel, el, (confirmed) => { + window.getSelection?.()?.removeAllRanges(); + addTagInput.value = confirmed; + closeSugg(); + submitTag(); + }); + }); + + el.addEventListener('click', e => { + const sel = window.getSelection?.()?.toString().trim(); + if (sel && sel !== r.tag) { + e.preventDefault(); + e.stopPropagation(); + return; + } + addTagInput.value = r.tag; + closeSugg(); + submitTag(); + }); addTagSuggBox.appendChild(el); }); addTagSuggBox.classList.add('show'); diff --git a/scripts/regen.mjs b/scripts/regen.mjs index 8321aaf..5abc393 100644 --- a/scripts/regen.mjs +++ b/scripts/regen.mjs @@ -29,12 +29,15 @@ if (args.length === 0) { process.exit(0); } +const THUMB_SIZE = 512; +console.log(`[regen] Thumb size: ${THUMB_SIZE}px\n`); + const regen = async (item) => { const { id, dest, mime, src } = item; console.log(`[${id}] Regenerating: ${dest} (${mime})`); try { - await queue.genThumbnail(dest, mime, id, src || '', false); + await queue.genThumbnail(dest, mime, id, src || '', false, THUMB_SIZE); if (mime.startsWith('audio/') && queue._lastCoverExtracted) { await db`UPDATE items SET has_coverart = TRUE WHERE id = ${id}`; diff --git a/src/inc/queue.mjs b/src/inc/queue.mjs index 7b0ac8b..cfe5853 100644 --- a/src/inc/queue.mjs +++ b/src/inc/queue.mjs @@ -246,12 +246,22 @@ export default new class queue { `)[0].id; }; - async genThumbnail(filename, mime, itemid, link, pending = false) { + /** + * Returns the optimal thumbnail size in pixels for a given contribution score tier. + * Tier 1 = default 1x1 (512px), Tier 2 = 2x2 (512px — same source, larger display) + */ + getThumbSize(tier) { + return 512; // Always generate at 512px for crisp display at any size + } + + async genThumbnail(filename, mime, itemid, link, pending = false, size = 512) { const bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b; const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t; const cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca; const tmpFile = path.join(os.tmpdir(), itemid + '.png'); const tmpJpg = path.join(os.tmpdir(), itemid + '.jpg'); + const thumbSize = (size && size > 128) ? size : 128; + const thumbSpec = `${thumbSize}x${thumbSize}`; // Resolve real path if it's a symlink (important for reposts) let sourcePath = path.join(bDir, filename); @@ -266,22 +276,37 @@ export default new class queue { if (mime === 'video/youtube') { const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null; if (videoId) { - const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; - try { - const curlArgs = ['-s', '-L', thumbUrl, '-o', tmpFile]; - if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') { - curlArgs.push('--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`); + // Try quality tiers from highest to lowest. maxresdefault may not exist for all videos + // (YouTube returns a 120×90 placeholder), so we check file size before accepting. + const thumbQualities = ['maxresdefault', 'sddefault', 'hqdefault']; + const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') + ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] + : []; + let fetched = false; + for (const quality of thumbQualities) { + const thumbUrl = `https://img.youtube.com/vi/${videoId}/${quality}.jpg`; + try { + await this.spawn('curl', ['-s', '-L', thumbUrl, '-o', tmpFile, ...proxyArgs]); + const stat = await fs.promises.stat(tmpFile).catch(() => null); + // maxresdefault placeholder is ~1.3KB; real thumbnails are much larger + if (stat && stat.size > 5000) { + fetched = true; + break; + } + } catch (err) { + // try next quality } - await this.spawn('curl', curlArgs); - } catch (err) { - console.error(`[QUEUE] YouTube thumbnail extraction failed for ${itemid}:`, err); + } + if (!fetched) { + console.error(`[QUEUE] YouTube thumbnail extraction failed for ${itemid}: all quality levels failed or returned placeholder`); } } } else if (mime.startsWith('video/') || mime == 'image/gif') { + const ffThumbSize = Math.max(thumbSize, 512); const seeks = ['20%', '40%', '60%', '80%']; for (const seek of seeks) { - await this.spawn('ffmpegthumbnailer', ['-i', sourcePath, '-s', '1024', '-t', seek, '-o', tmpFile]); + await this.spawn('ffmpegthumbnailer', ['-i', sourcePath, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]); try { const { stdout } = await this.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']); if (parseFloat(stdout.trim()) > 0.05) break; @@ -338,7 +363,7 @@ export default new class queue { const audioFallback = path.join(cfg.paths.s, 'img', 'audio.webp'); await fs.promises.copyFile(audioFallback, tmpFile).catch(async () => { // If copy fails, fall back to generated placeholder - await this.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '40', '-annotate', '0', '♪', tmpFile]).catch(() => {}); + await this.spawn('magick', ['-size', thumbSpec, 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '40', '-annotate', '0', '♪', tmpFile]).catch(() => {}); }); } // Store extraction result for caller @@ -361,7 +386,7 @@ export default new class queue { } if (!usedCustom) { await this.spawn('magick', [ - '-size', '256x256', 'xc:#1a1a2e', + '-size', thumbSpec, 'xc:#1a1a2e', '-gravity', 'center', '-fill', '#e040fb', '-pointsize', '48', @@ -386,7 +411,7 @@ export default new class queue { await fs.promises.copyFile(pdfFallback, tmpFile).catch(async () => { // If the asset is missing, generate a red PDF-style placeholder matching the user's reference await this.spawn('magick', [ - '-size', '256x256', 'xc:#d32f2f', // Professional PDF Red + '-size', thumbSpec, 'xc:#d32f2f', // Professional PDF Red '-gravity', 'center', '-fill', 'white', '-pointsize', '60', @@ -397,12 +422,13 @@ export default new class queue { } } - await this.spawn('magick', [tmpFile, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, itemid + '.webp')]); + await this.spawn('magick', [tmpFile, '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', path.join(tDir, itemid + '.webp')]); await fs.promises.unlink(tmpFile).catch(_ => { }); await fs.promises.unlink(tmpJpg).catch(_ => { }); return true; }; + async genBlurredThumbnail(itemid, pending = false) { const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t; const src = path.join(tDir, `${itemid}.webp`); diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 22d8bd1..e5c87e6 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -224,7 +224,13 @@ export default { ${user_id ? db`EXISTS (SELECT 1 FROM notifications WHERE user_id = ${user_id} AND item_id = items.id AND is_read = false) as has_notification,` : db`false as has_notification,`} (case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' else 'NSFL' end) as tag, min(ta.tag_id) as tag_id, - max(uo.display_name) as display_name + max(uo.display_name) as display_name, + ${cfg.websrv.enable_dynamic_thumbs ? db` + ( + (SELECT count(*) FROM favorites WHERE item_id = items.id) + + (SELECT count(*) FROM comments WHERE item_id = items.id AND is_deleted = false) + ) as contribution + ` : db`0 as contribution`} from items left join "user" author_u on author_u.user = items.username left join user_options uo on uo.user_id = author_u.id @@ -254,6 +260,19 @@ export default { limit ${eps} `; + // Compute thumb_size tier from contribution score (only meaningful when dynamic thumbs enabled) + if (cfg.websrv.enable_dynamic_thumbs) { + for (const row of rows) { + const c = Number(row.contribution) || 0; + row.thumb_size = c >= 5 ? 2 : 1; + } + } else { + for (const row of rows) { + row.thumb_size = 1; + } + } + + const cheat = []; // Increase range for better context const range = 3; diff --git a/src/index.mjs b/src/index.mjs index 70cbadc..5312e26 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1065,6 +1065,7 @@ process.on('uncaughtException', err => { show_koepfe: !!cfg.websrv.show_koepfe, allow_language_change: cfg.websrv.allow_language_change !== false, enable_xd_score: !!cfg.websrv.enable_xd_score, + enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs, enable_swf: !!cfg.websrv.enable_swf, enable_danmaku: cfg.websrv.enable_danmaku !== false, enable_global_chat: !!cfg.websrv.enable_global_chat, diff --git a/src/rethumb_handler.mjs b/src/rethumb_handler.mjs index 6ee191f..1f72970 100644 --- a/src/rethumb_handler.mjs +++ b/src/rethumb_handler.mjs @@ -125,9 +125,10 @@ export const handleRethumbUpload = async (req, res, itemId) => { // Generate the thumbnail const tDir = item.active ? cfg.paths.t : path.join(cfg.paths.pending, 't'); const finalPath = path.join(tDir, `${item.id}.webp`); + const thumbSpec = '512x512'; try { - await execFile('magick', [tmpPath, '-coalesce', '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', finalPath]); + await execFile('magick', [tmpPath, '-coalesce', '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', finalPath]); // Check if item contains NSFW or NSFL tag const tags = await db` diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index f68b2af..d262659 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -335,6 +335,7 @@ export const handleUpload = async (req, res, self) => { let thumbProcessed = false; // Custom Thumbnail for Flash + const dynThumbSize = 512; if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') { if (parts.thumbnail && parts.thumbnail.data && parts.thumbnail.data.length > 0) { try { @@ -344,7 +345,7 @@ export const handleUpload = async (req, res, self) => { const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t; const thumbDest = path.join(tDir, `${itemid}.webp`); - await queue.spawn('magick', [thumbTmp, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', thumbDest]); + await queue.spawn('magick', [thumbTmp, '-resize', `${dynThumbSize}x${dynThumbSize}^`, '-gravity', 'center', '-crop', `${dynThumbSize}x${dynThumbSize}+0+0`, '+repage', thumbDest]); await fs.unlink(thumbTmp).catch(() => {}); thumbProcessed = true; console.log(`[UPLOAD] Custom thumbnail processed for Flash item ${itemid}`); @@ -356,7 +357,7 @@ export const handleUpload = async (req, res, self) => { try { if (!thumbProcessed) { - await queue.genThumbnail(filename, actualMime, itemid, '', isPending); + await queue.genThumbnail(filename, actualMime, itemid, '', isPending, dynThumbSize); } if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) { @@ -369,7 +370,7 @@ export const handleUpload = async (req, res, self) => { const tPath = !isPending ? path.join(cfg.paths.t, itemid + '.webp') : path.join(cfg.paths.pending, 't', itemid + '.webp'); - await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', tPath]).catch(() => {}); + await queue.spawn('magick', ['-size', `${dynThumbSize}x${dynThumbSize}`, 'xc:#1a1a1a', tPath]).catch(() => {}); } } @@ -510,7 +511,7 @@ export const handleUpload = async (req, res, self) => { const thumbExists = await fs.access(thumbPath).then(() => true).catch(() => false); if (!thumbExists) { - await queue.genThumbnail(filename, actualMime, itemid, '', isPending); + await queue.genThumbnail(filename, actualMime, itemid, '', isPending, 512); } if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) { diff --git a/views/index-partial.html b/views/index-partial.html index e0391dd..f398bea 100644 --- a/views/index-partial.html +++ b/views/index-partial.html @@ -3,7 +3,7 @@ @include(snippets/page-title)
@each(items as item) - +