includio-cms 0.36.7 → 0.36.9
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 +9 -2
- package/CHANGELOG.md +29 -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/collection/collection-entries.svelte +20 -1
- package/dist/admin/client/collection/row-actions.svelte +12 -4
- package/dist/admin/client/collection/row-actions.svelte.d.ts +1 -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/admin/remote/entry.remote.d.ts +3 -0
- package/dist/admin/remote/entry.remote.js +14 -3
- package/dist/core/cms.js +7 -0
- package/dist/core/fields/dotPath.d.ts +4 -0
- package/dist/core/fields/dotPath.js +18 -0
- package/dist/core/fields/slugify.d.ts +3 -0
- package/dist/core/fields/slugify.js +4 -0
- package/dist/core/server/entries/operations/duplicate.d.ts +10 -0
- package/dist/core/server/entries/operations/duplicate.js +73 -0
- package/dist/core/server/entries/operations/slugNormalize.d.ts +8 -0
- package/dist/core/server/entries/operations/slugNormalize.js +20 -0
- package/dist/core/server/entries/operations/slugUniqueness.d.ts +17 -0
- package/dist/core/server/entries/operations/slugUniqueness.js +39 -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/0.36.9/index.d.ts +2 -0
- package/dist/updates/0.36.9/index.js +12 -0
- package/dist/updates/index.js +5 -1
- package/package.json +1 -1
package/dist/core/cms.js
CHANGED
|
@@ -178,6 +178,13 @@ export function initCMS(config) {
|
|
|
178
178
|
import('./server/media/operations/backgroundMaintenance.js')
|
|
179
179
|
.then((m) => m.startBackgroundMaintenance())
|
|
180
180
|
.catch((e) => console.warn('[cms] Failed to start background maintenance:', e));
|
|
181
|
+
// Start in-process media optimization worker (generuje warianty poza ścieżką renderu).
|
|
182
|
+
// Wyłączalny przez media.optimization.worker = false.
|
|
183
|
+
if (cms.mediaConfig.optimization?.worker !== false) {
|
|
184
|
+
import('./server/media/jobs/startWorker.js')
|
|
185
|
+
.then((m) => m.startMediaOptimizationWorker())
|
|
186
|
+
.catch((e) => console.warn('[cms] Failed to start media worker:', e));
|
|
187
|
+
}
|
|
181
188
|
// Apply shop variantAttribute GIN indexes (idempotent CREATE INDEX IF NOT EXISTS).
|
|
182
189
|
// Pass shop + drizzle explicitly so the dynamic import doesn't depend on a
|
|
183
190
|
// shared CMS singleton (vitest can give the dynamic module a fresh instance).
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Read a value at a dot-path. Returns undefined if any segment is missing. */
|
|
2
|
+
export declare function getAtPath(obj: Record<string, any>, path: string): any;
|
|
3
|
+
/** Set a value at a dot-path, creating intermediate objects. Mutates `obj` in place. */
|
|
4
|
+
export declare function setAtPath(obj: Record<string, unknown>, path: string, value: unknown): void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Read a value at a dot-path. Returns undefined if any segment is missing. */
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3
|
+
export function getAtPath(obj, path) {
|
|
4
|
+
const parts = path.split('.');
|
|
5
|
+
return parts.reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj);
|
|
6
|
+
}
|
|
7
|
+
/** Set a value at a dot-path, creating intermediate objects. Mutates `obj` in place. */
|
|
8
|
+
export function setAtPath(obj, path, value) {
|
|
9
|
+
const parts = path.split('.');
|
|
10
|
+
let current = obj;
|
|
11
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
12
|
+
const key = parts[i];
|
|
13
|
+
if (!current[key] || typeof current[key] !== 'object')
|
|
14
|
+
current[key] = {};
|
|
15
|
+
current = current[key];
|
|
16
|
+
}
|
|
17
|
+
current[parts[parts.length - 1]] = value;
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duplicate a collection entry as an unpublished copy. Copies the freshest
|
|
3
|
+
* content per language (draft ?? published ?? scheduled), prefixes the admin
|
|
4
|
+
* title, makes each slug unique (`-kopia`), and creates draft (publishedAt:
|
|
5
|
+
* null) versions on a fresh entry. Media/relation references are shared
|
|
6
|
+
* (shallow). Throws on archived source. Returns the new entry id.
|
|
7
|
+
*/
|
|
8
|
+
export declare function duplicateEntry(sourceId: string): Promise<{
|
|
9
|
+
id: string;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import slugify from '../../../fields/slugify.js';
|
|
2
|
+
import { getCMS } from '../../../cms.js';
|
|
3
|
+
import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
|
|
4
|
+
import { getAtPath, setAtPath } from '../../../fields/dotPath.js';
|
|
5
|
+
import { _getRawEntryOrThrow as getRawEntryOrThrow } from './get.js';
|
|
6
|
+
import { createEntryVersion } from './create.js';
|
|
7
|
+
import { collectSlugFieldPaths, deriveUniqueSlug } from './slugUniqueness.js';
|
|
8
|
+
const TITLE_PREFIX = 'Kopia — ';
|
|
9
|
+
/**
|
|
10
|
+
* Duplicate a collection entry as an unpublished copy. Copies the freshest
|
|
11
|
+
* content per language (draft ?? published ?? scheduled), prefixes the admin
|
|
12
|
+
* title, makes each slug unique (`-kopia`), and creates draft (publishedAt:
|
|
13
|
+
* null) versions on a fresh entry. Media/relation references are shared
|
|
14
|
+
* (shallow). Throws on archived source. Returns the new entry id.
|
|
15
|
+
*/
|
|
16
|
+
export async function duplicateEntry(sourceId) {
|
|
17
|
+
const cms = getCMS();
|
|
18
|
+
const source = await getRawEntryOrThrow({ id: sourceId });
|
|
19
|
+
if (source.archivedAt != null) {
|
|
20
|
+
throw new Error('Cannot duplicate an archived entry');
|
|
21
|
+
}
|
|
22
|
+
const config = cms.getBySlug(source.slug);
|
|
23
|
+
const slugPaths = collectSlugFieldPaths(getFieldsFromConfig(config));
|
|
24
|
+
const titlePath = 'entryAdminTitle' in config ? config.entryAdminTitle : undefined;
|
|
25
|
+
// Languages that have any content; freshest version wins per language.
|
|
26
|
+
const langs = new Set([
|
|
27
|
+
...Object.keys(source.draftVersions ?? {}),
|
|
28
|
+
...Object.keys(source.publishedVersions ?? {}),
|
|
29
|
+
...Object.keys(source.scheduledVersions ?? {})
|
|
30
|
+
]);
|
|
31
|
+
const perLang = {};
|
|
32
|
+
for (const lang of langs) {
|
|
33
|
+
const version = source.draftVersions?.[lang] ??
|
|
34
|
+
source.publishedVersions?.[lang] ??
|
|
35
|
+
source.scheduledVersions?.[lang] ??
|
|
36
|
+
null;
|
|
37
|
+
if (!version)
|
|
38
|
+
continue;
|
|
39
|
+
perLang[lang] = structuredClone(version.data);
|
|
40
|
+
}
|
|
41
|
+
// Transform each copy: title prefix + unique, slugified slug.
|
|
42
|
+
for (const [lang, data] of Object.entries(perLang)) {
|
|
43
|
+
if (titlePath) {
|
|
44
|
+
const title = getAtPath(data, titlePath);
|
|
45
|
+
if (typeof title === 'string' && title.trim() !== '') {
|
|
46
|
+
setAtPath(data, titlePath, `${TITLE_PREFIX}${title}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const path of slugPaths) {
|
|
50
|
+
const value = getAtPath(data, path);
|
|
51
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
52
|
+
const base = slugify(value, { lower: true, strict: true, trim: true });
|
|
53
|
+
const unique = await deriveUniqueSlug({ collection: source.slug, lang, path, base });
|
|
54
|
+
setAtPath(data, path, unique);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const newEntry = await cms.databaseAdapter.createEntry({
|
|
59
|
+
type: source.type,
|
|
60
|
+
slug: source.slug
|
|
61
|
+
});
|
|
62
|
+
const entries = Object.entries(perLang);
|
|
63
|
+
if (entries.length === 0) {
|
|
64
|
+
// Source had no content — seed an empty default-language draft.
|
|
65
|
+
await createEntryVersion({ entryId: newEntry.id, lang: cms.languages[0], data: {}, publishedAt: null }, { skipValidation: true });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
for (const [lang, data] of entries) {
|
|
69
|
+
await createEntryVersion({ entryId: newEntry.id, lang, data, publishedAt: null }, { skipValidation: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { id: newEntry.id };
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CollectionConfigWithType } from '../../../../types/collections.js';
|
|
2
|
+
import type { SingleConfigWithType } from '../../../../types/singles.js';
|
|
3
|
+
/**
|
|
4
|
+
* Return a copy of `data` where every slug-typed field (SlugField / seo.slug /
|
|
5
|
+
* nested) is normalized through slugify. Non-string / empty values and non-slug
|
|
6
|
+
* fields are left untouched. Does not mutate the input.
|
|
7
|
+
*/
|
|
8
|
+
export declare function normalizeSlugFields(config: CollectionConfigWithType | SingleConfigWithType, data: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import slugify from '../../../fields/slugify.js';
|
|
2
|
+
import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
|
|
3
|
+
import { getAtPath, setAtPath } from '../../../fields/dotPath.js';
|
|
4
|
+
import { collectSlugFieldPaths } from './slugUniqueness.js';
|
|
5
|
+
/**
|
|
6
|
+
* Return a copy of `data` where every slug-typed field (SlugField / seo.slug /
|
|
7
|
+
* nested) is normalized through slugify. Non-string / empty values and non-slug
|
|
8
|
+
* fields are left untouched. Does not mutate the input.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeSlugFields(config, data) {
|
|
11
|
+
const slugPaths = collectSlugFieldPaths(getFieldsFromConfig(config));
|
|
12
|
+
const out = structuredClone(data);
|
|
13
|
+
for (const path of slugPaths) {
|
|
14
|
+
const value = getAtPath(out, path);
|
|
15
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
16
|
+
setAtPath(out, path, slugify(value, { lower: true, strict: true, trim: true }));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
@@ -35,3 +35,20 @@ export interface AssertSlugUniqueArgs {
|
|
|
35
35
|
* which slug-field collided).
|
|
36
36
|
*/
|
|
37
37
|
export declare function assertSlugUnique({ collection, lang, data, excludeEntryId }: AssertSlugUniqueArgs): Promise<void>;
|
|
38
|
+
export interface DeriveUniqueSlugArgs {
|
|
39
|
+
/** Collection slug (config slug). */
|
|
40
|
+
collection: string;
|
|
41
|
+
/** Content language whose slugs must not collide. */
|
|
42
|
+
lang: string;
|
|
43
|
+
/** Dot-path of the slug field. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** Already-slugified base value to build the copy from. */
|
|
46
|
+
base: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build a slug based on `base` that does not collide with any published-or-
|
|
50
|
+
* scheduled slug at `path` in `collection` + `lang`. Always appends the
|
|
51
|
+
* `-kopia` marker; on further collision appends `-kopia-2`, `-kopia-3`, … .
|
|
52
|
+
* Drafts, archived entries and other languages are ignored.
|
|
53
|
+
*/
|
|
54
|
+
export declare function deriveUniqueSlug({ collection, lang, path, base }: DeriveUniqueSlugArgs): Promise<string>;
|
|
@@ -114,3 +114,42 @@ export async function assertSlugUnique({ collection, lang, data, excludeEntryId
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Build a slug based on `base` that does not collide with any published-or-
|
|
119
|
+
* scheduled slug at `path` in `collection` + `lang`. Always appends the
|
|
120
|
+
* `-kopia` marker; on further collision appends `-kopia-2`, `-kopia-3`, … .
|
|
121
|
+
* Drafts, archived entries and other languages are ignored.
|
|
122
|
+
*/
|
|
123
|
+
export async function deriveUniqueSlug({ collection, lang, path, base }) {
|
|
124
|
+
const cms = getCMS();
|
|
125
|
+
const dbEntries = await cms.databaseAdapter.getEntries({ slug: collection });
|
|
126
|
+
const ids = dbEntries.filter((e) => e.archivedAt == null).map((e) => e.id);
|
|
127
|
+
const used = new Set();
|
|
128
|
+
if (ids.length > 0) {
|
|
129
|
+
const versions = await cms.databaseAdapter.getEntryVersions({ entryIds: ids, lang });
|
|
130
|
+
const latestPerEntry = new Map();
|
|
131
|
+
for (const v of versions) {
|
|
132
|
+
if (v.publishedAt == null)
|
|
133
|
+
continue;
|
|
134
|
+
const cur = latestPerEntry.get(v.entryId);
|
|
135
|
+
if (!cur || v.versionNumber > cur.versionNumber) {
|
|
136
|
+
latestPerEntry.set(v.entryId, {
|
|
137
|
+
versionNumber: v.versionNumber,
|
|
138
|
+
data: v.data
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const { data } of latestPerEntry.values()) {
|
|
143
|
+
const s = readSlugAtPath(data, path, lang);
|
|
144
|
+
if (typeof s === 'string' && s.trim() !== '')
|
|
145
|
+
used.add(s);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let candidate = `${base}-kopia`;
|
|
149
|
+
let n = 2;
|
|
150
|
+
while (used.has(candidate)) {
|
|
151
|
+
candidate = `${base}-kopia-${n}`;
|
|
152
|
+
n++;
|
|
153
|
+
}
|
|
154
|
+
return candidate;
|
|
155
|
+
}
|
|
@@ -2,6 +2,8 @@ import { getImageStylesBatch } from '../../media/styles/operations/getImageStyle
|
|
|
2
2
|
import { imageStyleKey } from '../../../../types/adapters/db.js';
|
|
3
3
|
import { getCMS } from '../../../cms.js';
|
|
4
4
|
import { generateBlurDataUrl } from '../../media/utils/generateBlurDataUrl.js';
|
|
5
|
+
import { buildStyleSpecs } from '../../media/styles/buildStyleSpecs.js';
|
|
6
|
+
import { resolveImagesConfig } from '../../media/styles/imagesConfig.js';
|
|
5
7
|
export const defaultStyles = [
|
|
6
8
|
{ name: 'default', srcset: [640, 1024, 1920, 2560] }
|
|
7
9
|
];
|
|
@@ -72,10 +74,45 @@ function variantStyleFor(style, w) {
|
|
|
72
74
|
}
|
|
73
75
|
export async function getImageStyles(field, val) {
|
|
74
76
|
const hasCustomStyles = field.styles && field.styles.length > 0;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// Custom field.styles: zachowaj dotychczasową semantykę (expand formatów + baza pełno-res + srcset).
|
|
78
|
+
if (hasCustomStyles) {
|
|
79
|
+
return resolveLegacyStyles(expandStyleFormats(field.styles, getOriginalFormat(val)), val);
|
|
80
|
+
}
|
|
81
|
+
// Domyślnie: nowa lekka matryca z configu (avif+webp × szerokości, BEZ bazy pełno-res).
|
|
82
|
+
// Grupujemy warianty po formacie → jeden <source> per format ze srcset (bez bramki bazy).
|
|
83
|
+
const specs = isProcessableImage(val)
|
|
84
|
+
? buildStyleSpecs(val, undefined, resolveImagesConfig(getCMS().mediaConfig.images))
|
|
85
|
+
: [];
|
|
86
|
+
const requests = specs.map((style) => ({ mediaFileId: val.id, style }));
|
|
87
|
+
const [styleMap, blurDataUrl] = await Promise.all([
|
|
88
|
+
getImageStylesBatch(requests),
|
|
89
|
+
ensureBlurDataUrl(val)
|
|
90
|
+
]);
|
|
91
|
+
const byFormat = new Map();
|
|
92
|
+
for (const style of specs) {
|
|
93
|
+
const data = styleMap.get(imageStyleKey(val.id, style));
|
|
94
|
+
if (!data || typeof style.width !== 'number')
|
|
95
|
+
continue;
|
|
96
|
+
const fmt = style.format ?? 'orig';
|
|
97
|
+
const group = byFormat.get(fmt) ?? { media: data.media, mimeType: data.mimeType, variants: [] };
|
|
98
|
+
group.variants.push({ url: data.url, w: style.width });
|
|
99
|
+
byFormat.set(fmt, group);
|
|
100
|
+
}
|
|
101
|
+
const styles = {};
|
|
102
|
+
for (const [fmt, group] of byFormat) {
|
|
103
|
+
group.variants.sort((a, b) => a.w - b.w);
|
|
104
|
+
const largest = group.variants[group.variants.length - 1];
|
|
105
|
+
styles[`default_${fmt}`] = {
|
|
106
|
+
url: largest.url,
|
|
107
|
+
media: group.media,
|
|
108
|
+
mimeType: group.mimeType,
|
|
109
|
+
srcset: group.variants.map((v) => `${v.url} ${v.w}w`).join(', ')
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { styles, blurDataUrl };
|
|
113
|
+
}
|
|
114
|
+
/** Stara ścieżka dla custom field.styles: styl bazowy pełno-res (bramka istnienia) + srcset wariantów. */
|
|
115
|
+
async function resolveLegacyStyles(stylesArr, val) {
|
|
79
116
|
const requests = [];
|
|
80
117
|
for (const style of stylesArr) {
|
|
81
118
|
requests.push({ mediaFileId: val.id, style });
|
|
@@ -2,3 +2,6 @@ export { getCMS } from '../cms.js';
|
|
|
2
2
|
export { resolveMediaWithStyles, type ResolvedMedia } from './fields/utils/resolveMedia.js';
|
|
3
3
|
export { createEntityAPI } from '../../entity/index.js';
|
|
4
4
|
export { getAuth } from '../../server/auth.js';
|
|
5
|
+
export { enqueueStyleSpecs } from './media/jobs/enqueue.js';
|
|
6
|
+
export { startMediaWorker, stopMediaWorker } from './media/jobs/worker.js';
|
|
7
|
+
export type { MediaJob, MediaJobStatus } from '../../types/adapters/db.js';
|
|
@@ -2,3 +2,6 @@ export { getCMS } from '../cms.js';
|
|
|
2
2
|
export { resolveMediaWithStyles } from './fields/utils/resolveMedia.js';
|
|
3
3
|
export { createEntityAPI } from '../../entity/index.js';
|
|
4
4
|
export { getAuth } from '../../server/auth.js';
|
|
5
|
+
// Optymalizacja obrazów w tle (media_job)
|
|
6
|
+
export { enqueueStyleSpecs } from './media/jobs/enqueue.js';
|
|
7
|
+
export { startMediaWorker, stopMediaWorker } from './media/jobs/worker.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ImageFieldStyle } from '../../../../types/fields.js';
|
|
2
|
+
import type { MediaFile } from '../../../../types/media.js';
|
|
3
|
+
/** Kolejkuje generację wszystkich wariantów dla media (upload / render-miss / sweep). Idempotentne po dedupeKey. */
|
|
4
|
+
export declare function enqueueStyleSpecs(mediaFile: Pick<MediaFile, 'id' | 'width' | 'height'>, fieldStyles: ImageFieldStyle[] | undefined, opts?: {
|
|
5
|
+
priority?: number;
|
|
6
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getCMS } from '../../../cms.js';
|
|
2
|
+
import { resolveImagesConfig } from '../styles/imagesConfig.js';
|
|
3
|
+
import { buildStyleSpecs, styleDedupeKey } from '../styles/buildStyleSpecs.js';
|
|
4
|
+
/** Kolejkuje generację wszystkich wariantów dla media (upload / render-miss / sweep). Idempotentne po dedupeKey. */
|
|
5
|
+
export async function enqueueStyleSpecs(mediaFile, fieldStyles, opts = {}) {
|
|
6
|
+
const cms = getCMS();
|
|
7
|
+
const cfg = resolveImagesConfig(cms.mediaConfig.images);
|
|
8
|
+
const specs = buildStyleSpecs(mediaFile, fieldStyles, cfg);
|
|
9
|
+
for (const style of specs) {
|
|
10
|
+
await cms.databaseAdapter
|
|
11
|
+
.enqueueMediaJob({
|
|
12
|
+
type: 'image_style',
|
|
13
|
+
mediaFileId: mediaFile.id,
|
|
14
|
+
payload: JSON.stringify(style),
|
|
15
|
+
dedupeKey: styleDedupeKey(mediaFile.id, style),
|
|
16
|
+
priority: opts.priority ?? 0
|
|
17
|
+
})
|
|
18
|
+
.catch((e) => console.warn('[media] enqueue failed:', e));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MediaJob } from '../../../../types/adapters/db.js';
|
|
2
|
+
import type { MediaFile, ImageStyle } from '../../../../types/media.js';
|
|
3
|
+
import type { ImageFieldStyle } from '../../../../types/fields.js';
|
|
4
|
+
export type JobResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
retry: boolean;
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
type CreateImageStyleFn = (mediaFileId: string, style: ImageFieldStyle) => Promise<ImageStyle>;
|
|
12
|
+
export interface ProcessImageStyleDeps {
|
|
13
|
+
createImageStyle: CreateImageStyleFn;
|
|
14
|
+
getMediaFile: (id: string) => Promise<MediaFile | null>;
|
|
15
|
+
limitPixels: number;
|
|
16
|
+
}
|
|
17
|
+
/** Przetwarza pojedynczy job typu image_style. Klasyfikuje błędy: poison/not-found → retry:false. */
|
|
18
|
+
export declare function processImageStyleJob(job: MediaJob, deps: ProcessImageStyleDeps): Promise<JobResult>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { assertWithinPixelLimit } from '../styles/sharp/sharpConfig.js';
|
|
2
|
+
/** Przetwarza pojedynczy job typu image_style. Klasyfikuje błędy: poison/not-found → retry:false. */
|
|
3
|
+
export async function processImageStyleJob(job, deps) {
|
|
4
|
+
const style = JSON.parse(job.payload);
|
|
5
|
+
const media = await deps.getMediaFile(job.mediaFileId);
|
|
6
|
+
if (!media)
|
|
7
|
+
return { ok: false, retry: false, error: 'media file not found' };
|
|
8
|
+
try {
|
|
9
|
+
if (media.width && media.height) {
|
|
10
|
+
assertWithinPixelLimit(media.width, media.height, deps.limitPixels);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
return { ok: false, retry: false, error: e.message };
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
await deps.createImageStyle(job.mediaFileId, style);
|
|
18
|
+
return { ok: true };
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
return { ok: false, retry: true, error: e.message };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -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
|
+
}
|