@murumets-ee/media 0.72.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-D2Ubblh3.mjs.map → backfill-B1StTnxg.mjs.map} +1 -1
- package/dist/client.d.mts +16 -6
- 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 +6 -5
- package/dist/crop-editor.d.mts.map +1 -1
- package/dist/crop-editor.mjs +1 -1
- package/dist/crop-rect-B-AJ2pWm.mjs.map +1 -1
- package/dist/crop-rect-C-SbjoxK.d.mts.map +1 -1
- package/dist/{deps-CzHEhyJK.mjs → deps-DaGjHO16.mjs} +2 -2
- package/dist/{deps-CzHEhyJK.mjs.map → deps-DaGjHO16.mjs.map} +1 -1
- package/dist/en-Duj_Pp-O.mjs +2 -0
- package/dist/en-Duj_Pp-O.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-zyPjaGcy.mjs.map → generate-variants-Dpy4aT2o.mjs.map} +1 -1
- package/dist/i18n.mjs +1 -1
- package/dist/image-styles-settings.d.mts +1 -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 +1 -1
- package/dist/image-styles.mjs +1 -1
- package/dist/image-styles.mjs.map +1 -1
- package/dist/index.d.mts +38 -4
- 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-DmcTxuDM.mjs → media-config-DkbnXIjC.mjs} +2 -2
- package/dist/{media-config-DmcTxuDM.mjs.map → media-config-DkbnXIjC.mjs.map} +1 -1
- package/dist/picker.d.mts +1 -1
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/process-image-wQwiRhyk.mjs +2 -0
- package/dist/process-image-wQwiRhyk.mjs.map +1 -0
- package/dist/processing.d.mts +2 -2
- 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/{regenerate-variants-Cp3sNHkT.mjs → regenerate-variants-RwV_AQSo.mjs} +2 -2
- package/dist/{regenerate-variants-Cp3sNHkT.mjs.map → regenerate-variants-RwV_AQSo.mjs.map} +1 -1
- package/dist/{register-BSGkELj0.mjs → register-CJKjNl2w.mjs} +2 -2
- package/dist/{register-BSGkELj0.mjs.map → register-CJKjNl2w.mjs.map} +1 -1
- package/dist/{resolve-image-styles-pf7Nd3Zf.mjs → resolve-image-styles-YExI329D.mjs} +2 -2
- package/dist/{resolve-image-styles-pf7Nd3Zf.mjs.map → resolve-image-styles-YExI329D.mjs.map} +1 -1
- 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-DREjg2ji.mjs → schedule-JlvfUEVA.mjs} +2 -2
- package/dist/{schedule-DREjg2ji.mjs.map → schedule-JlvfUEVA.mjs.map} +1 -1
- package/dist/shapes-BpOEJeLx.mjs +2 -0
- package/dist/shapes-BpOEJeLx.mjs.map +1 -0
- package/dist/{shapes-40dHtTb-.d.mts → shapes-C51rXSDJ.d.mts} +2 -2
- package/dist/{shapes-40dHtTb-.d.mts.map → shapes-C51rXSDJ.d.mts.map} +1 -1
- package/dist/shapes-CqppzFzb.mjs +2 -0
- package/dist/shapes-CqppzFzb.mjs.map +1 -0
- package/dist/{types-CgkJF5dc.d.mts → types-6PZQ4Zq6.d.mts} +8 -11
- package/dist/{types-CgkJF5dc.d.mts.map → types-6PZQ4Zq6.d.mts.map} +1 -1
- package/dist/{types-DwzblZfW.d.mts → types-Lj3SkZQ_.d.mts} +11 -1
- 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 +9 -9
- package/dist/backfill-D2Ubblh3.mjs +0 -2
- package/dist/en-A8YzaZ51.mjs +0 -2
- package/dist/en-A8YzaZ51.mjs.map +0 -1
- package/dist/et-CXvu8U31.mjs +0 -2
- package/dist/et-CXvu8U31.mjs.map +0 -1
- package/dist/generate-variants-zyPjaGcy.mjs +0 -2
- package/dist/process-image-Deedtkh4.mjs +0 -2
- package/dist/process-image-Deedtkh4.mjs.map +0 -1
- package/dist/routes-BJ23Cp0g.mjs +0 -2
- package/dist/routes-BJ23Cp0g.mjs.map +0 -1
- package/dist/ru-Dj-ax8s_.mjs +0 -2
- package/dist/ru-Dj-ax8s_.mjs.map +0 -1
- package/dist/shapes-BlKW-0C1.mjs +0 -2
- package/dist/shapes-BlKW-0C1.mjs.map +0 -1
- package/dist/types-DwzblZfW.d.mts.map +0 -1
- package/dist/variant-key-Cki4n-42.mjs +0 -2
- package/dist/variant-key-Cki4n-42.mjs.map +0 -1
- package/dist/variant-plan-DV8hsMdz.mjs +0 -2
- package/dist/variant-plan-DV8hsMdz.mjs.map +0 -1
package/dist/index.d.mts
CHANGED
|
@@ -1,12 +1,46 @@
|
|
|
1
|
-
import { a as ImageVocabulary, c as MediaPluginConfig, d as MediaUploadOptions, f as MediaUploadResult, i as ImageStyle, l as MediaRecord, m as Media, n as ImageFormat, o as MediaListOptions, r as ImageShape, s as MediaListResult, t as CropShape, u as MediaType } from "./types-
|
|
1
|
+
import { a as ImageVocabulary, c as MediaPluginConfig, d as MediaUploadOptions, f as MediaUploadResult, i as ImageStyle, l as MediaRecord, m as Media, n as ImageFormat, o as MediaListOptions, r as ImageShape, s as MediaListResult, t as CropShape, u as MediaType } from "./types-6PZQ4Zq6.mjs";
|
|
2
2
|
import { defaultImageStyles, imageStylesSettings } from "./image-styles-settings.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { a as ResolvedShape, n as DEFAULT_IMAGE_SHAPES, r as DEFAULT_IMAGE_VOCABULARY, t as DEFAULT_IMAGE_LADDER } from "./shapes-C51rXSDJ.mjs";
|
|
4
|
+
import { i as MediaPickerListResult, r as MediaPickerItem, t as MediaPickerCallbacks } from "./types-Lj3SkZQ_.mjs";
|
|
5
5
|
//#region src/crop/shapes.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* The crop tabs for a resolved variant config — every shape the generator
|
|
8
|
+
* plans that has a fixed ratio, under the NAME the generator reads a crop by.
|
|
9
|
+
*
|
|
10
|
+
* 🔴 **This is the one the media edit page uses, and the name is the contract.**
|
|
11
|
+
* A crop is stored as `crops[<tab name>]` and the generator looks it up as
|
|
12
|
+
* `crops[shape.name]` (`variant-plan.ts` `effectiveRect`). Before images PR08
|
|
13
|
+
* the tabs came from {@link deriveCropShapes} over the LEGACY fixed styles —
|
|
14
|
+
* `card`, `hero`, `thumbnail` — while PR04 had folded each of those, at its
|
|
15
|
+
* default, into a vocabulary shape (`landscape`, `wide`, `square`). So on a
|
|
16
|
+
* default site every tab was a name no shape carried: a crop drawn on any of
|
|
17
|
+
* them changed no file, and the vocabulary's own shapes had no tab at all
|
|
18
|
+
* (images F031). Reading the SAME config the generator plans from makes the two
|
|
19
|
+
* name sets one set by construction.
|
|
20
|
+
*
|
|
21
|
+
* `natural` has no ratio and gets no tab; a legacy style an operator edited away
|
|
22
|
+
* from its default is its own one-rung shape, and keeps its tab. Neither does a
|
|
23
|
+
* name longer than `MAX_SHAPE_NAME_LENGTH`: `cropsValidation` refuses a crops
|
|
24
|
+
* map carrying such a key, so a tab for it could never be saved — and would
|
|
25
|
+
* take every other tab's save down with it.
|
|
26
|
+
*
|
|
27
|
+
* Sorted by code unit, not `localeCompare`, for the reason `shapes.ts`'s
|
|
28
|
+
* `compareNames` gives: collation depends on the runtime's ICU data.
|
|
29
|
+
*/
|
|
30
|
+
declare function cropShapesFor(config: {
|
|
31
|
+
readonly shapes: readonly Pick<ResolvedShape, 'name' | 'ratio'>[];
|
|
32
|
+
}): CropShape[];
|
|
6
33
|
/**
|
|
7
34
|
* Which of `styles` declare a fixed ratio, sorted by name for a stable tab
|
|
8
35
|
* order.
|
|
9
36
|
*
|
|
37
|
+
* @deprecated For the crop editor use {@link cropShapesFor} (images F031).
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ Superseded for the crop editor by {@link cropShapesFor} (images F031): the
|
|
40
|
+
* names here are LEGACY style names, which the generator does not read a crop
|
|
41
|
+
* by once a style is folded into a vocabulary shape. Kept for its existing
|
|
42
|
+
* public callers.
|
|
43
|
+
*
|
|
10
44
|
* A style with only ONE dimension set (`full: { width: 2400, fit: 'inside' }`)
|
|
11
45
|
* constrains one axis and lets the other float — there is no fixed hole to
|
|
12
46
|
* drag a crop window over, so it is excluded rather than offered a tab that
|
|
@@ -30,5 +64,5 @@ declare function enrichWithMediaUrls(entity: {
|
|
|
30
64
|
}>;
|
|
31
65
|
}, items: Record<string, unknown>[], styleName?: string): Promise<void>;
|
|
32
66
|
//#endregion
|
|
33
|
-
export { type CropShape, DEFAULT_IMAGE_LADDER, DEFAULT_IMAGE_SHAPES, DEFAULT_IMAGE_VOCABULARY, type ImageFormat, type ImageShape, type ImageStyle, type ImageVocabulary, Media, type MediaListOptions, type MediaListResult, type MediaPickerCallbacks, type MediaPickerItem, type MediaPickerListResult, type MediaPluginConfig, type MediaRecord, type MediaType, type MediaUploadOptions, type MediaUploadResult, defaultImageStyles, deriveCropShapes, enrichWithMediaUrls, imageStylesSettings };
|
|
67
|
+
export { type CropShape, DEFAULT_IMAGE_LADDER, DEFAULT_IMAGE_SHAPES, DEFAULT_IMAGE_VOCABULARY, type ImageFormat, type ImageShape, type ImageStyle, type ImageVocabulary, Media, type MediaListOptions, type MediaListResult, type MediaPickerCallbacks, type MediaPickerItem, type MediaPickerListResult, type MediaPluginConfig, type MediaRecord, type MediaType, type MediaUploadOptions, type MediaUploadResult, cropShapesFor, defaultImageStyles, deriveCropShapes, enrichWithMediaUrls, imageStylesSettings };
|
|
34
68
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/crop/shapes.ts","../src/enrich.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/crop/shapes.ts","../src/enrich.ts"],"mappings":";;;;;;;;;;;;;;;;;AAsCa;AA0Bb;;;;;;;;;;;iBA5BgB,aAAA,CAAc,MAAA;EAAA,SACnB,MAAA,WAAiB,IAAA,CAAK,aAAA;AAAA,IAC7B,SAAA;;;;ACRJ;;;;;;;;;;;;;iBDkCgB,gBAAA,CAAiB,MAAA,EAAQ,MAAA,SAAe,UAAA,IAAc,SAAA;;;;;AA1BzD;AA0Bb;;;;;;iBClCsB,mBAAA,CACpB,MAAA;EAAU,SAAA,EAAW,MAAA;IAAiB,IAAA;EAAA;AAAA,GACtC,KAAA,EAAO,MAAA,qBACP,SAAA,YACC,OAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{n as e,t}from"./shapes-BpOEJeLx.mjs";import{t as n}from"./entity-CsDdjKz6.mjs";import{n as r,r as i,t as a}from"./shapes-CqppzFzb.mjs";import{defaultImageStyles as o,imageStylesSettings as s}from"./image-styles-settings.mjs";import"server-only";async function c(e,t,n=`thumbnail`){let r=Object.entries(e.allFields).filter(([,e])=>e.type===`media`).map(([e])=>e);if(r.length===0)return;let i=new Set;for(let e of t)for(let t of r){let n=e[t];typeof n==`string`&&n.length>0&&i.add(n)}if(i.size===0)return;let{getMediaClient:a}=await import(`./client.mjs`),o=await(await a()).getVariantUrls([...i],n);for(let e of t)for(let t of r){let n=e[t];typeof n==`string`&&o.has(n)&&(e[`${t}Url`]=o.get(n))}}export{a as DEFAULT_IMAGE_LADDER,r as DEFAULT_IMAGE_SHAPES,i as DEFAULT_IMAGE_VOCABULARY,n as Media,t as cropShapesFor,o as defaultImageStyles,e as deriveCropShapes,c as enrichWithMediaUrls,s as imageStylesSettings};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/enrich.ts"],"sourcesContent":["/**\n * Server-side utility to enrich entity list items with resolved media URLs.\n *\n * For entities with `field.media()` columns, this scans items for media UUIDs,\n * batch-resolves them via MediaClient, and injects `${fieldName}Url` into each item.\n *\n * Convention: a media field named `coverImage` (UUID) gets enriched with\n * `coverImageUrl` (resolved URL string).\n *\n * @example\n * ```typescript\n * import { enrichWithMediaUrls } from '@murumets-ee/media'\n *\n * const items = await adminClient.findMany({ limit: 20 })\n * await enrichWithMediaUrls(Article, items)\n * // items[0].coverImageUrl → 'https://cdn.example.com/uploads/.../thumbnail_photo.webp'\n * ```\n */\n\nimport 'server-only'\n\n/**\n * Enrich entity list items by resolving media field UUIDs to variant URLs.\n *\n * Mutates items in place — injects `${fieldName}Url` for each media field.\n *\n * @param entity - Entity definition (or any object with allFields containing type info)\n * @param items - Array of entity records to enrich\n * @param styleName - Image style to resolve (default: 'thumbnail')\n */\nexport async function enrichWithMediaUrls(\n entity: { allFields: Record<string, { type: string }> },\n items: Record<string, unknown>[],\n styleName = 'thumbnail',\n): Promise<void> {\n // 1. Find media-type fields in entity definition\n const mediaFields = Object.entries(entity.allFields)\n .filter(([, config]) => config.type === 'media')\n .map(([name]) => name)\n\n if (mediaFields.length === 0) return\n\n // 2. Collect unique media UUIDs across all items\n const mediaIds = new Set<string>()\n for (const item of items) {\n for (const f of mediaFields) {\n const val = item[f]\n if (typeof val === 'string' && val.length > 0) {\n mediaIds.add(val)\n }\n }\n }\n\n if (mediaIds.size === 0) return\n\n // 3. Batch resolve via MediaClient\n const { getMediaClient } = await import('./client.js')\n const client = await getMediaClient()\n const urlMap = await client.getVariantUrls([...mediaIds], styleName)\n\n // 4. Inject ${fieldName}Url into each item\n for (const item of items) {\n for (const f of mediaFields) {\n const val = item[f]\n if (typeof val === 'string' && urlMap.has(val)) {\n item[`${f}Url`] = urlMap.get(val)\n }\n }\n }\n}\n"],"mappings":"4PA8BA,eAAsB,EACpB,EACA,EACA,EAAY,YACG,CAEf,IAAM,EAAc,OAAO,QAAQ,EAAO,SAAS,CAAC,CACjD,QAAQ,EAAG,KAAY,EAAO,OAAS,OAAO,CAAC,CAC/C,KAAK,CAAC,KAAU,CAAI,EAEvB,GAAI,EAAY,SAAW,EAAG,OAG9B,IAAM,EAAW,IAAI,IACrB,IAAK,IAAM,KAAQ,EACjB,IAAK,IAAM,KAAK,EAAa,CAC3B,IAAM,EAAM,EAAK,GACb,OAAO,GAAQ,UAAY,EAAI,OAAS,GAC1C,EAAS,IAAI,CAAG,CAEpB,CAGF,GAAI,EAAS,OAAS,EAAG,OAGzB,GAAM,CAAE,kBAAmB,MAAM,OAAO,gBAElC,EAAS,MAAM,MADA,EAAe,EAAA,CACR,eAAe,CAAC,GAAG,CAAQ,EAAG,CAAS,EAGnE,IAAK,IAAM,KAAQ,EACjB,IAAK,IAAM,KAAK,EAAa,CAC3B,IAAM,EAAM,EAAK,GACb,OAAO,GAAQ,UAAY,EAAO,IAAI,CAAG,IAC3C,EAAK,GAAG,EAAE,MAAQ,EAAO,IAAI,CAAG,EAEpC,CAEJ"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{r as e}from"./shapes-
|
|
2
|
-
//# sourceMappingURL=media-config-
|
|
1
|
+
import{r as e}from"./shapes-CqppzFzb.mjs";import{getPluginConfig as t,hasPluginConfig as n}from"@murumets-ee/core";const r=`@murumets-ee/media`,i=[`image/*`,`video/*`,`audio/*`,`application/pdf`,`application/vnd.openxmlformats-officedocument.*`,`application/vnd.oasis.opendocument.*`,`text/plain`,`text/csv`];function a(t){return{acceptedTypes:t?.acceptedTypes??[...i],maxUploadSize:t?.maxUploadSize!==void 0&&t.maxUploadSize>0?t.maxUploadSize:50*1024*1024,defaultVisibility:t?.defaultVisibility??`public`,imageStyles:t?.imageStyles??{thumbnail:{width:200,height:200,fit:`cover`,format:`webp`,quality:80}},vocabulary:t?.vocabulary??{shapes:Object.fromEntries(Object.entries(e.shapes).map(([e,t])=>[e,{...t}])),ladder:[...e.ladder]}}}function o(){return n(`@murumets-ee/media`)?t(r):a()}function s(){if(!n(`@murumets-ee/media`))throw Error(`@murumets-ee/media plugin not initialized. Add media() to your plugins array.`);return t(r)}export{s as n,a as r,o as t};
|
|
2
|
+
//# sourceMappingURL=media-config-DkbnXIjC.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"media-config-
|
|
1
|
+
{"version":3,"file":"media-config-DkbnXIjC.mjs","names":[],"sources":["../src/media-config.ts"],"sourcesContent":["/**\n * The media plugin's resolved configuration — defaults in one place, read\n * from the framework's `PluginConfigRegistry`.\n *\n * The plugin used to stash its config in a module variable from `init`, and\n * the upload route hardcoded 50 MB with a comment explaining it could not\n * trust that variable to be set yet. So `acceptedTypes` and `maxUploadSize`\n * were declared, documented, logged at boot — and enforced nowhere (F007).\n * `server.config` is placed in the registry at the merge step, BEFORE any\n * `init`, which removes the reason the route could not read it.\n */\n\nimport { getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\nimport { DEFAULT_IMAGE_VOCABULARY } from './shapes.js'\nimport type { MediaPluginConfig } from './types.js'\n\ndeclare module '@murumets-ee/core' {\n interface PluginConfigRegistry {\n '@murumets-ee/media': Required<MediaPluginConfig>\n }\n}\n\nexport const MEDIA_PLUGIN_NAME = '@murumets-ee/media'\n\n/**\n * The default `acceptedTypes`, matched against what `detectMimeType` (file-type\n * 21) actually REPORTS — not what a browser claims.\n *\n * Wider than the image/video/audio/PDF the option's docs used to list, on\n * purpose: nothing enforced that list, so modern Office and OpenDocument files\n * were accepted and are in libraries today. Enforcing the narrow list would\n * have turned an F007 memory fix into a silent feature removal.\n *\n * Deliberately NOT in the default, each refused with 415 unless an app lists it:\n * - **Legacy binary Office** (`.doc`, `.xls`, `.ppt`). file-type reports them as\n * `application/x-cfb` — the same container as `.msi` installers and Outlook\n * `.msg` files, so it cannot be allowed for one without the others.\n * - **Macro-enabled Office** (`.docm`, `.xlsm`, `.pptm`), reported with their own\n * `…macroenabled…` types.\n * - **Archives and executables**, and the text types (`application/json`,\n * `text/markdown`, TSV) that `detectMimeType` trusts but the library does not\n * classify as documents.\n *\n * Named office families rather than `application/vnd.*`, which also matches\n * executables and archives.\n */\nexport const DEFAULT_MEDIA_ACCEPTED_TYPES: readonly string[] = [\n 'image/*',\n 'video/*',\n 'audio/*',\n 'application/pdf',\n 'application/vnd.openxmlformats-officedocument.*',\n 'application/vnd.oasis.opendocument.*',\n 'text/plain',\n 'text/csv',\n]\n\n/** Fill every unset option with its default. */\nexport function resolveMediaConfig(config?: MediaPluginConfig): Required<MediaPluginConfig> {\n return {\n acceptedTypes: config?.acceptedTypes ?? [...DEFAULT_MEDIA_ACCEPTED_TYPES],\n // Non-positive means unset, as it does for storage's `maxFileSize`.\n maxUploadSize:\n config?.maxUploadSize !== undefined && config.maxUploadSize > 0\n ? config.maxUploadSize\n : 50 * 1024 * 1024,\n // Resolved for the picker's own use, and deliberately NOT applied by the\n // upload route: an upload that sends no visibility stays private (rent's\n // inspection photos rely on it). See the route's deny-by-default comment.\n defaultVisibility: config?.defaultVisibility ?? 'public',\n imageStyles: config?.imageStyles ?? {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n },\n vocabulary: config?.vocabulary ?? {\n shapes: Object.fromEntries(\n Object.entries(DEFAULT_IMAGE_VOCABULARY.shapes).map(([name, shape]) => [\n name,\n { ...shape },\n ]),\n ),\n ladder: [...DEFAULT_IMAGE_VOCABULARY.ladder],\n },\n }\n}\n\n/**\n * The registered config, or the defaults when the plugin is not registered\n * (a unit test, a CLI path that never ran `createApp`). The defaults are what\n * `media()` with no arguments resolves to, so the fallback is never looser\n * than a registered plugin that configured nothing.\n */\nexport function readMediaConfig(): Required<MediaPluginConfig> {\n return hasPluginConfig(MEDIA_PLUGIN_NAME)\n ? getPluginConfig(MEDIA_PLUGIN_NAME)\n : resolveMediaConfig()\n}\n\n/**\n * The resolved media configuration, read from the framework's registry.\n * THROWS when the `media()` plugin is not registered — unlike\n * {@link readMediaConfig}, which falls back to the defaults. The image-styles\n * waterfall needs the throw: \"not registered\" must reach its hardcoded tier.\n *\n * Lives here, not in `plugin.ts`, so a module that needs the config does not\n * have to import the plugin entry: `resolve-image-styles.ts` used to reach it\n * through `await import('./plugin.js')`, which closed a file cycle through the\n * admin routes and the client (the `KNOWN_CYCLES` media entry, now gone).\n */\nexport function requireMediaConfig(): Required<MediaPluginConfig> {\n if (!hasPluginConfig(MEDIA_PLUGIN_NAME)) {\n throw new Error('@murumets-ee/media plugin not initialized. Add media() to your plugins array.')\n }\n return getPluginConfig(MEDIA_PLUGIN_NAME)\n}\n"],"mappings":"mHAsBA,MAAa,EAAoB,qBAwBpB,EAAkD,CAC7D,UACA,UACA,UACA,kBACA,kDACA,uCACA,aACA,UACF,EAGA,SAAgB,EAAmB,EAAyD,CAC1F,MAAO,CACL,cAAe,GAAQ,eAAiB,CAAC,GAAG,CAA4B,EAExE,cACE,GAAQ,gBAAkB,IAAA,IAAa,EAAO,cAAgB,EAC1D,EAAO,cACP,GAAK,KAAO,KAIlB,kBAAmB,GAAQ,mBAAqB,SAChD,YAAa,GAAQ,aAAe,CAClC,UAAW,CAAE,MAAO,IAAK,OAAQ,IAAK,IAAK,QAAS,OAAQ,OAAQ,QAAS,EAAG,CAClF,EACA,WAAY,GAAQ,YAAc,CAChC,OAAQ,OAAO,YACb,OAAO,QAAQ,EAAyB,MAAM,CAAC,CAAC,KAAK,CAAC,EAAM,KAAW,CACrE,EACA,CAAE,GAAG,CAAM,CACb,CAAC,CACH,EACA,OAAQ,CAAC,GAAG,EAAyB,MAAM,CAC7C,CACF,CACF,CAQA,SAAgB,GAA+C,CAC7D,OAAO,EAAA,oBAAiC,EACpC,EAAgB,CAAiB,EACjC,EAAmB,CACzB,CAaA,SAAgB,GAAkD,CAChE,GAAI,CAAC,EAAA,oBAAiC,EACpC,MAAU,MAAM,+EAA+E,EAEjG,OAAO,EAAgB,CAAiB,CAC1C"}
|
package/dist/picker.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as MediaPickerMode, i as MediaPickerListResult, n as MediaPickerClassNames, o as MediaPickerProps, r as MediaPickerItem, t as MediaPickerCallbacks } from "./types-
|
|
1
|
+
import { a as MediaPickerMode, i as MediaPickerListResult, n as MediaPickerClassNames, o as MediaPickerProps, r as MediaPickerItem, t as MediaPickerCallbacks } from "./types-Lj3SkZQ_.mjs";
|
|
2
2
|
import { LucideIcon } from "lucide-react";
|
|
3
3
|
import { ReactNode } from "react";
|
|
4
4
|
|
package/dist/plugin.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ImageVocabulary, c as MediaPluginConfig, i as ImageStyle } from "./types-
|
|
1
|
+
import { a as ImageVocabulary, c as MediaPluginConfig, i as ImageStyle } from "./types-6PZQ4Zq6.mjs";
|
|
2
2
|
import { getMediaMessages } from "./i18n.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/plugin.d.ts
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./entity-CsDdjKz6.mjs";import{imageStylesSettings as t}from"./image-styles-settings.mjs";import{n,r}from"./media-config-
|
|
1
|
+
import{t as e}from"./entity-CsDdjKz6.mjs";import{imageStylesSettings as t}from"./image-styles-settings.mjs";import{n,r}from"./media-config-DkbnXIjC.mjs";import{t as i}from"./routes-DaE0M8h8.mjs";import{getMediaMessages as a}from"./i18n.mjs";import{definePlugin as o}from"@murumets-ee/core";import{ImageStylesManager as s,ImageVocabularyEditor as c,RegenerateVariantsAction as l}from"@murumets-ee/media/image-styles";function u(){return n()}function d(n){let u=r(n);return o({name:`@murumets-ee/media`,requires:[`@murumets-ee/storage`,`@murumets-ee/settings`],shared:{messages:a,settings:[t]},server:{entities:[e],routes:[...i()],config:u,init:async e=>{try{let{registerMediaJobs:t}=await import(`./register-CJKjNl2w.mjs`);await t(e)}catch(t){e.logger.warn({err:t},`media: could not register the variant jobs — uploads will generate variants inline`)}e.logger.info({acceptedTypes:u.acceptedTypes,maxUploadSize:u.maxUploadSize,defaultVisibility:u.defaultVisibility},`Media plugin initialized`)}},adminUi:{sidebar:[{id:`media`,group:`Library`,label:`Media`,href:`/admin/media`,iconName:`image`}],defaultRoutes:[{path:`media`,factory:`MediaListPage`},{path:`media/[id]`,factory:`MediaEditPage`}],settingRenderers:{"media.imageStyles":s,"media.imageVocabulary":c},settingsActions:{"media.imageStyles":l}}})}export{u as getMediaConfig,d as media};
|
|
2
2
|
//# sourceMappingURL=plugin.mjs.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{n as e}from"./variant-plan-86tJKtVi.mjs";import t from"sharp";const n=new Set([`image/svg+xml`,`image/gif`]);function r(e){return e.startsWith(`image/`)&&!n.has(e)}async function i(e,n){let{width:r,height:i}=u(await t(e).metadata()),a=new Map,o=Object.entries(n);return await Promise.all(o.map(async([n,r])=>{let i=r.format??`webp`,o=r.quality??80,s=r.fit??`cover`,{data:c,info:l}=await t(e).rotate().resize({width:r.width,height:r.height,fit:s,withoutEnlargement:!0})[i]({quality:o}).toBuffer({resolveWithObject:!0});a.set(n,{buffer:c,format:i,mimeType:`image/${i}`,width:l.width,height:l.height})})),{width:r,height:i,variants:a}}async function a(n,r,i){let{width:a,height:s}=u(await t(n).metadata()),c=e({width:a,height:s},r,i);return{width:a,height:s,variants:await o(n,c.variants),dropped:c.dropped}}async function o(e,t){let n=new Map;return c(t,2,async t=>{let r=JSON.stringify([t.extract,t.width,t.height,t.quality,t.format]),i=n.get(r);return i||(i=s(e,t),n.set(r,i)),{...await i,shape:t.shape,plannedWidth:t.width}})}async function s(e,n){let{data:r,info:i}=await t(e).rotate().extract(n.extract).resize({width:n.width,height:n.height,fit:`cover`,withoutEnlargement:!0})[n.format]({quality:n.quality}).toBuffer({resolveWithObject:!0});return{buffer:r,format:n.format,mimeType:`image/${n.format}`,width:i.width,height:i.height,shape:n.shape,plannedWidth:n.width,fingerprint:n.fingerprint}}async function c(e,t,n){let r=Array(e.length),i=e.map((e,t)=>({item:e,index:t})),a=!1;return await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=i.shift();e!==void 0&&!a;e=i.shift())try{r[e.index]=await n(e.item)}catch(e){throw a=!0,e}})),r}async function l(e){return u(await t(e).metadata())}function u(e){return{width:e.autoOrient?.width??e.width??0,height:e.autoOrient?.height??e.height??0}}async function d(e){let{data:n,info:r}=await t(e).rotate().resize(512,512,{fit:`inside`,withoutEnlargement:!0}).raw().toBuffer({resolveWithObject:!0}),{width:i,height:a,channels:o}=r;if(i<8||a<8)return null;let s=await t(n,{raw:{width:i,height:a,channels:o}}).flop().flip().raw().toBuffer(),[c,l]=await Promise.all([f(n,i,a,o),f(s,i,a,o)]);if(c===null||l===null)return null;let u=p(c.x,1-l.x),d=p(c.y,1-l.y);return u===null&&d===null?null:{x:m(u??.5),y:m(d??.5)}}async function f(e,n,r,i){let a=n>=r,{info:o}=await t(e,{raw:{width:n,height:r,channels:i}}).resize(a?n-1:n,a?r:r-1,{fit:`cover`,position:t.strategy.attention}).toBuffer({resolveWithObject:!0}),{attentionX:s,attentionY:c}=o;return s===void 0||c===void 0?null:{x:s/n,y:c/r}}function p(e,t){return Math.abs(e-t)<=.25?(e+t)/2:null}function m(e){return Number.isFinite(e)?Math.min(Math.max(Math.round(e*1e4)/1e4,0),1):.5}export{i as a,r as i,a as n,s as o,l as r,o as s,d as t};
|
|
2
|
+
//# sourceMappingURL=process-image-wQwiRhyk.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-image-wQwiRhyk.mjs","names":[],"sources":["../src/process-image.ts"],"sourcesContent":["/**\n * Server-side image processing via Sharp.\n *\n * Pure processing module — no DB or storage dependencies.\n * Takes a buffer and image styles, returns metadata + variant buffers.\n */\n\nimport sharp from 'sharp'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport type { VariantConfig } from './shapes.js'\nimport type { ImageStyle } from './types.js'\nimport { type PlannedVariant, planVariants, type VariantPlanInput } from './variant-plan.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n // `.rotate()` first: without it an EXIF-rotated phone photo's variant is\n // written SIDEWAYS — the output format drops the orientation tag — while\n // the row's width/height (`displayDimensions`) and its focal point are in\n // the orientation a browser draws. Keys are unchanged; only the pixels\n // of a rotated original change, and they change to what was intended.\n const resized = sharp(buffer).rotate().resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/** One generated (shape × rung) variant. */\nexport interface ShapedVariant extends ProcessedVariant {\n readonly shape: string\n /** The width the plan ASKED for. It names the key; it is not a claim about the file. */\n readonly plannedWidth: number\n /** Goes into the key — see `deriveShapedVariantKey`. */\n readonly fingerprint: string\n}\n\n/** A photograph's shaped variants. */\nexport interface ShapedImage {\n /** The image as a browser draws it (post-EXIF orientation). */\n width: number\n height: number\n variants: ShapedVariant[]\n /** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`) — for the caller to log. */\n dropped: number\n}\n\n/**\n * How many sharp pipelines one image runs at once. Each decodes the original,\n * so a 24-megapixel JPEG costs ~70 MB of pixels per pipeline in flight; the\n * seeded vocabulary plans 30 of them. libvips already parallelises inside each\n * pipeline, so a small number here loses little throughput.\n */\nconst SHAPED_CONCURRENCY = 2\n\n/**\n * Generate every (shape × rung) variant `config` calls for (images D003).\n *\n * The region comes from {@link planVariants} — `crops[shape]`, else the focal\n * point's window for that ratio, else the whole image — and sharp runs\n * `rotate() → extract() → resize()` in that order:\n *\n * - `rotate()` first, so `extract()` works in the coordinates a browser DRAWS\n * (measured, R013). The crop and the focal point are stored in that space.\n * - `extract()` BEFORE `resize()` — what sharp documents for pre-resize\n * extraction, and the only way to express a rect at all: `resize({position})`\n * takes nine gravities and two strategies and throws on coordinates (C5).\n *\n * 🔴 The variant records `info.width`/`info.height` — what sharp RETURNED —\n * never the rung it asked for (F003). `withoutEnlargement` silently returns a\n * smaller file than requested, and a `640w` descriptor on a 400px file makes a\n * browser's `srcset` choice wrong in the direction it never corrects (R007).\n * The plan never asks for more than the region supplies, so today the two\n * agree; recording the real one is what keeps them agreeing when they don't.\n */\nexport async function generateShapedVariants(\n buffer: Buffer,\n config: VariantConfig,\n input: VariantPlanInput,\n): Promise<ShapedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n const plan = planVariants({ width, height }, config, input)\n const variants = await renderPlannedVariants(buffer, plan.variants)\n return { width, height, variants, dropped: plan.dropped }\n}\n\n/**\n * Render `planned` — a whole plan, or the part of one a caller does not already\n * have (the variant job renders only keys that are missing) — at most\n * {@link SHAPED_CONCURRENCY} sharp pipelines at a time, results in input order.\n */\nexport async function renderPlannedVariants(\n buffer: Buffer,\n planned: readonly PlannedVariant[],\n): Promise<ShapedVariant[]> {\n // Two shapes can plan the SAME recipe — `wide` on a 16:9 photograph is the\n // whole image, exactly as `natural` is — and encoding it twice buys nothing.\n // Keyed by the full recipe, never by the fingerprint: a 32-bit hash that\n // collided here would hand one shape another shape's picture.\n const rendered = new Map<string, Promise<ShapedVariant>>()\n return mapBounded(planned, SHAPED_CONCURRENCY, async (planned) => {\n const recipe = JSON.stringify([\n planned.extract,\n planned.width,\n planned.height,\n planned.quality,\n planned.format,\n ])\n let pending = rendered.get(recipe)\n if (!pending) {\n pending = renderPlannedVariant(buffer, planned)\n rendered.set(recipe, pending)\n }\n const first = await pending\n return { ...first, shape: planned.shape, plannedWidth: planned.width }\n })\n}\n\nexport async function renderPlannedVariant(\n buffer: Buffer,\n planned: PlannedVariant,\n): Promise<ShapedVariant> {\n const pipeline = sharp(buffer).rotate().extract(planned.extract).resize({\n width: planned.width,\n height: planned.height,\n fit: 'cover',\n withoutEnlargement: true,\n })\n const { data, info } = await pipeline[planned.format]({ quality: planned.quality }).toBuffer({\n resolveWithObject: true,\n })\n return {\n buffer: data,\n format: planned.format,\n mimeType: `image/${planned.format}`,\n width: info.width,\n height: info.height,\n shape: planned.shape,\n plannedWidth: planned.width,\n fingerprint: planned.fingerprint,\n }\n}\n\n/** `Promise.all(items.map(fn))` with at most `limit` in flight, results in input order. */\nasync function mapBounded<T, R>(\n items: readonly T[],\n limit: number,\n fn: (item: T) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length)\n const queue = items.map((item, index) => ({ item, index }))\n // Once one job has failed the whole call rejects, so the other workers stop\n // taking jobs rather than running sharp for a result nobody will read.\n let failed = false\n const worker = async (): Promise<void> => {\n for (let job = queue.shift(); job !== undefined && !failed; job = queue.shift()) {\n try {\n results[job.index] = await fn(job.item)\n } catch (err) {\n failed = true\n throw err\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))\n return results\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n return displayDimensions(await sharp(buffer).metadata())\n}\n\n/**\n * The size a browser DRAWS the image at — after EXIF orientation — not the\n * sensor's.\n *\n * `metadata().width/height` are the stored pixel grid, so a phone portrait\n * tagged orientation 6 reports 400×200 while every browser draws it 200×400\n * (`image-orientation: from-image` is the default). Those numbers become the\n * media row's `width`/`height`, which are the `<img>`'s CLS reservation AND the\n * image ratio the focal-point crop is computed against (images PR02) — a\n * swapped ratio positions the crop on the wrong axis. `autoOrient` is sharp's\n * own post-orientation size (0.33+).\n */\nfunction displayDimensions(meta: sharp.Metadata): { width: number; height: number } {\n return {\n width: meta.autoOrient?.width ?? meta.width ?? 0,\n height: meta.autoOrient?.height ?? meta.height ?? 0,\n }\n}\n\n/**\n * The longest edge the attention pass looks at. libvips scores saliency on a\n * ~32px grid, so a larger buffer buys no precision — it only costs decode time.\n */\nconst ATTENTION_EDGE = 512\n\n/** Below this on either side there is nothing to guess about — see {@link detectFocalPoint}. */\nconst MIN_ATTENTION_EDGE = 8\n\n/**\n * How far the two attention passes may disagree on an axis and still be the\n * same subject. libvips places its hotspot on a ~32-cell grid (1/32 ≈ 0.03), so\n * a real subject agrees to within a cell or two (measured ≤ 0.07); a FLAT axis\n * disagrees by the whole image (0 in one pass, 1 in the mirrored one).\n */\nconst AXIS_AGREEMENT = 0.25\n\n/**\n * Guess where the subject is, with sharp's `attention` strategy — the seed for\n * a new photograph's focal point, so an editor who opens nothing still gets\n * something other than a blind centre crop (images D002).\n *\n * ## Two passes, one of them mirrored\n *\n * When the saliency map is flat along an axis — a white product background, a\n * sky, a horizontal band, a plain texture — libvips reports the FIRST cell of\n * it: 0. Stored, that pins every shaped slot to the left or top edge, which is\n * worse than no guess. But a subject genuinely near the edge also reports 0 (a\n * disc at 5% from the corner does, measured), so \"0 means flat\" is not a rule.\n *\n * So the same buffer is scored twice: as-is, and flipped on both axes. A real\n * subject moves with the flip and the two answers agree once the second is\n * mirrored back; a flat axis reports 0 both times, i.e. 0 and 1 once mirrored.\n * Per axis: agree → their mean; disagree → that axis says nothing → 0.5. Both\n * axes flat → `null` (the caller stores it; it renders as the centre, D010).\n * The second pass runs on the already-decoded ≤512px raw buffer, so it costs a\n * saliency pass over ~0.25 MP, not a second decode.\n *\n * Also `null`: an image under {@link MIN_ATTENTION_EDGE} on either side — a\n * strip or an icon has no subject worth cropping around.\n *\n * ## Why not read `info.attentionX` off an ordinary resize\n *\n * Because it is WRONG whenever the same call resizes. sharp converts the\n * libvips hotspot with `attention_x * jpegShrinkOnLoad / scale`\n * (`src/pipeline.cc:505`, 0.34.5 and 0.35.3 alike), and those terms do not\n * describe the image smartcrop ran on: a disc drawn at x = 0.80 reported 0.50\n * from a JPEG and 0.25 from a PNG (images R012). The crop OFFSET beside it is\n * right; only the reported point is not.\n *\n * So each pass runs at SCALE 1: decode once to a bounded raw buffer, then crop\n * that buffer to its own size minus one pixel on the long axis. No\n * shrink-on-load and no scale, so the reported point is in that buffer's own\n * pixels and dividing by its size is the answer. A square image has no long\n * axis, but trimming either one still runs the strategy, and libvips takes the\n * hotspot over the whole saliency map whichever axis it crops.\n *\n * `.rotate()` first, so the point is in the orientation a browser DRAWS — an\n * EXIF-rotated phone photo would otherwise be seeded in sensor coordinates.\n *\n * ⚠️ Attention scores luminance frequency, saturation and skin tones. It is a\n * guess, not detection, and on a product shot the product can win (R012).\n */\nexport async function detectFocalPoint(buffer: Buffer): Promise<FocalPoint | null> {\n const { data, info } = await sharp(buffer)\n .rotate()\n .resize(ATTENTION_EDGE, ATTENTION_EDGE, { fit: 'inside', withoutEnlargement: true })\n .raw()\n .toBuffer({ resolveWithObject: true })\n const { width, height, channels } = info\n if (width < MIN_ATTENTION_EDGE || height < MIN_ATTENTION_EDGE) return null\n const raw = { raw: { width, height, channels } }\n const mirrored = await sharp(data, raw).flop().flip().raw().toBuffer()\n const [straight, flipped] = await Promise.all([\n attentionHotspot(data, width, height, channels),\n attentionHotspot(mirrored, width, height, channels),\n ])\n if (straight === null || flipped === null) return null\n const x = agreeingAxis(straight.x, 1 - flipped.x)\n const y = agreeingAxis(straight.y, 1 - flipped.y)\n if (x === null && y === null) return null\n return { x: clampFraction(x ?? 0.5), y: clampFraction(y ?? 0.5) }\n}\n\n/** One attention pass at scale 1, as fractions of the buffer. */\nasync function attentionHotspot(\n data: Buffer,\n width: number,\n height: number,\n channels: 1 | 2 | 3 | 4,\n): Promise<FocalPoint | null> {\n const landscape = width >= height\n const { info } = await sharp(data, { raw: { width, height, channels } })\n .resize(landscape ? width - 1 : width, landscape ? height : height - 1, {\n fit: 'cover',\n position: sharp.strategy.attention,\n })\n .toBuffer({ resolveWithObject: true })\n const { attentionX, attentionY } = info\n if (attentionX === undefined || attentionY === undefined) return null\n return { x: attentionX / width, y: attentionY / height }\n}\n\n/** The two passes' answer for one axis, or `null` when they disagree (a flat axis). */\nfunction agreeingAxis(straight: number, mirrored: number): number | null {\n return Math.abs(straight - mirrored) <= AXIS_AGREEMENT ? (straight + mirrored) / 2 : null\n}\n\n/** Round to four decimals and keep inside `[0, 1]`, so the stored value passes `isFocalPoint`. */\nfunction clampFraction(value: number): number {\n if (!Number.isFinite(value)) return 0.5\n return Math.min(Math.max(Math.round(value * 10000) / 10000, 0), 1)\n}\n"],"mappings":"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"}
|
package/dist/processing.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as ImageVocabulary, i as ImageStyle, n as ImageFormat, r as ImageShape } from "./types-
|
|
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-
|
|
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";
|
|
3
3
|
|
|
4
4
|
//#region src/variant-plan.d.ts
|
|
5
5
|
/**
|
|
@@ -1 +1 @@
|
|
|
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":";;;;AAoEsB;AAGtB;;;;;;;;AAGkB;AANI,cArBT,sBAAA;;UAGI,SAAA;EAAA,SACN,IAAA;EAAA,SACA,GAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA;AAAA;;UAIM,cAAA;EAAA,SACN,KAAA;
|
|
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{a as e,n as t,r as n,s as r,t as i,u as a}from"./shapes-
|
|
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 { i as ImageStyle } from "./types-CgkJF5dc.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":"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"}
|
package/dist/query-client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { l as MediaRecord, m as Media } from "./types-
|
|
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
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{createVariantDeps as e}from"./deps-
|
|
2
|
-
//# sourceMappingURL=regenerate-variants-
|
|
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
|