feat: support generating animated thumbnails

GIF files only for now

existing installations will have to use "yarn thumbs" script to
re-generate thumbnails

doing "yarn thumbs 4 1" should be enough to only re-generate thumbnails
of GIF files
This commit is contained in:
Bobby 2023-09-06 18:45:50 +07:00
parent 25ca7a6bb7
commit b6b645df93
No known key found for this signature in database
GPG Key ID: B2F45B6A3C9A8FCA
4 changed files with 45 additions and 15 deletions

View File

@ -602,6 +602,9 @@ module.exports = {
generateThumbs: {
image: true,
video: true,
// Generate animated thumbnails wherever applicable.
// For now will only work with GIF images, also depends on "image" option being enabled.
animated: true,
// Placeholder defaults to 'public/images/unavailable.png'.
placeholder: null,
size: 200,

View File

@ -1809,7 +1809,11 @@ self.list = async (req, res) => {
for (const file of result.files) {
file.extname = utils.extname(file.name)
if (utils.mayGenerateThumb(file.extname)) {
file.thumb = `thumbs/${file.name.slice(0, -file.extname.length)}.png`
let thumbext = '.png'
if (file.extname === '.gif' && config.uploads.generateThumbs.animated) {
thumbext = '.gif'
}
file.thumb = `thumbs/${file.name.slice(0, -file.extname.length)}${thumbext}`
}
}

View File

@ -399,15 +399,30 @@ self.assertJSON = async (req, res) => {
self.generateThumbs = async (name, extname, force) => {
extname = extname.toLowerCase()
const thumbname = path.join(paths.thumbs, name.slice(0, -extname.length) + '.png')
const thumbname = name.slice(0, -extname.length)
let thumbext = '.png'
if (extname === '.gif' && config.uploads.generateThumbs.animated) {
thumbext = '.gif'
}
const thumbfile = path.join(paths.thumbs, thumbname + thumbext)
try {
// Check if old static thumbnail exists, then unlink it
if (thumbext === '.gif') {
const staticthumb = path.join(paths.thumbs, thumbname + '.png')
const stat = await jetpack.inspectAsync(staticthumb)
if (stat) {
await jetpack.removeAsync(staticthumb)
}
}
// Check if thumbnail already exists
const stat = await jetpack.inspectAsync(thumbname)
const stat = await jetpack.inspectAsync(thumbfile)
if (stat) {
if (stat.type === 'symlink') {
// Unlink if symlink (should be symlink to the placeholder)
await jetpack.removeAsync(thumbname)
await jetpack.removeAsync(thumbfile)
} else if (!force) {
// Continue only if it does not exist, unless forced to
return true
@ -419,6 +434,11 @@ self.generateThumbs = async (name, extname, force) => {
// If image extension
if (Constants.IMAGE_EXTS.includes(extname)) {
const sharpOptions = {}
if (thumbext === '.gif') {
sharpOptions.animated = true
}
const resizeOptions = {
width: self.thumbsSize,
height: self.thumbsSize,
@ -430,15 +450,17 @@ self.generateThumbs = async (name, extname, force) => {
alpha: 0
}
}
const image = sharp(input)
const image = sharp(input, sharpOptions)
const metadata = await image.metadata()
if (metadata.width > resizeOptions.width || metadata.height > resizeOptions.height) {
await image
.resize(resizeOptions)
.toFile(thumbname)
.toFile(thumbfile)
} else if (metadata.width === resizeOptions.width && metadata.height === resizeOptions.height) {
await image
.toFile(thumbname)
.toFile(thumbfile)
} else {
const x = resizeOptions.width - metadata.width
const y = resizeOptions.height - metadata.height
@ -450,7 +472,7 @@ self.generateThumbs = async (name, extname, force) => {
right: Math.ceil(x / 2),
background: resizeOptions.background
})
.toFile(thumbname)
.toFile(thumbfile)
}
} else if (Constants.VIDEO_EXTS.includes(extname)) {
const metadata = await self.ffprobe(input)
@ -486,7 +508,7 @@ self.generateThumbs = async (name, extname, force) => {
// Sometimes FFMPEG would throw errors but actually somehow succeeded in making the thumbnails
// (this could be a fallback mechanism of fluent-ffmpeg library instead)
// So instead we check if the thumbnail exists to really make sure
if (await jetpack.existsAsync(thumbname)) {
if (await jetpack.existsAsync(thumbfile)) {
return true
} else {
throw error || new Error('FFMPEG exited with empty output file')
@ -497,9 +519,9 @@ self.generateThumbs = async (name, extname, force) => {
}
} catch (error) {
logger.error(`[${name}]: generateThumbs(): ${error.toString().trim()}`)
await jetpack.removeAsync(thumbname) // try to unlink incomplete thumbs first
await jetpack.removeAsync(thumbfile) // try to unlink incomplete thumbs first
try {
await jetpack.symlinkAsync(paths.thumbPlaceholder, thumbname)
await jetpack.symlinkAsync(paths.thumbPlaceholder, thumbfile)
return true
} catch (err) {
logger.error(`[${name}]: generateThumbs(): ${err.toString().trim()}`)

View File

@ -8,7 +8,8 @@ const self = {
mode: null,
mayGenerateThumb: extname => {
return ([1, 3].includes(self.mode) && Constants.IMAGE_EXTS.includes(extname)) ||
([2, 3].includes(self.mode) && Constants.VIDEO_EXTS.includes(extname))
([2, 3].includes(self.mode) && Constants.VIDEO_EXTS.includes(extname)) ||
(self.mode === 4 && extname === '.gif')
},
getFiles: async directory => {
const names = await jetpack.listAsync(directory)
@ -34,7 +35,7 @@ const self = {
const verbose = parseInt(args[2]) || 0
const cfcache = parseInt(args[3]) || 0
if (![1, 2, 3].includes(self.mode) ||
if (![1, 2, 3, 4].includes(self.mode) ||
![0, 1].includes(force) ||
![0, 1, 2].includes(verbose) ||
![0, 1].includes(cfcache) ||
@ -44,9 +45,9 @@ const self = {
Generate thumbnails.
Usage:
node ${location} <mode=1|2|3> [force=0|1] [verbose=0|1] [cfcache=0|1]
node ${location} <mode=1|2|3|4> [force=0|1] [verbose=0|1] [cfcache=0|1]
mode : 1 = images only, 2 = videos only, 3 = both images and videos
mode : 1 = images only, 2 = videos only, 3 = both images and videos, 4 = animated only
force : 0 = no force (default), 1 = overwrite existing thumbnails
verbose : 0 = only print missing thumbs (default), 1 = print all, 2 = print nothing
cfcache : 0 = do not clear cloudflare cache (default), 1 = clear cloudflare cache