includio-cms 0.36.6 → 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.
Files changed (68) hide show
  1. package/API.md +8 -2
  2. package/CHANGELOG.md +30 -0
  3. package/DOCS.md +1 -1
  4. package/dist/admin/api/handler.js +2 -0
  5. package/dist/admin/api/media-jobs.d.ts +5 -0
  6. package/dist/admin/api/media-jobs.js +33 -0
  7. package/dist/admin/client/maintenance/maintenance-page.svelte +9 -0
  8. package/dist/admin/client/maintenance/media-jobs-panel.svelte +310 -0
  9. package/dist/admin/client/maintenance/media-jobs-panel.svelte.d.ts +3 -0
  10. package/dist/core/cms.js +7 -0
  11. package/dist/core/server/fields/utils/imageStyles.js +41 -4
  12. package/dist/core/server/index.d.ts +3 -0
  13. package/dist/core/server/index.js +3 -0
  14. package/dist/core/server/media/jobs/enqueue.d.ts +6 -0
  15. package/dist/core/server/media/jobs/enqueue.js +20 -0
  16. package/dist/core/server/media/jobs/processImageStyleJob.d.ts +19 -0
  17. package/dist/core/server/media/jobs/processImageStyleJob.js +23 -0
  18. package/dist/core/server/media/jobs/startWorker.d.ts +2 -0
  19. package/dist/core/server/media/jobs/startWorker.js +29 -0
  20. package/dist/core/server/media/jobs/worker.d.ts +29 -0
  21. package/dist/core/server/media/jobs/worker.js +54 -0
  22. package/dist/core/server/media/operations/replaceFile.js +2 -2
  23. package/dist/core/server/media/operations/uploadFile.js +10 -3
  24. package/dist/core/server/media/styles/buildStyleSpecs.d.ts +12 -0
  25. package/dist/core/server/media/styles/buildStyleSpecs.js +36 -0
  26. package/dist/core/server/media/styles/imagesConfig.d.ts +3 -0
  27. package/dist/core/server/media/styles/imagesConfig.js +16 -0
  28. package/dist/core/server/media/styles/operations/batchGenerateStyles.js +19 -41
  29. package/dist/core/server/media/styles/operations/generateFallbackVariant.d.ts +10 -0
  30. package/dist/core/server/media/styles/operations/generateFallbackVariant.js +21 -0
  31. package/dist/core/server/media/styles/operations/getImageStyle.js +12 -8
  32. package/dist/core/server/media/styles/sharp/sharpConfig.d.ts +7 -0
  33. package/dist/core/server/media/styles/sharp/sharpConfig.js +12 -0
  34. package/dist/db-postgres/index.js +113 -1
  35. package/dist/db-postgres/schema/imageStyle.js +1 -1
  36. package/dist/db-postgres/schema/index.d.ts +1 -0
  37. package/dist/db-postgres/schema/index.js +1 -0
  38. package/dist/db-postgres/schema/mediaFile.d.ts +17 -0
  39. package/dist/db-postgres/schema/mediaFile.js +1 -0
  40. package/dist/db-postgres/schema/mediaJob.d.ts +279 -0
  41. package/dist/db-postgres/schema/mediaJob.js +23 -0
  42. package/dist/paraglide/messages/_index.d.ts +36 -3
  43. package/dist/paraglide/messages/_index.js +71 -3
  44. package/dist/paraglide/messages/en.d.ts +5 -0
  45. package/dist/paraglide/messages/en.js +14 -0
  46. package/dist/paraglide/messages/pl.d.ts +5 -0
  47. package/dist/paraglide/messages/pl.js +14 -0
  48. package/dist/shop/adapters/fakturownia/payload.d.ts +2 -0
  49. package/dist/shop/adapters/fakturownia/payload.js +1 -0
  50. package/dist/shop/server/invoices.d.ts +16 -2
  51. package/dist/shop/server/invoices.js +86 -9
  52. package/dist/shop/types.d.ts +5 -0
  53. package/dist/sveltekit/components/image.svelte +1 -1
  54. package/dist/types/adapters/db.d.ts +51 -0
  55. package/dist/types/cms.d.ts +46 -0
  56. package/dist/types/media.d.ts +1 -0
  57. package/dist/updates/0.36.7/index.d.ts +2 -0
  58. package/dist/updates/0.36.7/index.js +13 -0
  59. package/dist/updates/0.36.8/index.d.ts +2 -0
  60. package/dist/updates/0.36.8/index.js +20 -0
  61. package/dist/updates/index.js +5 -1
  62. package/package.json +1 -1
  63. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  64. package/dist/paraglide/messages/hello_world.js +0 -33
  65. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  66. package/dist/paraglide/messages/login_hello.js +0 -34
  67. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  68. package/dist/paraglide/messages/login_please_login.js +0 -34
@@ -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 { generateDefaultStylesInBackground } from '../styles/operations/generateDefaultStyles.js';
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
- generateDefaultStylesInBackground(updated);
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 { generateDefaultStylesInBackground } from '../styles/operations/generateDefaultStyles.js';
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
- generateDefaultStylesInBackground(created);
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,3 @@
1
+ import type { ImagesConfig, ImagesConfigInput } from '../../../../types/cms.js';
2
+ /** Łączy wejściową (płytko-partial) konfigurację obrazów z domyślną. */
3
+ export declare function resolveImagesConfig(cfg?: ImagesConfigInput): ImagesConfig;
@@ -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, defaultStyles, expandStyleFormats, getOriginalFormat } from '../../../fields/utils/imageStyles.js';
3
- import { getImageStyle } from './getImageStyle.js';
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 origFormat = getOriginalFormat(file);
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
- for (const style of expanded) {
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
- else {
25
- await getImageStyle(file.id, style);
26
- created++;
27
- }
28
- if (style.srcset && file.width) {
29
- const widths = style.srcset.filter((w) => w <= file.width);
30
- for (const w of widths) {
31
- const widthStyle = {
32
- ...style,
33
- name: `${style.name}_${w}w`,
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 origFormat = getOriginalFormat(file);
103
- const expanded = expandStyleFormats(defaultStyles, origFormat);
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
- const key = imageStyleKey(mediaFileId, style);
26
- try {
27
- const created = await createImageStyle(mediaFileId, style);
28
- cache.set(key, created);
29
- }
30
- catch (e) {
31
- console.warn(`[CMS] Failed to generate missing image style "${style.name}" for media ${mediaFileId}:`, e);
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
  ]);
@@ -7,4 +7,5 @@ export * from './formSubmission.js';
7
7
  export * from './consentLog.js';
8
8
  export * from './imageStyle.js';
9
9
  export * from './videoStyle.js';
10
+ export * from './mediaJob.js';
10
11
  export * from './shop/index.js';
@@ -7,4 +7,5 @@ export * from './formSubmission.js';
7
7
  export * from './consentLog.js';
8
8
  export * from './imageStyle.js';
9
9
  export * from './videoStyle.js';
10
+ export * from './mediaJob.js';
10
11
  export * from './shop/index.js';
@@ -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)