feat: instantaneous thumb generation

This commit is contained in:
diced
2026-04-05 19:06:02 -07:00
parent 00f4254227
commit 13282988e8
11 changed files with 88 additions and 40 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsInstantaneous" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -58,9 +58,10 @@ model Zipline {
featuresOauthRegistration Boolean @default(false) featuresOauthRegistration Boolean @default(false)
featuresDeleteOnMaxViews Boolean @default(true) featuresDeleteOnMaxViews Boolean @default(true)
featuresThumbnailsEnabled Boolean @default(true) featuresThumbnailsEnabled Boolean @default(true)
featuresThumbnailsNumberThreads Int @default(4) featuresThumbnailsNumberThreads Int @default(4)
featuresThumbnailsFormat String @default("jpg") featuresThumbnailsFormat String @default("jpg")
featuresThumbnailsInstantaneous Boolean @default(false)
featuresMetricsEnabled Boolean @default(true) featuresMetricsEnabled Boolean @default(true)
featuresMetricsAdminOnly Boolean @default(false) featuresMetricsAdminOnly Boolean @default(false)

View File

@@ -2,11 +2,13 @@ import { Response } from '@/lib/api/response';
import { import {
Anchor, Anchor,
Button, Button,
Divider,
LoadingOverlay, LoadingOverlay,
NumberInput, NumberInput,
Paper, Paper,
Select, Select,
SimpleGrid, SimpleGrid,
Stack,
Switch, Switch,
TextInput, TextInput,
Title, Title,
@@ -35,6 +37,7 @@ export default function Features({
featuresThumbnailsEnabled: true, featuresThumbnailsEnabled: true,
featuresThumbnailsNumberThreads: 4, featuresThumbnailsNumberThreads: 4,
featuresThumbnailsFormat: 'jpg', featuresThumbnailsFormat: 'jpg',
featuresThumbnailsInstantaneous: false,
featuresMetricsEnabled: true, featuresMetricsEnabled: true,
featuresMetricsAdminOnly: false, featuresMetricsAdminOnly: false,
featuresMetricsShowUserSpecific: true, featuresMetricsShowUserSpecific: true,
@@ -61,6 +64,7 @@ export default function Features({
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', featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat ?? 'jpg',
featuresThumbnailsInstantaneous: data.settings.featuresThumbnailsInstantaneous ?? false,
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,
@@ -76,7 +80,7 @@ export default function Features({
<Title order={2}>Features</Title> <Title order={2}>Features</Title>
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'> <Stack gap='xs' mt='xs'>
<Switch <Switch
label='Image Compression' label='Image Compression'
description='Allows the ability for users to compress images.' description='Allows the ability for users to compress images.'
@@ -130,12 +134,21 @@ export default function Features({
description='Shows metrics specific to each user, for all users.' description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })} {...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/> />
<div />
<Switch <Divider />
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.' <SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })} <Switch
/> label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
/>
<Switch
label='Instantaneous Thumbnails'
description='Generates thumbnails immediately after a file is uploaded, instead of waiting for the task to run.'
{...form.getInputProps('featuresThumbnailsInstantaneous', { type: 'checkbox' })}
/>
</SimpleGrid>
<NumberInput <NumberInput
label='Thumbnails Number Threads' label='Thumbnails Number Threads'
@@ -157,13 +170,14 @@ export default function Features({
{...form.getInputProps('featuresThumbnailsFormat')} {...form.getInputProps('featuresThumbnailsFormat')}
/> />
<div /> <Divider />
<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.'
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })} {...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
/> />
<TextInput <TextInput
label='Version API URL' label='Version API URL'
description={ description={
@@ -182,7 +196,7 @@ export default function Features({
placeholder='https://zipline-version.diced.sh/' placeholder='https://zipline-version.diced.sh/'
{...form.getInputProps('featuresVersionAPI')} {...form.getInputProps('featuresVersionAPI')}
/> />
</SimpleGrid> </Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}> <Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save Save

View File

@@ -1,5 +1,5 @@
import { Response } from '@/lib/api/response'; import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Text, TextInput, Title } from '@mantine/core'; import { Button, Code, LoadingOverlay, Paper, SimpleGrid, Text, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -49,7 +49,7 @@ export default function Tasks({
<Title order={2}>Tasks</Title> <Title order={2}>Tasks</Title>
<Text c='dimmed' size='sm'> <Text c='dimmed' size='sm'>
All options require a restart to take effect. All options require a restart to take effect. Setting a value of <Code>0</Code> will disable the task.
</Text> </Text>
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit)}>

View File

@@ -47,6 +47,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', featuresThumbnailsFormat: 'features.thumbnails.format',
featuresThumbnailsInstantaneous: 'features.thumbnails.instantaneous',
featuresMetricsEnabled: 'features.metrics.enabled', featuresMetricsEnabled: 'features.metrics.enabled',
featuresMetricsAdminOnly: 'features.metrics.adminOnly', featuresMetricsAdminOnly: 'features.metrics.adminOnly',

View File

@@ -80,6 +80,7 @@ export const ENVS = [
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.thumbnails.format', 'FEATURES_THUMBNAILS_FORMAT', 'string', true),
env('features.thumbnails.instantaneous', 'FEATURES_THUMBNAILS_INSTANTANEOUS', 'boolean', 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),

View File

@@ -204,6 +204,7 @@ export const schema = 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'), format: z.enum(['jpg', 'png', 'webp']).default('jpg'),
instantaneous: z.boolean().default(false),
}), }),
metrics: z.object({ metrics: z.object({
enabled: z.boolean().default(true), enabled: z.boolean().default(true),

View File

@@ -169,4 +169,8 @@ export class Tasks {
return this.tasks[len - 1] as WorkerTask<Data>; return this.tasks[len - 1] as WorkerTask<Data>;
} }
public workersBy(starting: string): WorkerTask[] {
return this.tasks.filter((x) => 'worker' in x && x.id.startsWith(starting)) as WorkerTask[];
}
} }

View File

@@ -1,5 +1,30 @@
import { IntervalTask, WorkerTask } from '..'; import { IntervalTask, WorkerTask } from '..';
export function runThumbnailWorkers(workers: WorkerTask[], files: string[]) {
const thumbToWorker: { id: string; worker: number }[] = [];
let workerIndex = 0;
for (const file of files) {
thumbToWorker.push({
id: file,
worker: workerIndex,
});
workerIndex = (workerIndex + 1) % workers.length;
}
const ids = workers.map((_, i) => thumbToWorker.filter((x) => x.worker === i).map((x) => x.id));
for (let i = 0; i !== workers.length; ++i) {
if (!ids[i].length) continue;
workers[i].worker!.postMessage({
type: 0,
data: ids[i],
});
}
}
export default function thumbnails(prisma: typeof globalThis.__db__) { export default function thumbnails(prisma: typeof globalThis.__db__) {
return async function (this: IntervalTask, rerun = false) { return async function (this: IntervalTask, rerun = false) {
const thumbnailWorkers = this.tasks.tasks.filter( const thumbnailWorkers = this.tasks.tasks.filter(
@@ -23,27 +48,9 @@ export default function thumbnails(prisma: typeof globalThis.__db__) {
this.logger.debug(`found ${thumbnailNeeded.length} files that need thumbnails`); this.logger.debug(`found ${thumbnailNeeded.length} files that need thumbnails`);
const thumbToWorker: { id: string; worker: number }[] = []; runThumbnailWorkers(
thumbnailWorkers,
let workerIndex = 0; thumbnailNeeded.map((x) => x.id),
for (const file of thumbnailNeeded) { );
thumbToWorker.push({
id: file.id,
worker: workerIndex,
});
workerIndex = (workerIndex + 1) % thumbnailWorkers.length;
}
const ids = thumbnailWorkers.map((_, i) => thumbToWorker.filter((x) => x.worker === i).map((x) => x.id));
for (let i = 0; i !== thumbnailWorkers.length; ++i) {
if (!ids[i].length) continue;
thumbnailWorkers[i].worker!.postMessage({
type: 0,
data: ids[i],
});
}
}; };
} }

View File

@@ -55,10 +55,12 @@ const zMs = zStringTrimmed.refine(
); );
const zBytes = zStringTrimmed.refine((value) => bytes(value) > 0, 'Value must be greater than 0'); const zBytes = zStringTrimmed.refine((value) => bytes(value) > 0, 'Value must be greater than 0');
const zIntervalMs = zMs.refine( const zIntervalMs = zStringTrimmed
(value) => ms(value as StringValue) <= MAX_SAFE_TIMEOUT_MS, .refine((value) => ms((value ?? '0') as StringValue) >= 0, 'Value must be greater than or equal to 0')
`Value must be less than or equal to ${MAX_SAFE_TIMEOUT_MS}ms`, .refine(
); (value) => ms(value as StringValue) <= MAX_SAFE_TIMEOUT_MS,
`Value must be less than or equal to ${MAX_SAFE_TIMEOUT_MS}ms`,
);
const discordEmbed = z const discordEmbed = z
.union([ .union([
@@ -227,6 +229,7 @@ export default typedPlugin(
'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']), featuresThumbnailsFormat: z.enum(['jpg', 'png', 'webp']),
featuresThumbnailsInstantaneous: z.boolean(),
featuresMetricsEnabled: z.boolean(), featuresMetricsEnabled: z.boolean(),
featuresMetricsAdminOnly: z.boolean(), featuresMetricsAdminOnly: z.boolean(),

View File

@@ -10,6 +10,7 @@ import { fileSelect } from '@/lib/db/models/file';
import { sanitizeFilename } from '@/lib/fs'; import { sanitizeFilename } from '@/lib/fs';
import { removeGps } from '@/lib/gps'; import { removeGps } from '@/lib/gps';
import { log } from '@/lib/logger'; import { log } from '@/lib/logger';
import { runThumbnailWorkers } from '@/lib/tasks/run/thumbnails';
import { parseHeaders, UploadHeaders } from '@/lib/uploader/parseHeaders'; import { parseHeaders, UploadHeaders } from '@/lib/uploader/parseHeaders';
import { onUpload } from '@/lib/webhooks'; import { onUpload } from '@/lib/webhooks';
import { Prisma } from '@/prisma/client'; import { Prisma } from '@/prisma/client';
@@ -251,6 +252,19 @@ export default typedPlugin(
.type('text/plain') .type('text/plain')
.send(response.files.map((x) => x.url).join(',')); .send(response.files.map((x) => x.url).join(','));
if (config.features.thumbnails.instantaneous) {
logger.debug('running thumbnail workers immediately due to configuration', {
files: response.files.length,
});
const fileIds = response.files.map((x) => x.id);
const thumbnailWorkers = server.tasks.workersBy('thumbnail');
if (!thumbnailWorkers.length) return;
runThumbnailWorkers(thumbnailWorkers, fileIds);
}
return res.send(response); return res.send(response);
}, },
); );