includio-cms 0.36.7 → 0.36.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +8 -2
- package/CHANGELOG.md +19 -0
- package/DOCS.md +1 -1
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/media-jobs.d.ts +5 -0
- package/dist/admin/api/media-jobs.js +33 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +9 -0
- package/dist/admin/client/maintenance/media-jobs-panel.svelte +310 -0
- package/dist/admin/client/maintenance/media-jobs-panel.svelte.d.ts +3 -0
- package/dist/core/cms.js +7 -0
- package/dist/core/server/fields/utils/imageStyles.js +41 -4
- package/dist/core/server/index.d.ts +3 -0
- package/dist/core/server/index.js +3 -0
- package/dist/core/server/media/jobs/enqueue.d.ts +6 -0
- package/dist/core/server/media/jobs/enqueue.js +20 -0
- package/dist/core/server/media/jobs/processImageStyleJob.d.ts +19 -0
- package/dist/core/server/media/jobs/processImageStyleJob.js +23 -0
- package/dist/core/server/media/jobs/startWorker.d.ts +2 -0
- package/dist/core/server/media/jobs/startWorker.js +29 -0
- package/dist/core/server/media/jobs/worker.d.ts +29 -0
- package/dist/core/server/media/jobs/worker.js +54 -0
- package/dist/core/server/media/operations/replaceFile.js +2 -2
- package/dist/core/server/media/operations/uploadFile.js +10 -3
- package/dist/core/server/media/styles/buildStyleSpecs.d.ts +12 -0
- package/dist/core/server/media/styles/buildStyleSpecs.js +36 -0
- package/dist/core/server/media/styles/imagesConfig.d.ts +3 -0
- package/dist/core/server/media/styles/imagesConfig.js +16 -0
- package/dist/core/server/media/styles/operations/batchGenerateStyles.js +19 -41
- package/dist/core/server/media/styles/operations/generateFallbackVariant.d.ts +10 -0
- package/dist/core/server/media/styles/operations/generateFallbackVariant.js +21 -0
- package/dist/core/server/media/styles/operations/getImageStyle.js +12 -8
- package/dist/core/server/media/styles/sharp/sharpConfig.d.ts +7 -0
- package/dist/core/server/media/styles/sharp/sharpConfig.js +12 -0
- package/dist/db-postgres/index.js +113 -1
- package/dist/db-postgres/schema/imageStyle.js +1 -1
- package/dist/db-postgres/schema/index.d.ts +1 -0
- package/dist/db-postgres/schema/index.js +1 -0
- package/dist/db-postgres/schema/mediaFile.d.ts +17 -0
- package/dist/db-postgres/schema/mediaFile.js +1 -0
- package/dist/db-postgres/schema/mediaJob.d.ts +279 -0
- package/dist/db-postgres/schema/mediaJob.js +23 -0
- package/dist/sveltekit/components/image.svelte +1 -1
- package/dist/types/adapters/db.d.ts +51 -0
- package/dist/types/cms.d.ts +46 -0
- package/dist/types/media.d.ts +1 -0
- package/dist/updates/0.36.8/index.d.ts +2 -0
- package/dist/updates/0.36.8/index.js +20 -0
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { getCMS } from '../../../cms.js';
|
|
3
|
+
import { startMediaWorker } from './worker.js';
|
|
4
|
+
import { processImageStyleJob } from './processImageStyleJob.js';
|
|
5
|
+
import { createImageStyle } from '../styles/operations/createMediaStyle.js';
|
|
6
|
+
import { applySharpLimits } from '../styles/sharp/sharpConfig.js';
|
|
7
|
+
/** Składa zależności i startuje in-process worker generacji wariantów. */
|
|
8
|
+
export function startMediaOptimizationWorker() {
|
|
9
|
+
const cms = getCMS();
|
|
10
|
+
const opt = cms.mediaConfig.optimization ?? {};
|
|
11
|
+
const cfg = {
|
|
12
|
+
concurrency: opt.concurrency ?? 1,
|
|
13
|
+
maxAttempts: opt.maxAttempts ?? 3,
|
|
14
|
+
backoffBaseMs: opt.backoffBaseMs ?? 30_000,
|
|
15
|
+
staleLockMs: opt.staleLockMs ?? 300_000,
|
|
16
|
+
pollIntervalMs: opt.pollIntervalMs ?? 2_000,
|
|
17
|
+
limitPixels: opt.limitPixels ?? 100_000_000,
|
|
18
|
+
lockedBy: randomUUID()
|
|
19
|
+
};
|
|
20
|
+
applySharpLimits({ concurrency: cfg.concurrency, cacheMemoryMB: opt.cacheMemoryMB ?? 50 });
|
|
21
|
+
startMediaWorker(cfg, {
|
|
22
|
+
adapter: cms.databaseAdapter,
|
|
23
|
+
process: (job) => processImageStyleJob(job, {
|
|
24
|
+
createImageStyle,
|
|
25
|
+
getMediaFile: (id) => cms.databaseAdapter.getMediaFile({ data: { id } }),
|
|
26
|
+
limitPixels: cfg.limitPixels
|
|
27
|
+
})
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { MediaJob } from '../../../../types/adapters/db.js';
|
|
2
|
+
import type { JobResult } from './processImageStyleJob.js';
|
|
3
|
+
export interface WorkerConfig {
|
|
4
|
+
concurrency: number;
|
|
5
|
+
maxAttempts: number;
|
|
6
|
+
backoffBaseMs: number;
|
|
7
|
+
staleLockMs: number;
|
|
8
|
+
pollIntervalMs: number;
|
|
9
|
+
limitPixels: number;
|
|
10
|
+
lockedBy: string;
|
|
11
|
+
}
|
|
12
|
+
export interface WorkerAdapter {
|
|
13
|
+
claimNextMediaJob: (lockedBy: string) => Promise<MediaJob | null>;
|
|
14
|
+
completeMediaJob: (id: string) => Promise<void>;
|
|
15
|
+
failMediaJob: (id: string, error: string, opts: {
|
|
16
|
+
retry: boolean;
|
|
17
|
+
runAfter?: Date;
|
|
18
|
+
}) => Promise<void>;
|
|
19
|
+
resetStaleMediaJobs: (olderThan: Date) => Promise<number>;
|
|
20
|
+
}
|
|
21
|
+
export interface RunOnceDeps {
|
|
22
|
+
adapter: WorkerAdapter;
|
|
23
|
+
process: (job: MediaJob) => Promise<JobResult>;
|
|
24
|
+
}
|
|
25
|
+
/** Jeden cykl: claim → process → complete/fail (backoff + circuit-breaker). */
|
|
26
|
+
export declare function runOnce(cfg: WorkerConfig, deps: RunOnceDeps): Promise<'processed' | 'idle'>;
|
|
27
|
+
/** Startuje ciągłą pętlę workera (idempotentne). Concurrency=1: jeden job naraz. */
|
|
28
|
+
export declare function startMediaWorker(cfg: WorkerConfig, deps: RunOnceDeps): void;
|
|
29
|
+
export declare function stopMediaWorker(): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Jeden cykl: claim → process → complete/fail (backoff + circuit-breaker). */
|
|
2
|
+
export async function runOnce(cfg, deps) {
|
|
3
|
+
const job = await deps.adapter.claimNextMediaJob(cfg.lockedBy);
|
|
4
|
+
if (!job)
|
|
5
|
+
return 'idle';
|
|
6
|
+
const res = await deps.process(job);
|
|
7
|
+
if (res.ok) {
|
|
8
|
+
await deps.adapter.completeMediaJob(job.id);
|
|
9
|
+
return 'processed';
|
|
10
|
+
}
|
|
11
|
+
const willExhaust = job.attempts + 1 >= (job.maxAttempts ?? cfg.maxAttempts);
|
|
12
|
+
const retry = res.retry && !willExhaust;
|
|
13
|
+
const runAfter = new Date(Date.now() + cfg.backoffBaseMs * 2 ** job.attempts);
|
|
14
|
+
await deps.adapter.failMediaJob(job.id, res.error, { retry, runAfter });
|
|
15
|
+
return 'processed';
|
|
16
|
+
}
|
|
17
|
+
let running = false;
|
|
18
|
+
let started = false;
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
}
|
|
22
|
+
/** Startuje ciągłą pętlę workera (idempotentne). Concurrency=1: jeden job naraz. */
|
|
23
|
+
export function startMediaWorker(cfg, deps) {
|
|
24
|
+
if (started)
|
|
25
|
+
return;
|
|
26
|
+
started = true;
|
|
27
|
+
running = true;
|
|
28
|
+
const staleEvery = Math.max(1, Math.floor(cfg.staleLockMs / Math.max(1, cfg.pollIntervalMs)));
|
|
29
|
+
let cyclesSinceStale = 0;
|
|
30
|
+
const loop = async () => {
|
|
31
|
+
console.info(`[media] worker started (${cfg.lockedBy})`);
|
|
32
|
+
while (running) {
|
|
33
|
+
try {
|
|
34
|
+
if (cyclesSinceStale++ >= staleEvery) {
|
|
35
|
+
cyclesSinceStale = 0;
|
|
36
|
+
await deps.adapter.resetStaleMediaJobs(new Date(Date.now() - cfg.staleLockMs));
|
|
37
|
+
}
|
|
38
|
+
const r = await runOnce(cfg, deps);
|
|
39
|
+
if (r === 'idle')
|
|
40
|
+
await sleep(cfg.pollIntervalMs);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
console.warn('[media] worker cycle error:', e);
|
|
44
|
+
await sleep(cfg.pollIntervalMs);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
started = false;
|
|
48
|
+
console.info('[media] worker stopped');
|
|
49
|
+
};
|
|
50
|
+
void loop();
|
|
51
|
+
}
|
|
52
|
+
export function stopMediaWorker() {
|
|
53
|
+
running = false;
|
|
54
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getCMS } from '../../../cms.js';
|
|
2
2
|
import { generateBlurDataUrl } from '../utils/generateBlurDataUrl.js';
|
|
3
3
|
import { generateAdminThumbnail } from '../utils/generateAdminThumbnail.js';
|
|
4
|
-
import {
|
|
4
|
+
import { enqueueStyleSpecs } from '../jobs/enqueue.js';
|
|
5
5
|
import { generateDefaultVideoStylesInBackground } from '../styles/operations/generateDefaultVideoStyles.js';
|
|
6
6
|
export async function replaceFile(fileId, newFile) {
|
|
7
7
|
const cms = getCMS();
|
|
@@ -76,7 +76,7 @@ export async function replaceFile(fileId, newFile) {
|
|
|
76
76
|
blurDataUrl
|
|
77
77
|
}
|
|
78
78
|
});
|
|
79
|
-
|
|
79
|
+
void enqueueStyleSpecs(updated, undefined, {}).catch((e) => console.warn('[media] replace enqueue failed:', e));
|
|
80
80
|
generateDefaultVideoStylesInBackground(updated);
|
|
81
81
|
return updated;
|
|
82
82
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { getCMS } from '../../../cms.js';
|
|
2
2
|
import { generateBlurDataUrl } from '../utils/generateBlurDataUrl.js';
|
|
3
3
|
import { generateAdminThumbnail } from '../utils/generateAdminThumbnail.js';
|
|
4
|
-
import {
|
|
4
|
+
import { generateFallbackVariant } from '../styles/operations/generateFallbackVariant.js';
|
|
5
|
+
import { resolveImagesConfig } from '../styles/imagesConfig.js';
|
|
6
|
+
import { enqueueStyleSpecs } from '../jobs/enqueue.js';
|
|
5
7
|
import { generateDefaultVideoStylesInBackground } from '../styles/operations/generateDefaultVideoStyles.js';
|
|
6
8
|
import sharp from 'sharp';
|
|
7
9
|
import { withTimeout, sharpTimeoutMs } from '../../../../server/utils/withTimeout.js';
|
|
@@ -40,15 +42,18 @@ export async function uploadFile(file) {
|
|
|
40
42
|
const uploadedFile = await getCMS().filesAdapter.uploadFile(file);
|
|
41
43
|
let blurDataUrl = null;
|
|
42
44
|
let thumbnailUrl = uploadedFile.thumbnailUrl ?? null;
|
|
45
|
+
let fallbackUrl = null;
|
|
43
46
|
const isProcessableImage = uploadedFile.type === 'image' && !file.name.toLowerCase().endsWith('.svg');
|
|
44
47
|
if (isProcessableImage) {
|
|
45
48
|
try {
|
|
46
49
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
47
50
|
blurDataUrl = await generateBlurDataUrl(buffer);
|
|
48
51
|
thumbnailUrl = await generateAdminThumbnail(buffer, uploadedFile);
|
|
52
|
+
const fallbackCfg = resolveImagesConfig(getCMS().mediaConfig.images).fallback;
|
|
53
|
+
fallbackUrl = await generateFallbackVariant(buffer, uploadedFile, fallbackCfg);
|
|
49
54
|
}
|
|
50
55
|
catch (e) {
|
|
51
|
-
console.warn('LQIP/thumbnail generation failed:', e);
|
|
56
|
+
console.warn('LQIP/thumbnail/fallback generation failed:', e);
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
const created = await getCMS().databaseAdapter.createMediaFile({
|
|
@@ -61,12 +66,14 @@ export async function uploadFile(file) {
|
|
|
61
66
|
posterUrl: uploadedFile.posterUrl ?? null,
|
|
62
67
|
mimeType: uploadedFile.mimeType ?? null,
|
|
63
68
|
blurDataUrl,
|
|
69
|
+
fallbackUrl,
|
|
64
70
|
focalX: null,
|
|
65
71
|
focalY: null,
|
|
66
72
|
transcriptFileId: null,
|
|
67
73
|
audioDescriptionFileId: null
|
|
68
74
|
});
|
|
69
|
-
|
|
75
|
+
// Kolejkuj generację wariantów w tle (worker media_job) zamiast fire-and-forget bez retry.
|
|
76
|
+
await enqueueStyleSpecs(created, undefined, {});
|
|
70
77
|
generateDefaultVideoStylesInBackground(created);
|
|
71
78
|
return created;
|
|
72
79
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ImageFieldStyle } from '../../../../types/fields.js';
|
|
2
|
+
import type { MediaFile } from '../../../../types/media.js';
|
|
3
|
+
import type { ImagesConfig } from '../../../../types/cms.js';
|
|
4
|
+
/**
|
|
5
|
+
* Zestaw konkretnych specyfikacji styli do wygenerowania dla danego media.
|
|
6
|
+
* - Jeśli pole ma własne `fieldStyles` — honorujemy je (mogą mieć własny format/crop/width).
|
|
7
|
+
* - Inaczej z configu: każdy format × każda szerokość ≤ min(oryginał, maxDimension).
|
|
8
|
+
* BEZ wariantu bazowego pełno-res (to była bomba CPU i redundancja).
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildStyleSpecs(mediaFile: Pick<MediaFile, 'id' | 'width' | 'height'>, fieldStyles: ImageFieldStyle[] | undefined, cfg: ImagesConfig): ImageFieldStyle[];
|
|
11
|
+
/** Kanoniczny klucz dedupu po EFEKTYWNEJ specyfikacji (nie po nazwie). */
|
|
12
|
+
export declare function styleDedupeKey(mediaFileId: string, s: ImageFieldStyle): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zestaw konkretnych specyfikacji styli do wygenerowania dla danego media.
|
|
3
|
+
* - Jeśli pole ma własne `fieldStyles` — honorujemy je (mogą mieć własny format/crop/width).
|
|
4
|
+
* - Inaczej z configu: każdy format × każda szerokość ≤ min(oryginał, maxDimension).
|
|
5
|
+
* BEZ wariantu bazowego pełno-res (to była bomba CPU i redundancja).
|
|
6
|
+
*/
|
|
7
|
+
export function buildStyleSpecs(mediaFile, fieldStyles, cfg) {
|
|
8
|
+
if (fieldStyles && fieldStyles.length)
|
|
9
|
+
return fieldStyles;
|
|
10
|
+
const maxW = Math.min(mediaFile.width ?? cfg.maxDimension, cfg.maxDimension);
|
|
11
|
+
const widths = cfg.widths.filter((w) => w <= maxW);
|
|
12
|
+
const specs = [];
|
|
13
|
+
for (const format of cfg.formats) {
|
|
14
|
+
for (const w of widths) {
|
|
15
|
+
specs.push({
|
|
16
|
+
name: `default_${format}_${w}w`,
|
|
17
|
+
format: format,
|
|
18
|
+
width: w,
|
|
19
|
+
quality: cfg.quality[format]
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return specs;
|
|
24
|
+
}
|
|
25
|
+
/** Kanoniczny klucz dedupu po EFEKTYWNEJ specyfikacji (nie po nazwie). */
|
|
26
|
+
export function styleDedupeKey(mediaFileId, s) {
|
|
27
|
+
return [
|
|
28
|
+
mediaFileId,
|
|
29
|
+
s.format ?? '',
|
|
30
|
+
s.width ?? 0,
|
|
31
|
+
s.height ?? 0,
|
|
32
|
+
s.crop ? 1 : 0,
|
|
33
|
+
s.quality ?? 0,
|
|
34
|
+
s.aspectRatio ?? 0
|
|
35
|
+
].join(':');
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
formats: ['avif', 'webp'],
|
|
3
|
+
widths: [640, 1024, 1536, 2560],
|
|
4
|
+
quality: { avif: 52, webp: 78, jpeg: 80 },
|
|
5
|
+
maxDimension: 2560,
|
|
6
|
+
fallback: { width: 1280, format: 'jpeg' }
|
|
7
|
+
};
|
|
8
|
+
/** Łączy wejściową (płytko-partial) konfigurację obrazów z domyślną. */
|
|
9
|
+
export function resolveImagesConfig(cfg) {
|
|
10
|
+
return {
|
|
11
|
+
...DEFAULTS,
|
|
12
|
+
...cfg,
|
|
13
|
+
quality: { ...DEFAULTS.quality, ...(cfg?.quality ?? {}) },
|
|
14
|
+
fallback: { ...DEFAULTS.fallback, ...(cfg?.fallback ?? {}) }
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getCMS } from '../../../../cms.js';
|
|
2
|
-
import { isProcessableImage
|
|
3
|
-
import {
|
|
2
|
+
import { isProcessableImage } from '../../../fields/utils/imageStyles.js';
|
|
3
|
+
import { buildStyleSpecs, styleDedupeKey } from '../buildStyleSpecs.js';
|
|
4
|
+
import { resolveImagesConfig } from '../imagesConfig.js';
|
|
4
5
|
import { generateAndSaveAdminThumbnail } from '../../utils/generateAdminThumbnail.js';
|
|
5
6
|
const IMAGE_MIME_TYPES = [
|
|
6
7
|
'image/jpeg',
|
|
@@ -12,39 +13,26 @@ const IMAGE_MIME_TYPES = [
|
|
|
12
13
|
];
|
|
13
14
|
async function generateStylesForFile(file) {
|
|
14
15
|
const cms = getCMS();
|
|
15
|
-
const
|
|
16
|
-
const expanded = expandStyleFormats(defaultStyles, origFormat);
|
|
16
|
+
const specs = buildStyleSpecs(file, undefined, resolveImagesConfig(cms.mediaConfig.images));
|
|
17
17
|
let created = 0;
|
|
18
18
|
let skipped = 0;
|
|
19
|
-
|
|
19
|
+
// Enqueue brakujących wariantów (worker w tle je wygeneruje) — sweep NIE generuje inline.
|
|
20
|
+
for (const style of specs) {
|
|
20
21
|
const existing = await cms.databaseAdapter.getImageStyle(file.id, style);
|
|
21
22
|
if (existing) {
|
|
22
23
|
skipped++;
|
|
24
|
+
continue;
|
|
23
25
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
width: w,
|
|
35
|
-
srcset: undefined,
|
|
36
|
-
sizes: undefined
|
|
37
|
-
};
|
|
38
|
-
const existingW = await cms.databaseAdapter.getImageStyle(file.id, widthStyle);
|
|
39
|
-
if (existingW) {
|
|
40
|
-
skipped++;
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
await getImageStyle(file.id, widthStyle);
|
|
44
|
-
created++;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
26
|
+
await cms.databaseAdapter
|
|
27
|
+
.enqueueMediaJob({
|
|
28
|
+
type: 'image_style',
|
|
29
|
+
mediaFileId: file.id,
|
|
30
|
+
payload: JSON.stringify(style),
|
|
31
|
+
dedupeKey: styleDedupeKey(file.id, style),
|
|
32
|
+
priority: 0
|
|
33
|
+
})
|
|
34
|
+
.catch((e) => console.warn('[media] sweep enqueue failed:', e));
|
|
35
|
+
created++;
|
|
48
36
|
}
|
|
49
37
|
// Generate admin thumbnail if missing
|
|
50
38
|
if (!file.thumbnailUrl) {
|
|
@@ -99,18 +87,8 @@ export async function* batchGenerateAllStyles(signal) {
|
|
|
99
87
|
yield { type: 'done', total, processed: total, created: totalCreated, skipped: totalSkipped };
|
|
100
88
|
}
|
|
101
89
|
function getExpectedStyleNames(file) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const names = [];
|
|
105
|
-
for (const style of expanded) {
|
|
106
|
-
names.push(style.name);
|
|
107
|
-
if (style.srcset && file.width) {
|
|
108
|
-
for (const w of style.srcset.filter((sw) => sw <= file.width)) {
|
|
109
|
-
names.push(`${style.name}_${w}w`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return names;
|
|
90
|
+
const specs = buildStyleSpecs(file, undefined, resolveImagesConfig(getCMS().mediaConfig.images));
|
|
91
|
+
return specs.map((s) => s.name);
|
|
114
92
|
}
|
|
115
93
|
export async function getStylesStatus() {
|
|
116
94
|
const cms = getCMS();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MediaFile } from '../../../../../types/media.js';
|
|
2
|
+
/**
|
|
3
|
+
* Lekki, uniwersalny wariant (domyślnie jpeg ~1280) generowany synchronicznie przy uploadzie.
|
|
4
|
+
* Serwowany jako `<img src>` dopóki pełne warianty są pending — użytkownik nigdy nie dostaje
|
|
5
|
+
* ciężkiego oryginału. Tania operacja (mała szerokość, jpeg/webp), z timeoutem.
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateFallbackVariant(buffer: Buffer, mediaFile: Pick<MediaFile, 'id'>, opts: {
|
|
8
|
+
width: number;
|
|
9
|
+
format: 'jpeg' | 'webp';
|
|
10
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getCMS } from '../../../../cms.js';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
import { withTimeout, sharpTimeoutMs } from '../../../../../server/utils/withTimeout.js';
|
|
4
|
+
const FALLBACK_QUALITY = 72;
|
|
5
|
+
/**
|
|
6
|
+
* Lekki, uniwersalny wariant (domyślnie jpeg ~1280) generowany synchronicznie przy uploadzie.
|
|
7
|
+
* Serwowany jako `<img src>` dopóki pełne warianty są pending — użytkownik nigdy nie dostaje
|
|
8
|
+
* ciężkiego oryginału. Tania operacja (mała szerokość, jpeg/webp), z timeoutem.
|
|
9
|
+
*/
|
|
10
|
+
export async function generateFallbackVariant(buffer, mediaFile, opts) {
|
|
11
|
+
const output = await withTimeout(sharp(buffer)
|
|
12
|
+
.rotate()
|
|
13
|
+
.resize(opts.width, undefined, { withoutEnlargement: true })
|
|
14
|
+
.toFormat(opts.format, { quality: FALLBACK_QUALITY })
|
|
15
|
+
.toBuffer(), sharpTimeoutMs(), 'sharp.fallback');
|
|
16
|
+
const ext = opts.format === 'jpeg' ? 'jpg' : opts.format;
|
|
17
|
+
const mime = opts.format === 'jpeg' ? 'image/jpeg' : 'image/webp';
|
|
18
|
+
const filename = `${mediaFile.id}_fallback_${Date.now().toString(36)}.${ext}`;
|
|
19
|
+
const uploaded = await getCMS().filesAdapter.uploadFile(new File([new Uint8Array(output)], filename, { type: mime }));
|
|
20
|
+
return uploaded.url;
|
|
21
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getCMS } from '../../../../cms.js';
|
|
2
2
|
import { imageStyleKey } from '../../../../../types/adapters/db.js';
|
|
3
3
|
import { createImageStyle } from './createMediaStyle.js';
|
|
4
|
+
import { styleDedupeKey } from '../buildStyleSpecs.js';
|
|
4
5
|
export async function getImageStyle(mediaFileId, style) {
|
|
5
6
|
const imageStyle = await getCMS().databaseAdapter.getImageStyle(mediaFileId, style);
|
|
6
7
|
if (imageStyle)
|
|
@@ -22,14 +23,17 @@ export async function getImageStylesBatch(requests) {
|
|
|
22
23
|
const cache = await cms.databaseAdapter.getImageStylesBatch(requests);
|
|
23
24
|
const missing = requests.filter((r) => !cache.has(imageStyleKey(r.mediaFileId, r.style)));
|
|
24
25
|
for (const { mediaFileId, style } of missing) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
// Render NIE generuje sharpem — kolejkujemy brakujący styl (priority=high) i zwracamy
|
|
27
|
+
// tylko istniejące. Worker w tle dogeneruje; frontend serwuje fallback w międzyczasie.
|
|
28
|
+
await cms.databaseAdapter
|
|
29
|
+
.enqueueMediaJob({
|
|
30
|
+
type: 'image_style',
|
|
31
|
+
mediaFileId,
|
|
32
|
+
payload: JSON.stringify(style),
|
|
33
|
+
dedupeKey: styleDedupeKey(mediaFileId, style),
|
|
34
|
+
priority: 10
|
|
35
|
+
})
|
|
36
|
+
.catch((e) => console.warn(`[media] enqueue failed for style "${style.name}" media ${mediaFileId}:`, e));
|
|
33
37
|
}
|
|
34
38
|
return cache;
|
|
35
39
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Globalne limity sharp — wołane raz przy init CMS. Chroni proces przed wysyceniem CPU/RAM. */
|
|
2
|
+
export declare function applySharpLimits(opts: {
|
|
3
|
+
concurrency: number;
|
|
4
|
+
cacheMemoryMB: number;
|
|
5
|
+
}): void;
|
|
6
|
+
/** Poison-guard: odrzuca „image bomby" zanim sharp spróbuje je przetworzyć. */
|
|
7
|
+
export declare function assertWithinPixelLimit(width: number, height: number, limitPixels: number): void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
/** Globalne limity sharp — wołane raz przy init CMS. Chroni proces przed wysyceniem CPU/RAM. */
|
|
3
|
+
export function applySharpLimits(opts) {
|
|
4
|
+
sharp.concurrency(opts.concurrency);
|
|
5
|
+
sharp.cache({ memory: opts.cacheMemoryMB, files: 0 });
|
|
6
|
+
}
|
|
7
|
+
/** Poison-guard: odrzuca „image bomby" zanim sharp spróbuje je przetworzyć. */
|
|
8
|
+
export function assertWithinPixelLimit(width, height, limitPixels) {
|
|
9
|
+
if (width * height > limitPixels) {
|
|
10
|
+
throw new Error(`image exceeds pixel limit (${width}x${height} > ${limitPixels}px)`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -688,7 +688,7 @@ export function pg(config) {
|
|
|
688
688
|
const rows = await db.execute(sql `
|
|
689
689
|
INSERT INTO image_styles (id, media_file_id, name, url, width, height, crop, quality, media, mime_type, aspect_ratio)
|
|
690
690
|
VALUES (gen_random_uuid(), ${mediaFileId}, ${style.name}, ${file.url}, ${style.width ?? null}, ${style.height ?? null}, ${style.crop ?? false}, ${style.quality ?? null}, ${style.media ?? null}, ${mimeType}, ${style.aspectRatio ?? null})
|
|
691
|
-
ON CONFLICT (media_file_id, name, COALESCE(width, 0), COALESCE(height, 0), COALESCE(quality, 0), COALESCE(aspect_ratio, 0))
|
|
691
|
+
ON CONFLICT (media_file_id, name, COALESCE(width, 0), COALESCE(height, 0), COALESCE(quality, 0), COALESCE(aspect_ratio, 0), crop)
|
|
692
692
|
DO UPDATE SET url = EXCLUDED.url, mime_type = EXCLUDED.mime_type
|
|
693
693
|
RETURNING url, mime_type, media
|
|
694
694
|
`);
|
|
@@ -699,6 +699,118 @@ export function pg(config) {
|
|
|
699
699
|
media: row.media ? row.media : undefined
|
|
700
700
|
};
|
|
701
701
|
},
|
|
702
|
+
enqueueMediaJob: async (input) => {
|
|
703
|
+
await db
|
|
704
|
+
.insert(schema.mediaJobTable)
|
|
705
|
+
.values({
|
|
706
|
+
type: input.type,
|
|
707
|
+
mediaFileId: input.mediaFileId,
|
|
708
|
+
payload: input.payload,
|
|
709
|
+
dedupeKey: input.dedupeKey,
|
|
710
|
+
priority: input.priority ?? 0,
|
|
711
|
+
maxAttempts: input.maxAttempts ?? 3
|
|
712
|
+
})
|
|
713
|
+
.onConflictDoNothing({ target: schema.mediaJobTable.dedupeKey });
|
|
714
|
+
},
|
|
715
|
+
claimNextMediaJob: async (lockedBy) => {
|
|
716
|
+
return db.transaction(async (tx) => {
|
|
717
|
+
const rows = await tx.execute(sql `
|
|
718
|
+
SELECT id FROM media_job
|
|
719
|
+
WHERE status = 'pending' AND run_after <= now()
|
|
720
|
+
ORDER BY priority DESC, run_after ASC
|
|
721
|
+
LIMIT 1 FOR UPDATE SKIP LOCKED
|
|
722
|
+
`);
|
|
723
|
+
const id = rows[0]?.id;
|
|
724
|
+
if (!id)
|
|
725
|
+
return null;
|
|
726
|
+
const [job] = await tx
|
|
727
|
+
.update(schema.mediaJobTable)
|
|
728
|
+
.set({ status: 'processing', lockedAt: new Date(), lockedBy, updatedAt: new Date() })
|
|
729
|
+
.where(eq(schema.mediaJobTable.id, id))
|
|
730
|
+
.returning();
|
|
731
|
+
return job ?? null;
|
|
732
|
+
});
|
|
733
|
+
},
|
|
734
|
+
completeMediaJob: async (id) => {
|
|
735
|
+
await db
|
|
736
|
+
.update(schema.mediaJobTable)
|
|
737
|
+
.set({
|
|
738
|
+
status: 'done',
|
|
739
|
+
completedAt: new Date(),
|
|
740
|
+
updatedAt: new Date(),
|
|
741
|
+
lockedAt: null,
|
|
742
|
+
lockedBy: null
|
|
743
|
+
})
|
|
744
|
+
.where(eq(schema.mediaJobTable.id, id));
|
|
745
|
+
},
|
|
746
|
+
failMediaJob: async (id, error, opts) => {
|
|
747
|
+
await db
|
|
748
|
+
.update(schema.mediaJobTable)
|
|
749
|
+
.set({
|
|
750
|
+
status: opts.retry ? 'pending' : 'failed',
|
|
751
|
+
attempts: sql `${schema.mediaJobTable.attempts} + 1`,
|
|
752
|
+
lastError: error,
|
|
753
|
+
runAfter: opts.runAfter ?? new Date(),
|
|
754
|
+
lockedAt: null,
|
|
755
|
+
lockedBy: null,
|
|
756
|
+
updatedAt: new Date(),
|
|
757
|
+
completedAt: opts.retry ? null : new Date()
|
|
758
|
+
})
|
|
759
|
+
.where(eq(schema.mediaJobTable.id, id));
|
|
760
|
+
},
|
|
761
|
+
resetStaleMediaJobs: async (olderThan) => {
|
|
762
|
+
const res = await db
|
|
763
|
+
.update(schema.mediaJobTable)
|
|
764
|
+
.set({ status: 'pending', lockedAt: null, lockedBy: null, updatedAt: new Date() })
|
|
765
|
+
.where(and(eq(schema.mediaJobTable.status, 'processing'), lte(schema.mediaJobTable.lockedAt, olderThan)))
|
|
766
|
+
.returning({ id: schema.mediaJobTable.id });
|
|
767
|
+
return res.length;
|
|
768
|
+
},
|
|
769
|
+
listMediaJobs: async (opts) => {
|
|
770
|
+
const conds = [];
|
|
771
|
+
if (opts.status)
|
|
772
|
+
conds.push(eq(schema.mediaJobTable.status, opts.status));
|
|
773
|
+
if (opts.type)
|
|
774
|
+
conds.push(eq(schema.mediaJobTable.type, opts.type));
|
|
775
|
+
const rows = await db
|
|
776
|
+
.select()
|
|
777
|
+
.from(schema.mediaJobTable)
|
|
778
|
+
.where(conds.length ? and(...conds) : undefined)
|
|
779
|
+
.orderBy(desc(schema.mediaJobTable.updatedAt))
|
|
780
|
+
.limit(opts.limit)
|
|
781
|
+
.offset(opts.offset ?? 0);
|
|
782
|
+
return rows;
|
|
783
|
+
},
|
|
784
|
+
countMediaJobs: async () => {
|
|
785
|
+
const rows = await db
|
|
786
|
+
.select({ status: schema.mediaJobTable.status, c: count() })
|
|
787
|
+
.from(schema.mediaJobTable)
|
|
788
|
+
.groupBy(schema.mediaJobTable.status);
|
|
789
|
+
const out = {
|
|
790
|
+
pending: 0,
|
|
791
|
+
processing: 0,
|
|
792
|
+
done: 0,
|
|
793
|
+
failed: 0,
|
|
794
|
+
skipped: 0
|
|
795
|
+
};
|
|
796
|
+
for (const r of rows)
|
|
797
|
+
out[r.status] = Number(r.c);
|
|
798
|
+
return out;
|
|
799
|
+
},
|
|
800
|
+
retryMediaJob: async (id) => {
|
|
801
|
+
await db
|
|
802
|
+
.update(schema.mediaJobTable)
|
|
803
|
+
.set({
|
|
804
|
+
status: 'pending',
|
|
805
|
+
attempts: 0,
|
|
806
|
+
lastError: null,
|
|
807
|
+
runAfter: new Date(),
|
|
808
|
+
lockedAt: null,
|
|
809
|
+
lockedBy: null,
|
|
810
|
+
updatedAt: new Date()
|
|
811
|
+
})
|
|
812
|
+
.where(eq(schema.mediaJobTable.id, id));
|
|
813
|
+
},
|
|
702
814
|
deleteMediaFile: async (id) => {
|
|
703
815
|
await db.delete(schema.mediaFilesTable).where(eq(schema.mediaFilesTable.id, id));
|
|
704
816
|
},
|
|
@@ -13,5 +13,5 @@ export const imageStylesTable = pgTable('image_styles', {
|
|
|
13
13
|
mimeType: text('mime_type').notNull(),
|
|
14
14
|
aspectRatio: real('aspect_ratio')
|
|
15
15
|
}, (t) => [
|
|
16
|
-
uniqueIndex('image_styles_unique_key').on(t.mediaFileId, t.name, sql `COALESCE(${t.width}, 0)`, sql `COALESCE(${t.height}, 0)`, sql `COALESCE(${t.quality}, 0)`, sql `COALESCE(${t.aspectRatio}, 0)
|
|
16
|
+
uniqueIndex('image_styles_unique_key').on(t.mediaFileId, t.name, sql `COALESCE(${t.width}, 0)`, sql `COALESCE(${t.height}, 0)`, sql `COALESCE(${t.quality}, 0)`, sql `COALESCE(${t.aspectRatio}, 0)`, t.crop)
|
|
17
17
|
]);
|
|
@@ -225,6 +225,23 @@ export declare const mediaFilesTable: import("drizzle-orm/pg-core/table", { with
|
|
|
225
225
|
identity: undefined;
|
|
226
226
|
generated: undefined;
|
|
227
227
|
}, {}, {}>;
|
|
228
|
+
fallbackUrl: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
|
|
229
|
+
name: "fallback_url";
|
|
230
|
+
tableName: "media_file";
|
|
231
|
+
dataType: "string";
|
|
232
|
+
columnType: "PgText";
|
|
233
|
+
data: string;
|
|
234
|
+
driverParam: string;
|
|
235
|
+
notNull: false;
|
|
236
|
+
hasDefault: false;
|
|
237
|
+
isPrimaryKey: false;
|
|
238
|
+
isAutoincrement: false;
|
|
239
|
+
hasRuntimeDefault: false;
|
|
240
|
+
enumValues: [string, ...string[]];
|
|
241
|
+
baseColumn: never;
|
|
242
|
+
identity: undefined;
|
|
243
|
+
generated: undefined;
|
|
244
|
+
}, {}, {}>;
|
|
228
245
|
focalX: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
|
|
229
246
|
name: "focal_x";
|
|
230
247
|
tableName: "media_file";
|
|
@@ -15,6 +15,7 @@ export const mediaFilesTable = pgTable('media_file', {
|
|
|
15
15
|
posterUrl: text('poster_url'), // obraz poster (alternatywa dla thumbnail)
|
|
16
16
|
mimeType: text('mime_type'), // dokładny typ MIME (np. video/mp4, video/webm)
|
|
17
17
|
blurDataUrl: text('blur_data_url'), // LQIP placeholder for images
|
|
18
|
+
fallbackUrl: text('fallback_url'), // lekki fallback serwowany dopóki warianty pending
|
|
18
19
|
focalX: real('focal_x'), // focal point X (0-1)
|
|
19
20
|
focalY: real('focal_y'), // focal point Y (0-1)
|
|
20
21
|
// Accessibility: powiązane pliki (transkrypcja, audiodeskrypcja)
|