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

View File

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

View File

@@ -1,5 +1,5 @@
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 { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
@@ -49,7 +49,7 @@ export default function Tasks({
<Title order={2}>Tasks</Title>
<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>
<form onSubmit={form.onSubmit(onSubmit)}>

View File

@@ -47,6 +47,7 @@ export const DATABASE_TO_PROP = {
featuresThumbnailsEnabled: 'features.thumbnails.enabled',
featuresThumbnailsNumberThreads: 'features.thumbnails.num_threads',
featuresThumbnailsFormat: 'features.thumbnails.format',
featuresThumbnailsInstantaneous: 'features.thumbnails.instantaneous',
featuresMetricsEnabled: 'features.metrics.enabled',
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.num_threads', 'FEATURES_THUMBNAILS_NUM_THREADS', 'number', 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.adminOnly', 'FEATURES_METRICS_ADMIN_ONLY', 'boolean', true),

View File

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

View File

@@ -169,4 +169,8 @@ export class Tasks {
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 '..';
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__) {
return async function (this: IntervalTask, rerun = false) {
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`);
const thumbToWorker: { id: string; worker: number }[] = [];
let workerIndex = 0;
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],
});
}
runThumbnailWorkers(
thumbnailWorkers,
thumbnailNeeded.map((x) => x.id),
);
};
}

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

View File

@@ -10,6 +10,7 @@ import { fileSelect } from '@/lib/db/models/file';
import { sanitizeFilename } from '@/lib/fs';
import { removeGps } from '@/lib/gps';
import { log } from '@/lib/logger';
import { runThumbnailWorkers } from '@/lib/tasks/run/thumbnails';
import { parseHeaders, UploadHeaders } from '@/lib/uploader/parseHeaders';
import { onUpload } from '@/lib/webhooks';
import { Prisma } from '@/prisma/client';
@@ -251,6 +252,19 @@ export default typedPlugin(
.type('text/plain')
.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);
},
);