diff --git a/prisma/migrations/20250828035734_features_thumbnails_format/migration.sql b/prisma/migrations/20250828035734_features_thumbnails_format/migration.sql
new file mode 100644
index 00000000..0ab8367f
--- /dev/null
+++ b/prisma/migrations/20250828035734_features_thumbnails_format/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsFormat" TEXT NOT NULL DEFAULT 'jpg';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2e7c1db3..d2e2cbb4 100755
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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)
diff --git a/src/components/pages/serverSettings/parts/Features.tsx b/src/components/pages/serverSettings/parts/Features.tsx
index 6824a615..6522421e 100644
--- a/src/components/pages/serverSettings/parts/Features.tsx
+++ b/src/components/pages/serverSettings/parts/Features.tsx
@@ -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({
@@ -143,6 +146,20 @@ export default function Features({
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
+
+
+
+
{
+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 {
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 {
- 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('thumbnail.findFirst', {
@@ -131,7 +140,7 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
t = await dbProxy('thumbnail.create', {
data: {
fileId: file.id,
- path: `.thumbnail.${file.id}.jpg`,
+ path: name(`.thumbnail.${file.id}`),
},
});
} else {
diff --git a/src/server/routes/api/server/settings/index.ts b/src/server/routes/api/server/settings/index.ts
index 8c8ce337..97fa5ec5 100644
--- a/src/server/routes/api/server/settings/index.ts
+++ b/src/server/routes/api/server/settings/index.ts
@@ -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(),