#9 - adding first stage of dynamic thumbnails on the main page
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user