feat: thumbnails output format (jpg, png, webp)

This commit is contained in:
diced
2025-08-27 21:18:46 -07:00
parent ef13ef755c
commit ac37f13452
9 changed files with 57 additions and 19 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsFormat" TEXT NOT NULL DEFAULT 'jpg';

View File

@@ -56,6 +56,7 @@ model Zipline {
featuresThumbnailsEnabled Boolean @default(true) featuresThumbnailsEnabled Boolean @default(true)
featuresThumbnailsNumberThreads Int @default(4) featuresThumbnailsNumberThreads Int @default(4)
featuresThumbnailsFormat String @default("jpg")
featuresMetricsEnabled Boolean @default(true) featuresMetricsEnabled Boolean @default(true)
featuresMetricsAdminOnly Boolean @default(false) featuresMetricsAdminOnly Boolean @default(false)

View File

@@ -5,6 +5,7 @@ import {
LoadingOverlay, LoadingOverlay,
NumberInput, NumberInput,
Paper, Paper,
Select,
SimpleGrid, SimpleGrid,
Switch, Switch,
TextInput, TextInput,
@@ -33,6 +34,7 @@ export default function Features({
featuresDeleteOnMaxViews: true, featuresDeleteOnMaxViews: true,
featuresThumbnailsEnabled: true, featuresThumbnailsEnabled: true,
featuresThumbnailsNumberThreads: 4, featuresThumbnailsNumberThreads: 4,
featuresThumbnailsFormat: 'jpg',
featuresMetricsEnabled: true, featuresMetricsEnabled: true,
featuresMetricsAdminOnly: false, featuresMetricsAdminOnly: false,
featuresMetricsShowUserSpecific: true, featuresMetricsShowUserSpecific: true,
@@ -58,6 +60,7 @@ export default function Features({
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true, featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true, featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4, featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat ?? 'jpg',
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true, featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false, featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true, featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
@@ -82,7 +85,7 @@ export default function Features({
<Switch <Switch
label='/robots.txt' label='/robots.txt'
description='Enables a robots.txt file for search engine optimization. Requires a server restart.' description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })} {...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
/> />
@@ -143,6 +146,20 @@ export default function Features({
{...form.getInputProps('featuresThumbnailsNumberThreads')} {...form.getInputProps('featuresThumbnailsNumberThreads')}
/> />
<Select
label='Thumbnails Format'
description='The output format for thumbnails. Requires a server restart.'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
]}
allowDeselect={false}
{...form.getInputProps('featuresThumbnailsFormat')}
/>
<div />
<Switch <Switch
label='Version Checking' label='Version Checking'
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.' description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'

View File

@@ -42,6 +42,7 @@ export const DATABASE_TO_PROP = {
featuresThumbnailsEnabled: 'features.thumbnails.enabled', featuresThumbnailsEnabled: 'features.thumbnails.enabled',
featuresThumbnailsNumberThreads: 'features.thumbnails.num_threads', featuresThumbnailsNumberThreads: 'features.thumbnails.num_threads',
featuresThumbnailsFormat: 'features.thumbnails.format',
featuresMetricsEnabled: 'features.metrics.enabled', featuresMetricsEnabled: 'features.metrics.enabled',
featuresMetricsAdminOnly: 'features.metrics.adminOnly', featuresMetricsAdminOnly: 'features.metrics.adminOnly',

View File

@@ -68,11 +68,15 @@ export const ENVS = [
env('features.userRegistration', 'FEATURES_USER_REGISTRATION', 'boolean', true), env('features.userRegistration', 'FEATURES_USER_REGISTRATION', 'boolean', true),
env('features.oauthRegistration', 'FEATURES_OAUTH_REGISTRATION', 'boolean', true), env('features.oauthRegistration', 'FEATURES_OAUTH_REGISTRATION', 'boolean', true),
env('features.deleteOnMaxViews', 'FEATURES_DELETE_ON_MAX_VIEWS', 'boolean', true), env('features.deleteOnMaxViews', 'FEATURES_DELETE_ON_MAX_VIEWS', 'boolean', true),
env('features.thumbnails.enabled', 'FEATURES_THUMBNAILS_ENABLED', 'boolean', true), env('features.thumbnails.enabled', 'FEATURES_THUMBNAILS_ENABLED', 'boolean', true),
env('features.thumbnails.num_threads', 'FEATURES_THUMBNAILS_NUM_THREADS', 'number', true), env('features.thumbnails.num_threads', 'FEATURES_THUMBNAILS_NUM_THREADS', 'number', true),
env('features.thumbnails.format', 'FEATURES_THUMBNAILS_FORMAT', 'string', true),
env('features.metrics.enabled', 'FEATURES_METRICS_ENABLED', 'boolean', true), env('features.metrics.enabled', 'FEATURES_METRICS_ENABLED', 'boolean', true),
env('features.metrics.adminOnly', 'FEATURES_METRICS_ADMIN_ONLY', 'boolean', true), env('features.metrics.adminOnly', 'FEATURES_METRICS_ADMIN_ONLY', 'boolean', true),
env('features.metrics.showUserSpecific', 'FEATURES_METRICS_SHOW_USER_SPECIFIC', 'boolean', true), env('features.metrics.showUserSpecific', 'FEATURES_METRICS_SHOW_USER_SPECIFIC', 'boolean', true),
env('features.versionChecking', 'FEATURES_VERSION_CHECKING', 'boolean', true), env('features.versionChecking', 'FEATURES_VERSION_CHECKING', 'boolean', true),
env('features.versionAPI', 'FEATURES_VERSION_API', 'string', true), env('features.versionAPI', 'FEATURES_VERSION_API', 'string', true),

View File

@@ -38,6 +38,7 @@ export const rawConfig: any = {
removeGpsMetadata: undefined, removeGpsMetadata: undefined,
randomWordsNumAdjectives: undefined, randomWordsNumAdjectives: undefined,
randomWordsSeparator: undefined, randomWordsSeparator: undefined,
defaultCompressionFormat: undefined,
}, },
urls: { urls: {
route: undefined, route: undefined,
@@ -57,6 +58,7 @@ export const rawConfig: any = {
thumbnails: { thumbnails: {
enabled: undefined, enabled: undefined,
num_threads: undefined, num_threads: undefined,
format: undefined,
}, },
metrics: { metrics: {
enabled: undefined, enabled: undefined,

View File

@@ -160,6 +160,7 @@ export const schema = z.object({
thumbnails: z.object({ thumbnails: z.object({
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
num_threads: z.number().default(4), num_threads: z.number().default(4),
format: z.enum(['jpg', 'png', 'webp']).default('jpg'),
}), }),
metrics: z.object({ metrics: z.object({
enabled: z.boolean().default(true), enabled: z.boolean().default(true),

View File

@@ -34,13 +34,22 @@ if (!enabled) {
logger.debug('started thumbnail worker'); logger.debug('started thumbnail worker');
function genThumbnail(file: string, thumbnailTmp: string): Promise<Buffer | undefined> { const formatMimes = {
jpg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
};
function name(str: string) {
return `${str}.${config.features.thumbnails.format}`;
}
function genThumbnail(input: string, output: string): Promise<Buffer | undefined> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ffmpeg(file) ffmpeg(input)
.videoFilters('thumbnail') .videoFilters('thumbnail')
.frames(1) .frames(1)
.format('mjpeg') .output(output)
.output(thumbnailTmp)
.on('start', (cmd) => { .on('start', (cmd) => {
logger.debug('generating thumbnail', { cmd }); logger.debug('generating thumbnail', { cmd });
}) })
@@ -51,7 +60,7 @@ function genThumbnail(file: string, thumbnailTmp: string): Promise<Buffer | unde
// the method will return an empty buffer since there is no video stream // the method will return an empty buffer since there is no video stream
logger.error( logger.error(
`file ${file} does not contain any video stream, it is probably an audio file... ignoring...`, `file ${input} does not contain any video stream, it is probably an audio file... ignoring...`,
); );
resolve(Buffer.alloc(0)); resolve(Buffer.alloc(0));
} }
@@ -60,17 +69,17 @@ function genThumbnail(file: string, thumbnailTmp: string): Promise<Buffer | unde
reject(err); reject(err);
}) })
.on('end', () => { .on('end', () => {
if (!existsSync(thumbnailTmp)) { if (!existsSync(output)) {
logger.error('expected thumbnail file does not exist', { thumbnailTmp }); logger.error('expected thumbnail file does not exist', { thumbnailTmp: output });
unlinkSync(file); unlinkSync(input);
return resolve(undefined); return resolve(undefined);
} }
const buffer = readFileSync(thumbnailTmp); const buffer = readFileSync(output);
unlinkSync(thumbnailTmp); unlinkSync(output);
unlinkSync(file); unlinkSync(input);
logger.debug('removed temporary files', { file, thumbnail: thumbnailTmp }); logger.debug('removed temporary files', { file: input, thumbnail: output });
resolve(buffer); resolve(buffer);
}) })
@@ -107,17 +116,17 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
writeStream.on('finish', resolve as any); writeStream.on('finish', resolve as any);
}); });
const thumbnailTmpFile = join(config.core.tempDirectory, `zthumbnail_${file.id}.jpg`); const thumbnailTmpFile = join(config.core.tempDirectory, name(`zthumbnail_${file.id}`));
const thumbnail = await genThumbnail(tmpFile, thumbnailTmpFile); const thumbnail = await genThumbnail(tmpFile, thumbnailTmpFile);
if (!thumbnail) return; if (!thumbnail) return;
const existing = await datasource.size(`.thumbnail.${file.id}.jpg`); const existing = await datasource.size(name(`.thumbnail.${file.id}`));
if (existing || existing === 0) { if (existing || existing === 0) {
await datasource.delete(`.thumbnail.${file.id}.jpg`); await datasource.delete(name(`.thumbnail.${file.id}`));
} }
await datasource.put(`.thumbnail.${file.id}.jpg`, thumbnail, { await datasource.put(name(`.thumbnail.${file.id}`), thumbnail, {
mimetype: 'image/jpeg', mimetype: formatMimes[config.features.thumbnails.format] || 'image/jpeg',
}); });
const existingThumbnail = await dbProxy<ThumbnailId>('thumbnail.findFirst', { const existingThumbnail = await dbProxy<ThumbnailId>('thumbnail.findFirst', {
@@ -131,7 +140,7 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
t = await dbProxy<ThumbnailId>('thumbnail.create', { t = await dbProxy<ThumbnailId>('thumbnail.create', {
data: { data: {
fileId: file.id, fileId: file.id,
path: `.thumbnail.${file.id}.jpg`, path: name(`.thumbnail.${file.id}`),
}, },
}); });
} else { } else {

View File

@@ -182,6 +182,7 @@ export default fastifyPlugin(
cpus().length, cpus().length,
'Number of threads must be less than or equal to the number of CPUs: ' + cpus().length, 'Number of threads must be less than or equal to the number of CPUs: ' + cpus().length,
), ),
featuresThumbnailsFormat: z.enum(['jpg', 'png', 'webp']),
featuresMetricsEnabled: z.boolean(), featuresMetricsEnabled: z.boolean(),
featuresMetricsAdminOnly: z.boolean(), featuresMetricsAdminOnly: z.boolean(),