@murumets-ee/media 0.71.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/backfill-B1StTnxg.mjs +2 -0
  4. package/dist/backfill-B1StTnxg.mjs.map +1 -0
  5. package/dist/backfill-trigger-BFL1L7_j.mjs +2 -0
  6. package/dist/backfill-trigger-BFL1L7_j.mjs.map +1 -0
  7. package/dist/backlog-D-H5VUQA.mjs +2 -0
  8. package/dist/backlog-D-H5VUQA.mjs.map +1 -0
  9. package/dist/client.d.mts +65 -7
  10. package/dist/client.d.mts.map +1 -1
  11. package/dist/client.mjs +1 -1
  12. package/dist/client.mjs.map +1 -1
  13. package/dist/crop-editor.d.mts +61 -0
  14. package/dist/crop-editor.d.mts.map +1 -0
  15. package/dist/crop-editor.mjs +2 -0
  16. package/dist/crop-editor.mjs.map +1 -0
  17. package/dist/crop-rect-B-AJ2pWm.mjs +2 -0
  18. package/dist/crop-rect-B-AJ2pWm.mjs.map +1 -0
  19. package/dist/crop-rect-C-SbjoxK.d.mts +59 -0
  20. package/dist/crop-rect-C-SbjoxK.d.mts.map +1 -0
  21. package/dist/crop.d.mts +2 -1
  22. package/dist/crop.mjs +1 -1
  23. package/dist/definitions-LJpgrdxd.mjs +2 -0
  24. package/dist/definitions-LJpgrdxd.mjs.map +1 -0
  25. package/dist/deps-DaGjHO16.mjs +2 -0
  26. package/dist/deps-DaGjHO16.mjs.map +1 -0
  27. package/dist/derive-media-type-C38ZA_Cl.mjs +2 -0
  28. package/dist/derive-media-type-C38ZA_Cl.mjs.map +1 -0
  29. package/dist/en-Duj_Pp-O.mjs +2 -0
  30. package/dist/en-Duj_Pp-O.mjs.map +1 -0
  31. package/dist/entity-CsDdjKz6.mjs +2 -0
  32. package/dist/entity-CsDdjKz6.mjs.map +1 -0
  33. package/dist/et-BFdSZnb5.mjs +2 -0
  34. package/dist/et-BFdSZnb5.mjs.map +1 -0
  35. package/dist/generate-variants-Dpy4aT2o.mjs +2 -0
  36. package/dist/generate-variants-Dpy4aT2o.mjs.map +1 -0
  37. package/dist/i18n.mjs +1 -1
  38. package/dist/i18n.mjs.map +1 -1
  39. package/dist/image-styles-settings.d.mts +8 -1
  40. package/dist/image-styles-settings.d.mts.map +1 -1
  41. package/dist/image-styles-settings.mjs +1 -1
  42. package/dist/image-styles-settings.mjs.map +1 -1
  43. package/dist/image-styles.d.mts +35 -21
  44. package/dist/image-styles.d.mts.map +1 -1
  45. package/dist/image-styles.mjs +1 -2
  46. package/dist/image-styles.mjs.map +1 -1
  47. package/dist/index.d.mts +50 -42
  48. package/dist/index.d.mts.map +1 -1
  49. package/dist/index.mjs +1 -1
  50. package/dist/index.mjs.map +1 -1
  51. package/dist/media-config-DkbnXIjC.mjs +2 -0
  52. package/dist/media-config-DkbnXIjC.mjs.map +1 -0
  53. package/dist/owned-delete-Cn_KFFjS.mjs +2 -0
  54. package/dist/owned-delete-Cn_KFFjS.mjs.map +1 -0
  55. package/dist/picker.d.mts +1 -94
  56. package/dist/picker.d.mts.map +1 -1
  57. package/dist/picker.mjs +1 -2
  58. package/dist/picker.mjs.map +1 -1
  59. package/dist/plugin.d.mts +2 -1
  60. package/dist/plugin.d.mts.map +1 -1
  61. package/dist/plugin.mjs +1 -1
  62. package/dist/plugin.mjs.map +1 -1
  63. package/dist/process-image-wQwiRhyk.mjs +2 -0
  64. package/dist/process-image-wQwiRhyk.mjs.map +1 -0
  65. package/dist/processing.d.mts +201 -7
  66. package/dist/processing.d.mts.map +1 -1
  67. package/dist/processing.mjs +1 -1
  68. package/dist/public-resolver.d.mts +80 -16
  69. package/dist/public-resolver.d.mts.map +1 -1
  70. package/dist/public-resolver.mjs +1 -1
  71. package/dist/public-resolver.mjs.map +1 -1
  72. package/dist/query-client.d.mts +1 -1
  73. package/dist/query-client.mjs +1 -1
  74. package/dist/regenerate-variants-RwV_AQSo.mjs +2 -0
  75. package/dist/regenerate-variants-RwV_AQSo.mjs.map +1 -0
  76. package/dist/register-CJKjNl2w.mjs +2 -0
  77. package/dist/register-CJKjNl2w.mjs.map +1 -0
  78. package/dist/resolve-image-styles-YExI329D.mjs +2 -0
  79. package/dist/resolve-image-styles-YExI329D.mjs.map +1 -0
  80. package/dist/routes-DaE0M8h8.mjs +2 -0
  81. package/dist/routes-DaE0M8h8.mjs.map +1 -0
  82. package/dist/ru-ojvshRJy.mjs +2 -0
  83. package/dist/ru-ojvshRJy.mjs.map +1 -0
  84. package/dist/schedule-JlvfUEVA.mjs +2 -0
  85. package/dist/schedule-JlvfUEVA.mjs.map +1 -0
  86. package/dist/shapes-BpOEJeLx.mjs +2 -0
  87. package/dist/shapes-BpOEJeLx.mjs.map +1 -0
  88. package/dist/shapes-C51rXSDJ.d.mts +90 -0
  89. package/dist/shapes-C51rXSDJ.d.mts.map +1 -0
  90. package/dist/shapes-CqppzFzb.mjs +2 -0
  91. package/dist/shapes-CqppzFzb.mjs.map +1 -0
  92. package/dist/slot-CY4isqmN.mjs +2 -0
  93. package/dist/slot-CY4isqmN.mjs.map +1 -0
  94. package/dist/{types-CnORVkLZ.d.mts → types-6PZQ4Zq6.d.mts} +82 -5
  95. package/dist/types-6PZQ4Zq6.d.mts.map +1 -0
  96. package/dist/types-Lj3SkZQ_.d.mts +107 -0
  97. package/dist/types-Lj3SkZQ_.d.mts.map +1 -0
  98. package/dist/variant-plan-86tJKtVi.mjs +2 -0
  99. package/dist/variant-plan-86tJKtVi.mjs.map +1 -0
  100. package/dist/variant-state-CBtqb9Cb.mjs +2 -0
  101. package/dist/variant-state-CBtqb9Cb.mjs.map +1 -0
  102. package/package.json +21 -8
  103. package/dist/client-CSOH7Zun.mjs +0 -2
  104. package/dist/client-CSOH7Zun.mjs.map +0 -1
  105. package/dist/en-0YnQ_Qz-.mjs +0 -2
  106. package/dist/en-0YnQ_Qz-.mjs.map +0 -1
  107. package/dist/entity-CNxAERYG.mjs +0 -2
  108. package/dist/entity-CNxAERYG.mjs.map +0 -1
  109. package/dist/entity-DKFCdQJT.mjs +0 -2
  110. package/dist/entity-DKFCdQJT.mjs.map +0 -1
  111. package/dist/et-BIxGYWVK.mjs +0 -2
  112. package/dist/et-BIxGYWVK.mjs.map +0 -1
  113. package/dist/image-styles-settings-DEnu-V2E.mjs +0 -2
  114. package/dist/image-styles-settings-DEnu-V2E.mjs.map +0 -1
  115. package/dist/plugin-B_ZtfiAc.mjs +0 -2
  116. package/dist/plugin-B_ZtfiAc.mjs.map +0 -1
  117. package/dist/process-image-WByEHTXQ.mjs +0 -2
  118. package/dist/process-image-WByEHTXQ.mjs.map +0 -1
  119. package/dist/regenerate-variants-2Lmo5kf8.mjs +0 -2
  120. package/dist/regenerate-variants-2Lmo5kf8.mjs.map +0 -1
  121. package/dist/regenerate-variants-B9AXXN6N.mjs +0 -2
  122. package/dist/regenerate-variants-B9AXXN6N.mjs.map +0 -1
  123. package/dist/resolve-image-styles-Cakg1Qpp.mjs +0 -2
  124. package/dist/resolve-image-styles-Cakg1Qpp.mjs.map +0 -1
  125. package/dist/resolve-image-styles-iN9JbZYf.mjs +0 -2
  126. package/dist/resolve-image-styles-iN9JbZYf.mjs.map +0 -1
  127. package/dist/routes-C7Fx-055.mjs +0 -2
  128. package/dist/routes-C7Fx-055.mjs.map +0 -1
  129. package/dist/ru-UUTBfsMF.mjs +0 -2
  130. package/dist/ru-UUTBfsMF.mjs.map +0 -1
  131. package/dist/types-CnORVkLZ.d.mts.map +0 -1
  132. package/dist/usage-CVAqkS6h.mjs +0 -2
  133. package/dist/usage-CVAqkS6h.mjs.map +0 -1
  134. package/dist/variant-key-JBTJXPL1.mjs +0 -2
  135. package/dist/variant-key-JBTJXPL1.mjs.map +0 -1
  136. package/dist/variant-key-RKke2RfS.mjs +0 -2
  137. package/dist/variant-key-RKke2RfS.mjs.map +0 -1
@@ -0,0 +1,107 @@
1
+ import { ReactNode } from "react";
2
+
3
+ //#region src/picker/types.d.ts
4
+ /** Lightweight media item for the picker (no server-only deps) */
5
+ interface MediaPickerItem {
6
+ id: string;
7
+ title: string | null;
8
+ alt: string | null;
9
+ filename: string;
10
+ mimeType: string;
11
+ size: number;
12
+ width: number | null;
13
+ height: number | null;
14
+ mediaType: 'image' | 'video' | 'audio' | 'document' | 'other';
15
+ url: string;
16
+ /**
17
+ * Where the subject is, `{ x, y }` fractions (images D002) — the centre when
18
+ * the row has none. Optional because a host's own `fetchMedia` may predate it.
19
+ * The block editor hands it to the canvas beside the picked ref, never onto
20
+ * the stored ref (images F010, D007).
21
+ */
22
+ focalPoint?: {
23
+ x: number;
24
+ y: number;
25
+ } | undefined;
26
+ }
27
+ /** Paginated result from fetchMedia callback */
28
+ interface MediaPickerListResult {
29
+ items: MediaPickerItem[];
30
+ total: number;
31
+ /**
32
+ * False when the server reports storage is not configured (missing
33
+ * env vars etc.). UI renders a banner with `reason` instead of the
34
+ * upload zone + grid. Omitted/true when everything is wired.
35
+ */
36
+ configured?: boolean;
37
+ /** Human-readable explanation when `configured === false`. */
38
+ reason?: string;
39
+ }
40
+ /** Callbacks the consumer provides — framework-agnostic */
41
+ interface MediaPickerCallbacks {
42
+ /** Fetch media items with filtering and pagination */
43
+ fetchMedia: (options: {
44
+ search?: string | undefined;
45
+ mediaType?: string | undefined;
46
+ limit: number;
47
+ offset: number;
48
+ }) => Promise<MediaPickerListResult>;
49
+ /** Upload a file and return the created media item */
50
+ uploadMedia: (file: File) => Promise<MediaPickerItem>;
51
+ }
52
+ /** Selection mode */
53
+ type MediaPickerMode = 'single' | 'multiple';
54
+ /** Per-element class overrides for MediaPicker */
55
+ interface MediaPickerClassNames {
56
+ overlay?: string;
57
+ content?: string;
58
+ header?: string;
59
+ title?: string;
60
+ toolbar?: string;
61
+ searchInput?: string;
62
+ filterTabs?: string;
63
+ grid?: string;
64
+ card?: string;
65
+ cardSelected?: string;
66
+ cardImage?: string;
67
+ cardLabel?: string;
68
+ uploadZone?: string;
69
+ uploadZoneActive?: string;
70
+ footer?: string;
71
+ confirmButton?: string;
72
+ cancelButton?: string;
73
+ loading?: string;
74
+ empty?: string;
75
+ }
76
+ /** Props for the MediaPicker dialog */
77
+ interface MediaPickerProps {
78
+ /** Whether the dialog is open */
79
+ open: boolean;
80
+ /** Called when open state changes */
81
+ onOpenChange: (open: boolean) => void;
82
+ /** Called when user confirms selection */
83
+ onSelect: (items: MediaPickerItem[]) => void;
84
+ /** Selection mode (default: 'single') */
85
+ mode?: MediaPickerMode | undefined;
86
+ /** MIME patterns for upload restriction (e.g., ['image/*', 'video/*']) — passed to file input */
87
+ accept?: string[] | undefined;
88
+ /** Filter browse results to a specific media classification (e.g., 'image', 'video') */
89
+ mediaType?: string | undefined;
90
+ /** Maximum items selectable in multi mode */
91
+ maxSelect?: number | undefined;
92
+ /** Currently selected item IDs (for pre-selection) */
93
+ selectedIds?: string[] | undefined;
94
+ /** Dialog title (default: 'Select Media') */
95
+ title?: string | undefined;
96
+ /** Dialog description for screen readers (optional) */
97
+ description?: string | undefined;
98
+ /** Per-element class overrides */
99
+ classNames?: MediaPickerClassNames | undefined;
100
+ /** Additional className for the dialog content */
101
+ className?: string | undefined;
102
+ /** Children rendered in dialog footer (extra actions) */
103
+ children?: ReactNode | undefined;
104
+ }
105
+ //#endregion
106
+ export { MediaPickerMode as a, MediaPickerListResult as i, MediaPickerClassNames as n, MediaPickerProps as o, MediaPickerItem as r, MediaPickerCallbacks as t };
107
+ //# sourceMappingURL=types-Lj3SkZQ_.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-Lj3SkZQ_.d.mts","names":[],"sources":["../src/picker/types.ts"],"mappings":";;;;UAGiB,eAAA;EACf,EAAA;EACA,KAAA;EACA,GAAA;EACA,QAAA;EACA,QAAA;EACA,IAAA;EACA,KAAA;EACA,MAAA;EACA,SAAA;EACA,GAAA;EAHA;;;;;;EAUA,UAAA;IAAe,CAAA;IAAW,CAAA;EAAA;AAAA;;UAIX,qBAAA;EACf,KAAA,EAAO,eAAe;EACtB,KAAA;EAAA;;;;AAQM;EAFN,UAAA;EAMmC;EAJnC,MAAA;AAAA;;UAIe,oBAAA;EAUsB;EARrC,UAAA,GAAa,OAAA;IACX,MAAA;IACA,SAAA;IACA,KAAA;IACA,MAAA;EAAA,MACI,OAAA,CAAQ,qBAAA;EAFZ;EAKF,WAAA,GAAc,IAAA,EAAM,IAAA,KAAS,OAAA,CAAQ,eAAA;AAAA;;KAI3B,eAAA;;UAGK,qBAAA;EACf,OAAA;EACA,OAAA;EACA,MAAA;EACA,KAAA;EACA,OAAA;EACA,WAAA;EACA,UAAA;EACA,IAAA;EACA,IAAA;EACA,YAAA;EACA,SAAA;EACA,SAAA;EACA,UAAA;EACA,gBAAA;EACA,MAAA;EACA,aAAA;EACA,YAAA;EACA,OAAA;EACA,KAAA;AAAA;;UAIe,gBAAA;EAdf;EAgBA,IAAA;EAdA;EAgBA,YAAA,GAAe,IAAA;EAdf;EAgBA,QAAA,GAAW,KAAA,EAAO,eAAA;EAdlB;EAgBA,IAAA,GAAO,eAAA;EAdP;EAgBA,MAAA;EAdA;EAgBA,SAAA;EAhBK;EAkBL,SAAA;EAd+B;EAgB/B,WAAA;EAVkB;EAYlB,KAAA;EAIa;EAFb,WAAA;EAMoB;EAJpB,UAAA,GAAa,qBAAA;EApBb;EAsBA,SAAA;EApBe;EAsBf,QAAA,GAAW,SAAA;AAAA"}
@@ -0,0 +1,2 @@
1
+ import{i as e}from"./crop-rect-B-AJ2pWm.mjs";import{n as t,s as n}from"./focal-point-BWhtJzef.mjs";function r(e,t,n=`webp`){let r=e.lastIndexOf(`/`);return`${e.substring(0,r)}/${t}_${e.substring(r+1).replace(/\.[^.]+$/,``)}.${n}`}function i(e){let{extract:t}=e;return a(`${t.left},${t.top},${t.width},${t.height}>${e.width}x${e.height}q${e.quality}`)}function a(e){let t=2166136261;for(let n=0;n<e.length;n++)t^=e.charCodeAt(n),t=Math.imul(t,16777619);return(t>>>0).toString(36).padStart(7,`0`)}function o(e,t){let n=e.lastIndexOf(`/`);return`${n===-1?``:`${e.substring(0,n)}/`}variants/${e.substring(n+1).replace(/\.[^.]+$/,``)}_${t.shape}_${t.width}w_${t.fingerprint}.${t.format}`}const s=64;function c(e,t,n){return Math.min(Math.max(e,t),n)}function l(e,t,n){let r=c(Math.round(e.x*t),0,t-1),i=c(Math.round(e.y*n),0,n-1);return{left:r,top:i,width:c(Math.round(e.w*t),1,t-r),height:c(Math.round(e.h*n),1,n-i)}}function u(r,i,a){return r.ratio===null?{left:0,top:0,width:i.width,height:i.height}:l(e(a.crops).get(r.name)??t(n(a.focalPoint),i.width/i.height,r.ratio),i.width,i.height)}function d(e,t){let n=e.ratio??t.width/t.height,r=[];for(let i of e.widths){let e=Math.max(1,Math.round(i/n));i<=t.width&&e<=t.height&&r.push([i,e])}if(r.length>0)return r;let i=Math.max(1,Math.round(t.width/n));return i<=t.height?[[t.width,i]]:[[c(Math.round(t.height*n),1,t.width),t.height]]}function f(e,t,n){if(t.width>=1&&t.height>=1)return d(e,u(e,t,n)).reduce((e,[t])=>e===void 0||t>e?t:e,void 0)}function p(e,t,n){if(!(e.width>=1&&e.height>=1))return{variants:[],dropped:0};let r=t.shapes.map(t=>{let r=u(t,e,n);return{shape:t,region:r,sizes:d(t,r)}}),a=[],o=0,s=Math.max(0,...r.map(e=>e.sizes.length));for(let e=0;e<s;e++)for(let{shape:t,region:n,sizes:s}of r){let r=s[e];if(!r)continue;if(a.length>=64){o++;continue}let[c,l]=r,u={extract:n,width:c,height:l,quality:t.quality};a.push({shape:t.name,width:c,height:l,extract:n,format:t.format,quality:t.quality,fingerprint:i(u)})}return{variants:a,dropped:o}}export{r as a,o as i,p as n,i as o,f as r,s as t};
2
+ //# sourceMappingURL=variant-plan-86tJKtVi.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"variant-plan-86tJKtVi.mjs","names":[],"sources":["../src/variant-key.ts","../src/variant-plan.ts"],"sourcesContent":["/**\n * Variant key derivation — where a generated variant lives in storage.\n *\n * Two key shapes coexist (images D005):\n *\n * **Legacy** — one file per image STYLE, keyed by the style name alone:\n *\n * Original: uploads/2026/02/{uuid}/photo.jpg\n * Variant: uploads/2026/02/{uuid}/thumbnail_photo.webp\n *\n * Sound only while nothing about a variant can change. It stays exactly as it\n * is: every row that has not been regenerated under the shape model still has\n * its files there, and ticketing derives its thumbnails with it.\n *\n * **Shaped** — one file per (shape × rung), carrying a FINGERPRINT of the\n * recipe that produced it:\n *\n * Variant: uploads/2026/02/{uuid}/variants/photo_portrait_640w_1k2x9a0.webp\n *\n * The moment an editor can re-crop, a name-only key serves different bytes at\n * the same URL, and every cache in front of it — the CDN, the browser, the\n * content-api response that inlined the URL — keeps the old picture. So the\n * fingerprint is of the EFFECTIVE recipe: the integral pixel rect actually\n * extracted, the output box and the quality (R013). Same recipe → same key → a\n * backfill that re-derives it uploads nothing. Different recipe → different key\n * → every cache misses correctly, because it is a different file. No\n * timestamps, no salt: determinism is what makes the backfill idempotent.\n *\n * ⚠️ A query-string cache-buster is not an alternative. Storage keys are what\n * `getPublicUrls` resolves, so a query string is not part of the identity and\n * the stale file is still the file (D005 (d); Craft's issue #3685 is that\n * failure in a shipping product).\n *\n * The shaped keys live in a `variants/` directory beside the original, which is\n * what makes the two shapes impossible to confuse: a legacy key is always a\n * direct child of the original's directory (a style name cannot contain `/`),\n * and both stay under the original's own prefix — which ticketing's\n * prefix-ownership check relies on.\n */\n\n/**\n * Derive a LEGACY variant storage key from the original key + style name.\n *\n * @param originalKey - The original file's storage key\n * @param styleName - The image style name (e.g., 'thumbnail', 'medium')\n * @param format - The variant output format (default: 'webp')\n * @returns The derived variant key\n */\nexport function deriveVariantKey(originalKey: string, styleName: string, format = 'webp'): string {\n const lastSlash = originalKey.lastIndexOf('/')\n const dir = originalKey.substring(0, lastSlash)\n const filename = originalKey.substring(lastSlash + 1)\n const baseName = filename.replace(/\\.[^.]+$/, '')\n return `${dir}/${styleName}_${baseName}.${format}`\n}\n\n/** Everything that determines a shaped variant's bytes, apart from the source file itself. */\nexport interface VariantRecipe {\n /** The region `extract()` takes, in integral pixels of the image as DRAWN (post-EXIF). */\n readonly extract: {\n readonly left: number\n readonly top: number\n readonly width: number\n readonly height: number\n }\n /** The box the region is resized to. */\n readonly width: number\n readonly height: number\n readonly quality: number\n}\n\n/**\n * A short deterministic fingerprint of a {@link VariantRecipe}: 32-bit FNV-1a\n * over a canonical string, as 7 base-36 characters.\n *\n * Not cryptographic and not meant to be — it only has to differ between two\n * recipes for the SAME file, shape and width, and nothing an attacker controls\n * chooses between colliding recipes. Pure JS rather than `node:crypto` so the\n * key can be derived in any realm that needs to name a variant.\n */\nexport function variantFingerprint(recipe: VariantRecipe): string {\n const { extract: e } = recipe\n return fnv1a36(\n `${e.left},${e.top},${e.width},${e.height}>${recipe.width}x${recipe.height}q${recipe.quality}`,\n )\n}\n\n/** 32-bit FNV-1a of `canonical`, as 7 base-36 characters — see {@link variantFingerprint}. */\nexport function fnv1a36(canonical: string): string {\n let hash = 0x811c9dc5\n for (let i = 0; i < canonical.length; i++) {\n hash ^= canonical.charCodeAt(i)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0).toString(36).padStart(7, '0')\n}\n\n/** The parts a shaped variant key is made of. */\nexport interface ShapedVariantKeyParts {\n /** The shape name (`square`, `natural`, …). */\n readonly shape: string\n /** The rung the variant was PLANNED at — part of the name, not a claim about the file. */\n readonly width: number\n /** {@link variantFingerprint} of the recipe. */\n readonly fingerprint: string\n /** Output format — the extension. */\n readonly format: string\n}\n\n/**\n * Derive a SHAPED variant storage key (images D005).\n *\n * `photo.jpg` + `{portrait, 640, 1k2x9a0, webp}` →\n * `<dir>/variants/photo_portrait_640w_1k2x9a0.webp`.\n */\nexport function deriveShapedVariantKey(originalKey: string, parts: ShapedVariantKeyParts): string {\n const lastSlash = originalKey.lastIndexOf('/')\n const dir = lastSlash === -1 ? '' : `${originalKey.substring(0, lastSlash)}/`\n const filename = originalKey.substring(lastSlash + 1)\n const baseName = filename.replace(/\\.[^.]+$/, '')\n return `${dir}variants/${baseName}_${parts.shape}_${parts.width}w_${parts.fingerprint}.${parts.format}`\n}\n","/**\n * Planning a photograph's variants — which (shape × rung) files it gets, the\n * exact region each is cut from, and the fingerprint that names it.\n *\n * Pure: no sharp, no storage, no database. Planning is split from generation\n * so the same answer is available to a caller that must NAME a variant without\n * producing it — a queue job's idempotency key (images PR05), a resolver\n * deriving the key of a file that should already exist (images PR06) — and so\n * it can be unit-tested exhaustively without decoding a single image.\n *\n * ## The effective rect\n *\n * For a shape with a ratio, the region is `crops[shape]` when the editor drew\n * one, and otherwise the window the focal point implies for that ratio —\n * `focalCropRect`, the SAME function the browser's `object-position` comes\n * from, so the generated crop and the CSS crop cannot disagree (phase 03).\n * `natural` is the whole image. The rect is then denormalised to integral\n * pixels, because `extract()` takes nothing else (R001, C5).\n *\n * ## Never upscale\n *\n * A rung is planned only when the region can supply it on BOTH axes. When no\n * rung fits — a small source, or a tight crop — the shape still gets exactly\n * one variant, at the largest size the region supplies, so every shape has a\n * file and none of them is enlarged (R006: Astro filters the same way; unpic\n * cannot, because it does not know its originals).\n *\n * The planned width is what the generator asks for. What it RECORDS is what\n * sharp returned (F003) — see `generateShapedVariants`.\n */\n\nimport { readCrops } from './crop/crop-rect.js'\nimport { type FractionalRect, focalCropRect, readFocalPoint } from './crop/focal-point.js'\nimport type { ResolvedShape, VariantConfig } from './shapes.js'\nimport type { ImageFormat } from './types.js'\nimport { type VariantRecipe, variantFingerprint } from './variant-key.js'\n\n/**\n * The hard ceiling on variants per image, whatever the configuration says.\n *\n * The vocabulary is bounded (`MAX_SHAPES` × `MAX_LADDER_RUNGS`), but legacy\n * styles predate those bounds and each becomes a shape, so the product is not.\n * Every variant is a decode-extract-resize-encode on the generating process, so\n * this is the cap that keeps one upload from being an amplification vector. The\n * seeded vocabulary plans at most 30 (four shapes and `natural`, six rungs).\n * What it drops is COUNTED in {@link VariantPlan.dropped}, never silent.\n */\nexport const MAX_VARIANTS_PER_IMAGE = 64\n\n/** The region `extract()` takes, in integral pixels of the image as drawn. */\nexport interface PixelRect {\n readonly left: number\n readonly top: number\n readonly width: number\n readonly height: number\n}\n\n/** One variant to generate. */\nexport interface PlannedVariant {\n readonly shape: string\n /** The width to ask sharp for. Never above what {@link extract} supplies. */\n readonly width: number\n /** The height to ask sharp for — derived from the shape's ratio. */\n readonly height: number\n readonly extract: PixelRect\n readonly format: ImageFormat\n readonly quality: number\n /** {@link variantFingerprint} of this variant's recipe — goes into its key. */\n readonly fingerprint: string\n}\n\nexport interface VariantPlan {\n readonly variants: readonly PlannedVariant[]\n /** How many variants {@link MAX_VARIANTS_PER_IMAGE} cut off. */\n readonly dropped: number\n}\n\n/** What the plan needs from the media row. Untrusted — both are read leniently. */\nexport interface VariantPlanInput {\n readonly focalPoint?: unknown\n readonly crops?: unknown\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(Math.max(value, min), max)\n}\n\n/** A fractional rect as integral pixels, never empty and never outside the image. */\nexport function toPixelRect(rect: FractionalRect, width: number, height: number): PixelRect {\n const left = clamp(Math.round(rect.x * width), 0, width - 1)\n const top = clamp(Math.round(rect.y * height), 0, height - 1)\n return {\n left,\n top,\n width: clamp(Math.round(rect.w * width), 1, width - left),\n height: clamp(Math.round(rect.h * height), 1, height - top),\n }\n}\n\n/**\n * The region a shape is cut from — its crop, else its focal window, else the\n * whole image.\n *\n * A stored crop is used as drawn even when its ratio is not quite the shape's\n * (`isCropRect` checks bounds, not ratio): the resize is `cover`, so the drift\n * is trimmed evenly from the region's long axis. The crop editor constrains the\n * window to the shape, so in practice the drift is sub-pixel rounding.\n */\nexport function effectiveRect(\n shape: Pick<ResolvedShape, 'name' | 'ratio'>,\n source: { readonly width: number; readonly height: number },\n input: VariantPlanInput,\n): PixelRect {\n if (shape.ratio === null) return { left: 0, top: 0, width: source.width, height: source.height }\n const crop = readCrops(input.crops).get(shape.name)\n const rect =\n crop ??\n focalCropRect(readFocalPoint(input.focalPoint), source.width / source.height, shape.ratio)\n return toPixelRect(rect, source.width, source.height)\n}\n\n/**\n * The (width, height) boxes a shape is generated at, from one region: every\n * rung the region supplies on both axes, or — when none does — one box at the\n * largest size it supplies.\n */\nfunction sizesFor(\n shape: Pick<ResolvedShape, 'ratio' | 'widths'>,\n region: PixelRect,\n): Array<[number, number]> {\n const ratio = shape.ratio ?? region.width / region.height\n const sizes: Array<[number, number]> = []\n for (const rung of shape.widths) {\n const h = Math.max(1, Math.round(rung / ratio))\n if (rung <= region.width && h <= region.height) sizes.push([rung, h])\n }\n if (sizes.length > 0) return sizes\n // The whole region's width, unless the ratio makes that too tall — then the\n // whole region's height. Rounded, so a region that IS the shape (to the\n // pixel) is used whole rather than trimmed by a floor.\n const tall = Math.max(1, Math.round(region.width / ratio))\n if (tall <= region.height) return [[region.width, tall]]\n return [[clamp(Math.round(region.height * ratio), 1, region.width), region.height]]\n}\n\n/**\n * The widest file the planner writes for `shape` on this photograph, over a\n * given width list — or `undefined` for an image with no size.\n *\n * The SAME region (`effectiveRect`: the saved crop, else the focal window) and\n * the SAME rung test (`sizesFor`) the planner uses, exposed so a READER can ask\n * \"is this recorded list what a pass would have written for this region?\"\n * without re-deriving either (images PR06: a one-rung shape narrower than the\n * ladder must not replace the CSS-cropped original, and a zoomed saved crop,\n * whose region is small, must still count as complete).\n */\nexport function plannedTopWidth(\n shape: Pick<ResolvedShape, 'name' | 'ratio' | 'widths'>,\n source: { readonly width: number; readonly height: number },\n input: VariantPlanInput,\n): number | undefined {\n if (!(source.width >= 1 && source.height >= 1)) return undefined\n const sizes = sizesFor(shape, effectiveRect(shape, source, input))\n return sizes.reduce<number | undefined>(\n (top, [w]) => (top === undefined || w > top ? w : top),\n undefined,\n )\n}\n\n/**\n * Plan every variant a photograph of `source` dimensions gets under `config`.\n *\n * `source` must be the DRAWN size (post-EXIF orientation) of the actual file —\n * `getImageDimensions`, not the row's client-measured `width`/`height` — since\n * the regions are cut from the file.\n */\nexport function planVariants(\n source: { readonly width: number; readonly height: number },\n config: VariantConfig,\n input: VariantPlanInput,\n): VariantPlan {\n if (!(source.width >= 1 && source.height >= 1)) return { variants: [], dropped: 0 }\n const perShape = config.shapes.map((shape) => {\n const region = effectiveRect(shape, source, input)\n return { shape, region, sizes: sizesFor(shape, region) }\n })\n // Round-robin by rung — every shape's smallest variant before any shape's\n // second — so if the cap cuts, it cuts the LARGEST files of the most shapes\n // rather than every file of whichever shapes sort last. `config.shapes` puts\n // `natural` first, so it is never the one left with nothing.\n const variants: PlannedVariant[] = []\n let dropped = 0\n const deepest = Math.max(0, ...perShape.map((p) => p.sizes.length))\n for (let rung = 0; rung < deepest; rung++) {\n for (const { shape, region, sizes } of perShape) {\n const size = sizes[rung]\n if (!size) continue\n if (variants.length >= MAX_VARIANTS_PER_IMAGE) {\n dropped++\n continue\n }\n const [width, height] = size\n const recipe: VariantRecipe = { extract: region, width, height, quality: shape.quality }\n variants.push({\n shape: shape.name,\n width,\n height,\n extract: region,\n format: shape.format,\n quality: shape.quality,\n fingerprint: variantFingerprint(recipe),\n })\n }\n }\n return { variants, dropped }\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{a as e,i as t,n}from"./variant-plan-86tJKtVi.mjs";const r=`focalPointSeedTried`;function i(e){return a(e)&&e.focalPointSeedTried===!0}function a(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function o(e){let t=new Map;if(!a(e))return t;for(let[n,r]of Object.entries(e))typeof r==`string`&&t.set(n,r);return t}function s(e){return typeof e==`number`&&Number.isInteger(e)&&e>0}function c(e){if(!a(e))return null;let{shape:t,rung:n,width:r,height:i,format:o,key:c}=e;return typeof t!=`string`||typeof o!=`string`||typeof c!=`string`||!s(n)||!s(r)||!s(i)?null:{shape:t,rung:n,width:r,height:i,format:o,key:c}}function l(e){let t=a(e)?e:{},n=Array.isArray(t.shaped)?t.shaped.map(c).filter(e=>e!==null):[],r=Array.isArray(t.unconfirmedVariants)?t.unconfirmedVariants.filter(e=>typeof e==`string`):[];return{legacy:o(t.variants),legacyRecipes:o(t.legacyRecipes),shaped:n,unconfirmed:r}}function u(e,t){let{unconfirmedVariants:n,...r}=a(e)?e:{};return{...r,variants:Object.fromEntries(t.legacy),legacyRecipes:Object.fromEntries(t.legacyRecipes),shaped:t.shaped.map(e=>({...e})),...t.unconfirmed.length>0&&{unconfirmedVariants:[...t.unconfirmed]}}}function d(e){return[...new Set([...e.legacy.values(),...e.shaped.map(e=>e.key),...e.unconfirmed])]}function f(e){return[`${e.width??``}x${e.height??``}`,e.fit??`cover`,e.format??`webp`,`q${e.quality??80}`].join(`|`)}function p(e,t,n){if(e.unconfirmed.length>0||e.shaped.length!==t.size)return!1;for(let n of e.shaped)if(!t.has(n.key))return!1;if(e.legacy.size!==n.size)return!1;for(let[t,r]of n)if(e.legacy.get(t)!==r.key||e.legacyRecipes.get(t)!==r.recipe)return!1;return!0}function m(r,i,a,o,s){return{plannedKeys:new Set(n(i,a,s).variants.map(e=>t(r,{shape:e.shape,width:e.width,fingerprint:e.fingerprint,format:e.format}))),legacy:new Map(Object.entries(o).map(([t,n])=>[t,{key:e(r,t,n.format??`webp`),recipe:f(n)}]))}}export{p as a,u as c,i,d as n,f as o,m as r,l as s,r as t};
2
+ //# sourceMappingURL=variant-state-CBtqb9Cb.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"variant-state-CBtqb9Cb.mjs","names":[],"sources":["../src/jobs/variant-state.ts"],"sourcesContent":["/**\n * What a photograph's ORIGINAL file records about the variants that exist for\n * it — the single source a reader resolves variants from (images F016/F017).\n *\n * Stored in the original's `toolkit_files.metadata`, beside the `variants` map\n * the legacy path has always written there:\n *\n * ```jsonc\n * {\n * \"variants\": { \"thumbnail\": \"<dir>/thumbnail_photo.webp\" }, // legacy, unchanged\n * \"legacyRecipes\": { \"thumbnail\": \"200x200|cover|webp|q80\" }, // new\n * \"shaped\": [ // new\n * { \"shape\": \"square\", \"rung\": 320, \"width\": 320, \"height\": 320,\n * \"format\": \"webp\", \"key\": \"<dir>/variants/photo_square_320w_1k2x9a0.webp\" }\n * ],\n * \"unconfirmedVariants\": [\"<dir>/variants/…\"] // new, usually absent\n * }\n * ```\n *\n * 🔴 A shaped entry records `width`/`height` as sharp RETURNED them, never the\n * rung that was asked for (F003). The rung names the key; the recorded size is\n * what a `w` descriptor may claim. A reader must never re-derive keys from the\n * media row: the row's dimensions can differ from the file's (F017).\n *\n * `unconfirmedVariants` are files a FAILED pass uploaded before giving up. They\n * are not in `shaped` — so a reader never sees two crops for one (shape, rung)\n * — but they are recorded, so deleting the photograph deletes them too, and\n * the retry either adopts them or deletes them.\n *\n * Pure — no sharp, no storage, no database.\n */\n\nimport type { VariantConfig } from '../shapes.js'\nimport type { ImageStyle } from '../types.js'\nimport { deriveShapedVariantKey, deriveVariantKey } from '../variant-key.js'\nimport { planVariants, type VariantPlanInput } from '../variant-plan.js'\n\n/**\n * Set on the original's metadata when the pass looked for a focal point and\n * found nothing to guess about (`detectFocalPoint` → `null`: a strip, a flat\n * image). The row keeps `focalPoint: null` — the centre — and the backfill\n * stops treating that as \"never seeded\", which would queue it on every walk.\n */\nexport const FOCAL_SEED_TRIED = 'focalPointSeedTried'\n\n/** Did a pass already look for this photograph's focal point and find none? */\nexport function focalSeedTried(metadata: unknown): boolean {\n return isRecord(metadata) && metadata[FOCAL_SEED_TRIED] === true\n}\n\n/** One generated (shape × rung) variant, as recorded. */\nexport interface StoredShapedVariant {\n readonly shape: string\n /** The rung the variant was PLANNED at — part of its key. */\n readonly rung: number\n /** What the file actually is (F003). */\n readonly width: number\n readonly height: number\n readonly format: string\n readonly key: string\n}\n\n/** Everything the original's metadata says about its variants. */\nexport interface VariantState {\n /** Legacy style name → key (`metadata.variants`). */\n readonly legacy: ReadonlyMap<string, string>\n /** Legacy style name → the {@link legacyRecipe} its file was made with. */\n readonly legacyRecipes: ReadonlyMap<string, string>\n readonly shaped: readonly StoredShapedVariant[]\n /** Keys a failed pass uploaded that no reader should resolve yet — see the module doc. */\n readonly unconfirmed: readonly string[]\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction stringEntries(value: unknown): Map<string, string> {\n const out = new Map<string, string>()\n if (!isRecord(value)) return out\n for (const [k, v] of Object.entries(value)) if (typeof v === 'string') out.set(k, v)\n return out\n}\n\nfunction isPositiveInt(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value > 0\n}\n\nfunction readShapedEntry(value: unknown): StoredShapedVariant | null {\n if (!isRecord(value)) return null\n const { shape, rung, width, height, format, key } = value\n if (typeof shape !== 'string' || typeof format !== 'string' || typeof key !== 'string') {\n return null\n }\n if (!isPositiveInt(rung) || !isPositiveInt(width) || !isPositiveInt(height)) return null\n return { shape, rung, width, height, format, key }\n}\n\n/**\n * Read the variant state off an original's metadata. Lenient: the column is\n * caller-shaped JSON, so a malformed entry is dropped rather than trusted, and\n * a file that predates this module reads as its legacy map alone.\n */\nexport function readVariantState(metadata: unknown): VariantState {\n const meta = isRecord(metadata) ? metadata : {}\n const shaped = Array.isArray(meta.shaped)\n ? meta.shaped.map(readShapedEntry).filter((v): v is StoredShapedVariant => v !== null)\n : []\n const unconfirmed = Array.isArray(meta.unconfirmedVariants)\n ? meta.unconfirmedVariants.filter((k): k is string => typeof k === 'string')\n : []\n return {\n legacy: stringEntries(meta.variants),\n legacyRecipes: stringEntries(meta.legacyRecipes),\n shaped,\n unconfirmed,\n }\n}\n\n/**\n * `metadata` with `state` written into it — every other key the original's\n * metadata carries is kept as it was.\n */\nexport function writeVariantState(metadata: unknown, state: VariantState): Record<string, unknown> {\n const { unconfirmedVariants: _dropped, ...base } = isRecord(metadata) ? metadata : {}\n return {\n ...base,\n variants: Object.fromEntries(state.legacy),\n legacyRecipes: Object.fromEntries(state.legacyRecipes),\n shaped: state.shaped.map((v) => ({ ...v })),\n ...(state.unconfirmed.length > 0 && { unconfirmedVariants: [...state.unconfirmed] }),\n }\n}\n\n/**\n * Every variant file the original's metadata accounts for — legacy, shaped and\n * unconfirmed. What deleting the photograph must delete: a public photograph an\n * editor deleted must not stay reachable at its variant URLs.\n */\nexport function allVariantKeys(state: VariantState): string[] {\n return [\n ...new Set([...state.legacy.values(), ...state.shaped.map((v) => v.key), ...state.unconfirmed]),\n ]\n}\n\n/**\n * Everything that determines a LEGACY style file's bytes, as one string.\n *\n * A legacy key names the style and nothing else (`thumbnail_photo.webp`), so\n * without this a regeneration cannot tell \"the operator changed thumbnail to\n * 320×320\" from \"nothing changed\" — and re-rendering every legacy file on every\n * pass is exactly the all-or-nothing cost this module exists to remove.\n * Defaults are applied, so `{width: 200}` and `{width: 200, fit: 'cover'}` are\n * the same recipe, as they produce the same file.\n */\nexport function legacyRecipe(style: ImageStyle): string {\n return [\n `${style.width ?? ''}x${style.height ?? ''}`,\n style.fit ?? 'cover',\n style.format ?? 'webp',\n `q${style.quality ?? 80}`,\n ].join('|')\n}\n\n/**\n * Does the recorded state already say exactly what a pass would produce now?\n *\n * The backfill asks this per photograph from its metadata alone — no download,\n * no decode — and queues a pass only when the answer is no. That is what makes\n * a walk incremental (WordPress's `--only-missing`, R014): a library that is\n * already caught up costs one metadata read per photograph, and a walk that\n * stopped at its cap reaches further the next time.\n *\n * \"Exactly\" in both directions: a planned key that is not recorded needs\n * generating, and a recorded key that is not planned needs deleting (a removed\n * shape, a re-crop). Same for the legacy styles and their recipes.\n */\nexport function isUpToDate(\n state: VariantState,\n plannedKeys: ReadonlySet<string>,\n legacy: ReadonlyMap<string, { readonly key: string; readonly recipe: string }>,\n): boolean {\n // Leftovers of a failed pass are work: the next pass adopts or deletes them.\n if (state.unconfirmed.length > 0) return false\n if (state.shaped.length !== plannedKeys.size) return false\n for (const v of state.shaped) if (!plannedKeys.has(v.key)) return false\n if (state.legacy.size !== legacy.size) return false\n for (const [name, want] of legacy) {\n if (state.legacy.get(name) !== want.key || state.legacyRecipes.get(name) !== want.recipe) {\n return false\n }\n }\n return true\n}\n\n/**\n * The keys a pass would leave recorded for this photograph under this\n * configuration — the other half of {@link isUpToDate}. Planned from `drawn`,\n * which the pass takes from the FILE and the backfill from the row (a row\n * whose size disagrees with its file plans different keys, so it is queued,\n * and its pass corrects the row — F014).\n */\nexport function expectedVariants(\n fileKey: string,\n drawn: { readonly width: number; readonly height: number },\n config: VariantConfig,\n styles: Readonly<Record<string, ImageStyle>>,\n input: VariantPlanInput,\n): {\n plannedKeys: Set<string>\n legacy: Map<string, { key: string; recipe: string }>\n} {\n const plannedKeys = new Set(\n planVariants(drawn, config, input).variants.map((p) =>\n deriveShapedVariantKey(fileKey, {\n shape: p.shape,\n width: p.width,\n fingerprint: p.fingerprint,\n format: p.format,\n }),\n ),\n )\n const legacy = new Map(\n Object.entries(styles).map(([name, style]) => [\n name,\n { key: deriveVariantKey(fileKey, name, style.format ?? 'webp'), recipe: legacyRecipe(style) },\n ]),\n )\n return { plannedKeys, legacy }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/media",
3
- "version": "0.71.0",
3
+ "version": "0.73.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -55,6 +55,10 @@
55
55
  "./crop": {
56
56
  "types": "./dist/crop.d.mts",
57
57
  "import": "./dist/crop.mjs"
58
+ },
59
+ "./crop-editor": {
60
+ "types": "./dist/crop-editor.d.mts",
61
+ "import": "./dist/crop-editor.mjs"
58
62
  }
59
63
  },
60
64
  "files": [
@@ -65,29 +69,38 @@
65
69
  "@radix-ui/react-visually-hidden": "^1.2.5",
66
70
  "clsx": "^2.1.0",
67
71
  "drizzle-orm": "^0.45.2",
72
+ "react-easy-crop": "6.2.3",
68
73
  "server-only": "^0.0.1",
69
74
  "sharp": "^0.34.5",
70
75
  "tailwind-merge": "^2.6.0",
71
76
  "zod": "^3.24.1",
72
- "@murumets-ee/core": "0.71.0",
73
- "@murumets-ee/db": "0.71.0",
74
- "@murumets-ee/entity": "0.71.0",
75
- "@murumets-ee/logging": "0.71.0",
76
- "@murumets-ee/settings": "0.71.0",
77
- "@murumets-ee/storage": "0.71.0",
78
- "@murumets-ee/ui": "0.71.0"
77
+ "@murumets-ee/core": "0.73.0",
78
+ "@murumets-ee/db": "0.73.0",
79
+ "@murumets-ee/entity": "0.73.0",
80
+ "@murumets-ee/logging": "0.73.0",
81
+ "@murumets-ee/queue": "0.73.0",
82
+ "@murumets-ee/settings": "0.73.0",
83
+ "@murumets-ee/storage": "0.73.0",
84
+ "@murumets-ee/ui": "0.73.0"
79
85
  },
80
86
  "peerDependencies": {
81
87
  "lucide-react": ">=0.400.0 <1",
88
+ "next-intl": ">=4.0.0 <5",
82
89
  "react": ">=19.0.0 <20",
83
90
  "react-dom": ">=19.0.0 <20"
84
91
  },
92
+ "peerDependenciesMeta": {
93
+ "next-intl": {
94
+ "optional": true
95
+ }
96
+ },
85
97
  "devDependencies": {
86
98
  "@types/node": "^20.19.42",
87
99
  "@types/react": "^19",
88
100
  "@types/react-dom": "^19",
89
101
  "file-type": "^21.3.4",
90
102
  "lucide-react": "^0.577.0",
103
+ "next-intl": "^4.13.0",
91
104
  "react": "19.2.7",
92
105
  "react-dom": "19.2.7",
93
106
  "tsdown": "^0.22.2",
@@ -1,2 +0,0 @@
1
- import"./entity-CNxAERYG.mjs";import{i as e,n as t,r as n,t as r}from"./variant-key-RKke2RfS.mjs";import"server-only";function i(e){return e.startsWith(`image/`)?`image`:e.startsWith(`video/`)?`video`:e.startsWith(`audio/`)?`audio`:e===`application/pdf`||e.startsWith(`application/msword`)||e.startsWith(`application/vnd.`)?`document`:`other`}var a=class{admin;storage;imageStyles;constructor(e){this.admin=e.admin,this.storage=e.storage,this.imageStyles=e.imageStyles??null}async resolveImageStyles(){if(this.imageStyles)return this.imageStyles;let{getApp:e}=await import(`@murumets-ee/core`),{resolveImageStyles:t}=await import(`./resolve-image-styles-Cakg1Qpp.mjs`),n=e();return this.imageStyles=await t(n,n.logger),this.imageStyles}invalidateImageStylesCache(){this.imageStyles=null}async upload(a,c){let l=await this.storage.upload(a,{filename:c.filename,mimeType:c.mimeType,size:c.size,visibility:c.visibility,uploadedBy:c.uploadedBy}),u=c.width??null,d=c.height??null,f=null,p={};if(a instanceof Buffer&&n(c.mimeType))try{let n=await this.resolveImageStyles(),[i,o]=await Promise.all([e(a,n),t(a).catch(async e=>(await s(e,c.filename),null))]);f=o,u=i.width,d=i.height;let m=l.visibility;await Promise.all([...i.variants.entries()].map(async([e,t])=>{let n=r(l.key,e,t.format);try{await this.storage.upload(t.buffer,{key:n,filename:`${e}_${c.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:m,metadata:{variantOf:l.key,style:e},uploadedBy:c.uploadedBy}),p[e]=n}catch{}})),Object.keys(p).length>0&&await this.storage.updateMetadata(l.key,{metadata:{...l.metadata??{},variants:p}}).catch(()=>{})}catch{}let m=i(c.mimeType);try{return{media:await this.admin.create({title:c.title??o(c.filename),alt:c.alt??null,description:c.description??null,fileKey:l.key,filename:c.filename,mimeType:c.mimeType,size:c.size,width:u,height:d,focalPoint:f,mediaType:m}),url:await this.storage.getUrl(l.key)}}catch(e){for(let e of Object.values(p))await this.storage.delete(e).catch(()=>{});throw await this.storage.delete(l.key).catch(()=>{}),e}}async findById(e,t){return this.admin.findById(e,t)}async findMany(e){let{schemaRegistry:t}=await import(`@murumets-ee/db`),{and:n,asc:r,desc:i,eq:a,ilike:o,or:s,sql:c}=await import(`drizzle-orm`),l=t.get(`media`);if(!l)throw Error(`Media schema not registered. Is the media() plugin loaded?`);let u=[];if(e?.mediaType&&u.push(a(l.mediaType,e.mediaType)),e?.mimeTypePrefix){let t=e.mimeTypePrefix.replace(/[\\%_]/g,`\\$&`);u.push(o(l.mimeType,`${t}%`))}if(e?.search){let t=`%${e.search.replace(/[\\%_]/g,`\\$&`)}%`;u.push(s(o(l.filename,t),c`${l.fields} ->> 'title' ILIKE ${t}`))}let d=e?.limit??50,f=e?.offset??0,p=u.length>0?n(...u):void 0,m=await this.admin.count({where:p}),h=e?.orderBy===`filename`?l.filename:l.createdAt,g=(e?.orderDirection??`desc`)===`asc`?r:i;return{items:await this.admin.findMany({where:p,limit:d,offset:f,orderBy:g(h)}),total:m,limit:d,offset:f}}async update(e,t){return this.admin.update(e,t)}async delete(e){let t=await this.admin.findById(e);if(!t)throw Error(`Media not found: ${e}`);let n=t.fileKey;await this.admin.delete(e);let r=(await this.storage.getMetadata(n))?.metadata?.variants;if(r)for(let e of Object.values(r))await this.storage.delete(e).catch(()=>{});await this.storage.delete(n).catch(()=>{})}async getUrl(e){let t=await this.admin.findById(e);if(!t)throw Error(`Media not found: ${e}`);return this.storage.getUrl(t.fileKey)}async getUrls(e){if(e.length===0)return new Map;let{schemaRegistry:t}=await import(`@murumets-ee/db`),{inArray:n}=await import(`drizzle-orm`),r=t.get(`media`);if(!r)return new Map;let i=await this.admin.findMany({where:n(r.id,e),limit:e.length}),a=new Map;return await Promise.all(i.map(async e=>{let t=await this.storage.getUrl(e.fileKey);a.set(e.id,t)})),a}async getVariantUrl(e,t){let n=await this.admin.findById(e);if(!n)return null;let i=n.fileKey,a=(await this.resolveImageStyles())[t];if(a){let e=r(i,t,a.format??`webp`);try{return await this.storage.getUrl(e)}catch{}}try{return await this.storage.getUrl(i)}catch{return null}}async getVariantUrls(e,t){if(e.length===0)return new Map;let{schemaRegistry:n}=await import(`@murumets-ee/db`),{inArray:i}=await import(`drizzle-orm`),a=n.get(`media`);if(!a)return new Map;let o=await this.admin.findMany({where:i(a.id,e),limit:e.length}),s=(await this.resolveImageStyles())[t],c=new Map;return await Promise.all(o.map(async e=>{if(s){let n=r(e.fileKey,t,s.format??`webp`);try{let t=await this.storage.getUrl(n);c.set(e.id,t);return}catch{}}try{let t=await this.storage.getUrl(e.fileKey);c.set(e.id,t)}catch{}})),c}};function o(e){return e.replace(/\.[^.]+$/,``).replace(/[-_]/g,` `)}async function s(e,t){try{let{getApp:n}=await import(`@murumets-ee/core`);n().logger.warn({err:e,filename:t},`media: focal-point detection failed; stored as centre`)}catch(n){console.warn(`media: focal-point detection failed; stored as centre`,t,e,n)}}export{a as MediaClient};
2
- //# sourceMappingURL=client-CSOH7Zun.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"client-CSOH7Zun.mjs","names":[],"sources":["../src/async-cache.ts","../src/derive-media-type.ts","../src/client.ts"],"sourcesContent":["/**\n * Small async caching primitives shared by the media read paths.\n *\n * A LEAF module on purpose: `client.ts` needs\n * {@link cacheOnceUnlessRejected} and `public-resolver.ts` dynamically imports\n * `client.ts`, so keeping the helper in either would put a static edge across a\n * dynamic one. Both import this instead.\n *\n * Both helpers share one rule — **a rejection is never pinned**. A cached\n * failure turns one transient blip into a permanent outage that only a restart\n * clears, which is tolerable behind an admin action and is not behind the\n * anonymous request path these now serve.\n */\n\n/**\n * Cache a one-shot async initialisation — but NEVER a rejection.\n *\n * A bare `promise ??= load()` singleton pins a rejected promise for the life of\n * the process: one transient failure during initialisation and every later\n * caller replays that same rejection until restart. That is tolerable when the\n * singleton serves an admin operation someone will retry by hand; it is not\n * when it serves the anonymous request path, where the symptom is every image\n * on the site failing permanently with no way to recover short of a redeploy.\n *\n * Same rule as {@link memoiseWithTtl}, without the TTL — a success here is\n * process-lifetime by design (the value is configuration, not data).\n */\nexport function cacheOnceUnlessRejected<T>(load: () => Promise<T>): () => Promise<T> {\n let cached: Promise<T> | null = null\n return () => {\n if (cached) return cached\n const pending = load()\n cached = pending\n pending.catch(() => {\n // Only clear OUR entry — a later call may already have replaced it.\n if (cached === pending) cached = null\n })\n return pending\n }\n}\n","import type { MediaType } from './types.js'\n\n/**\n * Classify a MIME type into the coarse `MediaType` an upload is stored under.\n *\n * Pure and dependency-free, and in its own module on purpose: `client.ts`\n * (`import 'server-only'`) needs it to set `mediaType` at upload, and the admin\n * list's MIME-type cell (`picker/media-type-icon.ts`, a CLIENT entry) needs the\n * same answer to pick an icon. One rule in one file means the icon a row shows\n * cannot disagree with the type it was stored as — which two copies would, the\n * first time someone taught one of them about a new document type.\n */\nexport function deriveMediaType(mimeType: string): MediaType {\n if (mimeType.startsWith('image/')) return 'image'\n if (mimeType.startsWith('video/')) return 'video'\n if (mimeType.startsWith('audio/')) return 'audio'\n if (\n mimeType === 'application/pdf' ||\n mimeType.startsWith('application/msword') ||\n mimeType.startsWith('application/vnd.')\n ) {\n return 'document'\n }\n return 'other'\n}\n","/**\n * MediaClient — wraps AdminClient + StorageClient for media management.\n *\n * Usage:\n * import { createMediaClient } from '@murumets-ee/media/client'\n * const media = await createMediaClient(storageClient)\n * const result = await media.upload(buffer, { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 12345 })\n */\n\nimport 'server-only'\n\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { cacheOnceUnlessRejected } from './async-cache.js'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport { deriveMediaType } from './derive-media-type.js'\nimport { Media } from './entity.js'\nimport { detectFocalPoint, isProcessableImage, processImage } from './process-image.js'\nimport type {\n ImageStyle,\n MediaListOptions,\n MediaListResult,\n MediaRecord,\n MediaUploadOptions,\n MediaUploadResult,\n} from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\ntype MediaFields = typeof Media.allFields\n\nexport interface MediaClientConfig {\n admin: AdminClient<MediaFields>\n storage: StorageClient\n /** Image styles to generate on upload. Loaded from plugin config if not provided. */\n imageStyles?: Record<string, ImageStyle>\n}\n\nexport class MediaClient {\n private admin: AdminClient<MediaFields>\n private storage: StorageClient\n private imageStyles: Record<string, ImageStyle> | null\n\n constructor(config: MediaClientConfig) {\n this.admin = config.admin\n this.storage = config.storage\n this.imageStyles = config.imageStyles ?? null\n }\n\n /**\n * Resolve image styles via the shared waterfall (settings DB → plugin\n * config → hardcoded defaults) and cache the result on this instance.\n * Call `invalidateImageStylesCache()` after a settings update.\n */\n private async resolveImageStyles(): Promise<Record<string, ImageStyle>> {\n if (this.imageStyles) return this.imageStyles\n const { getApp } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('./resolve-image-styles.js')\n const app = getApp()\n this.imageStyles = await resolveImageStyles(app, app.logger)\n return this.imageStyles\n }\n\n /** Clear cached styles so next access re-reads from settings DB. */\n invalidateImageStylesCache(): void {\n this.imageStyles = null\n }\n\n // ---------------------------------------------------------------\n // Upload — the key convenience method\n // ---------------------------------------------------------------\n\n /**\n * Upload a file and create a media entity record in one step.\n * 1. Uploads original to storage (StorageClient)\n * 2. If image: extracts dimensions via Sharp + generates variants\n * 3. Creates media entity record (AdminClient)\n * 4. Returns the media record + URL\n *\n * Rolls back storage upload if entity creation fails.\n * Variant generation failures are logged but don't fail the upload.\n */\n async upload(\n data: Buffer | ReadableStream<Uint8Array>,\n options: MediaUploadOptions,\n ): Promise<MediaUploadResult> {\n // 1. Upload original file to storage\n const fileRecord = await this.storage.upload(data, {\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n visibility: options.visibility,\n uploadedBy: options.uploadedBy,\n })\n\n // 2. Image processing — extract dimensions + generate variants\n let width = options.width ?? null\n let height = options.height ?? null\n // `null` renders as the centre (images D010), so a failed or skipped guess\n // is exactly today's crop rather than an error.\n let focalPoint: FocalPoint | null = null\n const variantKeys: Record<string, string> = {}\n\n if (data instanceof Buffer && isProcessableImage(options.mimeType)) {\n try {\n const styles = await this.resolveImageStyles()\n // The focal-point seed runs beside the variants rather than after them —\n // two independent sharp passes over the same buffer. Its own failure is\n // swallowed here so a photograph the attention pass cannot read still\n // gets its variants.\n const [processed, seeded] = await Promise.all([\n processImage(data, styles),\n detectFocalPoint(data).catch(async (err: unknown) => {\n await warnFocalPointFailed(err, options.filename)\n return null\n }),\n ])\n focalPoint = seeded\n\n // Set dimensions from Sharp metadata\n width = processed.width\n height = processed.height\n\n // Upload each variant to storage\n const visibility = fileRecord.visibility\n await Promise.all(\n [...processed.variants.entries()].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(fileRecord.key, styleName, variant.format)\n try {\n await this.storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${options.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: fileRecord.key, style: styleName },\n uploadedBy: options.uploadedBy,\n })\n variantKeys[styleName] = vKey\n } catch {\n // Variant upload failure is non-fatal — original is saved\n }\n }),\n )\n\n // Store variant keys in original file's metadata\n if (Object.keys(variantKeys).length > 0) {\n await this.storage\n .updateMetadata(fileRecord.key, {\n metadata: {\n ...(fileRecord.metadata ?? {}),\n variants: variantKeys,\n },\n })\n .catch(() => {\n // Metadata update failure is non-fatal\n })\n }\n } catch {\n // Image processing failure is non-fatal — original is saved\n }\n }\n\n // 3. Create media entity record\n const mediaType = deriveMediaType(options.mimeType)\n\n try {\n const entityRecord = await this.admin.create({\n title: options.title ?? deriveTitle(options.filename),\n alt: options.alt ?? null,\n description: options.description ?? null,\n fileKey: fileRecord.key,\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n width,\n height,\n focalPoint,\n mediaType,\n })\n\n // 4. Get URL\n const url = await this.storage.getUrl(fileRecord.key)\n\n return {\n media: entityRecord,\n url,\n }\n } catch (error) {\n // Rollback: delete variants + original if entity creation fails\n\n // Delete variants first (best-effort)\n for (const vKey of Object.values(variantKeys)) {\n await this.storage.delete(vKey).catch(() => {})\n }\n\n // Delete original\n await this.storage.delete(fileRecord.key).catch(() => {\n // Storage rollback failure is already being handled — propagate original error\n })\n throw error\n }\n }\n\n // ---------------------------------------------------------------\n // CRUD delegation\n // ---------------------------------------------------------------\n\n async findById(id: string, options?: { locale?: string }): Promise<MediaRecord | null> {\n return this.admin.findById(id, options)\n }\n\n async findMany(options?: MediaListOptions): Promise<MediaListResult> {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, desc, eq, ilike, or, sql } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n\n // Build where conditions\n const conditions = []\n\n if (options?.mediaType) {\n conditions.push(eq(table.mediaType, options.mediaType))\n }\n if (options?.mimeTypePrefix) {\n // Escape ILIKE wildcards (%, _) in user input to prevent pattern injection\n const escaped = options.mimeTypePrefix.replace(/[\\\\%_]/g, '\\\\$&')\n conditions.push(ilike(table.mimeType, `${escaped}%`))\n }\n if (options?.search) {\n // Escape ILIKE wildcards in user input, then wrap with %...%\n const escaped = options.search.replace(/[\\\\%_]/g, '\\\\$&')\n const pattern = `%${escaped}%`\n conditions.push(\n or(\n ilike(table.filename, pattern),\n sql`${table.fields} ->> 'title' ILIKE ${pattern}`,\n )!,\n )\n }\n\n const limit = options?.limit ?? 50\n const offset = options?.offset ?? 0\n const whereClause = conditions.length > 0 ? and(...conditions) : undefined\n\n // Count total via AdminClient\n const total = await this.admin.count({ where: whereClause })\n\n // Fetch items via AdminClient for proper DTO shaping\n const orderField = options?.orderBy === 'filename' ? table.filename : table.createdAt\n const orderFn = (options?.orderDirection ?? 'desc') === 'asc' ? asc : desc\n\n const items = await this.admin.findMany({\n where: whereClause,\n limit,\n offset,\n orderBy: orderFn(orderField),\n })\n\n return {\n items,\n total,\n limit,\n offset,\n }\n }\n\n async update(\n id: string,\n data: { title?: string; alt?: string; description?: string },\n ): Promise<MediaRecord> {\n return this.admin.update(id, data)\n }\n\n /**\n * Delete a media entity, its variants, and its original file in storage.\n */\n async delete(id: string): Promise<void> {\n const record = await this.admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n const fileKey = record.fileKey\n\n // 1. Delete entity first (ref checking via entity_refs happens here)\n await this.admin.delete(id)\n\n // 2. Look up original file record for variant metadata\n const fileRecord = await this.storage.getMetadata(fileKey)\n const variants = (fileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n\n // 3. Delete variants (best-effort)\n if (variants) {\n for (const vKey of Object.values(variants)) {\n await this.storage.delete(vKey).catch(() => {\n // Variant deletion failure is non-fatal\n })\n }\n }\n\n // 4. Delete original file (best-effort)\n await this.storage.delete(fileKey).catch(() => {\n // Storage deletion failure is non-fatal — entity is already deleted\n })\n }\n\n // ---------------------------------------------------------------\n // URL resolution\n // ---------------------------------------------------------------\n\n /**\n * Get URL for a media entity by its ID.\n * Resolves entity -> fileKey -> storage URL.\n */\n async getUrl(id: string): Promise<string> {\n const record = await this.admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n return this.storage.getUrl(record.fileKey)\n }\n\n /**\n * Get URLs for multiple media entities (batch).\n * Returns a Map of mediaId -> url.\n */\n async getUrls(ids: string[]): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await this.admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n records.map(async (record) => {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n }),\n )\n\n return urlMap\n }\n\n /**\n * Get variant URL for a specific image style.\n * Falls back to original URL if the variant doesn't exist.\n *\n * @param id - Media entity ID\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns The variant URL, or original URL as fallback, or null if media not found\n */\n async getVariantUrl(id: string, styleName: string): Promise<string | null> {\n const record = await this.admin.findById(id)\n if (!record) return null\n\n const fileKey = record.fileKey\n\n // Try variant key first\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n if (style) {\n const vKey = deriveVariantKey(fileKey, styleName, style.format ?? 'webp')\n try {\n return await this.storage.getUrl(vKey)\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n return await this.storage.getUrl(fileKey)\n } catch {\n return null\n }\n }\n\n /**\n * Get variant URLs for multiple media entities (batch).\n * Falls back to original URL per item if the variant doesn't exist.\n *\n * @param ids - Media entity IDs\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns Map of mediaId -> variant URL (or original URL as fallback)\n */\n async getVariantUrls(ids: string[], styleName: string): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await this.admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n records.map(async (record) => {\n // Try variant URL\n if (style) {\n const vKey = deriveVariantKey(record.fileKey, styleName, style.format ?? 'webp')\n try {\n const url = await this.storage.getUrl(vKey)\n urlMap.set(record.id, url)\n return\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n } catch {\n // Skip — no URL available\n }\n }),\n )\n\n return urlMap\n }\n}\n\n// ---------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------\n\nfunction deriveTitle(filename: string): string {\n const withoutExt = filename.replace(/\\.[^.]+$/, '')\n return withoutExt.replace(/[-_]/g, ' ')\n}\n\n/**\n * Factory — creates a MediaClient with an explicit StorageClient.\n * Must be called after createApp().\n */\nexport async function createMediaClient(storage: StorageClient): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory\n// ---------------------------------------------------------------------------\n//\n// NOTE: Storage config is process-global and safe to cache.\n// MediaClient / AdminClient must be built per-request — they carry a\n// context resolver tied to the calling request's user + permissions.\n// A singleton would leak one request's security context across others.\n\n/**\n * The storage singleton, which does NOT pin a failed initialisation.\n *\n * `_storagePromise ??= (...)()` used to cache the rejected promise too, so a\n * transient failure during storage init (config not yet loaded, a DB blip)\n * broke every subsequent caller until the process restarted. That mattered\n * little while this served admin operations; `plan/api` PR 5 puts it on the\n * ANONYMOUS request path via `getSharedStorageClient`, where the symptom\n * becomes every image on the site failing permanently with no recovery short of\n * a redeploy. See `cacheOnceUnlessRejected`, which is unit-tested.\n */\nconst _storageOnce = cacheOnceUnlessRejected<StorageClient>(async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n})\n\n/**\n * The process-wide `StorageClient`, built once from the storage plugin config.\n *\n * Exported so the PUBLIC media resolver (`./public-resolver.ts`) reaches the\n * same instance rather than constructing a second one from a copy of these two\n * lines. Safe to share, and unlike `MediaClient`/`AdminClient` it MUST NOT be\n * rebuilt per request: it carries no security context — the read-side gate\n * lives in whichever client resolves the media rows, not here.\n */\nexport async function getSharedStorageClient(): Promise<StorageClient> {\n return getStorageSingleton()\n}\n\nasync function getStorageSingleton(): Promise<StorageClient> {\n return _storageOnce()\n}\n\n/**\n * Returns a fresh MediaClient wired to the current request's context.\n * Must be called after createApp(), inside a request context\n * (withAdminContext, runAsCli, etc.).\n *\n * Despite the name, this is NOT cached — the storage config is cached\n * internally but the MediaClient and its AdminClient are rebuilt per call\n * so the security context resolver attaches to the correct request.\n */\nexport async function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const storage = await getStorageSingleton()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n/**\n * A failed focal-point guess is not an upload failure — the row is stored with\n * `null`, which renders as the centre (D010) — but it is not silent either: a\n * decoder that cannot read a whole format would otherwise leave every such\n * photograph centred with nothing saying why.\n */\nasync function warnFocalPointFailed(err: unknown, filename: string): Promise<void> {\n try {\n const { getApp } = await import('@murumets-ee/core')\n getApp().logger.warn({ err, filename }, 'media: focal-point detection failed; stored as centre')\n } catch (loggerErr) {\n // No app (a script, a test) — fall back to the console rather than lose it.\n console.warn('media: focal-point detection failed; stored as centre', filename, err, loggerErr)\n }\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- var e={PluginNav:{media:`Media`}};export{e as default};
2
- //# sourceMappingURL=en-0YnQ_Qz-.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"en-0YnQ_Qz-.mjs","names":[],"sources":["../src/messages/en.json"],"sourcesContent":[""],"mappings":""}
@@ -1,2 +0,0 @@
1
- import{behavior as e,defineEntity as t,field as n,searchable as r}from"@murumets-ee/entity";import{z as i}from"zod";Object.freeze({x:.5,y:.5});function a(e){return typeof e==`number`&&Number.isFinite(e)&&e>=0&&e<=1}function o(e){if(typeof e!=`object`||!e||Array.isArray(e))return!1;let t=Object.keys(e);if(t.length!==2||!t.includes(`x`)||!t.includes(`y`))return!1;let{x:n,y:r}=e;return a(n)&&a(r)}function s(e){if(!(`focalPoint`in e))return e;let t=e.focalPoint;if(t==null||o(t))return e;throw new i.ZodError([{code:`custom`,path:[`focalPoint`],message:`focalPoint must be { x, y } with both between 0 and 1, or null.`}])}function c(e){if(e.keys.includes(`focalPoint`))throw new i.ZodError([{code:`custom`,path:[`focalPoint`],message:`bulk update on '${e.entityName}' cannot write 'focalPoint': updateMany runs no per-row hooks, so the value would bypass its shape check. Update each row individually.`}])}function l(){return{name:`focalPointValidation`,hooks:{beforeCreate:async e=>s(e),beforeUpdate:async(e,t)=>s(t)},assertBulkUpdate:c}}const u=t({name:`media`,fields:{title:n.text({translatable:!0}),alt:n.text({translatable:!0}),description:n.text({translatable:!0}),fileKey:n.text({required:!0,indexed:!0}),filename:n.text({required:!0}),mimeType:n.text({required:!0,indexed:!0,admin:{display:{type:`mimeType`}}}),size:n.number({required:!0,integer:!0,admin:{display:{type:`filesize`}}}),width:n.number({integer:!0}),height:n.number({integer:!0}),focalPoint:n.json(),mediaType:n.select({options:[`image`,`video`,`audio`,`document`,`other`],required:!0,indexed:!0})},behaviors:[e.auditable(),l(),r({fields:[`filename`,`fileKey`,`mimeType`,`title`,`alt`,`description`],fts:{fields:[`filename`,`title`,`alt`,`description`],language:`simple`},projection:e=>({id:e.id,label:e.title??e.filename,description:e.mimeType})})],admin:{list:{fields:[`title`,`mimeType`,`size`,`createdAt`],layouts:[`table`,`grid`]}},scope:`global`,access:{view:`public`,create:`group.editor`,update:`group.editor`,delete:`group.admin`}});export{u as Media};
2
- //# sourceMappingURL=entity-CNxAERYG.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"entity-CNxAERYG.mjs","names":[],"sources":["../src/crop/focal-point.ts","../src/crop/focal-point-validation.ts","../src/entity.ts"],"sourcesContent":["/**\n * The focal point, and every number derived from it — ONE pure module.\n *\n * A photograph's focal point is stored once, on the media row, as a fraction of\n * the image (`{x, y}` in `[0, 1]`, images D002). Two consumers turn it into a\n * crop: the browser, through `object-position` on a shaped slot (this PR), and\n * sharp, through `extract()` before a shaped variant is resized (images PR04).\n * Both import this file, so the generated crop and the CSS crop cannot\n * disagree — they are the same arithmetic, not two copies of it.\n *\n * No DOM, no sharp, no database. It is imported by `@murumets-ee/blocks`' render\n * spine, which ships to every visitor's browser, so it must stay small and must\n * never import anything.\n *\n * ## Fractions, never pixels\n *\n * A pixel rect breaks the moment the source file is replaced at a different\n * resolution, and this toolkit's stored `width`/`height` are client-measured and\n * can be wrong (images R003). A fraction survives both.\n *\n * ## Why `object-position` is NOT `focal × 100%`\n *\n * `object-position` is an ALIGNMENT percentage: `75%` puts the image's 75% point\n * at the BOX's 75% point, not at its centre (css-backgrounds-3, \"the point 75%\n * across … is to be placed at the point 75% across the area\"). The naive\n * `object-position: {f·100}%` is therefore wrong by `|f − 0.5| × box_width` —\n * exactly zero for a centred subject and 50% of the box at the edges, which is\n * why it survives review (R002). The conversion, per axis:\n *\n * ```\n * w_vis = C / I // visible window, as a fraction of the image\n * L = clamp(f − w_vis / 2, 0, 1 − w_vis) // the window's leading edge\n * P = L / (1 − w_vis) // the object-position fraction\n * ```\n *\n * `P` depends on the CONTAINER's ratio through `w_vis`, which is why it is\n * computed at render and never stored (D002 rules out storing it).\n *\n * ## Absent and centre are the same thing\n *\n * {@link readFocalPoint} turns every absent, malformed or out-of-range value into\n * `{0.5, 0.5}`, and the centre produces `object-position: 50% 50%`, which is\n * CSS's initial value. So a legacy row with no focal point and a row whose focal\n * point is the centre render identically, and nothing downstream branches on\n * the difference (D010).\n */\n\n/**\n * A point in the image, as fractions of its width and height. Both in `[0, 1]`.\n *\n * A `type`, not an `interface`, on purpose: it is stored in a `field.json()`\n * column, and only a type alias is assignable to the entity's `JsonValue`\n * (an interface has no implicit index signature).\n */\nexport type FocalPoint = {\n readonly x: number\n readonly y: number\n}\n\n/** A rectangle in the image, as fractions of its width and height. */\nexport type FractionalRect = {\n readonly x: number\n readonly y: number\n readonly w: number\n readonly h: number\n}\n\n/** The default — and what every absent or unusable focal point means. */\nexport const CENTRE_FOCAL_POINT: FocalPoint = Object.freeze({ x: 0.5, y: 0.5 })\n\nfunction isUnitFraction(value: unknown): value is number {\n return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1\n}\n\n/**\n * Is `value` a storable focal point — exactly `{x, y}`, both finite fractions?\n *\n * STRICT, because it guards a WRITE (the media row's `focalPoint` column). An\n * extra key is refused rather than stripped: a JSON column that stores what it\n * was sent would otherwise accept `{x, y, notes: '<a megabyte>'}` and hand it to\n * every reader of every page that draws the photograph.\n */\nexport function isFocalPoint(value: unknown): value is FocalPoint {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const keys = Object.keys(value)\n if (keys.length !== 2 || !keys.includes('x') || !keys.includes('y')) return false\n const { x, y } = value as { x: unknown; y: unknown }\n return isUnitFraction(x) && isUnitFraction(y)\n}\n\n/**\n * The focal point to RENDER with, from whatever a projection carried.\n *\n * LENIENT, because it guards a READ: an absent, null or malformed value is the\n * centre, never a throw. A render that threw on a bad stored value would take\n * the whole page down over one photograph's crop.\n */\nexport function readFocalPoint(value: unknown): FocalPoint {\n return isFocalPoint(value) ? value : CENTRE_FOCAL_POINT\n}\n\n/** Is this the centre — the point that needs no `object-position` at all? */\nexport function isCentreFocalPoint(focal: FocalPoint): boolean {\n return focal.x === 0.5 && focal.y === 0.5\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(Math.max(value, min), max)\n}\n\n/**\n * The window a `cover` crop leaves visible, as a fraction of the image on each\n * axis.\n *\n * Under `object-fit: cover` the image is scaled until it covers the box, so at\n * most ONE axis overflows and the other is exactly `1`. `imageRatio` and\n * `boxRatio` are width / height. A ratio that is not a positive finite number\n * is a caller bug; it is answered with \"nothing overflows\" (`{1, 1}`), which\n * renders as today's centre crop rather than as `NaN%`.\n */\nexport function visibleWindow(\n imageRatio: number,\n boxRatio: number,\n): { readonly w: number; readonly h: number } {\n if (!(imageRatio > 0 && Number.isFinite(imageRatio) && boxRatio > 0 && Number.isFinite(boxRatio)))\n return { w: 1, h: 1 }\n // Image wider than the box → the horizontal axis overflows.\n if (imageRatio > boxRatio) return { w: boxRatio / imageRatio, h: 1 }\n return { w: 1, h: imageRatio / boxRatio }\n}\n\n/**\n * One axis of the conversion: the window's leading edge `L`, clamped so the\n * window never leaves the image.\n *\n * Clamping `L` to `[0, 1 − w_vis]` is exactly equivalent to clamping the\n * resulting `object-position` to `[0, 1]`, so \"never reveal background\" has one\n * spelling here rather than two (R002).\n */\nfunction windowStart(focal: number, visible: number): number {\n return clamp(focal - visible / 2, 0, 1 - visible)\n}\n\n/**\n * The crop a focal point implies for a box of `boxRatio`, as a fractional rect\n * of the image — the region sharp will `extract()` (images PR04) and the region\n * the browser shows through {@link objectPositionFor}.\n */\nexport function focalCropRect(\n focal: FocalPoint,\n imageRatio: number,\n boxRatio: number,\n): FractionalRect {\n const visible = visibleWindow(imageRatio, boxRatio)\n return {\n x: windowStart(focal.x, visible.w),\n y: windowStart(focal.y, visible.h),\n w: visible.w,\n h: visible.h,\n }\n}\n\n/**\n * The `object-position` fractions that make a `cover`-fitted image show\n * {@link focalCropRect}'s window.\n *\n * An axis that does not overflow (`w_vis == 1`) has no position to choose —\n * `object-position` is inert on it — so it answers `0.5`, CSS's own default.\n * That keeps the centre case literally `{0.5, 0.5}` and the emitted CSS\n * byte-comparable with a slot that has no focal point.\n */\nexport function objectPositionFor(\n focal: FocalPoint,\n imageRatio: number,\n boxRatio: number,\n): FocalPoint {\n const rect = focalCropRect(focal, imageRatio, boxRatio)\n return {\n x: rect.w < 1 ? rect.x / (1 - rect.w) : 0.5,\n y: rect.h < 1 ? rect.y / (1 - rect.h) : 0.5,\n }\n}\n\n/**\n * Format a position as a CSS `object-position` value: two percentages, rounded\n * to two decimals so the emitted markup is stable across floating-point noise\n * (`33.33% 50%`, not `33.333333333333336% 50%`).\n */\nexport function formatObjectPosition(position: FocalPoint): string {\n return `${percent(position.x)} ${percent(position.y)}`\n}\n\nfunction percent(fraction: number): string {\n return `${Math.round(fraction * 10000) / 100}%`\n}\n","/**\n * `focalPointValidation()` — the write guard on `media.focalPoint`.\n *\n * `field.json()` validates as the permissive JSON union on purpose, so without\n * this hook an editor's PATCH could park any shape in the column — a megabyte\n * string, a nested object — and every public page drawing the photograph would\n * carry it, because the projection hands the value to the render. CLAUDE.md's\n * \"validate at the boundary\", on the one column this package adds.\n *\n * Errors are `ZodError`s, so the admin API answers 400 with an issue pathed at\n * `focalPoint` rather than a 500 — the contract `menuEntityTypesValidation()`\n * already relies on.\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { isFocalPoint } from './focal-point.js'\n\n/**\n * `null` is a legitimate value — it means \"no focal point\", which renders as the\n * centre (D010). Anything else must be exactly `{x, y}` in `[0, 1]`.\n */\nfunction validateFocalPoint(data: Record<string, unknown>): Record<string, unknown> {\n // A payload without the key is a partial update that does not touch the\n // column. Defaulting it here would let a title-only PATCH reset the point.\n if (!('focalPoint' in data)) return data\n const value = data.focalPoint\n if (value === null || value === undefined || isFocalPoint(value)) return data\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['focalPoint'],\n message: 'focalPoint must be { x, y } with both between 0 and 1, or null.',\n },\n ])\n}\n\n/**\n * `updateMany` runs no per-row hooks, so `beforeUpdate` never sees a bulk write.\n * Refused rather than validated: `BulkUpdateInfo` is deliberately shape-only (it\n * names the columns, never the values), and \"every photograph has the same\n * subject position\" is not an intent anyone has. A migration is the tool for a\n * one-off backfill.\n */\nfunction assertNoBulkFocalPointWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('focalPoint')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['focalPoint'],\n message:\n `bulk update on '${info.entityName}' cannot write 'focalPoint': updateMany runs no ` +\n 'per-row hooks, so the value would bypass its shape check. Update each row individually.',\n },\n ])\n}\n\n/**\n * Contributes NO fields — the empty map `Record<never, never>`, not\n * `Record<string, never>`, which would merge into the entity's field map and\n * collapse every field to `never`.\n */\nexport function focalPointValidation(): Behavior<Record<never, never>, 'focalPointValidation'> {\n return {\n name: 'focalPointValidation',\n hooks: {\n beforeCreate: async (data) => validateFocalPoint(data),\n beforeUpdate: async (_id, data) => validateFocalPoint(data),\n },\n assertBulkUpdate: assertNoBulkFocalPointWrite,\n }\n}\n","/**\n * Pre-defined Media entity.\n *\n * Auto-registered by the media() plugin — users do NOT add this to their entities array.\n * Connected to toolkit_files via the fileKey field (stores the StorageClient file key).\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineConfig({\n * plugins: [storage(), media()],\n * // Media entity is auto-added — no need to list it here\n * })\n * ```\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport { focalPointValidation } from './crop/focal-point-validation.js'\n\nexport const Media = defineEntity({\n name: 'media',\n fields: {\n // --- Display metadata (editable, translatable) ---\n title: field.text({ translatable: true }),\n alt: field.text({ translatable: true }),\n description: field.text({ translatable: true }),\n\n // --- File linkage (set on upload, immutable in practice) ---\n /** Key into toolkit_files table (e.g., 'uploads/2026/02/uuid/photo.jpg') */\n fileKey: field.text({ required: true, indexed: true }),\n\n // --- File metadata (set on upload) ---\n filename: field.text({ required: true }),\n // Drawn as an icon in the admin list (a film for `video/mp4`), not the raw\n // string — declared on the field, so any list of media reads the same\n // (entity-table D008).\n mimeType: field.text({\n required: true,\n indexed: true,\n admin: { display: { type: 'mimeType' } },\n }),\n // Bytes, drawn as \"1.5 MB\" in the operator's locale in the admin list\n // (entity-table D008). Hidden by default — an unindexed number is — and one\n // click away in the View menu.\n size: field.number({\n required: true,\n integer: true,\n admin: { display: { type: 'filesize' } },\n }),\n\n // --- Image-specific metadata (client-measured on upload) ---\n width: field.number({ integer: true }),\n height: field.number({ integer: true }),\n /**\n * Where the subject is, as `{ x, y }` fractions of the image — never pixels,\n * so it survives the file being replaced at another resolution (images\n * D002). A shaped slot crops around it (`@murumets-ee/media/crop`).\n *\n * Nullable, and `null` means the centre: an absent point and `{0.5, 0.5}`\n * render identically, so no reader branches on a legacy row (D010). The\n * shape is enforced by `focalPointValidation()` below — `field.json()` on\n * its own accepts any JSON.\n */\n focalPoint: field.json(),\n\n // --- Classification (derived from mimeType on upload) ---\n mediaType: field.select({\n options: ['image', 'video', 'audio', 'document', 'other'] as const,\n required: true,\n indexed: true,\n }),\n },\n behaviors: [\n behavior.auditable(),\n focalPointValidation(),\n // filename / fileKey / mimeType live on the main table.\n // title/alt/description are translatable — their tsvector goes on\n // media_translations so non-default-locale callers still get FTS.\n searchable({\n fields: ['filename', 'fileKey', 'mimeType', 'title', 'alt', 'description'],\n fts: {\n fields: ['filename', 'title', 'alt', 'description'],\n language: 'simple',\n },\n projection: (row) => ({\n id: row.id as string,\n label: (row.title as string | undefined) ?? (row.filename as string),\n description: row.mimeType as string,\n }),\n }),\n ],\n admin: {\n list: {\n // Title first (its own row-link column), then the type — the icon\n // display already draws it, so the raw `mimeType` string is not a\n // second column — size and when it was added. `fileKey` (the raw\n // storage path) and `mediaType` (redundant with the icon) stay off by\n // default, one View-menu click away (entity-table PR06 §6).\n fields: ['title', 'mimeType', 'size', 'createdAt'],\n layouts: ['table', 'grid'],\n },\n },\n scope: 'global',\n access: {\n view: 'public',\n create: 'group.editor',\n update: 'group.editor',\n delete: 'group.admin',\n },\n})\n"],"mappings":"oHAoE8C,OAAO,OAAO,CAAE,EAAG,GAAK,EAAG,EAAI,CAAC,EAE9E,SAAS,EAAe,EAAiC,CACvD,OAAO,OAAO,GAAU,UAAY,OAAO,SAAS,CAAK,GAAK,GAAS,GAAK,GAAS,CACvF,CAUA,SAAgB,EAAa,EAAqC,CAChE,GAAI,OAAO,GAAU,WAAY,GAAkB,MAAM,QAAQ,CAAK,EAAG,MAAO,GAChF,IAAM,EAAO,OAAO,KAAK,CAAK,EAC9B,GAAI,EAAK,SAAW,GAAK,CAAC,EAAK,SAAS,GAAG,GAAK,CAAC,EAAK,SAAS,GAAG,EAAG,MAAO,GAC5E,GAAM,CAAE,IAAG,KAAM,EACjB,OAAO,EAAe,CAAC,GAAK,EAAe,CAAC,CAC9C,CClEA,SAAS,EAAmB,EAAwD,CAGlF,GAAI,EAAE,eAAgB,GAAO,OAAO,EACpC,IAAM,EAAQ,EAAK,WACnB,GAAI,GAAU,MAA+B,EAAa,CAAK,EAAG,OAAO,EACzE,MAAM,IAAI,EAAE,SAAS,CACnB,CACE,KAAM,SACN,KAAM,CAAC,YAAY,EACnB,QAAS,iEACX,CACF,CAAC,CACH,CASA,SAAS,EAA4B,EAA4B,CAC1D,KAAK,KAAK,SAAS,YAAY,EACpC,MAAM,IAAI,EAAE,SAAS,CACnB,CACE,KAAM,SACN,KAAM,CAAC,YAAY,EACnB,QACE,mBAAmB,EAAK,WAAW,wIAEvC,CACF,CAAC,CACH,CAOA,SAAgB,GAA+E,CAC7F,MAAO,CACL,KAAM,uBACN,MAAO,CACL,aAAc,KAAO,IAAS,EAAmB,CAAI,EACrD,aAAc,MAAO,EAAK,IAAS,EAAmB,CAAI,CAC5D,EACA,iBAAkB,CACpB,CACF,CCnDA,MAAa,EAAQ,EAAa,CAChC,KAAM,QACN,OAAQ,CAEN,MAAO,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EACxC,IAAK,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EACtC,YAAa,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EAI9C,QAAS,EAAM,KAAK,CAAE,SAAU,GAAM,QAAS,EAAK,CAAC,EAGrD,SAAU,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAIvC,SAAU,EAAM,KAAK,CACnB,SAAU,GACV,QAAS,GACT,MAAO,CAAE,QAAS,CAAE,KAAM,UAAW,CAAE,CACzC,CAAC,EAID,KAAM,EAAM,OAAO,CACjB,SAAU,GACV,QAAS,GACT,MAAO,CAAE,QAAS,CAAE,KAAM,UAAW,CAAE,CACzC,CAAC,EAGD,MAAO,EAAM,OAAO,CAAE,QAAS,EAAK,CAAC,EACrC,OAAQ,EAAM,OAAO,CAAE,QAAS,EAAK,CAAC,EAWtC,WAAY,EAAM,KAAK,EAGvB,UAAW,EAAM,OAAO,CACtB,QAAS,CAAC,QAAS,QAAS,QAAS,WAAY,OAAO,EACxD,SAAU,GACV,QAAS,EACX,CAAC,CACH,EACA,UAAW,CACT,EAAS,UAAU,EACnB,EAAqB,EAIrB,EAAW,CACT,OAAQ,CAAC,WAAY,UAAW,WAAY,QAAS,MAAO,aAAa,EACzE,IAAK,CACH,OAAQ,CAAC,WAAY,QAAS,MAAO,aAAa,EAClD,SAAU,QACZ,EACA,WAAa,IAAS,CACpB,GAAI,EAAI,GACR,MAAQ,EAAI,OAAiC,EAAI,SACjD,YAAa,EAAI,QACnB,EACF,CAAC,CACH,EACA,MAAO,CACL,KAAM,CAMJ,OAAQ,CAAC,QAAS,WAAY,OAAQ,WAAW,EACjD,QAAS,CAAC,QAAS,MAAM,CAC3B,CACF,EACA,MAAO,SACP,OAAQ,CACN,KAAM,SACN,OAAQ,eACR,OAAQ,eACR,OAAQ,aACV,CACF,CAAC"}
@@ -1,2 +0,0 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{a as t}from"./focal-point-BWhtJzef.mjs";import{behavior as n,defineEntity as r,field as i,searchable as a}from"@murumets-ee/entity";import{z as o}from"zod";function s(e){if(!(`focalPoint`in e))return e;let n=e.focalPoint;if(n==null||t(n))return e;throw new o.ZodError([{code:`custom`,path:[`focalPoint`],message:`focalPoint must be { x, y } with both between 0 and 1, or null.`}])}function c(e){if(e.keys.includes(`focalPoint`))throw new o.ZodError([{code:`custom`,path:[`focalPoint`],message:`bulk update on '${e.entityName}' cannot write 'focalPoint': updateMany runs no per-row hooks, so the value would bypass its shape check. Update each row individually.`}])}function l(){return{name:`focalPointValidation`,hooks:{beforeCreate:async e=>s(e),beforeUpdate:async(e,t)=>s(t)},assertBulkUpdate:c}}var u=e({Media:()=>d});const d=r({name:`media`,fields:{title:i.text({translatable:!0}),alt:i.text({translatable:!0}),description:i.text({translatable:!0}),fileKey:i.text({required:!0,indexed:!0}),filename:i.text({required:!0}),mimeType:i.text({required:!0,indexed:!0,admin:{display:{type:`mimeType`}}}),size:i.number({required:!0,integer:!0,admin:{display:{type:`filesize`}}}),width:i.number({integer:!0}),height:i.number({integer:!0}),focalPoint:i.json(),mediaType:i.select({options:[`image`,`video`,`audio`,`document`,`other`],required:!0,indexed:!0})},behaviors:[n.auditable(),l(),a({fields:[`filename`,`fileKey`,`mimeType`,`title`,`alt`,`description`],fts:{fields:[`filename`,`title`,`alt`,`description`],language:`simple`},projection:e=>({id:e.id,label:e.title??e.filename,description:e.mimeType})})],admin:{list:{fields:[`title`,`mimeType`,`size`,`createdAt`],layouts:[`table`,`grid`]}},scope:`global`,access:{view:`public`,create:`group.editor`,update:`group.editor`,delete:`group.admin`}});export{u as n,d as t};
2
- //# sourceMappingURL=entity-DKFCdQJT.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"entity-DKFCdQJT.mjs","names":[],"sources":["../src/crop/focal-point-validation.ts","../src/entity.ts"],"sourcesContent":["/**\n * `focalPointValidation()` — the write guard on `media.focalPoint`.\n *\n * `field.json()` validates as the permissive JSON union on purpose, so without\n * this hook an editor's PATCH could park any shape in the column — a megabyte\n * string, a nested object — and every public page drawing the photograph would\n * carry it, because the projection hands the value to the render. CLAUDE.md's\n * \"validate at the boundary\", on the one column this package adds.\n *\n * Errors are `ZodError`s, so the admin API answers 400 with an issue pathed at\n * `focalPoint` rather than a 500 — the contract `menuEntityTypesValidation()`\n * already relies on.\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { isFocalPoint } from './focal-point.js'\n\n/**\n * `null` is a legitimate value — it means \"no focal point\", which renders as the\n * centre (D010). Anything else must be exactly `{x, y}` in `[0, 1]`.\n */\nfunction validateFocalPoint(data: Record<string, unknown>): Record<string, unknown> {\n // A payload without the key is a partial update that does not touch the\n // column. Defaulting it here would let a title-only PATCH reset the point.\n if (!('focalPoint' in data)) return data\n const value = data.focalPoint\n if (value === null || value === undefined || isFocalPoint(value)) return data\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['focalPoint'],\n message: 'focalPoint must be { x, y } with both between 0 and 1, or null.',\n },\n ])\n}\n\n/**\n * `updateMany` runs no per-row hooks, so `beforeUpdate` never sees a bulk write.\n * Refused rather than validated: `BulkUpdateInfo` is deliberately shape-only (it\n * names the columns, never the values), and \"every photograph has the same\n * subject position\" is not an intent anyone has. A migration is the tool for a\n * one-off backfill.\n */\nfunction assertNoBulkFocalPointWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('focalPoint')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['focalPoint'],\n message:\n `bulk update on '${info.entityName}' cannot write 'focalPoint': updateMany runs no ` +\n 'per-row hooks, so the value would bypass its shape check. Update each row individually.',\n },\n ])\n}\n\n/**\n * Contributes NO fields — the empty map `Record<never, never>`, not\n * `Record<string, never>`, which would merge into the entity's field map and\n * collapse every field to `never`.\n */\nexport function focalPointValidation(): Behavior<Record<never, never>, 'focalPointValidation'> {\n return {\n name: 'focalPointValidation',\n hooks: {\n beforeCreate: async (data) => validateFocalPoint(data),\n beforeUpdate: async (_id, data) => validateFocalPoint(data),\n },\n assertBulkUpdate: assertNoBulkFocalPointWrite,\n }\n}\n","/**\n * Pre-defined Media entity.\n *\n * Auto-registered by the media() plugin — users do NOT add this to their entities array.\n * Connected to toolkit_files via the fileKey field (stores the StorageClient file key).\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineConfig({\n * plugins: [storage(), media()],\n * // Media entity is auto-added — no need to list it here\n * })\n * ```\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport { focalPointValidation } from './crop/focal-point-validation.js'\n\nexport const Media = defineEntity({\n name: 'media',\n fields: {\n // --- Display metadata (editable, translatable) ---\n title: field.text({ translatable: true }),\n alt: field.text({ translatable: true }),\n description: field.text({ translatable: true }),\n\n // --- File linkage (set on upload, immutable in practice) ---\n /** Key into toolkit_files table (e.g., 'uploads/2026/02/uuid/photo.jpg') */\n fileKey: field.text({ required: true, indexed: true }),\n\n // --- File metadata (set on upload) ---\n filename: field.text({ required: true }),\n // Drawn as an icon in the admin list (a film for `video/mp4`), not the raw\n // string — declared on the field, so any list of media reads the same\n // (entity-table D008).\n mimeType: field.text({\n required: true,\n indexed: true,\n admin: { display: { type: 'mimeType' } },\n }),\n // Bytes, drawn as \"1.5 MB\" in the operator's locale in the admin list\n // (entity-table D008). Hidden by default — an unindexed number is — and one\n // click away in the View menu.\n size: field.number({\n required: true,\n integer: true,\n admin: { display: { type: 'filesize' } },\n }),\n\n // --- Image-specific metadata (client-measured on upload) ---\n width: field.number({ integer: true }),\n height: field.number({ integer: true }),\n /**\n * Where the subject is, as `{ x, y }` fractions of the image — never pixels,\n * so it survives the file being replaced at another resolution (images\n * D002). A shaped slot crops around it (`@murumets-ee/media/crop`).\n *\n * Nullable, and `null` means the centre: an absent point and `{0.5, 0.5}`\n * render identically, so no reader branches on a legacy row (D010). The\n * shape is enforced by `focalPointValidation()` below — `field.json()` on\n * its own accepts any JSON.\n */\n focalPoint: field.json(),\n\n // --- Classification (derived from mimeType on upload) ---\n mediaType: field.select({\n options: ['image', 'video', 'audio', 'document', 'other'] as const,\n required: true,\n indexed: true,\n }),\n },\n behaviors: [\n behavior.auditable(),\n focalPointValidation(),\n // filename / fileKey / mimeType live on the main table.\n // title/alt/description are translatable — their tsvector goes on\n // media_translations so non-default-locale callers still get FTS.\n searchable({\n fields: ['filename', 'fileKey', 'mimeType', 'title', 'alt', 'description'],\n fts: {\n fields: ['filename', 'title', 'alt', 'description'],\n language: 'simple',\n },\n projection: (row) => ({\n id: row.id as string,\n label: (row.title as string | undefined) ?? (row.filename as string),\n description: row.mimeType as string,\n }),\n }),\n ],\n admin: {\n list: {\n // Title first (its own row-link column), then the type — the icon\n // display already draws it, so the raw `mimeType` string is not a\n // second column — size and when it was added. `fileKey` (the raw\n // storage path) and `mediaType` (redundant with the icon) stay off by\n // default, one View-menu click away (entity-table PR06 §6).\n fields: ['title', 'mimeType', 'size', 'createdAt'],\n layouts: ['table', 'grid'],\n },\n },\n scope: 'global',\n access: {\n view: 'public',\n create: 'group.editor',\n update: 'group.editor',\n delete: 'group.admin',\n },\n})\n"],"mappings":"uNAsBA,SAAS,EAAmB,EAAwD,CAGlF,GAAI,EAAE,eAAgB,GAAO,OAAO,EACpC,IAAM,EAAQ,EAAK,WACnB,GAAI,GAAU,MAA+B,EAAa,CAAK,EAAG,OAAO,EACzE,MAAM,IAAI,EAAE,SAAS,CACnB,CACE,KAAM,SACN,KAAM,CAAC,YAAY,EACnB,QAAS,iEACX,CACF,CAAC,CACH,CASA,SAAS,EAA4B,EAA4B,CAC1D,KAAK,KAAK,SAAS,YAAY,EACpC,MAAM,IAAI,EAAE,SAAS,CACnB,CACE,KAAM,SACN,KAAM,CAAC,YAAY,EACnB,QACE,mBAAmB,EAAK,WAAW,wIAEvC,CACF,CAAC,CACH,CAOA,SAAgB,GAA+E,CAC7F,MAAO,CACL,KAAM,uBACN,MAAO,CACL,aAAc,KAAO,IAAS,EAAmB,CAAI,EACrD,aAAc,MAAO,EAAK,IAAS,EAAmB,CAAI,CAC5D,EACA,iBAAkB,CACpB,CACF,wBCnDA,MAAa,EAAQ,EAAa,CAChC,KAAM,QACN,OAAQ,CAEN,MAAO,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EACxC,IAAK,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EACtC,YAAa,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EAI9C,QAAS,EAAM,KAAK,CAAE,SAAU,GAAM,QAAS,EAAK,CAAC,EAGrD,SAAU,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAIvC,SAAU,EAAM,KAAK,CACnB,SAAU,GACV,QAAS,GACT,MAAO,CAAE,QAAS,CAAE,KAAM,UAAW,CAAE,CACzC,CAAC,EAID,KAAM,EAAM,OAAO,CACjB,SAAU,GACV,QAAS,GACT,MAAO,CAAE,QAAS,CAAE,KAAM,UAAW,CAAE,CACzC,CAAC,EAGD,MAAO,EAAM,OAAO,CAAE,QAAS,EAAK,CAAC,EACrC,OAAQ,EAAM,OAAO,CAAE,QAAS,EAAK,CAAC,EAWtC,WAAY,EAAM,KAAK,EAGvB,UAAW,EAAM,OAAO,CACtB,QAAS,CAAC,QAAS,QAAS,QAAS,WAAY,OAAO,EACxD,SAAU,GACV,QAAS,EACX,CAAC,CACH,EACA,UAAW,CACT,EAAS,UAAU,EACnB,EAAqB,EAIrB,EAAW,CACT,OAAQ,CAAC,WAAY,UAAW,WAAY,QAAS,MAAO,aAAa,EACzE,IAAK,CACH,OAAQ,CAAC,WAAY,QAAS,MAAO,aAAa,EAClD,SAAU,QACZ,EACA,WAAa,IAAS,CACpB,GAAI,EAAI,GACR,MAAQ,EAAI,OAAiC,EAAI,SACjD,YAAa,EAAI,QACnB,EACF,CAAC,CACH,EACA,MAAO,CACL,KAAM,CAMJ,OAAQ,CAAC,QAAS,WAAY,OAAQ,WAAW,EACjD,QAAS,CAAC,QAAS,MAAM,CAC3B,CACF,EACA,MAAO,SACP,OAAQ,CACN,KAAM,SACN,OAAQ,eACR,OAAQ,eACR,OAAQ,aACV,CACF,CAAC"}
@@ -1,2 +0,0 @@
1
- var e={PluginNav:{media:`Meedia`}};export{e as default};
2
- //# sourceMappingURL=et-BIxGYWVK.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"et-BIxGYWVK.mjs","names":[],"sources":["../src/messages/et.json"],"sourcesContent":[""],"mappings":""}
@@ -1,2 +0,0 @@
1
- import{z as e}from"zod";import{defineSettings as t,setting as n}from"@murumets-ee/settings/define";const r=e.object({width:e.number().int().positive().optional(),height:e.number().int().positive().optional(),fit:e.enum([`cover`,`contain`,`inside`,`outside`,`fill`]).optional(),format:e.enum([`webp`,`jpeg`,`png`,`avif`]).optional(),quality:e.number().int().min(1).max(100).optional()}).refine(e=>e.width!==void 0||e.height!==void 0,{message:`Style must have at least width or height`}),i=/^[a-z][a-z0-9_-]*$/,a=e.record(e.string(),r).superRefine((t,n)=>{for(let r of Object.keys(t))i.test(r)||n.addIssue({code:e.ZodIssueCode.custom,path:[r],message:`Invalid style name "${r}": must be lowercase alphanumeric (a-z, 0-9, -, _)`})}),o=t({namespace:`media.imageStyles`,scope:`global`,label:`Image Styles`,iconName:`image-down`,schema:{imageStyles:n.json({default:{thumbnail:{thumbnail:{width:200,height:200,fit:`cover`,format:`webp`,quality:80},card:{width:600,height:400,fit:`cover`,format:`webp`,quality:85},hero:{width:1920,height:800,fit:`cover`,format:`webp`,quality:85},full:{width:2400,fit:`inside`,format:`webp`,quality:90}}.thumbnail},label:`Image processing presets`,schema:a,renderer:`media.imageStyles`})}});export{o as imageStylesSettings};
2
- //# sourceMappingURL=image-styles-settings-DEnu-V2E.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"image-styles-settings-DEnu-V2E.mjs","names":[],"sources":["../src/image-styles-settings.ts"],"sourcesContent":["/**\n * Settings definition for media image styles.\n *\n * Stores the full Record<string, ImageStyle> as a single JSON key\n * in the toolkit_settings table under namespace 'media.imageStyles'.\n *\n * The media() plugin seeds this from config on first boot.\n * After that, the settings DB is the source of truth.\n */\n\nimport { defineSettings, setting } from '@murumets-ee/settings/define'\nimport { z } from 'zod'\nimport type { ImageStyle } from './types.js'\n\n/**\n * Zod schema for the `imageStyles` value (Record<styleName, ImageStyle>).\n *\n * Drives both server-side validation (settings PATCH route) and\n * client-side resolverless surfacing of field errors. The keys (style\n * names) are constrained at validation time via `.superRefine` because\n * Zod records do not support per-key regex.\n */\nconst styleSchema = z\n .object({\n width: z.number().int().positive().optional(),\n height: z.number().int().positive().optional(),\n fit: z.enum(['cover', 'contain', 'inside', 'outside', 'fill']).optional(),\n format: z.enum(['webp', 'jpeg', 'png', 'avif']).optional(),\n quality: z.number().int().min(1).max(100).optional(),\n })\n .refine((s) => s.width !== undefined || s.height !== undefined, {\n message: 'Style must have at least width or height',\n })\n\nconst STYLE_NAME_RE = /^[a-z][a-z0-9_-]*$/\n\nconst imageStylesSchema = z\n .record(z.string(), styleSchema)\n .superRefine((styles, ctx) => {\n for (const name of Object.keys(styles)) {\n if (!STYLE_NAME_RE.test(name)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [name],\n message: `Invalid style name \"${name}\": must be lowercase alphanumeric (a-z, 0-9, -, _)`,\n })\n }\n }\n })\n\n/**\n * Curated default image style presets for new projects.\n *\n * Consumers can register all of these via the media plugin config:\n * media({ imageStyles: defaultImageStyles })\n *\n * Or pick a subset:\n * media({ imageStyles: { thumbnail: defaultImageStyles.thumbnail } })\n *\n * The `imageStylesSettings` default below intentionally only includes\n * `thumbnail` — adding more here would auto-create them for every project\n * that uses the plugin without opting in.\n */\nexport const defaultImageStyles: Record<string, ImageStyle> = {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n card: { width: 600, height: 400, fit: 'cover', format: 'webp', quality: 85 },\n hero: { width: 1920, height: 800, fit: 'cover', format: 'webp', quality: 85 },\n full: { width: 2400, fit: 'inside', format: 'webp', quality: 90 },\n}\n\nexport const imageStylesSettings = defineSettings({\n namespace: 'media.imageStyles',\n scope: 'global',\n label: 'Image Styles',\n iconName: 'image-down',\n schema: {\n imageStyles: setting.json<Record<string, ImageStyle>>({\n default: {\n thumbnail: defaultImageStyles.thumbnail,\n },\n label: 'Image processing presets',\n schema: imageStylesSchema,\n renderer: 'media.imageStyles',\n }),\n },\n})\n"],"mappings":"mGAsBA,MAAM,EAAc,EACjB,OAAO,CACN,MAAO,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS,EAC5C,OAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS,EAC7C,IAAK,EAAE,KAAK,CAAC,QAAS,UAAW,SAAU,UAAW,MAAM,CAAC,CAAC,CAAC,SAAS,EACxE,OAAQ,EAAE,KAAK,CAAC,OAAQ,OAAQ,MAAO,MAAM,CAAC,CAAC,CAAC,SAAS,EACzD,QAAS,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,SAAS,CACrD,CAAC,CAAC,CACD,OAAQ,GAAM,EAAE,QAAU,IAAA,IAAa,EAAE,SAAW,IAAA,GAAW,CAC9D,QAAS,0CACX,CAAC,EAEG,EAAgB,qBAEhB,EAAoB,EACvB,OAAO,EAAE,OAAO,EAAG,CAAW,CAAC,CAC/B,aAAa,EAAQ,IAAQ,CAC5B,IAAK,IAAM,KAAQ,OAAO,KAAK,CAAM,EAC9B,EAAc,KAAK,CAAI,GAC1B,EAAI,SAAS,CACX,KAAM,EAAE,aAAa,OACrB,KAAM,CAAC,CAAI,EACX,QAAS,uBAAuB,EAAK,mDACvC,CAAC,CAGP,CAAC,EAsBU,EAAsB,EAAe,CAChD,UAAW,oBACX,MAAO,SACP,MAAO,eACP,SAAU,aACV,OAAQ,CACN,YAAa,EAAQ,KAAiC,CACpD,QAAS,CACP,UAAW,CAdjB,UAAW,CAAE,MAAO,IAAK,OAAQ,IAAK,IAAK,QAAS,OAAQ,OAAQ,QAAS,EAAG,EAChF,KAAM,CAAE,MAAO,IAAK,OAAQ,IAAK,IAAK,QAAS,OAAQ,OAAQ,QAAS,EAAG,EAC3E,KAAM,CAAE,MAAO,KAAM,OAAQ,IAAK,IAAK,QAAS,OAAQ,OAAQ,QAAS,EAAG,EAC5E,KAAM,CAAE,MAAO,KAAM,IAAK,SAAU,OAAQ,OAAQ,QAAS,EAAG,CAW/C,EAAmB,SAChC,EACA,MAAO,2BACP,OAAQ,EACR,SAAU,mBACZ,CAAC,CACH,CACF,CAAC"}
@@ -1,2 +0,0 @@
1
- import{n as e}from"./routes-C7Fx-055.mjs";import"./entity-CNxAERYG.mjs";import"./image-styles-settings-DEnu-V2E.mjs";import{getPluginConfig as t,hasPluginConfig as n}from"@murumets-ee/core";import"@murumets-ee/media/image-styles";function r(){if(!n(`@murumets-ee/media`))throw Error(`@murumets-ee/media plugin not initialized. Add media() to your plugins array.`);return t(e)}export{r as getMediaConfig};
2
- //# sourceMappingURL=plugin-B_ZtfiAc.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"plugin-B_ZtfiAc.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Media plugin — auto-registers the Media entity, mounts admin routes, and\n * contributes sidebar + default-route scaffolding for the admin shell.\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * storage(),\n * media({ maxUploadSize: 10 * 1024 * 1024 }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin, getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\n// Self-reference the `./image-styles` subpath (not the internal source\n// files) so the plugin bundle externalizes the React components instead\n// of inlining them — the subpath is built separately with a `'use client'`\n// banner, and Next.js needs to see that boundary preserved.\nimport { ImageStylesManager, RegenerateVariantsAction } from '@murumets-ee/media/image-styles'\nimport { mediaRoutes } from './admin/routes.js'\nimport { Media } from './entity.js'\n// Relative: `src/i18n.ts` is a sibling entry of this one in the same tsdown\n// config, so the loader lands in a shared chunk rather than being\n// duplicated, and its `import('./messages/et.json')` resolves against\n// `dist/`, which this package's build script populates.\nimport { getMediaMessages } from './i18n.js'\nimport { imageStylesSettings } from './image-styles-settings.js'\nimport { MEDIA_PLUGIN_NAME, resolveMediaConfig } from './media-config.js'\nimport type { MediaPluginConfig } from './types.js'\n\n/**\n * Get the resolved media plugin configuration.\n * Throws if plugin not registered.\n *\n * Reads the framework's `PluginConfigRegistry`, which the merge step fills\n * from `server.config` below before any plugin `init` runs.\n */\nexport function getMediaConfig(): 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\n/**\n * Media plugin factory.\n *\n * - Registers the Media entity\n * - Mounts the media admin API routes\n * - Contributes sidebar + default-route metadata\n * - Declares @murumets-ee/storage + @murumets-ee/settings as required deps\n * (framework-validated before any plugin init runs — see `requires` field below)\n * - Declares its resolved configuration (`server.config`) for `getMediaConfig()`\n * and the upload route\n */\nexport function media(config?: MediaPluginConfig) {\n const resolvedConfig = resolveMediaConfig(config)\n\n return definePlugin({\n name: '@murumets-ee/media',\n // Declarative dep validation — replaces the per-plugin\n // `if (!app.plugins.has(...)) throw` loop that used to live in\n // `init`. The framework validates BEFORE any init runs, so a\n // missing dep surfaces immediately with the full punchlist\n // instead of throwing partway through boot.\n requires: ['@murumets-ee/storage', '@murumets-ee/settings'],\n shared: {\n // The interface-message catalogue, declared rather than left for each\n // app to remember (commerce-launch F148/F155). The framework gathers\n // it at the merge step; nothing loads a locale until one is asked for.\n messages: getMediaMessages,\n // Self-contributes the media.imageStyles namespace. The merge engine\n // auto-derives the permission resource (`settings_media.imageStyles`)\n // and the sidebar entry under the \"Settings\" group. The app's\n // admin-api-handler call site aggregates plugin-contributed\n // namespaces into `settingsRoutes(...)` (see apps/admin's\n // `getAllSettings()` for the canonical pattern); apps that follow\n // the scaffold pattern get this for free.\n // Drop the `as PluginSettingsDefinition` cast — it would widen the\n // namespace literal (e.g. `'media.imageStyles'`) to `string`,\n // leaking `settings_${string}:view|update` into the resolved\n // permission union via `<const N>` capture loss.\n // `imageStylesSettings` is structurally a superset of\n // `PluginSettingsDefinition` (full vs. narrow `SettingsDefinition`),\n // which TS accepts directly. The `readonly` outer tuple comes from\n // the parent definePlugin's `<const S>` capture.\n settings: [imageStylesSettings],\n },\n server: {\n entities: [Media],\n routes: [...mediaRoutes()],\n // Declarative config — registered at the merge step, before any init,\n // so the upload route can enforce `acceptedTypes` / `maxUploadSize`\n // (F007) instead of hardcoding a limit it could not trust to be set.\n config: resolvedConfig,\n init: async (app) => {\n // Image style defaults live in the plugin config and are exposed via\n // `getMediaConfig()`. Persistent overrides go to the settings DB\n // through the generic settings API at `/api/admin/settings/media.imageStyles`\n // — see `image-styles-settings.ts`. `resolveImageStyles` reads\n // DB-first, config-fallback.\n //\n // We deliberately do NOT seed the DB here. Init must stay lightweight:\n // it runs in every context (Next.js server, Next.js build, CLI commands).\n // The prior eager-seed path pulled in `@murumets-ee/settings`'s main entry —\n // which carries `import 'server-only'` — and crashed non-RSC Node bootstrap.\n\n app.logger.info(\n {\n acceptedTypes: resolvedConfig.acceptedTypes,\n maxUploadSize: resolvedConfig.maxUploadSize,\n defaultVisibility: resolvedConfig.defaultVisibility,\n },\n 'Media plugin initialized',\n )\n },\n },\n adminUi: {\n sidebar: [\n {\n id: 'media',\n group: 'Library',\n label: 'Media',\n href: '/admin/media',\n iconName: 'image',\n },\n ],\n defaultRoutes: [\n // Routing only — sidebar nav comes from `adminUi.sidebar` above\n // (admin-page-routing SD003).\n {\n path: 'media',\n factory: 'MediaListPage',\n },\n {\n path: 'media/[id]',\n factory: 'MediaEditPage',\n },\n ],\n // Self-contributes the rich image-styles editor and the\n // \"Regenerate All Variants\" action. Apps don't have to wire either\n // — they appear automatically on /admin/settings/media.imageStyles\n // (route + sidebar entry auto-derived from `shared.settings`).\n settingRenderers: {\n 'media.imageStyles': ImageStylesManager,\n },\n settingsActions: {\n 'media.imageStyles': RegenerateVariantsAction,\n },\n },\n })\n}\n"],"mappings":"sOAyCA,SAAgB,GAA8C,CAC5D,GAAI,CAAC,EAAA,oBAAiC,EACpC,MAAU,MAAM,+EAA+E,EAEjG,OAAO,EAAgB,CAAiB,CAC1C"}
@@ -1,2 +0,0 @@
1
- import e from"sharp";const t=new Set([`image/svg+xml`,`image/gif`]);function n(e){return e.startsWith(`image/`)&&!t.has(e)}async function r(t,n){let{width:r,height:i}=a(await e(t).metadata()),o=new Map,s=Object.entries(n);return await Promise.all(s.map(async([n,r])=>{let i=r.format??`webp`,a=r.quality??80,s=r.fit??`cover`,{data:c,info:l}=await e(t).rotate().resize({width:r.width,height:r.height,fit:s,withoutEnlargement:!0})[i]({quality:a}).toBuffer({resolveWithObject:!0});o.set(n,{buffer:c,format:i,mimeType:`image/${i}`,width:l.width,height:l.height})})),{width:r,height:i,variants:o}}async function i(t){return a(await e(t).metadata())}function a(e){return{width:e.autoOrient?.width??e.width??0,height:e.autoOrient?.height??e.height??0}}async function o(t){let{data:n,info:r}=await e(t).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 u=await e(n,{raw:{width:i,height:a,channels:o}}).flop().flip().raw().toBuffer(),[d,f]=await Promise.all([s(n,i,a,o),s(u,i,a,o)]);if(d===null||f===null)return null;let p=c(d.x,1-f.x),m=c(d.y,1-f.y);return p===null&&m===null?null:{x:l(p??.5),y:l(m??.5)}}async function s(t,n,r,i){let a=n>=r,{info:o}=await e(t,{raw:{width:n,height:r,channels:i}}).resize(a?n-1:n,a?r:r-1,{fit:`cover`,position:e.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 c(e,t){return Math.abs(e-t)<=.25?(e+t)/2:null}function l(e){return Number.isFinite(e)?Math.min(Math.max(Math.round(e*1e4)/1e4,0),1):.5}export{r as i,i as n,n as r,o as t};
2
- //# sourceMappingURL=process-image-WByEHTXQ.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"process-image-WByEHTXQ.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 { ImageStyle } from './types.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/**\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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"}
@@ -1,2 +0,0 @@
1
- import{i as e,r as t}from"./process-image-WByEHTXQ.mjs";import{t as n}from"./variant-key-JBTJXPL1.mjs";import"server-only";async function r(r){let{app:i,storage:a,logger:o,styles:s}=r,c=i.db.readWrite,{AdminClient:l}=await import(`@murumets-ee/entity/admin`),{Media:u}=await import(`./entity-DKFCdQJT.mjs`).then(e=>e.n),{schemaRegistry:d}=await import(`@murumets-ee/db`),{eq:f}=await import(`drizzle-orm`),p=new l({entity:u,db:c,logger:o,contextResolver:r.contextResolver}),m=d.get(`media`);if(!m)throw Error(`Media schema not registered`);let h={total:0,processed:0,skipped:0,errors:0},g=0;for(o?.info({styles:Object.keys(s)},`Starting variant regeneration`);;){let r=await p.findMany({where:f(m.mediaType,`image`),limit:100,offset:g});if(r.length===0)break;h.total+=r.length;for(let i of r)try{if(!t(i.mimeType)){h.skipped++;continue}let r=await a.download(i.fileKey),c;if(Buffer.isBuffer(r.body))c=r.body;else{let e=[],t=r.body.getReader();for(;;){let{done:n,value:r}=await t.read();if(n)break;r&&e.push(r)}c=Buffer.concat(e)}let l=await e(c,s),u=await a.getMetadata(i.fileKey),d=u?.metadata?.variants;d&&await Promise.all(Object.values(d).map(e=>a.delete(e).catch(()=>{})));let f={},p=u?.visibility??`public`,m=await Promise.all([...l.variants].map(async([e,t])=>{let r=n(i.fileKey,e,t.format);try{return await a.upload(t.buffer,{key:r,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:p,metadata:{variantOf:i.fileKey,style:e}}),{styleName:e,vKey:r,ok:!0}}catch(t){return o?.warn({style:e,key:r,error:t},`Failed to upload regenerated variant (non-fatal)`),{styleName:e,vKey:r,ok:!1}}}));for(let e of m)e.ok&&(f[e.styleName]=e.vKey);Object.keys(f).length>0&&await a.updateMetadata(i.fileKey,{metadata:{...u?.metadata??{},variants:f}}).catch(e=>{o?.warn({key:i.fileKey,error:e},`Failed to update variant metadata (non-fatal)`)}),h.processed++,o?.debug({id:i.id,variants:Object.keys(f)},`Regenerated variants`)}catch(e){h.errors++,o?.error({id:i.id,fileKey:i.fileKey,error:e},`Failed to regenerate variants for media record`)}if(g+=100,r.length<100)break}return o?.info(h,`Variant regeneration complete`),h}export{r as regenerateAllVariants};
2
- //# sourceMappingURL=regenerate-variants-2Lmo5kf8.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"regenerate-variants-2Lmo5kf8.mjs","names":[],"sources":["../src/regenerate-variants.ts"],"sourcesContent":["/**\n * Batch variant regeneration — reprocesses all image media with updated styles.\n *\n * Downloads each original from storage, generates new variants via Sharp,\n * cleans up old variant files, and uploads new ones.\n *\n * Per-image errors are logged but don't stop the batch.\n */\n\nimport 'server-only'\n\nimport type { Logger, ToolkitApp } from '@murumets-ee/core'\nimport type { ContextResolver } from '@murumets-ee/entity'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { isProcessableImage, processImage } from './process-image.js'\nimport type { ImageStyle } from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\nconst BATCH_SIZE = 100\n\nexport interface RegenerateOptions {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n storage: StorageClient\n logger?: Logger\n /** Current image styles to generate */\n styles: Record<string, ImageStyle>\n /** Security context resolver — passed through to AdminClient. */\n contextResolver?: ContextResolver\n}\n\nexport interface RegenerateResult {\n /** Total image media records found */\n total: number\n /** Successfully reprocessed */\n processed: number\n /** Skipped (non-processable mimeType, download failed, etc.) */\n skipped: number\n /** Failed with errors */\n errors: number\n}\n\n/**\n * Regenerate variants for all image media.\n * Processes in batches of 100 to avoid memory pressure.\n */\nexport async function regenerateAllVariants(options: RegenerateOptions): Promise<RegenerateResult> {\n const { app, storage, logger, styles } = options\n const db = app.db.readWrite\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n const { Media } = await import('./entity.js')\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { eq } = await import('drizzle-orm')\n\n const admin = new AdminClient<typeof Media.allFields>({\n entity: Media,\n db,\n logger,\n contextResolver: options.contextResolver,\n })\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered')\n\n const result: RegenerateResult = { total: 0, processed: 0, skipped: 0, errors: 0 }\n let offset = 0\n\n logger?.info({ styles: Object.keys(styles) }, 'Starting variant regeneration')\n\n // Process in batches\n while (true) {\n const batch = await admin.findMany({\n where: eq(table.mediaType, 'image'),\n limit: BATCH_SIZE,\n offset,\n })\n\n if (batch.length === 0) break\n result.total += batch.length\n\n for (const record of batch) {\n try {\n // Skip non-processable images (SVG, GIF)\n if (!isProcessableImage(record.mimeType)) {\n result.skipped++\n continue\n }\n\n // Download original from storage\n const downloaded = await storage.download(record.fileKey)\n let buffer: Buffer\n if (Buffer.isBuffer(downloaded.body)) {\n buffer = downloaded.body\n } else {\n // ReadableStream → Buffer\n const chunks: Uint8Array[] = []\n const reader = downloaded.body.getReader()\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (value) chunks.push(value)\n }\n buffer = Buffer.concat(chunks)\n }\n\n // Generate new variants\n const processed = await processImage(buffer, styles)\n\n // Delete old variant files (best-effort, parallel)\n const oldFileRecord = await storage.getMetadata(record.fileKey)\n const oldVariants = (oldFileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n if (oldVariants) {\n await Promise.all(\n Object.values(oldVariants).map((vKey) => storage.delete(vKey).catch(() => {})),\n )\n }\n\n // Upload new variants (parallel)\n const newVariantKeys: Record<string, string> = {}\n const visibility = oldFileRecord?.visibility ?? 'public'\n const uploadResults = await Promise.all(\n [...processed.variants].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(record.fileKey, styleName, variant.format)\n try {\n await storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${record.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: record.fileKey, style: styleName },\n })\n return { styleName, vKey, ok: true as const }\n } catch (uploadErr) {\n logger?.warn(\n { style: styleName, key: vKey, error: uploadErr },\n 'Failed to upload regenerated variant (non-fatal)',\n )\n return { styleName, vKey, ok: false as const }\n }\n }),\n )\n for (const r of uploadResults) {\n if (r.ok) newVariantKeys[r.styleName] = r.vKey\n }\n\n // Update original file's metadata with new variant keys\n if (Object.keys(newVariantKeys).length > 0) {\n await storage\n .updateMetadata(record.fileKey, {\n metadata: {\n ...(oldFileRecord?.metadata ?? {}),\n variants: newVariantKeys,\n },\n })\n .catch((metaErr: unknown) => {\n logger?.warn(\n { key: record.fileKey, error: metaErr },\n 'Failed to update variant metadata (non-fatal)',\n )\n })\n }\n\n result.processed++\n logger?.debug(\n { id: record.id, variants: Object.keys(newVariantKeys) },\n 'Regenerated variants',\n )\n } catch (err) {\n result.errors++\n logger?.error(\n { id: record.id, fileKey: record.fileKey, error: err },\n 'Failed to regenerate variants for media record',\n )\n }\n }\n\n offset += BATCH_SIZE\n if (batch.length < BATCH_SIZE) break\n }\n\n logger?.info(result, 'Variant regeneration complete')\n return result\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- import{i as e,r as t,t as n}from"./variant-key-RKke2RfS.mjs";import"server-only";async function r(r){let{app:i,storage:a,logger:o,styles:s}=r,c=i.db.readWrite,{AdminClient:l}=await import(`@murumets-ee/entity/admin`),{Media:u}=await import(`./entity-CNxAERYG.mjs`),{schemaRegistry:d}=await import(`@murumets-ee/db`),{eq:f}=await import(`drizzle-orm`),p=new l({entity:u,db:c,logger:o,contextResolver:r.contextResolver}),m=d.get(`media`);if(!m)throw Error(`Media schema not registered`);let h={total:0,processed:0,skipped:0,errors:0},g=0;for(o?.info({styles:Object.keys(s)},`Starting variant regeneration`);;){let r=await p.findMany({where:f(m.mediaType,`image`),limit:100,offset:g});if(r.length===0)break;h.total+=r.length;for(let i of r)try{if(!t(i.mimeType)){h.skipped++;continue}let r=await a.download(i.fileKey),c;if(Buffer.isBuffer(r.body))c=r.body;else{let e=[],t=r.body.getReader();for(;;){let{done:n,value:r}=await t.read();if(n)break;r&&e.push(r)}c=Buffer.concat(e)}let l=await e(c,s),u=await a.getMetadata(i.fileKey),d=u?.metadata?.variants;d&&await Promise.all(Object.values(d).map(e=>a.delete(e).catch(()=>{})));let f={},p=u?.visibility??`public`,m=await Promise.all([...l.variants].map(async([e,t])=>{let r=n(i.fileKey,e,t.format);try{return await a.upload(t.buffer,{key:r,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:p,metadata:{variantOf:i.fileKey,style:e}}),{styleName:e,vKey:r,ok:!0}}catch(t){return o?.warn({style:e,key:r,error:t},`Failed to upload regenerated variant (non-fatal)`),{styleName:e,vKey:r,ok:!1}}}));for(let e of m)e.ok&&(f[e.styleName]=e.vKey);Object.keys(f).length>0&&await a.updateMetadata(i.fileKey,{metadata:{...u?.metadata??{},variants:f}}).catch(e=>{o?.warn({key:i.fileKey,error:e},`Failed to update variant metadata (non-fatal)`)}),h.processed++,o?.debug({id:i.id,variants:Object.keys(f)},`Regenerated variants`)}catch(e){h.errors++,o?.error({id:i.id,fileKey:i.fileKey,error:e},`Failed to regenerate variants for media record`)}if(g+=100,r.length<100)break}return o?.info(h,`Variant regeneration complete`),h}export{r as regenerateAllVariants};
2
- //# sourceMappingURL=regenerate-variants-B9AXXN6N.mjs.map