@murumets-ee/media 0.71.0 → 0.73.0
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/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/backfill-B1StTnxg.mjs +2 -0
- package/dist/backfill-B1StTnxg.mjs.map +1 -0
- package/dist/backfill-trigger-BFL1L7_j.mjs +2 -0
- package/dist/backfill-trigger-BFL1L7_j.mjs.map +1 -0
- package/dist/backlog-D-H5VUQA.mjs +2 -0
- package/dist/backlog-D-H5VUQA.mjs.map +1 -0
- package/dist/client.d.mts +65 -7
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/crop-editor.d.mts +61 -0
- package/dist/crop-editor.d.mts.map +1 -0
- package/dist/crop-editor.mjs +2 -0
- package/dist/crop-editor.mjs.map +1 -0
- package/dist/crop-rect-B-AJ2pWm.mjs +2 -0
- package/dist/crop-rect-B-AJ2pWm.mjs.map +1 -0
- package/dist/crop-rect-C-SbjoxK.d.mts +59 -0
- package/dist/crop-rect-C-SbjoxK.d.mts.map +1 -0
- package/dist/crop.d.mts +2 -1
- package/dist/crop.mjs +1 -1
- package/dist/definitions-LJpgrdxd.mjs +2 -0
- package/dist/definitions-LJpgrdxd.mjs.map +1 -0
- package/dist/deps-DaGjHO16.mjs +2 -0
- package/dist/deps-DaGjHO16.mjs.map +1 -0
- package/dist/derive-media-type-C38ZA_Cl.mjs +2 -0
- package/dist/derive-media-type-C38ZA_Cl.mjs.map +1 -0
- package/dist/en-Duj_Pp-O.mjs +2 -0
- package/dist/en-Duj_Pp-O.mjs.map +1 -0
- package/dist/entity-CsDdjKz6.mjs +2 -0
- package/dist/entity-CsDdjKz6.mjs.map +1 -0
- package/dist/et-BFdSZnb5.mjs +2 -0
- package/dist/et-BFdSZnb5.mjs.map +1 -0
- package/dist/generate-variants-Dpy4aT2o.mjs +2 -0
- package/dist/generate-variants-Dpy4aT2o.mjs.map +1 -0
- package/dist/i18n.mjs +1 -1
- package/dist/i18n.mjs.map +1 -1
- package/dist/image-styles-settings.d.mts +8 -1
- package/dist/image-styles-settings.d.mts.map +1 -1
- package/dist/image-styles-settings.mjs +1 -1
- package/dist/image-styles-settings.mjs.map +1 -1
- package/dist/image-styles.d.mts +35 -21
- package/dist/image-styles.d.mts.map +1 -1
- package/dist/image-styles.mjs +1 -2
- package/dist/image-styles.mjs.map +1 -1
- package/dist/index.d.mts +50 -42
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/media-config-DkbnXIjC.mjs +2 -0
- package/dist/media-config-DkbnXIjC.mjs.map +1 -0
- package/dist/owned-delete-Cn_KFFjS.mjs +2 -0
- package/dist/owned-delete-Cn_KFFjS.mjs.map +1 -0
- package/dist/picker.d.mts +1 -94
- package/dist/picker.d.mts.map +1 -1
- package/dist/picker.mjs +1 -2
- package/dist/picker.mjs.map +1 -1
- package/dist/plugin.d.mts +2 -1
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/process-image-wQwiRhyk.mjs +2 -0
- package/dist/process-image-wQwiRhyk.mjs.map +1 -0
- package/dist/processing.d.mts +201 -7
- package/dist/processing.d.mts.map +1 -1
- package/dist/processing.mjs +1 -1
- package/dist/public-resolver.d.mts +80 -16
- package/dist/public-resolver.d.mts.map +1 -1
- package/dist/public-resolver.mjs +1 -1
- package/dist/public-resolver.mjs.map +1 -1
- package/dist/query-client.d.mts +1 -1
- package/dist/query-client.mjs +1 -1
- package/dist/regenerate-variants-RwV_AQSo.mjs +2 -0
- package/dist/regenerate-variants-RwV_AQSo.mjs.map +1 -0
- package/dist/register-CJKjNl2w.mjs +2 -0
- package/dist/register-CJKjNl2w.mjs.map +1 -0
- package/dist/resolve-image-styles-YExI329D.mjs +2 -0
- package/dist/resolve-image-styles-YExI329D.mjs.map +1 -0
- package/dist/routes-DaE0M8h8.mjs +2 -0
- package/dist/routes-DaE0M8h8.mjs.map +1 -0
- package/dist/ru-ojvshRJy.mjs +2 -0
- package/dist/ru-ojvshRJy.mjs.map +1 -0
- package/dist/schedule-JlvfUEVA.mjs +2 -0
- package/dist/schedule-JlvfUEVA.mjs.map +1 -0
- package/dist/shapes-BpOEJeLx.mjs +2 -0
- package/dist/shapes-BpOEJeLx.mjs.map +1 -0
- package/dist/shapes-C51rXSDJ.d.mts +90 -0
- package/dist/shapes-C51rXSDJ.d.mts.map +1 -0
- package/dist/shapes-CqppzFzb.mjs +2 -0
- package/dist/shapes-CqppzFzb.mjs.map +1 -0
- package/dist/slot-CY4isqmN.mjs +2 -0
- package/dist/slot-CY4isqmN.mjs.map +1 -0
- package/dist/{types-CnORVkLZ.d.mts → types-6PZQ4Zq6.d.mts} +82 -5
- package/dist/types-6PZQ4Zq6.d.mts.map +1 -0
- package/dist/types-Lj3SkZQ_.d.mts +107 -0
- package/dist/types-Lj3SkZQ_.d.mts.map +1 -0
- package/dist/variant-plan-86tJKtVi.mjs +2 -0
- package/dist/variant-plan-86tJKtVi.mjs.map +1 -0
- package/dist/variant-state-CBtqb9Cb.mjs +2 -0
- package/dist/variant-state-CBtqb9Cb.mjs.map +1 -0
- package/package.json +21 -8
- package/dist/client-CSOH7Zun.mjs +0 -2
- package/dist/client-CSOH7Zun.mjs.map +0 -1
- package/dist/en-0YnQ_Qz-.mjs +0 -2
- package/dist/en-0YnQ_Qz-.mjs.map +0 -1
- package/dist/entity-CNxAERYG.mjs +0 -2
- package/dist/entity-CNxAERYG.mjs.map +0 -1
- package/dist/entity-DKFCdQJT.mjs +0 -2
- package/dist/entity-DKFCdQJT.mjs.map +0 -1
- package/dist/et-BIxGYWVK.mjs +0 -2
- package/dist/et-BIxGYWVK.mjs.map +0 -1
- package/dist/image-styles-settings-DEnu-V2E.mjs +0 -2
- package/dist/image-styles-settings-DEnu-V2E.mjs.map +0 -1
- package/dist/plugin-B_ZtfiAc.mjs +0 -2
- package/dist/plugin-B_ZtfiAc.mjs.map +0 -1
- package/dist/process-image-WByEHTXQ.mjs +0 -2
- package/dist/process-image-WByEHTXQ.mjs.map +0 -1
- package/dist/regenerate-variants-2Lmo5kf8.mjs +0 -2
- package/dist/regenerate-variants-2Lmo5kf8.mjs.map +0 -1
- package/dist/regenerate-variants-B9AXXN6N.mjs +0 -2
- package/dist/regenerate-variants-B9AXXN6N.mjs.map +0 -1
- package/dist/resolve-image-styles-Cakg1Qpp.mjs +0 -2
- package/dist/resolve-image-styles-Cakg1Qpp.mjs.map +0 -1
- package/dist/resolve-image-styles-iN9JbZYf.mjs +0 -2
- package/dist/resolve-image-styles-iN9JbZYf.mjs.map +0 -1
- package/dist/routes-C7Fx-055.mjs +0 -2
- package/dist/routes-C7Fx-055.mjs.map +0 -1
- package/dist/ru-UUTBfsMF.mjs +0 -2
- package/dist/ru-UUTBfsMF.mjs.map +0 -1
- package/dist/types-CnORVkLZ.d.mts.map +0 -1
- package/dist/usage-CVAqkS6h.mjs +0 -2
- package/dist/usage-CVAqkS6h.mjs.map +0 -1
- package/dist/variant-key-JBTJXPL1.mjs +0 -2
- package/dist/variant-key-JBTJXPL1.mjs.map +0 -1
- package/dist/variant-key-RKke2RfS.mjs +0 -2
- package/dist/variant-key-RKke2RfS.mjs.map +0 -1
package/dist/processing.d.mts
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as ImageVocabulary, i as ImageStyle, n as ImageFormat, r as ImageShape } from "./types-6PZQ4Zq6.mjs";
|
|
2
|
+
import { a as ResolvedShape, c as VariantConfig, i as NATURAL_SHAPE, l as buildVariantConfig, n as DEFAULT_IMAGE_SHAPES, o as ShapeAlias, r as DEFAULT_IMAGE_VOCABULARY, s as UnmappedStyle, t as DEFAULT_IMAGE_LADDER, u as resolveStyleName } from "./shapes-C51rXSDJ.mjs";
|
|
2
3
|
|
|
4
|
+
//#region src/variant-plan.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The hard ceiling on variants per image, whatever the configuration says.
|
|
7
|
+
*
|
|
8
|
+
* The vocabulary is bounded (`MAX_SHAPES` × `MAX_LADDER_RUNGS`), but legacy
|
|
9
|
+
* styles predate those bounds and each becomes a shape, so the product is not.
|
|
10
|
+
* Every variant is a decode-extract-resize-encode on the generating process, so
|
|
11
|
+
* this is the cap that keeps one upload from being an amplification vector. The
|
|
12
|
+
* seeded vocabulary plans at most 30 (four shapes and `natural`, six rungs).
|
|
13
|
+
* What it drops is COUNTED in {@link VariantPlan.dropped}, never silent.
|
|
14
|
+
*/
|
|
15
|
+
declare const MAX_VARIANTS_PER_IMAGE = 64;
|
|
16
|
+
/** The region `extract()` takes, in integral pixels of the image as drawn. */
|
|
17
|
+
interface PixelRect {
|
|
18
|
+
readonly left: number;
|
|
19
|
+
readonly top: number;
|
|
20
|
+
readonly width: number;
|
|
21
|
+
readonly height: number;
|
|
22
|
+
}
|
|
23
|
+
/** One variant to generate. */
|
|
24
|
+
interface PlannedVariant {
|
|
25
|
+
readonly shape: string;
|
|
26
|
+
/** The width to ask sharp for. Never above what {@link extract} supplies. */
|
|
27
|
+
readonly width: number;
|
|
28
|
+
/** The height to ask sharp for — derived from the shape's ratio. */
|
|
29
|
+
readonly height: number;
|
|
30
|
+
readonly extract: PixelRect;
|
|
31
|
+
readonly format: ImageFormat;
|
|
32
|
+
readonly quality: number;
|
|
33
|
+
/** {@link variantFingerprint} of this variant's recipe — goes into its key. */
|
|
34
|
+
readonly fingerprint: string;
|
|
35
|
+
}
|
|
36
|
+
interface VariantPlan {
|
|
37
|
+
readonly variants: readonly PlannedVariant[];
|
|
38
|
+
/** How many variants {@link MAX_VARIANTS_PER_IMAGE} cut off. */
|
|
39
|
+
readonly dropped: number;
|
|
40
|
+
}
|
|
41
|
+
/** What the plan needs from the media row. Untrusted — both are read leniently. */
|
|
42
|
+
interface VariantPlanInput {
|
|
43
|
+
readonly focalPoint?: unknown;
|
|
44
|
+
readonly crops?: unknown;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Plan every variant a photograph of `source` dimensions gets under `config`.
|
|
48
|
+
*
|
|
49
|
+
* `source` must be the DRAWN size (post-EXIF orientation) of the actual file —
|
|
50
|
+
* `getImageDimensions`, not the row's client-measured `width`/`height` — since
|
|
51
|
+
* the regions are cut from the file.
|
|
52
|
+
*/
|
|
53
|
+
declare function planVariants(source: {
|
|
54
|
+
readonly width: number;
|
|
55
|
+
readonly height: number;
|
|
56
|
+
}, config: VariantConfig, input: VariantPlanInput): VariantPlan;
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/jobs/variant-state.d.ts
|
|
59
|
+
/** One generated (shape × rung) variant, as recorded. */
|
|
60
|
+
interface StoredShapedVariant {
|
|
61
|
+
readonly shape: string;
|
|
62
|
+
/** The rung the variant was PLANNED at — part of its key. */
|
|
63
|
+
readonly rung: number;
|
|
64
|
+
/** What the file actually is (F003). */
|
|
65
|
+
readonly width: number;
|
|
66
|
+
readonly height: number;
|
|
67
|
+
readonly format: string;
|
|
68
|
+
readonly key: string;
|
|
69
|
+
}
|
|
70
|
+
/** Everything the original's metadata says about its variants. */
|
|
71
|
+
interface VariantState {
|
|
72
|
+
/** Legacy style name → key (`metadata.variants`). */
|
|
73
|
+
readonly legacy: ReadonlyMap<string, string>;
|
|
74
|
+
/** Legacy style name → the {@link legacyRecipe} its file was made with. */
|
|
75
|
+
readonly legacyRecipes: ReadonlyMap<string, string>;
|
|
76
|
+
readonly shaped: readonly StoredShapedVariant[];
|
|
77
|
+
/** Keys a failed pass uploaded that no reader should resolve yet — see the module doc. */
|
|
78
|
+
readonly unconfirmed: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read the variant state off an original's metadata. Lenient: the column is
|
|
82
|
+
* caller-shaped JSON, so a malformed entry is dropped rather than trusted, and
|
|
83
|
+
* a file that predates this module reads as its legacy map alone.
|
|
84
|
+
*/
|
|
85
|
+
declare function readVariantState(metadata: unknown): VariantState;
|
|
86
|
+
//#endregion
|
|
3
87
|
//#region src/process-image.d.ts
|
|
4
88
|
/** Result of processing an image through Sharp */
|
|
5
89
|
interface ProcessedImage {
|
|
@@ -31,6 +115,45 @@ declare function isProcessableImage(mimeType: string): boolean;
|
|
|
31
115
|
* @returns Metadata (width/height) and variant buffers
|
|
32
116
|
*/
|
|
33
117
|
declare function processImage(buffer: Buffer, styles: Record<string, ImageStyle>): Promise<ProcessedImage>;
|
|
118
|
+
/** One generated (shape × rung) variant. */
|
|
119
|
+
interface ShapedVariant extends ProcessedVariant {
|
|
120
|
+
readonly shape: string;
|
|
121
|
+
/** The width the plan ASKED for. It names the key; it is not a claim about the file. */
|
|
122
|
+
readonly plannedWidth: number;
|
|
123
|
+
/** Goes into the key — see `deriveShapedVariantKey`. */
|
|
124
|
+
readonly fingerprint: string;
|
|
125
|
+
}
|
|
126
|
+
/** A photograph's shaped variants. */
|
|
127
|
+
interface ShapedImage {
|
|
128
|
+
/** The image as a browser draws it (post-EXIF orientation). */
|
|
129
|
+
width: number;
|
|
130
|
+
height: number;
|
|
131
|
+
variants: ShapedVariant[];
|
|
132
|
+
/** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`) — for the caller to log. */
|
|
133
|
+
dropped: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Generate every (shape × rung) variant `config` calls for (images D003).
|
|
137
|
+
*
|
|
138
|
+
* The region comes from {@link planVariants} — `crops[shape]`, else the focal
|
|
139
|
+
* point's window for that ratio, else the whole image — and sharp runs
|
|
140
|
+
* `rotate() → extract() → resize()` in that order:
|
|
141
|
+
*
|
|
142
|
+
* - `rotate()` first, so `extract()` works in the coordinates a browser DRAWS
|
|
143
|
+
* (measured, R013). The crop and the focal point are stored in that space.
|
|
144
|
+
* - `extract()` BEFORE `resize()` — what sharp documents for pre-resize
|
|
145
|
+
* extraction, and the only way to express a rect at all: `resize({position})`
|
|
146
|
+
* takes nine gravities and two strategies and throws on coordinates (C5).
|
|
147
|
+
*
|
|
148
|
+
* 🔴 The variant records `info.width`/`info.height` — what sharp RETURNED —
|
|
149
|
+
* never the rung it asked for (F003). `withoutEnlargement` silently returns a
|
|
150
|
+
* smaller file than requested, and a `640w` descriptor on a 400px file makes a
|
|
151
|
+
* browser's `srcset` choice wrong in the direction it never corrects (R007).
|
|
152
|
+
* The plan never asks for more than the region supplies, so today the two
|
|
153
|
+
* agree; recording the real one is what keeps them agreeing when they don't.
|
|
154
|
+
*/
|
|
155
|
+
declare function generateShapedVariants(buffer: Buffer, config: VariantConfig, input: VariantPlanInput): Promise<ShapedImage>;
|
|
156
|
+
declare function renderPlannedVariant(buffer: Buffer, planned: PlannedVariant): Promise<ShapedVariant>;
|
|
34
157
|
/**
|
|
35
158
|
* Extract only image dimensions (no variant generation).
|
|
36
159
|
* Useful for getting width/height when styles are empty.
|
|
@@ -42,17 +165,46 @@ declare function getImageDimensions(buffer: Buffer): Promise<{
|
|
|
42
165
|
//#endregion
|
|
43
166
|
//#region src/variant-key.d.ts
|
|
44
167
|
/**
|
|
45
|
-
* Variant key derivation
|
|
168
|
+
* Variant key derivation — where a generated variant lives in storage.
|
|
46
169
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
170
|
+
* Two key shapes coexist (images D005):
|
|
171
|
+
*
|
|
172
|
+
* **Legacy** — one file per image STYLE, keyed by the style name alone:
|
|
49
173
|
*
|
|
50
|
-
* Convention:
|
|
51
174
|
* Original: uploads/2026/02/{uuid}/photo.jpg
|
|
52
175
|
* Variant: uploads/2026/02/{uuid}/thumbnail_photo.webp
|
|
176
|
+
*
|
|
177
|
+
* Sound only while nothing about a variant can change. It stays exactly as it
|
|
178
|
+
* is: every row that has not been regenerated under the shape model still has
|
|
179
|
+
* its files there, and ticketing derives its thumbnails with it.
|
|
180
|
+
*
|
|
181
|
+
* **Shaped** — one file per (shape × rung), carrying a FINGERPRINT of the
|
|
182
|
+
* recipe that produced it:
|
|
183
|
+
*
|
|
184
|
+
* Variant: uploads/2026/02/{uuid}/variants/photo_portrait_640w_1k2x9a0.webp
|
|
185
|
+
*
|
|
186
|
+
* The moment an editor can re-crop, a name-only key serves different bytes at
|
|
187
|
+
* the same URL, and every cache in front of it — the CDN, the browser, the
|
|
188
|
+
* content-api response that inlined the URL — keeps the old picture. So the
|
|
189
|
+
* fingerprint is of the EFFECTIVE recipe: the integral pixel rect actually
|
|
190
|
+
* extracted, the output box and the quality (R013). Same recipe → same key → a
|
|
191
|
+
* backfill that re-derives it uploads nothing. Different recipe → different key
|
|
192
|
+
* → every cache misses correctly, because it is a different file. No
|
|
193
|
+
* timestamps, no salt: determinism is what makes the backfill idempotent.
|
|
194
|
+
*
|
|
195
|
+
* ⚠️ A query-string cache-buster is not an alternative. Storage keys are what
|
|
196
|
+
* `getPublicUrls` resolves, so a query string is not part of the identity and
|
|
197
|
+
* the stale file is still the file (D005 (d); Craft's issue #3685 is that
|
|
198
|
+
* failure in a shipping product).
|
|
199
|
+
*
|
|
200
|
+
* The shaped keys live in a `variants/` directory beside the original, which is
|
|
201
|
+
* what makes the two shapes impossible to confuse: a legacy key is always a
|
|
202
|
+
* direct child of the original's directory (a style name cannot contain `/`),
|
|
203
|
+
* and both stay under the original's own prefix — which ticketing's
|
|
204
|
+
* prefix-ownership check relies on.
|
|
53
205
|
*/
|
|
54
206
|
/**
|
|
55
|
-
* Derive a variant storage key from the original key + style name.
|
|
207
|
+
* Derive a LEGACY variant storage key from the original key + style name.
|
|
56
208
|
*
|
|
57
209
|
* @param originalKey - The original file's storage key
|
|
58
210
|
* @param styleName - The image style name (e.g., 'thumbnail', 'medium')
|
|
@@ -60,6 +212,48 @@ declare function getImageDimensions(buffer: Buffer): Promise<{
|
|
|
60
212
|
* @returns The derived variant key
|
|
61
213
|
*/
|
|
62
214
|
declare function deriveVariantKey(originalKey: string, styleName: string, format?: string): string;
|
|
215
|
+
/** Everything that determines a shaped variant's bytes, apart from the source file itself. */
|
|
216
|
+
interface VariantRecipe {
|
|
217
|
+
/** The region `extract()` takes, in integral pixels of the image as DRAWN (post-EXIF). */
|
|
218
|
+
readonly extract: {
|
|
219
|
+
readonly left: number;
|
|
220
|
+
readonly top: number;
|
|
221
|
+
readonly width: number;
|
|
222
|
+
readonly height: number;
|
|
223
|
+
};
|
|
224
|
+
/** The box the region is resized to. */
|
|
225
|
+
readonly width: number;
|
|
226
|
+
readonly height: number;
|
|
227
|
+
readonly quality: number;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* A short deterministic fingerprint of a {@link VariantRecipe}: 32-bit FNV-1a
|
|
231
|
+
* over a canonical string, as 7 base-36 characters.
|
|
232
|
+
*
|
|
233
|
+
* Not cryptographic and not meant to be — it only has to differ between two
|
|
234
|
+
* recipes for the SAME file, shape and width, and nothing an attacker controls
|
|
235
|
+
* chooses between colliding recipes. Pure JS rather than `node:crypto` so the
|
|
236
|
+
* key can be derived in any realm that needs to name a variant.
|
|
237
|
+
*/
|
|
238
|
+
declare function variantFingerprint(recipe: VariantRecipe): string;
|
|
239
|
+
/** The parts a shaped variant key is made of. */
|
|
240
|
+
interface ShapedVariantKeyParts {
|
|
241
|
+
/** The shape name (`square`, `natural`, …). */
|
|
242
|
+
readonly shape: string;
|
|
243
|
+
/** The rung the variant was PLANNED at — part of the name, not a claim about the file. */
|
|
244
|
+
readonly width: number;
|
|
245
|
+
/** {@link variantFingerprint} of the recipe. */
|
|
246
|
+
readonly fingerprint: string;
|
|
247
|
+
/** Output format — the extension. */
|
|
248
|
+
readonly format: string;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Derive a SHAPED variant storage key (images D005).
|
|
252
|
+
*
|
|
253
|
+
* `photo.jpg` + `{portrait, 640, 1k2x9a0, webp}` →
|
|
254
|
+
* `<dir>/variants/photo_portrait_640w_1k2x9a0.webp`.
|
|
255
|
+
*/
|
|
256
|
+
declare function deriveShapedVariantKey(originalKey: string, parts: ShapedVariantKeyParts): string;
|
|
63
257
|
//#endregion
|
|
64
|
-
export { type ImageStyle, type ProcessedImage, type ProcessedVariant, deriveVariantKey, getImageDimensions, isProcessableImage, processImage };
|
|
258
|
+
export { DEFAULT_IMAGE_LADDER, DEFAULT_IMAGE_SHAPES, DEFAULT_IMAGE_VOCABULARY, type ImageFormat, type ImageShape, type ImageStyle, type ImageVocabulary, MAX_VARIANTS_PER_IMAGE, NATURAL_SHAPE, type PixelRect, type PlannedVariant, type ProcessedImage, type ProcessedVariant, type ResolvedShape, type ShapeAlias, type ShapedImage, type ShapedVariant, type ShapedVariantKeyParts, type StoredShapedVariant, type UnmappedStyle, type VariantConfig, type VariantPlan, type VariantPlanInput, type VariantRecipe, type VariantState, buildVariantConfig, deriveShapedVariantKey, deriveVariantKey, generateShapedVariants, getImageDimensions, isProcessableImage, planVariants, processImage, readVariantState, renderPlannedVariant, resolveStyleName, variantFingerprint };
|
|
65
259
|
//# sourceMappingURL=processing.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"processing.d.mts","names":[],"sources":["../src/process-image.ts","../src/variant-key.ts"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"processing.d.mts","names":[],"sources":["../src/variant-plan.ts","../src/jobs/variant-state.ts","../src/process-image.ts","../src/variant-key.ts"],"mappings":"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"}
|
package/dist/processing.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{a as e,n as t,r as n,s as r,t as i,u as a}from"./shapes-CqppzFzb.mjs";import{a as o,i as s,n as c,o as l,t as u}from"./variant-plan-86tJKtVi.mjs";import{s as d}from"./variant-state-CBtqb9Cb.mjs";import{a as f,i as p,n as m,o as h,r as g}from"./process-image-wQwiRhyk.mjs";export{i as DEFAULT_IMAGE_LADDER,t as DEFAULT_IMAGE_SHAPES,n as DEFAULT_IMAGE_VOCABULARY,u as MAX_VARIANTS_PER_IMAGE,e as NATURAL_SHAPE,r as buildVariantConfig,s as deriveShapedVariantKey,o as deriveVariantKey,m as generateShapedVariants,g as getImageDimensions,p as isProcessableImage,c as planVariants,f as processImage,d as readVariantState,h as renderPlannedVariant,a as resolveStyleName,l as variantFingerprint};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { t as ImageStyle } from "./types-CnORVkLZ.mjs";
|
|
2
1
|
import { n as FocalPoint } from "./focal-point-C_0drV03.mjs";
|
|
3
2
|
|
|
4
3
|
//#region src/async-cache.d.ts
|
|
@@ -31,6 +30,19 @@ import { n as FocalPoint } from "./focal-point-C_0drV03.mjs";
|
|
|
31
30
|
declare function cacheOnceUnlessRejected<T>(load: () => Promise<T>): () => Promise<T>;
|
|
32
31
|
//#endregion
|
|
33
32
|
//#region src/public-resolver.d.ts
|
|
33
|
+
/**
|
|
34
|
+
* One generated file of a photograph, as a render can use it in a `srcset`.
|
|
35
|
+
*
|
|
36
|
+
* `width`/`height` are what the FILE is — as sharp returned them when it was
|
|
37
|
+
* written (images D003/F003) — never the ladder rung that was asked for. A `w`
|
|
38
|
+
* descriptor that claims a width the file does not have makes the browser pick
|
|
39
|
+
* the wrong candidate, silently.
|
|
40
|
+
*/
|
|
41
|
+
interface PublicMediaSource {
|
|
42
|
+
url: string;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
}
|
|
34
46
|
/**
|
|
35
47
|
* One media item as it reaches an HTTP consumer.
|
|
36
48
|
*
|
|
@@ -71,8 +83,32 @@ interface PublicMediaProjection {
|
|
|
71
83
|
* grow it. So this needs no `?populate=`-style opt-in — unlike reference
|
|
72
84
|
* expansion, where the caller could otherwise drag arbitrarily large targets
|
|
73
85
|
* in. A style whose variant file is missing or not public is simply absent.
|
|
86
|
+
*
|
|
87
|
+
* Read from what the photograph's ORIGINAL records it has (`metadata.variants`,
|
|
88
|
+
* written by every pass since the pipeline existed), not re-derived from the
|
|
89
|
+
* current style list (images F017) — so a style added a moment ago that the
|
|
90
|
+
* catch-up has not reached yet is absent here, which a consumer already
|
|
91
|
+
* handles as `variants[style] ?? url`.
|
|
74
92
|
*/
|
|
75
93
|
variants: Record<string, string>;
|
|
94
|
+
/**
|
|
95
|
+
* Every generated file, by SHAPE name, ascending by width — what a render
|
|
96
|
+
* builds a `srcset` from (images D007, lumi-cms#1160).
|
|
97
|
+
*
|
|
98
|
+
* `natural` is the photograph's own ratio over the width ladder; every other
|
|
99
|
+
* key is a vocabulary shape (`square`, `portrait`, …) or a legacy style that
|
|
100
|
+
* became a one-rung shape, cropped around the focal point or the editor's
|
|
101
|
+
* saved crop window for that shape. A shape the photograph has no files for
|
|
102
|
+
* YET — the catch-up has not reached it — is absent, never an empty list.
|
|
103
|
+
*
|
|
104
|
+
* Only rungs that exist for THIS photograph are listed: a rung wider than the
|
|
105
|
+
* source is never generated, and a tight crop may yield a single file. So a
|
|
106
|
+
* reader takes what is here rather than assuming the configured ladder.
|
|
107
|
+
*
|
|
108
|
+
* Bounded by configuration exactly like `variants` — `MAX_VARIANTS_PER_IMAGE`
|
|
109
|
+
* files per photograph, whatever the page holds.
|
|
110
|
+
*/
|
|
111
|
+
sources: Record<string, PublicMediaSource[]>;
|
|
76
112
|
/**
|
|
77
113
|
* Where the subject is, as `{ x, y }` fractions of the image (images D002).
|
|
78
114
|
*
|
|
@@ -95,6 +131,8 @@ interface PublicMediaRow {
|
|
|
95
131
|
title?: string | null;
|
|
96
132
|
/** The stored `media.focalPoint` — untrusted here, read through `readFocalPoint`. */
|
|
97
133
|
focalPoint?: unknown;
|
|
134
|
+
/** The stored `media.crops` — the editor's saved windows, read the way the planner reads them. */
|
|
135
|
+
crops?: unknown;
|
|
98
136
|
}
|
|
99
137
|
interface PublicMediaResolverDeps {
|
|
100
138
|
/**
|
|
@@ -108,15 +146,27 @@ interface PublicMediaResolverDeps {
|
|
|
108
146
|
*/
|
|
109
147
|
readMedia: (ids: readonly string[], locale?: string) => Promise<PublicMediaRow[]>;
|
|
110
148
|
/**
|
|
111
|
-
* Public URLs for a batch of storage keys
|
|
112
|
-
*
|
|
113
|
-
*
|
|
149
|
+
* Public URLs for a batch of ORIGINAL storage keys, each with the file's
|
|
150
|
+
* stored `metadata` — `StorageClient.getPublicFiles`. The metadata is where a
|
|
151
|
+
* photograph records which variants it has (F016/F017); it is read here and
|
|
152
|
+
* never forwarded. Anything missing or non-public is ABSENT from the map.
|
|
153
|
+
*/
|
|
154
|
+
getPublicFiles: (keys: readonly string[]) => Promise<Map<string, {
|
|
155
|
+
url: string;
|
|
156
|
+
metadata: Record<string, unknown> | null;
|
|
157
|
+
}>>;
|
|
158
|
+
/**
|
|
159
|
+
* Public URLs for a batch of VARIANT storage keys. Anything missing or
|
|
160
|
+
* non-public is ABSENT from the map — see `StorageClient.getPublicUrls`, which
|
|
161
|
+
* is the ask-for-public shape this whole module rests on. Asked per variant,
|
|
162
|
+
* not inferred from the original's visibility: a variant is its own storage
|
|
163
|
+
* row and can be made private on its own.
|
|
114
164
|
*/
|
|
115
165
|
getPublicUrls: (keys: readonly string[]) => Promise<Map<string, string>>;
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
166
|
+
/** Where a truncated record is reported. Optional; nothing else logs. */
|
|
167
|
+
logger?: {
|
|
168
|
+
warn: (obj: Record<string, unknown>, msg: string) => void;
|
|
169
|
+
} | undefined;
|
|
120
170
|
}
|
|
121
171
|
/**
|
|
122
172
|
* Resolve media ids to public projections.
|
|
@@ -125,13 +175,19 @@ interface PublicMediaResolverDeps {
|
|
|
125
175
|
* public are ABSENT from the returned map — one absence, four causes, exactly
|
|
126
176
|
* as phase 05's failure semantics require. The caller renders without them.
|
|
127
177
|
*
|
|
128
|
-
* Cost is THREE round trips at most, regardless of batch size:
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
178
|
+
* Cost is THREE round trips at most, regardless of batch size: one entity read,
|
|
179
|
+
* one storage read over the ORIGINALS (their public URLs and the variant set
|
|
180
|
+
* each records), and one storage read over every VARIANT key those records
|
|
181
|
+
* name (its visibility, checked per file). Emphatically NOT N×(styles+1), which
|
|
182
|
+
* is what a per-item convenience method would have made it. Each storage read
|
|
183
|
+
* is internally chunked when the key count exceeds what one query may carry,
|
|
184
|
+
* so "one storage read" is `ceil(keys / 500)` in the extreme.
|
|
185
|
+
*
|
|
186
|
+
* (Until images PR06 the three were an image-style lookup, the entity read and
|
|
187
|
+
* one storage read over keys DERIVED from the current styles. Deriving keys is
|
|
188
|
+
* what F017 retired — a shaped key depends on the file's drawn size and a
|
|
189
|
+
* fingerprint, so only the record knows it — and reading the record made the
|
|
190
|
+
* style lookup unnecessary, so the count did not grow.)
|
|
135
191
|
*/
|
|
136
192
|
declare function resolvePublicMedia(ids: readonly string[], deps: PublicMediaResolverDeps, opts?: {
|
|
137
193
|
locale?: string | undefined;
|
|
@@ -180,6 +236,14 @@ interface CanvasImage {
|
|
|
180
236
|
readonly alt: string | null;
|
|
181
237
|
readonly width: number | null;
|
|
182
238
|
readonly height: number | null;
|
|
239
|
+
/**
|
|
240
|
+
* The same two keys the site's projection carries (images D007, F010), so a
|
|
241
|
+
* shaped slot on the canvas crops around the point and picks the same file
|
|
242
|
+
* the published page does. Stripping them is how the canvas and the site
|
|
243
|
+
* drifted on exactly the thing this plan exists to keep in step.
|
|
244
|
+
*/
|
|
245
|
+
readonly focalPoint: FocalPoint;
|
|
246
|
+
readonly sources: Readonly<Record<string, readonly PublicMediaSource[]>>;
|
|
183
247
|
}
|
|
184
248
|
/**
|
|
185
249
|
* Media ids → images the block-editor CANVAS may draw: a BOUND `field.media()`
|
|
@@ -218,5 +282,5 @@ declare function resolveCanvasImages(ids: readonly string[], opts: {
|
|
|
218
282
|
*/
|
|
219
283
|
declare function toCanvasImages(found: ReadonlyMap<string, PublicMediaProjection>): Map<string, CanvasImage>;
|
|
220
284
|
//#endregion
|
|
221
|
-
export { CanvasImage, PublicMediaProjection, PublicMediaResolverDeps, PublicMediaRow, cacheOnceUnlessRejected, createPublicMediaResolver, memoiseWithTtl, resolveCanvasImages, resolvePublicMedia, toCanvasImages };
|
|
285
|
+
export { CanvasImage, PublicMediaProjection, PublicMediaResolverDeps, PublicMediaRow, PublicMediaSource, cacheOnceUnlessRejected, createPublicMediaResolver, memoiseWithTtl, resolveCanvasImages, resolvePublicMedia, toCanvasImages };
|
|
222
286
|
//# sourceMappingURL=public-resolver.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-resolver.d.mts","names":[],"sources":["../src/async-cache.ts","../src/public-resolver.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"public-resolver.d.mts","names":[],"sources":["../src/async-cache.ts","../src/public-resolver.ts"],"mappings":";;;;;;AA2BA;;;;;;;;;;;;;;;;AAAmF;;;;ACwBnF;;;iBDxBgB,uBAAA,GAAA,CAA2B,IAAA,QAAY,OAAA,CAAQ,CAAA,UAAW,OAAA,CAAQ,CAAA;;;;;;;;;;;UCwBjE,iBAAA;EACf,GAAA;EACA,KAAA;EACA,MAAA;AAAA;;;;;;;;;AA8EsB;AAIxB;;;;;;UAhEiB,qBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EAoEK;AAGP;;;;;;;;;;;;;;;;;;;;;;;EA9CE,QAAA,EAAU,MAAA;EAiEwC;;;;;;;;;;;AAUS;AAuG7D;;;;;EAhKE,OAAA,EAAS,MAAA,SAAe,iBAAA;EAoKvB;;;;;;;;;EA1JD,UAAA,EAAY,UAAA;AAAA;;UAIG,cAAA;EACf,EAAA;EACA,OAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EAyRc;EAvRd,UAAA;EAmR6B;EAjR7B,KAAA;AAAA;AAAA,UAGe,uBAAA;EAgRf;;;;;AAEgB;AA2BlB;;;EAnSE,SAAA,GAAY,GAAA,qBAAwB,MAAA,cAAoB,OAAA,CAAQ,cAAA;EAsSrD;;;;;;EA/RX,cAAA,GACE,IAAA,wBACG,OAAA,CAAQ,GAAA;IAAc,GAAA;IAAa,QAAA,EAAU,MAAA;EAAA;EA6RN;AAAA;AAiD9C;;;;;EAtUE,aAAA,GAAgB,IAAA,wBAA4B,OAAA,CAAQ,GAAA;EAkVlC;EAhVlB,MAAA;IAAW,IAAA,GAAO,GAAA,EAAK,MAAA,mBAAyB,GAAA;EAAA;AAAA;;;;;;;;;;AAgVoB;AAgCtE;;;;;;;;;;;iBAzQsB,kBAAA,CACpB,GAAA,qBACA,IAAA,EAAM,uBAAA,EACN,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,qBAAA;;AAwQW;AAUlC;;;;;;;;;;;;;;;AAE0B;;;;;;;;;;;;;iBA9IV,cAAA,GAAA,CACd,IAAA,QAAY,OAAA,CAAQ,CAAA,GACpB,KAAA,UACA,GAAA,wBACO,OAAA,CAAQ,CAAA;AAAA,iBA2BD,yBAAA,CAAA,IACd,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;UAiDR,WAAA;EAAA,SACN,GAAA;EAAA,SACA,GAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA;;;;;;;WAOA,UAAA,EAAY,UAAA;EAAA,SACZ,OAAA,EAAS,QAAA,CAAS,MAAA,kBAAwB,iBAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgC/B,mBAAA,CACpB,GAAA,qBACA,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,WAAA;;;;;;iBAUP,cAAA,CACd,KAAA,EAAO,WAAA,SAAoB,qBAAA,IAC1B,GAAA,SAAY,WAAA"}
|
package/dist/public-resolver.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{s as e}from"./focal-point-BWhtJzef.mjs";import{t}from"./async-cache-C_Ycvs7b.mjs";
|
|
1
|
+
import{s as e}from"./focal-point-BWhtJzef.mjs";import{a as t}from"./shapes-CqppzFzb.mjs";import{r as n}from"./variant-plan-86tJKtVi.mjs";import{t as r}from"./async-cache-C_Ycvs7b.mjs";import{s as i}from"./variant-state-CBtqb9Cb.mjs";function a(e){let t=e.lastIndexOf(`/`);return t===-1?null:e.slice(0,t+1)}function o(e){let t=[...e].sort((e,t)=>e.width-t.width);return t.filter((e,n)=>n===0||t[n-1]?.width!==e.width)}function s(e,t,r,i){let a=t[t.length-1],{width:o,height:s}=i;if(a===void 0||r===void 0||r.length===0||typeof o!=`number`||typeof s!=`number`)return!1;let c=n({name:e,ratio:a.width/a.height,widths:r.map(e=>e.width)},{width:o,height:s},{focalPoint:i.focalPoint,crops:i.crops});return c!==void 0&&a.width>=c*.98}async function c(n,r,c={}){let l=new Map,u=[...new Set(n)];if(u.length===0)return l;let d=await r.readMedia(u,c.locale),f=d.map(e=>e.fileKey).filter(e=>typeof e==`string`&&e!==``);if(f.length===0)return l;let p=await r.getPublicFiles(f),m=new Map,h=[];for(let e of d){let t=p.get(e.fileKey);if(t===void 0||m.has(e.fileKey))continue;let n=a(e.fileKey),o=t=>n!==null&&t!==e.fileKey&&t.startsWith(n),s=i(t.metadata),c=[...s.legacy].filter(([,e])=>o(e)),l=s.shaped.filter(e=>o(e.key)),u=c.slice(0,64),d=l.slice(0,64);(u.length<c.length||d.length<l.length)&&r.logger?.warn({mediaId:e.id,legacy:c.length,shaped:l.length,cap:64},`public media: a photograph records more variants than any pass writes — the excess is ignored`),m.set(e.fileKey,{legacy:u,shaped:d}),h.push(...u.map(([,e])=>e),...d.map(e=>e.key))}let g=h.length>0?await r.getPublicUrls(h):new Map;for(let n of d){let r=p.get(n.fileKey),i=m.get(n.fileKey);if(r===void 0||i===void 0)continue;let a=Object.fromEntries(i.legacy.flatMap(([e,t])=>{let n=g.get(t);return n===void 0?[]:[[e,n]]})),c=new Map;for(let e of i.shaped){let t=g.get(e.key);if(t===void 0)continue;let n=c.get(e.shape)??[];n.push({url:t,width:e.width,height:e.height}),c.set(e.shape,n)}let u=new Map([...c].map(([e,t])=>[e,o(t)])),d=u.get(t),f=Object.fromEntries([...u].filter(([e,t])=>e===`natural`||s(e,t,d,n)));l.set(n.id,{id:n.id,url:r.url,mimeType:n.mimeType??null,width:n.width??null,height:n.height??null,alt:n.alt??null,title:n.title??null,variants:a,sources:f,focalPoint:e(n.focalPoint)})}return l}function l(e,t,n=Date.now){let r=null;return async()=>{let i=n();if(r&&i-r.at<t)return r.value;let a=e();return r={value:a,at:i},a.catch(()=>{r?.value===a&&(r=null)}),a}}async function u(){try{let{getApp:e}=await import(`@murumets-ee/core`);return e().logger}catch{return}}function d(){return async(e,t={})=>{let[{createMediaQueryClient:n},{getSharedStorageClient:r}]=await Promise.all([import(`./query-client.mjs`),import(`./client.mjs`)]),[i,a,o]=await Promise.all([n(),r(),u()]);return c(e,{readMedia:async(e,t)=>{let{schemaRegistry:n}=await import(`@murumets-ee/db`),{getTableColumns:r,inArray:a}=await import(`drizzle-orm`),o=n.get(`media`);if(!o)return[];let s=r(o).id;return s?await i.findMany({where:a(s,[...e]),limit:e.length,...t!==void 0&&{locale:t}}):[]},getPublicFiles:e=>a.getPublicFiles(e),getPublicUrls:e=>a.getPublicUrls(e),logger:o},t)}}let f;async function p(e,t){return f??=d(),m(await f(e,t))}function m(e){let t=new Map;for(let[n,r]of e)r.mimeType?.startsWith(`image/`)===!0&&t.set(n,{url:r.url,alt:r.alt,width:r.width,height:r.height,focalPoint:r.focalPoint,sources:r.sources});return t}export{r as cacheOnceUnlessRejected,d as createPublicMediaResolver,l as memoiseWithTtl,p as resolveCanvasImages,c as resolvePublicMedia,m as toCanvasImages};
|
|
2
2
|
//# sourceMappingURL=public-resolver.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-resolver.mjs","names":[],"sources":["../src/public-resolver.ts"],"sourcesContent":["/**\n * The PUBLIC media resolver — media as data, for a consumer that builds its own\n * markup (plan/api phase 06, D021).\n *\n * **Why not `resolveMediaRefs`.** That helper parses `[media:type:id:variant]`\n * tags out of a text string and substitutes rendered HTML (`<img …>`, `<a\n * href=…>`) through per-type renderers. Right for a server-rendered page, wrong\n * for an HTTP API: a frontend needs `{url, width, height, alt}` so it can emit\n * its own `<picture>` with its own `srcset`, not a pre-baked tag it has to parse\n * back apart. Nothing here deprecates `resolveMediaRefs`; the API simply uses a\n * different, data-shaped path.\n *\n * **Why the entity cannot just be whitelisted.** `Media` is a real entity, but\n * it has no `url` field and cannot have one: what is stored is `fileKey`, and\n * the URL is DERIVED by the storage layer, which also decides whether it is a\n * direct public link or a time-limited signed one. Serving `media` as an\n * ordinary whitelisted entity would hand a consumer a storage key — useless for\n * rendering, and a gratuitous disclosure of storage layout.\n *\n * **This resolver can never emit a signed URL, structurally.** It asks storage\n * for PUBLIC urls (`getPublicUrls`) rather than asking for \"a url\" and\n * inspecting what came back. The second shape produces a signed URL on the\n * happy path and relies on a later check to suppress it — and a signed URL is a\n * bearer capability that outlives the permission check that minted it, on a\n * response the public role is deliberately allowed to cache. There is no branch\n * here to forget.\n *\n * D021 permits a permission-gated scoped URL for NON-public principals on\n * responses already forced to `no-store`. It is deliberately not built: no\n * consumer needs it, and an unexercised signed-URL branch is a check that\n * cannot fail. The shape is fixed so nobody re-derives it; the path waits for a\n * named consumer.\n */\n\nimport type { PgColumn } from 'drizzle-orm/pg-core'\n\nexport { cacheOnceUnlessRejected } from './async-cache.js'\n\nimport { type FocalPoint, readFocalPoint } from './crop/focal-point.js'\nimport type { ImageStyle } from './types.js'\n\n/**\n * One media item as it reaches an HTTP consumer.\n *\n * A STRUCTURAL match for `@murumets-ee/content-api`'s `MediaProjection`, not an\n * import of it: `content-api` is a leaf that this package's consumers wire into\n * by hand, exactly like `ContentApiResolution` mirrors content's\n * `ResolveResult`. The app that wires the two is where TypeScript checks they\n * agree.\n *\n * `width`/`height` ride along because a frontend needs them to reserve layout\n * space before the image loads; omitting them forces either a layout shift or a\n * second request, and the second request is the 4+N failure D001 exists to\n * prevent. `alt`/`title` are translatable on the Media entity, so this\n * projection is locale-dependent.\n */\nexport interface PublicMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n /**\n * Variant URLs by image-style name, INLINED as data rather than handed over\n * as a URL pattern to interpolate.\n *\n * Two reasons, and the second is a security property. D008's rule is that a\n * client derives nothing — a consumer that constructs URLs from a pattern\n * encodes an assumption the server never published, and nothing can detect\n * when it goes stale. And a URL pattern is necessarily public-SHAPED: the\n * moment a consumer can build its own URL, the server has lost the ability to\n * decide what that consumer may address, and the \"never a signed URL\"\n * guarantee becomes unenforceable because the consumer stopped asking.\n *\n * The payload multiplier is bounded by CONFIGURATION, not by content: the set\n * of image styles is operator-chosen and neither the caller nor the editor can\n * grow it. So this needs no `?populate=`-style opt-in — unlike reference\n * expansion, where the caller could otherwise drag arbitrarily large targets\n * in. A style whose variant file is missing or not public is simply absent.\n */\n variants: Record<string, string>\n /**\n * Where the subject is, as `{ x, y }` fractions of the image (images D002).\n *\n * ALWAYS present: a row with no focal point, or one whose stored value is\n * unusable, projects as the centre, so no consumer branches on absence (D010).\n * It rides the PROJECTION and never the stored block ref — the resolver\n * substitutes fresh data on every read, so a point edited today reaches a page\n * saved last year with nothing rewritten (D007).\n */\n focalPoint: FocalPoint\n}\n\n/** The Media rows this resolver needs. Structural, so any read path can supply them. */\nexport interface PublicMediaRow {\n id: string\n fileKey: string\n mimeType?: string | null\n width?: number | null\n height?: number | null\n alt?: string | null\n title?: string | null\n /** The stored `media.focalPoint` — untrusted here, read through `readFocalPoint`. */\n focalPoint?: unknown\n}\n\nexport interface PublicMediaResolverDeps {\n /**\n * Read media rows by id through a PUBLISHED-ONLY path.\n *\n * **The wiring must guarantee this** — pass a `MediaQueryClient`/`QueryClient`\n * read, never an `AdminClient` one. This module cannot enforce the choice (it\n * is injected), and an admin read would surface unpublished media on the\n * anonymous surface. Same posture, and same reason, as\n * `ContentApiResolution.resolvePath`.\n */\n readMedia: (ids: readonly string[], locale?: string) => Promise<PublicMediaRow[]>\n /**\n * Public URLs for a batch of storage keys. Anything missing or non-public is\n * ABSENT from the map — see `StorageClient.getPublicUrls`, which is the ask-\n * for-public shape this whole module rests on.\n */\n getPublicUrls: (keys: readonly string[]) => Promise<Map<string, string>>\n /** The configured image styles. Operator-owned; neither caller nor editor grows it. */\n imageStyles: () => Promise<Record<string, ImageStyle>>\n /** Derives a variant's storage key from the original's. */\n variantKey: (fileKey: string, styleName: string, format: string) => string\n}\n\n/**\n * Resolve media ids to public projections.\n *\n * Ids that do not exist, are unpublished for this caller, or whose file is not\n * public are ABSENT from the returned map — one absence, four causes, exactly\n * as phase 05's failure semantics require. The caller renders without them.\n *\n * Cost is THREE round trips at most, regardless of batch size: the image-style\n * lookup (usually served from the caller's cache — see\n * {@link createPublicMediaResolver}), one entity read, and one storage read\n * covering every original and every variant key. Emphatically NOT\n * N×(styles+1), which is what a per-item convenience method would have made it.\n * The storage read is internally chunked when the key count exceeds what one\n * query may carry, so \"one storage read\" is `ceil(keys / 500)` in the extreme.\n */\nexport async function resolvePublicMedia(\n ids: readonly string[],\n deps: PublicMediaResolverDeps,\n opts: { locale?: string | undefined } = {},\n): Promise<Map<string, PublicMediaProjection>> {\n const out = new Map<string, PublicMediaProjection>()\n const unique = [...new Set(ids)]\n if (unique.length === 0) return out\n\n const rows = await deps.readMedia(unique, opts.locale)\n if (rows.length === 0) return out\n\n const styles = await deps.imageStyles()\n const styleEntries = Object.entries(styles)\n\n // Every key we might need — originals AND variants — resolved in one storage\n // query. `getPublicUrls` returns nothing for a key that is missing or not\n // public, so a private original drops the whole item and a private variant\n // drops only that variant.\n const keysToLookUp: string[] = []\n /** Per row: its original key, plus (styleName → variant key). */\n const perRow = new Map<string, { fileKey: string; variantKeys: Array<[string, string]> }>()\n\n for (const row of rows) {\n if (typeof row.fileKey !== 'string' || row.fileKey === '') continue\n const variantKeys: Array<[string, string]> = styleEntries.map(([name, style]) => [\n name,\n deps.variantKey(row.fileKey, name, style.format ?? 'webp'),\n ])\n perRow.set(row.id, { fileKey: row.fileKey, variantKeys })\n keysToLookUp.push(row.fileKey, ...variantKeys.map(([, key]) => key))\n }\n\n const publicUrls = await deps.getPublicUrls(keysToLookUp)\n\n for (const row of rows) {\n const keys = perRow.get(row.id)\n if (!keys) continue\n const url = publicUrls.get(keys.fileKey)\n // No public URL for the ORIGINAL → the item does not resolve at all. Not\n // \"resolves with variants only\": the variants of a private original are an\n // accident of processing, not a sanctioned public view of it.\n if (url === undefined) continue\n\n const variants: Record<string, string> = {}\n for (const [styleName, variantKey] of keys.variantKeys) {\n const variantUrl = publicUrls.get(variantKey)\n if (variantUrl !== undefined) variants[styleName] = variantUrl\n }\n\n out.set(row.id, {\n id: row.id,\n url,\n mimeType: row.mimeType ?? null,\n width: row.width ?? null,\n height: row.height ?? null,\n alt: row.alt ?? null,\n title: row.title ?? null,\n variants,\n focalPoint: readFocalPoint(row.focalPoint),\n })\n }\n\n return out\n}\n\n/**\n * Wire {@link resolvePublicMedia} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveMedia })`.\n *\n * Every dependency is resolved through the PUBLIC read path:\n * `MediaQueryClient` (so the publish filter, the `view` gate and locale merging\n * all apply, and `alt`/`title` come back in the requested locale), and\n * `StorageClient.getPublicUrls` (which has no signed-URL branch at all).\n *\n * Imports are dynamic for the same reason `createMediaQueryClient`'s are: this\n * must be callable from a route module without dragging the storage/settings\n * graph into whatever bundle imports it.\n */\n/**\n * A single-value cache with a wall-clock TTL.\n *\n * Extracted rather than inlined so it can be tested with an injected clock:\n * the thing being asserted is time-dependent, and new stateful code on the\n * anonymous request path with no coverage is how a cache ends up serving the\n * wrong thing for a window nobody measured.\n *\n * Caches the PROMISE, not the settled value, so concurrent callers arriving\n * before the first load resolves share it. Caching only the value would let N\n * simultaneous requests each start their own read — the stampede this memo\n * exists to remove, on the one path where bursts are expected.\n *\n * A rejection is still NOT pinned for the TTL: the entry is dropped when the\n * promise rejects, so the next call retries rather than replaying a transient\n * outage for the rest of the window.\n */\nexport function memoiseWithTtl<T>(\n load: () => Promise<T>,\n ttlMs: number,\n now: () => number = Date.now,\n): () => Promise<T> {\n let cached: { value: Promise<T>; at: number } | null = null\n return async () => {\n const t = now()\n if (cached && t - cached.at < ttlMs) return cached.value\n const value = load()\n cached = { value, at: t }\n value.catch(() => {\n // Only clear OUR entry — a later call may already have replaced it.\n if (cached?.value === value) cached = null\n })\n return value\n }\n}\n\nexport function createPublicMediaResolver(): (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, PublicMediaProjection>> {\n // The image-style set, memoised with a SHORT TTL.\n //\n // `resolveImageStyles` reads the settings DB and is documented as not cached,\n // so without this every media pass costs a settings query — and there is more\n // than one pass per response (the root rows, plus one per `?populate=`\n // expansion group), on an anonymous request path. A process-lifetime cache\n // would be wrong in the other direction: image styles are ADMIN-EDITABLE at\n // runtime, so it would serve a stale style set until redeploy.\n //\n // 30s mirrors the TTL `content-api` uses for its permission checker, and the\n // bound is the same shape: per process, so a multi-instance deployment sees a\n // new style within one TTL of each instance's own expiry. The cost of being\n // stale here is a variant URL for a style that was just added or removed —\n // the \"missing or not public\" path already handles it as an absent variant.\n const STYLES_TTL_MS = 30_000\n const imageStyles = memoiseWithTtl(async (): Promise<Record<string, ImageStyle>> => {\n const { getApp } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('./resolve-image-styles.js')\n return resolveImageStyles(getApp())\n }, STYLES_TTL_MS)\n\n return async (ids, opts = {}) => {\n const [{ createMediaQueryClient }, { getSharedStorageClient }, { deriveVariantKey }] =\n await Promise.all([\n import('./query-client.js'),\n import('./client.js'),\n import('./variant-key.js'),\n ])\n\n const [mediaQuery, storage] = await Promise.all([\n createMediaQueryClient(),\n getSharedStorageClient(),\n ])\n\n return resolvePublicMedia(\n ids,\n {\n readMedia: async (mediaIds, locale) => {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { getTableColumns, inArray } = await import('drizzle-orm')\n const table = schemaRegistry.get('media')\n if (!table) return []\n // Column refs come from drizzle's own typed accessor, never from a bare\n // `table.id` — the registry hands back a dynamically-generated table\n // whose property access is untyped, so reaching through it would put an\n // `any` straight into the WHERE clause.\n const columns = getTableColumns(table) as Record<string, PgColumn>\n const idColumn = columns.id\n if (!idColumn) return []\n const rows = await mediaQuery.findMany({\n where: inArray(idColumn, [...mediaIds]),\n limit: mediaIds.length,\n ...(locale !== undefined && { locale }),\n })\n return rows as unknown as PublicMediaRow[]\n },\n getPublicUrls: (keys) => storage.getPublicUrls(keys),\n imageStyles,\n variantKey: deriveVariantKey,\n },\n opts,\n )\n }\n}\n\n/**\n * One image the CANVAS can draw for a media id — the shape `@murumets-ee/blocks`'\n * `CanvasImageResolver` asks for, declared structurally so this package does not\n * depend on blocks.\n */\nexport interface CanvasImage {\n readonly url: string\n readonly alt: string | null\n readonly width: number | null\n readonly height: number | null\n}\n\nlet canvasResolver: ReturnType<typeof createPublicMediaResolver> | undefined\n\n/**\n * Media ids → images the block-editor CANVAS may draw: a BOUND `field.media()`\n * column (frontend-delivery F112) and a collection card's picture.\n *\n * The ready-made `blocksPlugin({ resolveImages })` value, so an app wires one lazy\n * line instead of copying a resolver (frontend-delivery D025 — no builder's\n * manual):\n *\n * ```ts\n * blocksPlugin({\n * resolveImages: async (ids, opts) =>\n * (await import('@murumets-ee/media/public')).resolveCanvasImages(ids, opts),\n * })\n * ```\n *\n * Two properties are the point, not conveniences:\n *\n * - **The SAME public resolver content-api uses**, so the canvas cannot show an\n * image the published site would not (a private asset draws as empty on both).\n * - **Images only.** A `field.media({ kind: 'image' })` constrains only the\n * picker's list (editor F116), so a video or PDF can sit in the column; drawn\n * through an `<img>` it would be a broken image. Anything that is not\n * `image/*` is left out, and the canvas draws its empty placeholder instead.\n *\n * The resolver is built once per process — it memoises the image-style set, which\n * a fresh one per call would re-read.\n */\nexport async function resolveCanvasImages(\n ids: readonly string[],\n opts: { locale: string },\n): Promise<Map<string, CanvasImage>> {\n canvasResolver ??= createPublicMediaResolver()\n return toCanvasImages(await canvasResolver(ids, opts))\n}\n\n/**\n * The pure half of {@link resolveCanvasImages}: keep images, drop everything else,\n * project to what the canvas draws. Exported so the filter has a test that does\n * not need a database.\n */\nexport function toCanvasImages(\n found: ReadonlyMap<string, PublicMediaProjection>,\n): Map<string, CanvasImage> {\n const images = new Map<string, CanvasImage>()\n for (const [id, media] of found) {\n if (media.mimeType?.startsWith('image/') !== true) continue\n images.set(id, { url: media.url, alt: media.alt, width: media.width, height: media.height })\n }\n return images\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"public-resolver.mjs","names":[],"sources":["../src/public-resolver.ts"],"sourcesContent":["/**\n * The PUBLIC media resolver — media as data, for a consumer that builds its own\n * markup (plan/api phase 06, D021).\n *\n * **Why not `resolveMediaRefs`.** That helper parses `[media:type:id:variant]`\n * tags out of a text string and substitutes rendered HTML (`<img …>`, `<a\n * href=…>`) through per-type renderers. Right for a server-rendered page, wrong\n * for an HTTP API: a frontend needs `{url, width, height, alt}` so it can emit\n * its own `<picture>` with its own `srcset`, not a pre-baked tag it has to parse\n * back apart. Nothing here deprecates `resolveMediaRefs`; the API simply uses a\n * different, data-shaped path.\n *\n * **Why the entity cannot just be whitelisted.** `Media` is a real entity, but\n * it has no `url` field and cannot have one: what is stored is `fileKey`, and\n * the URL is DERIVED by the storage layer, which also decides whether it is a\n * direct public link or a time-limited signed one. Serving `media` as an\n * ordinary whitelisted entity would hand a consumer a storage key — useless for\n * rendering, and a gratuitous disclosure of storage layout.\n *\n * **This resolver can never emit a signed URL, structurally.** It asks storage\n * for PUBLIC urls (`getPublicUrls`) rather than asking for \"a url\" and\n * inspecting what came back. The second shape produces a signed URL on the\n * happy path and relies on a later check to suppress it — and a signed URL is a\n * bearer capability that outlives the permission check that minted it, on a\n * response the public role is deliberately allowed to cache. There is no branch\n * here to forget.\n *\n * D021 permits a permission-gated scoped URL for NON-public principals on\n * responses already forced to `no-store`. It is deliberately not built: no\n * consumer needs it, and an unexercised signed-URL branch is a check that\n * cannot fail. The shape is fixed so nobody re-derives it; the path waits for a\n * named consumer.\n */\n\nimport type { PgColumn } from 'drizzle-orm/pg-core'\n\nexport { cacheOnceUnlessRejected } from './async-cache.js'\n\nimport { type FocalPoint, readFocalPoint } from './crop/focal-point.js'\nimport { readVariantState } from './jobs/variant-state.js'\nimport { NATURAL_SHAPE } from './shapes.js'\nimport { MAX_VARIANTS_PER_IMAGE, plannedTopWidth } from './variant-plan.js'\n\n/**\n * One generated file of a photograph, as a render can use it in a `srcset`.\n *\n * `width`/`height` are what the FILE is — as sharp returned them when it was\n * written (images D003/F003) — never the ladder rung that was asked for. A `w`\n * descriptor that claims a width the file does not have makes the browser pick\n * the wrong candidate, silently.\n */\nexport interface PublicMediaSource {\n url: string\n width: number\n height: number\n}\n\n/**\n * One media item as it reaches an HTTP consumer.\n *\n * A STRUCTURAL match for `@murumets-ee/content-api`'s `MediaProjection`, not an\n * import of it: `content-api` is a leaf that this package's consumers wire into\n * by hand, exactly like `ContentApiResolution` mirrors content's\n * `ResolveResult`. The app that wires the two is where TypeScript checks they\n * agree.\n *\n * `width`/`height` ride along because a frontend needs them to reserve layout\n * space before the image loads; omitting them forces either a layout shift or a\n * second request, and the second request is the 4+N failure D001 exists to\n * prevent. `alt`/`title` are translatable on the Media entity, so this\n * projection is locale-dependent.\n */\nexport interface PublicMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n /**\n * Variant URLs by image-style name, INLINED as data rather than handed over\n * as a URL pattern to interpolate.\n *\n * Two reasons, and the second is a security property. D008's rule is that a\n * client derives nothing — a consumer that constructs URLs from a pattern\n * encodes an assumption the server never published, and nothing can detect\n * when it goes stale. And a URL pattern is necessarily public-SHAPED: the\n * moment a consumer can build its own URL, the server has lost the ability to\n * decide what that consumer may address, and the \"never a signed URL\"\n * guarantee becomes unenforceable because the consumer stopped asking.\n *\n * The payload multiplier is bounded by CONFIGURATION, not by content: the set\n * of image styles is operator-chosen and neither the caller nor the editor can\n * grow it. So this needs no `?populate=`-style opt-in — unlike reference\n * expansion, where the caller could otherwise drag arbitrarily large targets\n * in. A style whose variant file is missing or not public is simply absent.\n *\n * Read from what the photograph's ORIGINAL records it has (`metadata.variants`,\n * written by every pass since the pipeline existed), not re-derived from the\n * current style list (images F017) — so a style added a moment ago that the\n * catch-up has not reached yet is absent here, which a consumer already\n * handles as `variants[style] ?? url`.\n */\n variants: Record<string, string>\n /**\n * Every generated file, by SHAPE name, ascending by width — what a render\n * builds a `srcset` from (images D007, lumi-cms#1160).\n *\n * `natural` is the photograph's own ratio over the width ladder; every other\n * key is a vocabulary shape (`square`, `portrait`, …) or a legacy style that\n * became a one-rung shape, cropped around the focal point or the editor's\n * saved crop window for that shape. A shape the photograph has no files for\n * YET — the catch-up has not reached it — is absent, never an empty list.\n *\n * Only rungs that exist for THIS photograph are listed: a rung wider than the\n * source is never generated, and a tight crop may yield a single file. So a\n * reader takes what is here rather than assuming the configured ladder.\n *\n * Bounded by configuration exactly like `variants` — `MAX_VARIANTS_PER_IMAGE`\n * files per photograph, whatever the page holds.\n */\n sources: Record<string, PublicMediaSource[]>\n /**\n * Where the subject is, as `{ x, y }` fractions of the image (images D002).\n *\n * ALWAYS present: a row with no focal point, or one whose stored value is\n * unusable, projects as the centre, so no consumer branches on absence (D010).\n * It rides the PROJECTION and never the stored block ref — the resolver\n * substitutes fresh data on every read, so a point edited today reaches a page\n * saved last year with nothing rewritten (D007).\n */\n focalPoint: FocalPoint\n}\n\n/** The Media rows this resolver needs. Structural, so any read path can supply them. */\nexport interface PublicMediaRow {\n id: string\n fileKey: string\n mimeType?: string | null\n width?: number | null\n height?: number | null\n alt?: string | null\n title?: string | null\n /** The stored `media.focalPoint` — untrusted here, read through `readFocalPoint`. */\n focalPoint?: unknown\n /** The stored `media.crops` — the editor's saved windows, read the way the planner reads them. */\n crops?: unknown\n}\n\nexport interface PublicMediaResolverDeps {\n /**\n * Read media rows by id through a PUBLISHED-ONLY path.\n *\n * **The wiring must guarantee this** — pass a `MediaQueryClient`/`QueryClient`\n * read, never an `AdminClient` one. This module cannot enforce the choice (it\n * is injected), and an admin read would surface unpublished media on the\n * anonymous surface. Same posture, and same reason, as\n * `ContentApiResolution.resolvePath`.\n */\n readMedia: (ids: readonly string[], locale?: string) => Promise<PublicMediaRow[]>\n /**\n * Public URLs for a batch of ORIGINAL storage keys, each with the file's\n * stored `metadata` — `StorageClient.getPublicFiles`. The metadata is where a\n * photograph records which variants it has (F016/F017); it is read here and\n * never forwarded. Anything missing or non-public is ABSENT from the map.\n */\n getPublicFiles: (\n keys: readonly string[],\n ) => Promise<Map<string, { url: string; metadata: Record<string, unknown> | null }>>\n /**\n * Public URLs for a batch of VARIANT storage keys. Anything missing or\n * non-public is ABSENT from the map — see `StorageClient.getPublicUrls`, which\n * is the ask-for-public shape this whole module rests on. Asked per variant,\n * not inferred from the original's visibility: a variant is its own storage\n * row and can be made private on its own.\n */\n getPublicUrls: (keys: readonly string[]) => Promise<Map<string, string>>\n /** Where a truncated record is reported. Optional; nothing else logs. */\n logger?: { warn: (obj: Record<string, unknown>, msg: string) => void } | undefined\n}\n\n/**\n * The directory every variant of `fileKey` lives under — `<dir>/` for the\n * legacy `<dir>/<style>_<base>` files and the shaped `<dir>/variants/…` ones\n * alike (`variant-key.ts`).\n *\n * The variant list comes from the original's metadata, which is caller-shaped\n * JSON: an admin with storage access can write any key into it. Keys outside\n * the original's own directory are refused, so a crafted list can at most\n * point at this photograph's own files — never at another upload's.\n */\nfunction variantDirectory(fileKey: string): string | null {\n const lastSlash = fileKey.lastIndexOf('/')\n // No directory → no variants at all. The generator always writes originals\n // under `uploads/<y>/<m>/<id>/`; a caller-chosen key with no slash would\n // otherwise make the prefix `''`, and every public key would pass.\n return lastSlash === -1 ? null : fileKey.slice(0, lastSlash + 1)\n}\n\n/**\n * How many recorded entries of each kind one photograph may contribute to the\n * variant lookup. The metadata is caller-shaped JSON — an admin with storage\n * access can write a hundred thousand same-directory keys into it — and this\n * runs on the ANONYMOUS path, once per page view that embeds the photograph.\n * A pass never records more than `MAX_VARIANTS_PER_IMAGE` shaped files, and\n * the legacy styles are bounded the same way, so anything past it is not\n * something the generator wrote.\n */\nconst MAX_RECORDED_PER_KIND = MAX_VARIANTS_PER_IMAGE\n\n/** Sort ascending by width and keep the first file at each width. */\nfunction ladder(sources: PublicMediaSource[]): PublicMediaSource[] {\n const sorted = [...sources].sort((a, b) => a.width - b.width)\n return sorted.filter((source, index) => index === 0 || sorted[index - 1]?.width !== source.width)\n}\n\n/**\n * Is this recorded shaped list what a pass would have written for THIS\n * photograph's region of that shape?\n *\n * A render replaces the CSS-cropped original with a shaped list whose ratio\n * matches the slot, so a shaped list must never be a downgrade. Two recorded\n * lists would be:\n * - a one-rung shape narrower than the ladder (an operator's `card` edited to\n * 800×600 becomes one): a 4:3 slot over a 4000×3000 photograph could have\n * had 2560px files, and 800 would replace the original;\n * - a list a failed or partial pass left short.\n *\n * So the list is checked against the planner's OWN answer (`plannedTopWidth`):\n * the same region — the editor's saved crop for the shape, else the focal\n * window — and the same rung test, over the photograph's natural widths, which\n * ARE the ladder as it applies to this photograph. That is also what keeps a\n * ZOOMED saved crop: its region is small, the planner writes small files for\n * it, and those are complete for it — a check against the full region would\n * refuse exactly the crops an editor chose on purpose.\n *\n * A list that fails is left out of `sources`, so a render draws the natural\n * file cropped by CSS, exactly as before shaped files existed. No size or no\n * natural list means nothing to check against — left out too.\n */\nfunction isCompleteShapedList(\n shape: string,\n list: readonly PublicMediaSource[],\n natural: readonly PublicMediaSource[] | undefined,\n row: PublicMediaRow,\n): boolean {\n const largest = list[list.length - 1]\n const { width, height } = row\n if (largest === undefined || natural === undefined || natural.length === 0) return false\n if (typeof width !== 'number' || typeof height !== 'number') return false\n const top = plannedTopWidth(\n { name: shape, ratio: largest.width / largest.height, widths: natural.map((n) => n.width) },\n { width, height },\n { focalPoint: row.focalPoint, crops: row.crops },\n )\n // 2%: the recorded width is what sharp returned, and a file can be a pixel\n // narrower than the width it was planned at.\n return top !== undefined && largest.width >= top * 0.98\n}\n\n/**\n * Resolve media ids to public projections.\n *\n * Ids that do not exist, are unpublished for this caller, or whose file is not\n * public are ABSENT from the returned map — one absence, four causes, exactly\n * as phase 05's failure semantics require. The caller renders without them.\n *\n * Cost is THREE round trips at most, regardless of batch size: one entity read,\n * one storage read over the ORIGINALS (their public URLs and the variant set\n * each records), and one storage read over every VARIANT key those records\n * name (its visibility, checked per file). Emphatically NOT N×(styles+1), which\n * is what a per-item convenience method would have made it. Each storage read\n * is internally chunked when the key count exceeds what one query may carry,\n * so \"one storage read\" is `ceil(keys / 500)` in the extreme.\n *\n * (Until images PR06 the three were an image-style lookup, the entity read and\n * one storage read over keys DERIVED from the current styles. Deriving keys is\n * what F017 retired — a shaped key depends on the file's drawn size and a\n * fingerprint, so only the record knows it — and reading the record made the\n * style lookup unnecessary, so the count did not grow.)\n */\nexport async function resolvePublicMedia(\n ids: readonly string[],\n deps: PublicMediaResolverDeps,\n opts: { locale?: string | undefined } = {},\n): Promise<Map<string, PublicMediaProjection>> {\n const out = new Map<string, PublicMediaProjection>()\n const unique = [...new Set(ids)]\n if (unique.length === 0) return out\n\n const rows = await deps.readMedia(unique, opts.locale)\n const fileKeys = rows\n .map((row) => row.fileKey)\n .filter((key): key is string => typeof key === 'string' && key !== '')\n if (fileKeys.length === 0) return out\n\n // No public ORIGINAL → the item does not resolve at all, so its variants are\n // never even looked up. Not \"resolves with variants only\": the variants of a\n // private original are an accident of processing, not a sanctioned public\n // view of it.\n const originals = await deps.getPublicFiles(fileKeys)\n\n interface Recorded {\n readonly legacy: Array<[string, string]>\n readonly shaped: Array<{ shape: string; key: string; width: number; height: number }>\n }\n const recorded = new Map<string, Recorded>()\n const variantKeys: string[] = []\n for (const row of rows) {\n const original = originals.get(row.fileKey)\n if (original === undefined || recorded.has(row.fileKey)) continue\n const directory = variantDirectory(row.fileKey)\n const inDirectory = (key: string): boolean =>\n directory !== null && key !== row.fileKey && key.startsWith(directory)\n // `unconfirmedVariants` are deliberately not read: those are files a FAILED\n // pass uploaded, which the retry adopts or deletes (PR05). A reader serving\n // them would show a crop no pass vouched for.\n const state = readVariantState(original.metadata)\n const allLegacy = [...state.legacy].filter(([, key]) => inDirectory(key))\n const allShaped = state.shaped.filter((v) => inDirectory(v.key))\n const legacy = allLegacy.slice(0, MAX_RECORDED_PER_KIND)\n const shaped = allShaped.slice(0, MAX_RECORDED_PER_KIND)\n if (legacy.length < allLegacy.length || shaped.length < allShaped.length) {\n deps.logger?.warn(\n {\n mediaId: row.id,\n legacy: allLegacy.length,\n shaped: allShaped.length,\n cap: MAX_RECORDED_PER_KIND,\n },\n 'public media: a photograph records more variants than any pass writes — the excess is ignored',\n )\n }\n recorded.set(row.fileKey, { legacy, shaped })\n variantKeys.push(...legacy.map(([, key]) => key), ...shaped.map((v) => v.key))\n }\n\n const variantUrls =\n variantKeys.length > 0 ? await deps.getPublicUrls(variantKeys) : new Map<string, string>()\n\n for (const row of rows) {\n const original = originals.get(row.fileKey)\n const record = recorded.get(row.fileKey)\n if (original === undefined || record === undefined) continue\n\n // Built with `Object.fromEntries`, which defines OWN properties: a style\n // or shape named `__proto__` in the caller-shaped record is then an\n // ordinary key, never a prototype write on the object we hand out.\n const variants: Record<string, string> = Object.fromEntries(\n record.legacy.flatMap(([styleName, key]) => {\n const url = variantUrls.get(key)\n return url === undefined ? [] : [[styleName, url] as const]\n }),\n )\n\n const byShape = new Map<string, PublicMediaSource[]>()\n for (const variant of record.shaped) {\n const url = variantUrls.get(variant.key)\n if (url === undefined) continue\n const list = byShape.get(variant.shape) ?? []\n list.push({ url, width: variant.width, height: variant.height })\n byShape.set(variant.shape, list)\n }\n const laddered = new Map([...byShape].map(([shape, list]) => [shape, ladder(list)] as const))\n const natural = laddered.get(NATURAL_SHAPE)\n const sources: Record<string, PublicMediaSource[]> = Object.fromEntries(\n [...laddered].filter(\n ([shape, list]) =>\n shape === NATURAL_SHAPE || isCompleteShapedList(shape, list, natural, row),\n ),\n )\n\n out.set(row.id, {\n id: row.id,\n url: original.url,\n mimeType: row.mimeType ?? null,\n width: row.width ?? null,\n height: row.height ?? null,\n alt: row.alt ?? null,\n title: row.title ?? null,\n variants,\n sources,\n focalPoint: readFocalPoint(row.focalPoint),\n })\n }\n\n return out\n}\n\n/**\n * Wire {@link resolvePublicMedia} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveMedia })`.\n *\n * Every dependency is resolved through the PUBLIC read path:\n * `MediaQueryClient` (so the publish filter, the `view` gate and locale merging\n * all apply, and `alt`/`title` come back in the requested locale), and\n * `StorageClient.getPublicUrls` (which has no signed-URL branch at all).\n *\n * Imports are dynamic for the same reason `createMediaQueryClient`'s are: this\n * must be callable from a route module without dragging the storage/settings\n * graph into whatever bundle imports it.\n */\n/**\n * A single-value cache with a wall-clock TTL.\n *\n * Extracted rather than inlined so it can be tested with an injected clock:\n * the thing being asserted is time-dependent, and new stateful code on the\n * anonymous request path with no coverage is how a cache ends up serving the\n * wrong thing for a window nobody measured.\n *\n * Caches the PROMISE, not the settled value, so concurrent callers arriving\n * before the first load resolves share it. Caching only the value would let N\n * simultaneous requests each start their own read — the stampede this memo\n * exists to remove, on the one path where bursts are expected.\n *\n * A rejection is still NOT pinned for the TTL: the entry is dropped when the\n * promise rejects, so the next call retries rather than replaying a transient\n * outage for the rest of the window.\n */\nexport function memoiseWithTtl<T>(\n load: () => Promise<T>,\n ttlMs: number,\n now: () => number = Date.now,\n): () => Promise<T> {\n let cached: { value: Promise<T>; at: number } | null = null\n return async () => {\n const t = now()\n if (cached && t - cached.at < ttlMs) return cached.value\n const value = load()\n cached = { value, at: t }\n value.catch(() => {\n // Only clear OUR entry — a later call may already have replaced it.\n if (cached?.value === value) cached = null\n })\n return value\n }\n}\n\n/** The app's logger, when there is an app — this resolver still works without one. */\nasync function appLogger(): Promise<PublicMediaResolverDeps['logger']> {\n try {\n const { getApp } = await import('@murumets-ee/core')\n return getApp().logger\n } catch {\n // No initialised app (a script, a test): there is no logger to report a\n // truncated record through, and nothing else in this resolver needs one.\n return undefined\n }\n}\n\nexport function createPublicMediaResolver(): (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, PublicMediaProjection>> {\n return async (ids, opts = {}) => {\n const [{ createMediaQueryClient }, { getSharedStorageClient }] = await Promise.all([\n import('./query-client.js'),\n import('./client.js'),\n ])\n\n const [mediaQuery, storage, logger] = await Promise.all([\n createMediaQueryClient(),\n getSharedStorageClient(),\n appLogger(),\n ])\n\n return resolvePublicMedia(\n ids,\n {\n readMedia: async (mediaIds, locale) => {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { getTableColumns, inArray } = await import('drizzle-orm')\n const table = schemaRegistry.get('media')\n if (!table) return []\n // Column refs come from drizzle's own typed accessor, never from a bare\n // `table.id` — the registry hands back a dynamically-generated table\n // whose property access is untyped, so reaching through it would put an\n // `any` straight into the WHERE clause.\n const columns = getTableColumns(table) as Record<string, PgColumn>\n const idColumn = columns.id\n if (!idColumn) return []\n const rows = await mediaQuery.findMany({\n where: inArray(idColumn, [...mediaIds]),\n limit: mediaIds.length,\n ...(locale !== undefined && { locale }),\n })\n return rows as unknown as PublicMediaRow[]\n },\n getPublicFiles: (keys) => storage.getPublicFiles(keys),\n getPublicUrls: (keys) => storage.getPublicUrls(keys),\n logger,\n },\n opts,\n )\n }\n}\n\n/**\n * One image the CANVAS can draw for a media id — the shape `@murumets-ee/blocks`'\n * `CanvasImageResolver` asks for, declared structurally so this package does not\n * depend on blocks.\n */\nexport interface CanvasImage {\n readonly url: string\n readonly alt: string | null\n readonly width: number | null\n readonly height: number | null\n /**\n * The same two keys the site's projection carries (images D007, F010), so a\n * shaped slot on the canvas crops around the point and picks the same file\n * the published page does. Stripping them is how the canvas and the site\n * drifted on exactly the thing this plan exists to keep in step.\n */\n readonly focalPoint: FocalPoint\n readonly sources: Readonly<Record<string, readonly PublicMediaSource[]>>\n}\n\nlet canvasResolver: ReturnType<typeof createPublicMediaResolver> | undefined\n\n/**\n * Media ids → images the block-editor CANVAS may draw: a BOUND `field.media()`\n * column (frontend-delivery F112) and a collection card's picture.\n *\n * The ready-made `blocksPlugin({ resolveImages })` value, so an app wires one lazy\n * line instead of copying a resolver (frontend-delivery D025 — no builder's\n * manual):\n *\n * ```ts\n * blocksPlugin({\n * resolveImages: async (ids, opts) =>\n * (await import('@murumets-ee/media/public')).resolveCanvasImages(ids, opts),\n * })\n * ```\n *\n * Two properties are the point, not conveniences:\n *\n * - **The SAME public resolver content-api uses**, so the canvas cannot show an\n * image the published site would not (a private asset draws as empty on both).\n * - **Images only.** A `field.media({ kind: 'image' })` constrains only the\n * picker's list (editor F116), so a video or PDF can sit in the column; drawn\n * through an `<img>` it would be a broken image. Anything that is not\n * `image/*` is left out, and the canvas draws its empty placeholder instead.\n *\n * The resolver is built once per process — it memoises the image-style set, which\n * a fresh one per call would re-read.\n */\nexport async function resolveCanvasImages(\n ids: readonly string[],\n opts: { locale: string },\n): Promise<Map<string, CanvasImage>> {\n canvasResolver ??= createPublicMediaResolver()\n return toCanvasImages(await canvasResolver(ids, opts))\n}\n\n/**\n * The pure half of {@link resolveCanvasImages}: keep images, drop everything else,\n * project to what the canvas draws. Exported so the filter has a test that does\n * not need a database.\n */\nexport function toCanvasImages(\n found: ReadonlyMap<string, PublicMediaProjection>,\n): Map<string, CanvasImage> {\n const images = new Map<string, CanvasImage>()\n for (const [id, media] of found) {\n if (media.mimeType?.startsWith('image/') !== true) continue\n images.set(id, {\n url: media.url,\n alt: media.alt,\n width: media.width,\n height: media.height,\n focalPoint: media.focalPoint,\n sources: media.sources,\n })\n }\n return images\n}\n"],"mappings":"yOAgMA,SAAS,EAAiB,EAAgC,CACxD,IAAM,EAAY,EAAQ,YAAY,GAAG,EAIzC,OAAO,IAAc,GAAK,KAAO,EAAQ,MAAM,EAAG,EAAY,CAAC,CACjE,CAcA,SAAS,EAAO,EAAmD,CACjE,IAAM,EAAS,CAAC,GAAG,CAAO,CAAC,CAAC,MAAM,EAAG,IAAM,EAAE,MAAQ,EAAE,KAAK,EAC5D,OAAO,EAAO,QAAQ,EAAQ,IAAU,IAAU,GAAK,EAAO,EAAQ,EAAE,EAAE,QAAU,EAAO,KAAK,CAClG,CA0BA,SAAS,EACP,EACA,EACA,EACA,EACS,CACT,IAAM,EAAU,EAAK,EAAK,OAAS,GAC7B,CAAE,QAAO,UAAW,EAE1B,GADI,IAAY,IAAA,IAAa,IAAY,IAAA,IAAa,EAAQ,SAAW,GACrE,OAAO,GAAU,UAAY,OAAO,GAAW,SAAU,MAAO,GACpE,IAAM,EAAM,EACV,CAAE,KAAM,EAAO,MAAO,EAAQ,MAAQ,EAAQ,OAAQ,OAAQ,EAAQ,IAAK,GAAM,EAAE,KAAK,CAAE,EAC1F,CAAE,QAAO,QAAO,EAChB,CAAE,WAAY,EAAI,WAAY,MAAO,EAAI,KAAM,CACjD,EAGA,OAAO,IAAQ,IAAA,IAAa,EAAQ,OAAS,EAAM,GACrD,CAuBA,eAAsB,EACpB,EACA,EACA,EAAwC,CAAC,EACI,CAC7C,IAAM,EAAM,IAAI,IACV,EAAS,CAAC,GAAG,IAAI,IAAI,CAAG,CAAC,EAC/B,GAAI,EAAO,SAAW,EAAG,OAAO,EAEhC,IAAM,EAAO,MAAM,EAAK,UAAU,EAAQ,EAAK,MAAM,EAC/C,EAAW,EACd,IAAK,GAAQ,EAAI,OAAO,CAAC,CACzB,OAAQ,GAAuB,OAAO,GAAQ,UAAY,IAAQ,EAAE,EACvE,GAAI,EAAS,SAAW,EAAG,OAAO,EAMlC,IAAM,EAAY,MAAM,EAAK,eAAe,CAAQ,EAM9C,EAAW,IAAI,IACf,EAAwB,CAAC,EAC/B,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAW,EAAU,IAAI,EAAI,OAAO,EAC1C,GAAI,IAAa,IAAA,IAAa,EAAS,IAAI,EAAI,OAAO,EAAG,SACzD,IAAM,EAAY,EAAiB,EAAI,OAAO,EACxC,EAAe,GACnB,IAAc,MAAQ,IAAQ,EAAI,SAAW,EAAI,WAAW,CAAS,EAIjE,EAAQ,EAAiB,EAAS,QAAQ,EAC1C,EAAY,CAAC,GAAG,EAAM,MAAM,CAAC,CAAC,QAAQ,EAAG,KAAS,EAAY,CAAG,CAAC,EAClE,EAAY,EAAM,OAAO,OAAQ,GAAM,EAAY,EAAE,GAAG,CAAC,EACzD,EAAS,EAAU,MAAM,EAAG,EAAqB,EACjD,EAAS,EAAU,MAAM,EAAG,EAAqB,GACnD,EAAO,OAAS,EAAU,QAAU,EAAO,OAAS,EAAU,SAChE,EAAK,QAAQ,KACX,CACE,QAAS,EAAI,GACb,OAAQ,EAAU,OAClB,OAAQ,EAAU,OAClB,IAAK,EACP,EACA,+FACF,EAEF,EAAS,IAAI,EAAI,QAAS,CAAE,SAAQ,QAAO,CAAC,EAC5C,EAAY,KAAK,GAAG,EAAO,KAAK,EAAG,KAAS,CAAG,EAAG,GAAG,EAAO,IAAK,GAAM,EAAE,GAAG,CAAC,CAC/E,CAEA,IAAM,EACJ,EAAY,OAAS,EAAI,MAAM,EAAK,cAAc,CAAW,EAAI,IAAI,IAEvE,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAW,EAAU,IAAI,EAAI,OAAO,EACpC,EAAS,EAAS,IAAI,EAAI,OAAO,EACvC,GAAI,IAAa,IAAA,IAAa,IAAW,IAAA,GAAW,SAKpD,IAAM,EAAmC,OAAO,YAC9C,EAAO,OAAO,SAAS,CAAC,EAAW,KAAS,CAC1C,IAAM,EAAM,EAAY,IAAI,CAAG,EAC/B,OAAO,IAAQ,IAAA,GAAY,CAAC,EAAI,CAAC,CAAC,EAAW,CAAG,CAAU,CAC5D,CAAC,CACH,EAEM,EAAU,IAAI,IACpB,IAAK,IAAM,KAAW,EAAO,OAAQ,CACnC,IAAM,EAAM,EAAY,IAAI,EAAQ,GAAG,EACvC,GAAI,IAAQ,IAAA,GAAW,SACvB,IAAM,EAAO,EAAQ,IAAI,EAAQ,KAAK,GAAK,CAAC,EAC5C,EAAK,KAAK,CAAE,MAAK,MAAO,EAAQ,MAAO,OAAQ,EAAQ,MAAO,CAAC,EAC/D,EAAQ,IAAI,EAAQ,MAAO,CAAI,CACjC,CACA,IAAM,EAAW,IAAI,IAAI,CAAC,GAAG,CAAO,CAAC,CAAC,KAAK,CAAC,EAAO,KAAU,CAAC,EAAO,EAAO,CAAI,CAAC,CAAU,CAAC,EACtF,EAAU,EAAS,IAAI,CAAa,EACpC,EAA+C,OAAO,YAC1D,CAAC,GAAG,CAAQ,CAAC,CAAC,QACX,CAAC,EAAO,KACP,IAAA,WAA2B,EAAqB,EAAO,EAAM,EAAS,CAAG,CAC7E,CACF,EAEA,EAAI,IAAI,EAAI,GAAI,CACd,GAAI,EAAI,GACR,IAAK,EAAS,IACd,SAAU,EAAI,UAAY,KAC1B,MAAO,EAAI,OAAS,KACpB,OAAQ,EAAI,QAAU,KACtB,IAAK,EAAI,KAAO,KAChB,MAAO,EAAI,OAAS,KACpB,WACA,UACA,WAAY,EAAe,EAAI,UAAU,CAC3C,CAAC,CACH,CAEA,OAAO,CACT,CAgCA,SAAgB,EACd,EACA,EACA,EAAoB,KAAK,IACP,CAClB,IAAI,EAAmD,KACvD,OAAO,SAAY,CACjB,IAAM,EAAI,EAAI,EACd,GAAI,GAAU,EAAI,EAAO,GAAK,EAAO,OAAO,EAAO,MACnD,IAAM,EAAQ,EAAK,EAMnB,MALA,GAAS,CAAE,QAAO,GAAI,CAAE,EACxB,EAAM,UAAY,CAEZ,GAAQ,QAAU,IAAO,EAAS,KACxC,CAAC,EACM,CACT,CACF,CAGA,eAAe,GAAwD,CACrE,GAAI,CACF,GAAM,CAAE,UAAW,MAAM,OAAO,qBAChC,OAAO,EAAO,CAAC,CAAC,MAClB,MAAQ,CAGN,MACF,CACF,CAEA,SAAgB,GAGiC,CAC/C,OAAO,MAAO,EAAK,EAAO,CAAC,IAAM,CAC/B,GAAM,CAAC,CAAE,0BAA0B,CAAE,2BAA4B,MAAM,QAAQ,IAAI,CACjF,OAAO,sBACP,OAAO,eACT,CAAC,EAEK,CAAC,EAAY,EAAS,GAAU,MAAM,QAAQ,IAAI,CACtD,EAAuB,EACvB,EAAuB,EACvB,EAAU,CACZ,CAAC,EAED,OAAO,EACL,EACA,CACE,UAAW,MAAO,EAAU,IAAW,CACrC,GAAM,CAAE,kBAAmB,MAAM,OAAO,mBAClC,CAAE,kBAAiB,WAAY,MAAM,OAAO,eAC5C,EAAQ,EAAe,IAAI,OAAO,EACxC,GAAI,CAAC,EAAO,MAAO,CAAC,EAMpB,IAAM,EADU,EAAgB,CACT,CAAC,CAAC,GAOzB,OANK,EAME,MALY,EAAW,SAAS,CACrC,MAAO,EAAQ,EAAU,CAAC,GAAG,CAAQ,CAAC,EACtC,MAAO,EAAS,OAChB,GAAI,IAAW,IAAA,IAAa,CAAE,QAAO,CACvC,CAAC,EALqB,CAAC,CAOzB,EACA,eAAiB,GAAS,EAAQ,eAAe,CAAI,EACrD,cAAgB,GAAS,EAAQ,cAAc,CAAI,EACnD,QACF,EACA,CACF,CACF,CACF,CAsBA,IAAI,EA6BJ,eAAsB,EACpB,EACA,EACmC,CAEnC,MADA,KAAmB,EAA0B,EACtC,EAAe,MAAM,EAAe,EAAK,CAAI,CAAC,CACvD,CAOA,SAAgB,EACd,EAC0B,CAC1B,IAAM,EAAS,IAAI,IACnB,IAAK,GAAM,CAAC,EAAI,KAAU,EACpB,EAAM,UAAU,WAAW,QAAQ,IAAM,IAC7C,EAAO,IAAI,EAAI,CACb,IAAK,EAAM,IACX,IAAK,EAAM,IACX,MAAO,EAAM,MACb,OAAQ,EAAM,OACd,WAAY,EAAM,WAClB,QAAS,EAAM,OACjB,CAAC,EAEH,OAAO,CACT"}
|
package/dist/query-client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { l as MediaRecord, m as Media } from "./types-6PZQ4Zq6.mjs";
|
|
2
2
|
import { CountOptions, FindByIdOptions, FindManyOptions, QueryClient } from "@murumets-ee/entity/query";
|
|
3
3
|
|
|
4
4
|
//#region src/query-client.d.ts
|
package/dist/query-client.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var e=class{query;constructor(e){this.query=e.query}async findById(e,t){return this.query.findById(e,t)}async findMany(e){return this.query.findMany(e)}async count(e){return this.query.count(e)}};async function t(){let{createQueryClient:t}=await import(`@murumets-ee/core/clients`),{Media:n}=await import(`./entity-
|
|
1
|
+
var e=class{query;constructor(e){this.query=e.query}async findById(e,t){return this.query.findById(e,t)}async findMany(e){return this.query.findMany(e)}async count(e){return this.query.count(e)}};async function t(){let{createQueryClient:t}=await import(`@murumets-ee/core/clients`),{Media:n}=await import(`./entity-CsDdjKz6.mjs`).then(e=>e.n);return new e({query:t(n)})}export{e as MediaQueryClient,t as createMediaQueryClient};
|
|
2
2
|
//# sourceMappingURL=query-client.mjs.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{createVariantDeps as e}from"./deps-DaGjHO16.mjs";import{generateMediaVariants as t}from"./generate-variants-Dpy4aT2o.mjs";import"server-only";async function n(n){let{app:r,storage:i,logger:a}=n,{AdminClient:o}=await import(`@murumets-ee/entity/admin`),{Media:s}=await import(`./entity-CsDdjKz6.mjs`).then(e=>e.n),{schemaRegistry:c}=await import(`@murumets-ee/db`),{and:l,asc:u,eq:d,gt:f}=await import(`drizzle-orm`),p=new o({entity:s,db:r.db.readWrite,logger:a,contextResolver:n.contextResolver}),m=c.get(`media`);if(!m)throw Error(`Media schema not registered`);let h=await e(r,{media:p,storage:i}),g={total:0,processed:0,skipped:0,errors:0},_=null;for(a?.info(`Starting variant regeneration (inline — no queue in this app)`);;){let e=await p.findMany({where:_?l(d(m.mediaType,`image`),f(m.id,_)):d(m.mediaType,`image`),orderBy:u(m.id),limit:100}),n=e[e.length-1];if(!n)break;_=n.id,g.total+=e.length;for(let n of e)try{let e=await t(n.id,h);e.status===`generated`||e.status===`stale`?g.processed++:g.skipped++}catch(e){g.errors++,a?.error({id:n.id,error:e},`Failed to regenerate variants for media record`)}if(e.length<100)break}return a?.info(g,`Variant regeneration complete`),g}export{n as regenerateAllVariants};
|
|
2
|
+
//# sourceMappingURL=regenerate-variants-RwV_AQSo.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"regenerate-variants-RwV_AQSo.mjs","names":[],"sources":["../src/regenerate-variants.ts"],"sourcesContent":["/**\n * Synchronous library regeneration — the path for an app WITHOUT the queue\n * plugin only.\n *\n * With a queue, the regenerate button queues `media:variants-backfill` instead\n * (`jobs/backfill.ts`): bounded, resumable, off the request, with the site\n * serving throughout. This loop is what a queueless app keeps, so it does not\n * silently lose the button — it runs inside the request, as it always has.\n *\n * It runs the SAME per-photograph pass the worker does (`generateMediaVariants`),\n * so it inherits its order (images F002): new files first, then the pointer,\n * then — only then — deletion of what they superseded. The previous version of\n * this file deleted every variant of a photograph before generating any, so a\n * removed style lost its files with nothing replacing them and every variant\n * URL 404'd for as long as the run took.\n *\n * Per-image errors are logged and counted, never stop the loop.\n */\n\nimport 'server-only'\n\nimport type { Logger, ToolkitApp } from '@murumets-ee/core'\nimport type { ContextResolver } from '@murumets-ee/entity'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { createVariantDeps } from './jobs/deps.js'\nimport { generateMediaVariants } from './jobs/generate-variants.js'\n\nconst BATCH_SIZE = 100\n\nexport interface RegenerateOptions {\n /** Toolkit app (provides db + the variant configuration). */\n app: ToolkitApp\n storage: StorageClient\n logger?: Logger\n /** Security context resolver — passed through to AdminClient. */\n contextResolver?: ContextResolver\n}\n\nexport interface RegenerateResult {\n /** Total image media records found */\n total: number\n /** Passes that ran (including ones that found nothing to do) */\n processed: number\n /** Skipped (non-processable mimeType, original missing, row gone) */\n skipped: number\n /** Failed with errors */\n errors: number\n}\n\n/** Regenerate variants for every image, in keyset pages of {@link BATCH_SIZE}. */\nexport async function regenerateAllVariants(options: RegenerateOptions): Promise<RegenerateResult> {\n const { app, storage, logger } = options\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n const { Media } = await import('./entity.js')\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, eq, gt } = await import('drizzle-orm')\n\n const admin = new AdminClient<typeof Media.allFields>({\n entity: Media,\n db: app.db.readWrite,\n logger,\n contextResolver: options.contextResolver,\n })\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered')\n\n const deps = await createVariantDeps(app, { media: admin, storage })\n const result: RegenerateResult = { total: 0, processed: 0, skipped: 0, errors: 0 }\n let cursor: string | null = null\n\n logger?.info('Starting variant regeneration (inline — no queue in this app)')\n\n for (;;) {\n const batch = await admin.findMany({\n where: cursor\n ? and(eq(table.mediaType, 'image'), gt(table.id, cursor))\n : eq(table.mediaType, 'image'),\n orderBy: asc(table.id),\n limit: BATCH_SIZE,\n })\n const last = batch[batch.length - 1]\n if (!last) break\n cursor = last.id\n result.total += batch.length\n\n for (const record of batch) {\n try {\n const outcome = await generateMediaVariants(record.id, deps)\n if (outcome.status === 'generated' || outcome.status === 'stale') result.processed++\n else result.skipped++\n } catch (err) {\n result.errors++\n logger?.error(\n { id: record.id, error: err },\n 'Failed to regenerate variants for media record',\n )\n }\n }\n\n if (batch.length < BATCH_SIZE) break\n }\n\n logger?.info(result, 'Variant regeneration complete')\n return result\n}\n"],"mappings":"qJAkDA,eAAsB,EAAsB,EAAuD,CACjG,GAAM,CAAE,MAAK,UAAS,UAAW,EAC3B,CAAE,eAAgB,MAAM,OAAO,6BAC/B,CAAE,SAAU,MAAM,OAAO,wBAAc,CAAA,KAAA,GAAA,EAAA,CAAA,EACvC,CAAE,kBAAmB,MAAM,OAAO,mBAClC,CAAE,MAAK,MAAK,KAAI,MAAO,MAAM,OAAO,eAEpC,EAAQ,IAAI,EAAoC,CACpD,OAAQ,EACR,GAAI,EAAI,GAAG,UACX,SACA,gBAAiB,EAAQ,eAC3B,CAAC,EACK,EAAQ,EAAe,IAAI,OAAO,EACxC,GAAI,CAAC,EAAO,MAAU,MAAM,6BAA6B,EAEzD,IAAM,EAAO,MAAM,EAAkB,EAAK,CAAE,MAAO,EAAO,SAAQ,CAAC,EAC7D,EAA2B,CAAE,MAAO,EAAG,UAAW,EAAG,QAAS,EAAG,OAAQ,CAAE,EAC7E,EAAwB,KAI5B,IAFA,GAAQ,KAAK,+DAA+D,IAEnE,CACP,IAAM,EAAQ,MAAM,EAAM,SAAS,CACjC,MAAO,EACH,EAAI,EAAG,EAAM,UAAW,OAAO,EAAG,EAAG,EAAM,GAAI,CAAM,CAAC,EACtD,EAAG,EAAM,UAAW,OAAO,EAC/B,QAAS,EAAI,EAAM,EAAE,EACrB,MAAO,GACT,CAAC,EACK,EAAO,EAAM,EAAM,OAAS,GAClC,GAAI,CAAC,EAAM,MACX,EAAS,EAAK,GACd,EAAO,OAAS,EAAM,OAEtB,IAAK,IAAM,KAAU,EACnB,GAAI,CACF,IAAM,EAAU,MAAM,EAAsB,EAAO,GAAI,CAAI,EACvD,EAAQ,SAAW,aAAe,EAAQ,SAAW,QAAS,EAAO,YACpE,EAAO,SACd,OAAS,EAAK,CACZ,EAAO,SACP,GAAQ,MACN,CAAE,GAAI,EAAO,GAAI,MAAO,CAAI,EAC5B,gDACF,CACF,CAGF,GAAI,EAAM,OAAS,IAAY,KACjC,CAGA,OADA,GAAQ,KAAK,EAAQ,+BAA+B,EAC7C,CACT"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{c as e,i as t,l as n,s as r,t as i}from"./definitions-LJpgrdxd.mjs";import{n as a}from"./slot-CY4isqmN.mjs";import{runAsCli as o}from"@murumets-ee/core";async function s(s){if(!s.plugins.has(`@murumets-ee/queue`))return;let{defineJob:c,registerJob:l}=await import(`@murumets-ee/queue/client`),u=c({name:i,description:`Generate one photograph's image variants (every shape × width, plus fixed styles), record them on the original, then delete the ones they supersede.`,schema:e,idempotencyKey:r}),d=c({name:t,description:`Walk the image library and queue a variant pass for every photograph — after a shape, width or style changes. Bounded and resumable; the site keeps serving throughout.`,schema:n,defaultRetries:2});l(u,async t=>{let{mediaId:n}=e.parse(t.payload);await o(async()=>{let{createJobVariantDeps:e}=await import(`./deps-DaGjHO16.mjs`),{generateMediaVariants:t}=await import(`./generate-variants-Dpy4aT2o.mjs`),r=await t(n,await e(s));s.logger.info(r,`media variants: pass finished`)})}),l(d,async e=>{n.parse(e.payload),await o(async()=>{let{runVariantsBackfill:t}=await import(`./backfill-B1StTnxg.mjs`),n=await t(s,u,{walkJobId:e.id,onProgress:t=>e.updateProgress(t)});s.logger.info(n,`media variants: backfill queued`)})}),a({generateVariants:u,variantsBackfill:d})}export{s as registerMediaJobs};
|
|
2
|
+
//# sourceMappingURL=register-CJKjNl2w.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"register-CJKjNl2w.mjs","names":[],"sources":["../src/jobs/register.ts"],"sourcesContent":["/**\n * Registers the media queue jobs, and holds the handles every enqueuer uses.\n *\n * ⚠️ Imperative on purpose, and known debt: `defineJob` + `registerJob` from a\n * plugin's `server.init` is the ONE pattern the queue offers today — the same\n * one `packages/blocks/src/server/jobs/*`, `packages/commerce/src/<area>/jobs.ts`\n * and `packages/content/src/paths/jobs.ts` follow. It sits against CLAUDE.md's\n * declarative-contributions rule and is tracked as lumi-cms#497 (a declarative\n * `Plugin.jobs` slot). Do not invent a second pattern here; lift all of them\n * together when #497 lands.\n *\n * `@murumets-ee/queue/client` is imported DYNAMICALLY so an app without the\n * queue plugin (`apps/web-astro`) never loads it — it gets inline generation\n * instead (`schedule.ts`).\n */\n\nimport { runAsCli, type ToolkitApp } from '@murumets-ee/core'\nimport {\n GENERATE_VARIANTS_JOB_NAME,\n type GenerateVariantsPayload,\n generateVariantsJobKey,\n generateVariantsPayloadSchema,\n QUEUE_PLUGIN_NAME,\n VARIANTS_BACKFILL_JOB_NAME,\n type VariantsBackfillPayload,\n variantsBackfillPayloadSchema,\n} from './definitions.js'\nimport { setMediaJobs } from './slot.js'\n\n/**\n * Define and register `media:generate-variants` and `media:variants-backfill`.\n * A no-op when the app has no queue plugin. Sync-registry only — no DB — so it\n * is safe in every context `init` runs in (the Next server, `next build`, CLI).\n */\nexport async function registerMediaJobs(app: ToolkitApp): Promise<void> {\n if (!app.plugins.has(QUEUE_PLUGIN_NAME)) return\n const { defineJob, registerJob } = await import('@murumets-ee/queue/client')\n\n const generateVariants = defineJob<GenerateVariantsPayload>({\n name: GENERATE_VARIANTS_JOB_NAME,\n description:\n \"Generate one photograph's image variants (every shape × width, plus fixed styles), \" +\n 'record them on the original, then delete the ones they supersede.',\n schema: generateVariantsPayloadSchema,\n idempotencyKey: generateVariantsJobKey,\n })\n const variantsBackfill = defineJob<VariantsBackfillPayload>({\n name: VARIANTS_BACKFILL_JOB_NAME,\n description:\n 'Walk the image library and queue a variant pass for every photograph — after a shape, ' +\n 'width or style changes. Bounded and resumable; the site keeps serving throughout.',\n schema: variantsBackfillPayloadSchema,\n // Rerunning a backfill is always meaningful (the configuration may have\n // changed since), so it has no idempotency key; the passes it queues do.\n defaultRetries: 2,\n })\n\n registerJob(generateVariants, async (ctx) => {\n const { mediaId } = generateVariantsPayloadSchema.parse(ctx.payload)\n // A worker has no request, so no user: the pass reads and writes the media\n // row as the synthetic CLI admin, the same wrapping every entity-touching\n // job in this repo uses. What it writes is derived from the file, never\n // from a payload field (the payload is an id and a hash).\n await runAsCli(async () => {\n const { createJobVariantDeps } = await import('./deps.js')\n const { generateMediaVariants } = await import('./generate-variants.js')\n const outcome = await generateMediaVariants(mediaId, await createJobVariantDeps(app))\n app.logger.info(outcome, 'media variants: pass finished')\n })\n })\n\n registerJob(variantsBackfill, async (ctx) => {\n variantsBackfillPayloadSchema.parse(ctx.payload)\n await runAsCli(async () => {\n const { runVariantsBackfill } = await import('./backfill.js')\n const outcome = await runVariantsBackfill(app, generateVariants, {\n walkJobId: ctx.id,\n onProgress: (p) => ctx.updateProgress(p),\n })\n app.logger.info(outcome, 'media variants: backfill queued')\n })\n })\n\n setMediaJobs({ generateVariants, variantsBackfill })\n}\n"],"mappings":"gKAkCA,eAAsB,EAAkB,EAAgC,CACtE,GAAI,CAAC,EAAI,QAAQ,IAAA,oBAAqB,EAAG,OACzC,GAAM,CAAE,YAAW,eAAgB,MAAM,OAAO,6BAE1C,EAAmB,EAAmC,CAC1D,KAAM,EACN,YACE,uJAEF,OAAQ,EACR,eAAgB,CAClB,CAAC,EACK,EAAmB,EAAmC,CAC1D,KAAM,EACN,YACE,0KAEF,OAAQ,EAGR,eAAgB,CAClB,CAAC,EAED,EAAY,EAAkB,KAAO,IAAQ,CAC3C,GAAM,CAAE,WAAY,EAA8B,MAAM,EAAI,OAAO,EAKnE,MAAM,EAAS,SAAY,CACzB,GAAM,CAAE,wBAAyB,MAAM,OAAO,uBACxC,CAAE,yBAA0B,MAAM,OAAO,oCACzC,EAAU,MAAM,EAAsB,EAAS,MAAM,EAAqB,CAAG,CAAC,EACpF,EAAI,OAAO,KAAK,EAAS,+BAA+B,CAC1D,CAAC,CACH,CAAC,EAED,EAAY,EAAkB,KAAO,IAAQ,CAC3C,EAA8B,MAAM,EAAI,OAAO,EAC/C,MAAM,EAAS,SAAY,CACzB,GAAM,CAAE,uBAAwB,MAAM,OAAO,2BACvC,EAAU,MAAM,EAAoB,EAAK,EAAkB,CAC/D,UAAW,EAAI,GACf,WAAa,GAAM,EAAI,eAAe,CAAC,CACzC,CAAC,EACD,EAAI,OAAO,KAAK,EAAS,iCAAiC,CAC5D,CAAC,CACH,CAAC,EAED,EAAa,CAAE,mBAAkB,kBAAiB,CAAC,CACrD"}
|