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)
featuresThumbnailsNumberThreads Int @default(4)
featuresThumbnailsFormat String @default("jpg")
featuresMetricsEnabled Boolean @default(true)
featuresMetricsAdminOnly Boolean @default(false)

View File

@@ -5,6 +5,7 @@ import {
LoadingOverlay,
NumberInput,
Paper,
Select,
SimpleGrid,
Switch,
TextInput,
@@ -33,6 +34,7 @@ export default function Features({
featuresDeleteOnMaxViews: true,
featuresThumbnailsEnabled: true,
featuresThumbnailsNumberThreads: 4,
featuresThumbnailsFormat: 'jpg',
featuresMetricsEnabled: true,
featuresMetricsAdminOnly: false,
featuresMetricsShowUserSpecific: true,
@@ -58,6 +60,7 @@ export default function Features({
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat ?? 'jpg',
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
@@ -82,7 +85,7 @@ export default function Features({
<Switch
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' })}
/>
@@ -143,6 +146,20 @@ export default function Features({
{...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
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.'

View File

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

View File

@@ -68,11 +68,15 @@ export const ENVS = [
env('features.userRegistration', 'FEATURES_USER_REGISTRATION', 'boolean', true),
env('features.oauthRegistration', 'FEATURES_OAUTH_REGISTRATION', 'boolean', true),
env('features.deleteOnMaxViews', 'FEATURES_DELETE_ON_MAX_VIEWS', '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.format', 'FEATURES_THUMBNAILS_FORMAT', 'string', true),
env('features.metrics.enabled', 'FEATURES_METRICS_ENABLED', '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.versionChecking', 'FEATURES_VERSION_CHECKING', 'boolean', true),
env('features.versionAPI', 'FEATURES_VERSION_API', 'string', true),

View File

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

View File

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

View File

@@ -34,13 +34,22 @@ if (!enabled) {
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) => {
ffmpeg(file)
ffmpeg(input)
.videoFilters('thumbnail')
.frames(1)
.format('mjpeg')
.output(thumbnailTmp)
.output(output)
.on('start', (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
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));
}
@@ -60,17 +69,17 @@ function genThumbnail(file: string, thumbnailTmp: string): Promise<Buffer | unde
reject(err);
})
.on('end', () => {
if (!existsSync(thumbnailTmp)) {
logger.error('expected thumbnail file does not exist', { thumbnailTmp });
unlinkSync(file);
if (!existsSync(output)) {
logger.error('expected thumbnail file does not exist', { thumbnailTmp: output });
unlinkSync(input);
return resolve(undefined);
}
const buffer = readFileSync(thumbnailTmp);
const buffer = readFileSync(output);
unlinkSync(thumbnailTmp);
unlinkSync(file);
logger.debug('removed temporary files', { file, thumbnail: thumbnailTmp });
unlinkSync(output);
unlinkSync(input);
logger.debug('removed temporary files', { file: input, thumbnail: output });
resolve(buffer);
})
@@ -107,17 +116,17 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
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);
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) {
await datasource.delete(`.thumbnail.${file.id}.jpg`);
await datasource.delete(name(`.thumbnail.${file.id}`));
}
await datasource.put(`.thumbnail.${file.id}.jpg`, thumbnail, {
mimetype: 'image/jpeg',
await datasource.put(name(`.thumbnail.${file.id}`), thumbnail, {
mimetype: formatMimes[config.features.thumbnails.format] || 'image/jpeg',
});
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', {
data: {
fileId: file.id,
path: `.thumbnail.${file.id}.jpg`,
path: name(`.thumbnail.${file.id}`),
},
});
} else {

View File

@@ -182,6 +182,7 @@ export default fastifyPlugin(
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(),
featuresMetricsAdminOnly: z.boolean(),