@murumets-ee/media 0.74.1 → 0.75.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 (65) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/backfill-BpcWvL5P.mjs +2 -0
  4. package/dist/backfill-BpcWvL5P.mjs.map +1 -0
  5. package/dist/blur-data-url-EtSndBso.mjs +2 -0
  6. package/dist/blur-data-url-EtSndBso.mjs.map +1 -0
  7. package/dist/client.d.mts +3 -2
  8. package/dist/client.d.mts.map +1 -1
  9. package/dist/client.mjs +1 -1
  10. package/dist/client.mjs.map +1 -1
  11. package/dist/crop-editor.d.mts +1 -1
  12. package/dist/crop.d.mts +53 -1
  13. package/dist/crop.d.mts.map +1 -0
  14. package/dist/crop.mjs +1 -1
  15. package/dist/{deps-DaGjHO16.mjs → deps-BMOCedBq.mjs} +2 -2
  16. package/dist/{deps-DaGjHO16.mjs.map → deps-BMOCedBq.mjs.map} +1 -1
  17. package/dist/entity-CSrPWVJu.mjs +2 -0
  18. package/dist/entity-CSrPWVJu.mjs.map +1 -0
  19. package/dist/generate-variants-CJ1oDbdp.mjs +2 -0
  20. package/dist/generate-variants-CJ1oDbdp.mjs.map +1 -0
  21. package/dist/image-styles-settings.d.mts +1 -1
  22. package/dist/image-styles.d.mts +1 -1
  23. package/dist/index.d.mts +3 -3
  24. package/dist/index.mjs +1 -1
  25. package/dist/picker.d.mts +1 -1
  26. package/dist/plugin.d.mts +1 -1
  27. package/dist/plugin.mjs +1 -1
  28. package/dist/process-image-Cwa8LlGb.mjs +2 -0
  29. package/dist/process-image-Cwa8LlGb.mjs.map +1 -0
  30. package/dist/processing.d.mts +2 -2
  31. package/dist/processing.d.mts.map +1 -1
  32. package/dist/processing.mjs +1 -1
  33. package/dist/public-resolver.d.mts +15 -0
  34. package/dist/public-resolver.d.mts.map +1 -1
  35. package/dist/public-resolver.mjs +1 -1
  36. package/dist/public-resolver.mjs.map +1 -1
  37. package/dist/query-client.d.mts +1 -1
  38. package/dist/query-client.mjs +1 -1
  39. package/dist/{regenerate-variants-RwV_AQSo.mjs → regenerate-variants-BpCAq5U5.mjs} +2 -2
  40. package/dist/{regenerate-variants-RwV_AQSo.mjs.map → regenerate-variants-BpCAq5U5.mjs.map} +1 -1
  41. package/dist/{register-CJKjNl2w.mjs → register-CJAkO7a2.mjs} +2 -2
  42. package/dist/{register-CJKjNl2w.mjs.map → register-CJAkO7a2.mjs.map} +1 -1
  43. package/dist/routes-Dk_oVBmE.mjs +2 -0
  44. package/dist/routes-Dk_oVBmE.mjs.map +1 -0
  45. package/dist/{schedule-JlvfUEVA.mjs → schedule-BIcDoT1d.mjs} +2 -2
  46. package/dist/{schedule-JlvfUEVA.mjs.map → schedule-BIcDoT1d.mjs.map} +1 -1
  47. package/dist/{shapes-C51rXSDJ.d.mts → shapes-BQH9_3h8.d.mts} +2 -2
  48. package/dist/{shapes-C51rXSDJ.d.mts.map → shapes-BQH9_3h8.d.mts.map} +1 -1
  49. package/dist/{types-Lj3SkZQ_.d.mts → types-Ba76KK9j.d.mts} +7 -1
  50. package/dist/types-Ba76KK9j.d.mts.map +1 -0
  51. package/dist/{types-6PZQ4Zq6.d.mts → types-lobQhTow.d.mts} +16 -2
  52. package/dist/types-lobQhTow.d.mts.map +1 -0
  53. package/package.json +9 -9
  54. package/dist/backfill-B1StTnxg.mjs +0 -2
  55. package/dist/backfill-B1StTnxg.mjs.map +0 -1
  56. package/dist/entity-CsDdjKz6.mjs +0 -2
  57. package/dist/entity-CsDdjKz6.mjs.map +0 -1
  58. package/dist/generate-variants-Dpy4aT2o.mjs +0 -2
  59. package/dist/generate-variants-Dpy4aT2o.mjs.map +0 -1
  60. package/dist/process-image-wQwiRhyk.mjs +0 -2
  61. package/dist/process-image-wQwiRhyk.mjs.map +0 -1
  62. package/dist/routes-DaE0M8h8.mjs +0 -2
  63. package/dist/routes-DaE0M8h8.mjs.map +0 -1
  64. package/dist/types-6PZQ4Zq6.d.mts.map +0 -1
  65. package/dist/types-Lj3SkZQ_.d.mts.map +0 -1
package/dist/picker.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as MediaPickerMode, i as MediaPickerListResult, n as MediaPickerClassNames, o as MediaPickerProps, r as MediaPickerItem, t as MediaPickerCallbacks } from "./types-Lj3SkZQ_.mjs";
1
+ import { a as MediaPickerMode, i as MediaPickerListResult, n as MediaPickerClassNames, o as MediaPickerProps, r as MediaPickerItem, t as MediaPickerCallbacks } from "./types-Ba76KK9j.mjs";
2
2
  import { LucideIcon } from "lucide-react";
3
3
  import { ReactNode } from "react";
4
4
 
package/dist/plugin.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ImageVocabulary, c as MediaPluginConfig, i as ImageStyle } from "./types-6PZQ4Zq6.mjs";
1
+ import { a as ImageVocabulary, c as MediaPluginConfig, i as ImageStyle } from "./types-lobQhTow.mjs";
2
2
  import { getMediaMessages } from "./i18n.mjs";
3
3
 
4
4
  //#region src/plugin.d.ts
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{t as e}from"./entity-CsDdjKz6.mjs";import{imageStylesSettings as t}from"./image-styles-settings.mjs";import{n,r}from"./media-config-DkbnXIjC.mjs";import{t as i}from"./routes-DaE0M8h8.mjs";import{getMediaMessages as a}from"./i18n.mjs";import{definePlugin as o}from"@murumets-ee/core";import{ImageStylesManager as s,ImageVocabularyEditor as c,RegenerateVariantsAction as l}from"@murumets-ee/media/image-styles";function u(){return n()}function d(n){let u=r(n);return o({name:`@murumets-ee/media`,requires:[`@murumets-ee/storage`,`@murumets-ee/settings`],shared:{messages:a,settings:[t]},server:{entities:[e],routes:[...i()],config:u,init:async e=>{try{let{registerMediaJobs:t}=await import(`./register-CJKjNl2w.mjs`);await t(e)}catch(t){e.logger.warn({err:t},`media: could not register the variant jobs — uploads will generate variants inline`)}e.logger.info({acceptedTypes:u.acceptedTypes,maxUploadSize:u.maxUploadSize,defaultVisibility:u.defaultVisibility},`Media plugin initialized`)}},adminUi:{sidebar:[{id:`media`,group:`Library`,label:`Media`,href:`/admin/media`,iconName:`image`}],defaultRoutes:[{path:`media`,factory:`MediaListPage`},{path:`media/[id]`,factory:`MediaEditPage`}],settingRenderers:{"media.imageStyles":s,"media.imageVocabulary":c},settingsActions:{"media.imageStyles":l}}})}export{u as getMediaConfig,d as media};
1
+ import{t as e}from"./entity-CSrPWVJu.mjs";import{imageStylesSettings as t}from"./image-styles-settings.mjs";import{n,r}from"./media-config-DkbnXIjC.mjs";import{t as i}from"./routes-Dk_oVBmE.mjs";import{getMediaMessages as a}from"./i18n.mjs";import{definePlugin as o}from"@murumets-ee/core";import{ImageStylesManager as s,ImageVocabularyEditor as c,RegenerateVariantsAction as l}from"@murumets-ee/media/image-styles";function u(){return n()}function d(n){let u=r(n);return o({name:`@murumets-ee/media`,requires:[`@murumets-ee/storage`,`@murumets-ee/settings`],shared:{messages:a,settings:[t]},server:{entities:[e],routes:[...i()],config:u,init:async e=>{try{let{registerMediaJobs:t}=await import(`./register-CJAkO7a2.mjs`);await t(e)}catch(t){e.logger.warn({err:t},`media: could not register the variant jobs — uploads will generate variants inline`)}e.logger.info({acceptedTypes:u.acceptedTypes,maxUploadSize:u.maxUploadSize,defaultVisibility:u.defaultVisibility},`Media plugin initialized`)}},adminUi:{sidebar:[{id:`media`,group:`Library`,label:`Media`,href:`/admin/media`,iconName:`image`}],defaultRoutes:[{path:`media`,factory:`MediaListPage`},{path:`media/[id]`,factory:`MediaEditPage`}],settingRenderers:{"media.imageStyles":s,"media.imageVocabulary":c},settingsActions:{"media.imageStyles":l}}})}export{u as getMediaConfig,d as media};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -0,0 +1,2 @@
1
+ import{n as e}from"./blur-data-url-EtSndBso.mjs";import{n as t}from"./variant-plan-86tJKtVi.mjs";import n from"sharp";const r=new Set([`image/svg+xml`,`image/gif`]);function i(e){return e.startsWith(`image/`)&&!r.has(e)}async function a(e,t){let{width:r,height:i}=d(await n(e).metadata()),a=new Map,o=Object.entries(t);return await Promise.all(o.map(async([t,r])=>{let i=r.format??`webp`,o=r.quality??80,s=r.fit??`cover`,{data:c,info:l}=await n(e).rotate().resize({width:r.width,height:r.height,fit:s,withoutEnlargement:!0})[i]({quality:o}).toBuffer({resolveWithObject:!0});a.set(t,{buffer:c,format:i,mimeType:`image/${i}`,width:l.width,height:l.height})})),{width:r,height:i,variants:a}}async function o(e,r,i){let{width:a,height:o}=d(await n(e).metadata()),c=t({width:a,height:o},r,i);return{width:a,height:o,variants:await s(e,c.variants),dropped:c.dropped}}async function s(e,t){let n=new Map;return l(t,2,async t=>{let r=JSON.stringify([t.extract,t.width,t.height,t.quality,t.format]),i=n.get(r);return i||(i=c(e,t),n.set(r,i)),{...await i,shape:t.shape,plannedWidth:t.width}})}async function c(e,t){let{data:r,info:i}=await n(e).rotate().extract(t.extract).resize({width:t.width,height:t.height,fit:`cover`,withoutEnlargement:!0})[t.format]({quality:t.quality}).toBuffer({resolveWithObject:!0});return{buffer:r,format:t.format,mimeType:`image/${t.format}`,width:i.width,height:i.height,shape:t.shape,plannedWidth:t.width,fingerprint:t.fingerprint}}async function l(e,t,n){let r=Array(e.length),i=e.map((e,t)=>({item:e,index:t})),a=!1;return await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=i.shift();e!==void 0&&!a;e=i.shift())try{r[e.index]=await n(e.item)}catch(e){throw a=!0,e}})),r}async function u(e){return d(await n(e).metadata())}function d(e){return{width:e.autoOrient?.width??e.width??0,height:e.autoOrient?.height??e.height??0}}async function f(e){let{data:t,info:r}=await n(e).rotate().resize(512,512,{fit:`inside`,withoutEnlargement:!0}).raw().toBuffer({resolveWithObject:!0}),{width:i,height:a,channels:o}=r;if(i<8||a<8)return null;let s=await n(t,{raw:{width:i,height:a,channels:o}}).flop().flip().raw().toBuffer(),[c,l]=await Promise.all([p(t,i,a,o),p(s,i,a,o)]);if(c===null||l===null)return null;let u=m(c.x,1-l.x),d=m(c.y,1-l.y);return u===null&&d===null?null:{x:h(u??.5),y:h(d??.5)}}async function p(e,t,r,i){let a=t>=r,{info:o}=await n(e,{raw:{width:t,height:r,channels:i}}).resize(a?t-1:t,a?r:r-1,{fit:`cover`,position:n.strategy.attention}).toBuffer({resolveWithObject:!0}),{attentionX:s,attentionY:c}=o;return s===void 0||c===void 0?null:{x:s/t,y:c/r}}function m(e,t){return Math.abs(e-t)<=.25?(e+t)/2:null}function h(e){return Number.isFinite(e)?Math.min(Math.max(Math.round(e*1e4)/1e4,0),1):.5}async function g(t){if(await _(t))return``;let{data:r,info:i}=await n(t).rotate().resize(32,32,{fit:`inside`,withoutEnlargement:!0}).raw({depth:`uchar`}).toBuffer({resolveWithObject:!0}),{width:a,height:o,channels:s}=i;if(a<1||o<1)return``;let c=`data:image/webp;base64,${(await n(r,{raw:{width:a,height:o,channels:s}}).removeAlpha().blur(1.5).webp({quality:50,effort:6}).toBuffer()).toString(`base64`)}`;return e(c)?c:``}async function _(e){if(!(await n(e).metadata()).hasAlpha)return!1;let t=await n(e).extractChannel(`alpha`).raw().toBuffer();for(let e=0;e<t.length;e+=v.length){let n=Math.min(e+v.length,t.length);if(t.compare(v,0,n-e,e,n)!==0)return!0}return!1}const v=Buffer.alloc(64*1024,255);export{i as a,s as c,u as i,g as n,a as o,o as r,c as s,f as t};
2
+ //# sourceMappingURL=process-image-Cwa8LlGb.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process-image-Cwa8LlGb.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 { isBlurDataUrl } from './crop/blur-data-url.js'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport type { VariantConfig } from './shapes.js'\nimport type { ImageStyle } from './types.js'\nimport { type PlannedVariant, planVariants, type VariantPlanInput } from './variant-plan.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n // `.rotate()` first: without it an EXIF-rotated phone photo's variant is\n // written SIDEWAYS — the output format drops the orientation tag — while\n // the row's width/height (`displayDimensions`) and its focal point are in\n // the orientation a browser draws. Keys are unchanged; only the pixels\n // of a rotated original change, and they change to what was intended.\n const resized = sharp(buffer).rotate().resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/** One generated (shape × rung) variant. */\nexport interface ShapedVariant extends ProcessedVariant {\n readonly shape: string\n /** The width the plan ASKED for. It names the key; it is not a claim about the file. */\n readonly plannedWidth: number\n /** Goes into the key — see `deriveShapedVariantKey`. */\n readonly fingerprint: string\n}\n\n/** A photograph's shaped variants. */\nexport interface ShapedImage {\n /** The image as a browser draws it (post-EXIF orientation). */\n width: number\n height: number\n variants: ShapedVariant[]\n /** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`) — for the caller to log. */\n dropped: number\n}\n\n/**\n * How many sharp pipelines one image runs at once. Each decodes the original,\n * so a 24-megapixel JPEG costs ~70 MB of pixels per pipeline in flight; the\n * seeded vocabulary plans 30 of them. libvips already parallelises inside each\n * pipeline, so a small number here loses little throughput.\n */\nconst SHAPED_CONCURRENCY = 2\n\n/**\n * Generate every (shape × rung) variant `config` calls for (images D003).\n *\n * The region comes from {@link planVariants} — `crops[shape]`, else the focal\n * point's window for that ratio, else the whole image — and sharp runs\n * `rotate() → extract() → resize()` in that order:\n *\n * - `rotate()` first, so `extract()` works in the coordinates a browser DRAWS\n * (measured, R013). The crop and the focal point are stored in that space.\n * - `extract()` BEFORE `resize()` — what sharp documents for pre-resize\n * extraction, and the only way to express a rect at all: `resize({position})`\n * takes nine gravities and two strategies and throws on coordinates (C5).\n *\n * 🔴 The variant records `info.width`/`info.height` — what sharp RETURNED —\n * never the rung it asked for (F003). `withoutEnlargement` silently returns a\n * smaller file than requested, and a `640w` descriptor on a 400px file makes a\n * browser's `srcset` choice wrong in the direction it never corrects (R007).\n * The plan never asks for more than the region supplies, so today the two\n * agree; recording the real one is what keeps them agreeing when they don't.\n */\nexport async function generateShapedVariants(\n buffer: Buffer,\n config: VariantConfig,\n input: VariantPlanInput,\n): Promise<ShapedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n const plan = planVariants({ width, height }, config, input)\n const variants = await renderPlannedVariants(buffer, plan.variants)\n return { width, height, variants, dropped: plan.dropped }\n}\n\n/**\n * Render `planned` — a whole plan, or the part of one a caller does not already\n * have (the variant job renders only keys that are missing) — at most\n * {@link SHAPED_CONCURRENCY} sharp pipelines at a time, results in input order.\n */\nexport async function renderPlannedVariants(\n buffer: Buffer,\n planned: readonly PlannedVariant[],\n): Promise<ShapedVariant[]> {\n // Two shapes can plan the SAME recipe — `wide` on a 16:9 photograph is the\n // whole image, exactly as `natural` is — and encoding it twice buys nothing.\n // Keyed by the full recipe, never by the fingerprint: a 32-bit hash that\n // collided here would hand one shape another shape's picture.\n const rendered = new Map<string, Promise<ShapedVariant>>()\n return mapBounded(planned, SHAPED_CONCURRENCY, async (planned) => {\n const recipe = JSON.stringify([\n planned.extract,\n planned.width,\n planned.height,\n planned.quality,\n planned.format,\n ])\n let pending = rendered.get(recipe)\n if (!pending) {\n pending = renderPlannedVariant(buffer, planned)\n rendered.set(recipe, pending)\n }\n const first = await pending\n return { ...first, shape: planned.shape, plannedWidth: planned.width }\n })\n}\n\nexport async function renderPlannedVariant(\n buffer: Buffer,\n planned: PlannedVariant,\n): Promise<ShapedVariant> {\n const pipeline = sharp(buffer).rotate().extract(planned.extract).resize({\n width: planned.width,\n height: planned.height,\n fit: 'cover',\n withoutEnlargement: true,\n })\n const { data, info } = await pipeline[planned.format]({ quality: planned.quality }).toBuffer({\n resolveWithObject: true,\n })\n return {\n buffer: data,\n format: planned.format,\n mimeType: `image/${planned.format}`,\n width: info.width,\n height: info.height,\n shape: planned.shape,\n plannedWidth: planned.width,\n fingerprint: planned.fingerprint,\n }\n}\n\n/** `Promise.all(items.map(fn))` with at most `limit` in flight, results in input order. */\nasync function mapBounded<T, R>(\n items: readonly T[],\n limit: number,\n fn: (item: T) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length)\n const queue = items.map((item, index) => ({ item, index }))\n // Once one job has failed the whole call rejects, so the other workers stop\n // taking jobs rather than running sharp for a result nobody will read.\n let failed = false\n const worker = async (): Promise<void> => {\n for (let job = queue.shift(); job !== undefined && !failed; job = queue.shift()) {\n try {\n results[job.index] = await fn(job.item)\n } catch (err) {\n failed = true\n throw err\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))\n return results\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n return displayDimensions(await sharp(buffer).metadata())\n}\n\n/**\n * The size a browser DRAWS the image at — after EXIF orientation — not the\n * sensor's.\n *\n * `metadata().width/height` are the stored pixel grid, so a phone portrait\n * tagged orientation 6 reports 400×200 while every browser draws it 200×400\n * (`image-orientation: from-image` is the default). Those numbers become the\n * media row's `width`/`height`, which are the `<img>`'s CLS reservation AND the\n * image ratio the focal-point crop is computed against (images PR02) — a\n * swapped ratio positions the crop on the wrong axis. `autoOrient` is sharp's\n * own post-orientation size (0.33+).\n */\nfunction displayDimensions(meta: sharp.Metadata): { width: number; height: number } {\n return {\n width: meta.autoOrient?.width ?? meta.width ?? 0,\n height: meta.autoOrient?.height ?? meta.height ?? 0,\n }\n}\n\n/**\n * The longest edge the attention pass looks at. libvips scores saliency on a\n * ~32px grid, so a larger buffer buys no precision — it only costs decode time.\n */\nconst ATTENTION_EDGE = 512\n\n/** Below this on either side there is nothing to guess about — see {@link detectFocalPoint}. */\nconst MIN_ATTENTION_EDGE = 8\n\n/**\n * How far the two attention passes may disagree on an axis and still be the\n * same subject. libvips places its hotspot on a ~32-cell grid (1/32 ≈ 0.03), so\n * a real subject agrees to within a cell or two (measured ≤ 0.07); a FLAT axis\n * disagrees by the whole image (0 in one pass, 1 in the mirrored one).\n */\nconst AXIS_AGREEMENT = 0.25\n\n/**\n * Guess where the subject is, with sharp's `attention` strategy — the seed for\n * a new photograph's focal point, so an editor who opens nothing still gets\n * something other than a blind centre crop (images D002).\n *\n * ## Two passes, one of them mirrored\n *\n * When the saliency map is flat along an axis — a white product background, a\n * sky, a horizontal band, a plain texture — libvips reports the FIRST cell of\n * it: 0. Stored, that pins every shaped slot to the left or top edge, which is\n * worse than no guess. But a subject genuinely near the edge also reports 0 (a\n * disc at 5% from the corner does, measured), so \"0 means flat\" is not a rule.\n *\n * So the same buffer is scored twice: as-is, and flipped on both axes. A real\n * subject moves with the flip and the two answers agree once the second is\n * mirrored back; a flat axis reports 0 both times, i.e. 0 and 1 once mirrored.\n * Per axis: agree → their mean; disagree → that axis says nothing → 0.5. Both\n * axes flat → `null` (the caller stores it; it renders as the centre, D010).\n * The second pass runs on the already-decoded ≤512px raw buffer, so it costs a\n * saliency pass over ~0.25 MP, not a second decode.\n *\n * Also `null`: an image under {@link MIN_ATTENTION_EDGE} on either side — a\n * strip or an icon has no subject worth cropping around.\n *\n * ## Why not read `info.attentionX` off an ordinary resize\n *\n * Because it is WRONG whenever the same call resizes. sharp converts the\n * libvips hotspot with `attention_x * jpegShrinkOnLoad / scale`\n * (`src/pipeline.cc:505`, 0.34.5 and 0.35.3 alike), and those terms do not\n * describe the image smartcrop ran on: a disc drawn at x = 0.80 reported 0.50\n * from a JPEG and 0.25 from a PNG (images R012). The crop OFFSET beside it is\n * right; only the reported point is not.\n *\n * So each pass runs at SCALE 1: decode once to a bounded raw buffer, then crop\n * that buffer to its own size minus one pixel on the long axis. No\n * shrink-on-load and no scale, so the reported point is in that buffer's own\n * pixels and dividing by its size is the answer. A square image has no long\n * axis, but trimming either one still runs the strategy, and libvips takes the\n * hotspot over the whole saliency map whichever axis it crops.\n *\n * `.rotate()` first, so the point is in the orientation a browser DRAWS — an\n * EXIF-rotated phone photo would otherwise be seeded in sensor coordinates.\n *\n * ⚠️ Attention scores luminance frequency, saturation and skin tones. It is a\n * guess, not detection, and on a product shot the product can win (R012).\n */\nexport async function detectFocalPoint(buffer: Buffer): Promise<FocalPoint | null> {\n const { data, info } = await sharp(buffer)\n .rotate()\n .resize(ATTENTION_EDGE, ATTENTION_EDGE, { fit: 'inside', withoutEnlargement: true })\n .raw()\n .toBuffer({ resolveWithObject: true })\n const { width, height, channels } = info\n if (width < MIN_ATTENTION_EDGE || height < MIN_ATTENTION_EDGE) return null\n const raw = { raw: { width, height, channels } }\n const mirrored = await sharp(data, raw).flop().flip().raw().toBuffer()\n const [straight, flipped] = await Promise.all([\n attentionHotspot(data, width, height, channels),\n attentionHotspot(mirrored, width, height, channels),\n ])\n if (straight === null || flipped === null) return null\n const x = agreeingAxis(straight.x, 1 - flipped.x)\n const y = agreeingAxis(straight.y, 1 - flipped.y)\n if (x === null && y === null) return null\n return { x: clampFraction(x ?? 0.5), y: clampFraction(y ?? 0.5) }\n}\n\n/** One attention pass at scale 1, as fractions of the buffer. */\nasync function attentionHotspot(\n data: Buffer,\n width: number,\n height: number,\n channels: 1 | 2 | 3 | 4,\n): Promise<FocalPoint | null> {\n const landscape = width >= height\n const { info } = await sharp(data, { raw: { width, height, channels } })\n .resize(landscape ? width - 1 : width, landscape ? height : height - 1, {\n fit: 'cover',\n position: sharp.strategy.attention,\n })\n .toBuffer({ resolveWithObject: true })\n const { attentionX, attentionY } = info\n if (attentionX === undefined || attentionY === undefined) return null\n return { x: attentionX / width, y: attentionY / height }\n}\n\n/** The two passes' answer for one axis, or `null` when they disagree (a flat axis). */\nfunction agreeingAxis(straight: number, mirrored: number): number | null {\n return Math.abs(straight - mirrored) <= AXIS_AGREEMENT ? (straight + mirrored) / 2 : null\n}\n\n/** Round to four decimals and keep inside `[0, 1]`, so the stored value passes `isFocalPoint`. */\nfunction clampFraction(value: number): number {\n if (!Number.isFinite(value)) return 0.5\n return Math.min(Math.max(Math.round(value * 10000) / 10000, 0), 1)\n}\n\n/** The long edge of the blurred preview, in pixels — see {@link generateBlurDataUrl}. */\nexport const BLUR_EDGE = 32\n\n/** The Gaussian sigma baked into the preview, in ITS pixels — see {@link generateBlurDataUrl}. */\nconst BLUR_SIGMA = 1.5\n\n/** WebP quality for the preview — see {@link generateBlurDataUrl}. */\nconst BLUR_QUALITY = 50\n\n/**\n * The blurred preview a photograph shows while it loads (images PR11): a tiny\n * WebP of it, as a `data:image/webp;base64,…` URL of a few hundred characters,\n * which a render draws as the `<img>`'s own background and the browser scales\n * up to the box the photograph will fill.\n *\n * ## Why 32px with the blur baked in (images research, PR11)\n *\n * Measured on 24 Kulli Farm originals in Chromium. A raw 16px WebP is the\n * smallest (median 211 B) but a browser upscales it BILINEARLY, which shows a\n * grid of crosses at card size and bands at hero size. next/image hides that\n * with an SVG `feGaussianBlur` wrapper — about 590 more characters of HTML per\n * image, a filter pass per paint, and an SVG document built into a `style` from\n * content. Baking a 1.5px Gaussian into a 32px WebP instead looked as smooth at\n * card size (faint bands only on a full-width hero), for median 267 B / max\n * 331 B, and keeps the stored value a plain raster the validator can check\n * character by character. BlurHash and ThumbHash were both larger once decoded\n * server-side (no client JS is a requirement) and less faithful.\n *\n * Generated ONCE, on the upload path beside the focal-point seed (images D004:\n * cheap work stays inline — one decode with shrink-on-load, one ~32px encode;\n * about 20 ms for a JPEG, almost all of it the decode; a source with an alpha\n * channel also pays one full-resolution alpha scan, 185 ms for a 4000×3000\n * PNG), and by the catch-up for rows from before it existed.\n *\n * Returns `''` — \"looked, and there is none\" — for a source with transparency\n * in use. The preview is drawn BEHIND the photograph and only works because the\n * photograph then covers it completely; a logo's transparent pixels would show\n * the blur through them for ever. Decided on the FULL-resolution alpha\n * channel ({@link hasTransparency}), and only for a source that has one at\n * all: a reduced 32px copy averages a few transparent pixels (a rounded\n * corner, a small hole) back to opaque (measured: four in a 2000×1500 PNG left\n * a minimum alpha of 255). An alpha channel that is fully opaque — a PNG\n * export of a photograph — is not transparency and gets a preview.\n *\n * `.rotate()` first, so the preview has the orientation a browser draws — a\n * sideways preview under an upright photograph would be worse than none.\n * sharp converts to sRGB on the way out, as it does for every variant.\n *\n * Throws only if sharp cannot read the file; callers treat that as \"no\n * preview\", never as a failed upload.\n */\nexport async function generateBlurDataUrl(buffer: Buffer): Promise<string> {\n if (await hasTransparency(buffer)) return ''\n const { data, info } = await sharp(buffer)\n .rotate()\n .resize(BLUR_EDGE, BLUR_EDGE, { fit: 'inside', withoutEnlargement: true })\n .raw({ depth: 'uchar' })\n .toBuffer({ resolveWithObject: true })\n const { width, height, channels } = info\n if (width < 1 || height < 1) return ''\n // The blur runs on the reduced pixels, so its sigma is in the preview's own\n // pixels — the same smoothing whatever the source's resolution.\n const encoded = await sharp(data, { raw: { width, height, channels } })\n .removeAlpha()\n .blur(BLUR_SIGMA)\n .webp({ quality: BLUR_QUALITY, effort: 6 })\n .toBuffer()\n const url = `data:image/webp;base64,${encoded.toString('base64')}`\n // The render refuses anything over the cap; storing it would only ship bytes\n // no page draws.\n return isBlurDataUrl(url) ? url : ''\n}\n\n/**\n * Does any pixel of the source have an alpha below fully opaque?\n *\n * Only the alpha band, at full resolution. Not `stats().isOpaque`, which\n * answers the same question but also computes entropy, a 3×3 sharpness\n * convolution and a colour histogram over the whole image: 986 ms against\n * 185 ms for a 4000×3000 PNG, measured, on the upload path.\n *\n * Read as 8-bit on purpose. sharp's default `raw()` output SCALES a 16-bit\n * alpha down (30000/65535 reads 117), where `raw({ depth: 'ushort' })`\n * returns unscaled samples that a fixed maximum cannot be compared against.\n * The cost is transparency below 1/255 — invisible — reading as opaque.\n */\nasync function hasTransparency(buffer: Buffer): Promise<boolean> {\n if (!(await sharp(buffer).metadata()).hasAlpha) return false\n const alpha = await sharp(buffer).extractChannel('alpha').raw().toBuffer()\n // Native block compares, not a callback per pixel: this runs on the upload\n // request's event loop, and a 16000×16000 design export is 256 MB of alpha\n // (CodeRabbit, images PR11).\n for (let start = 0; start < alpha.length; start += OPAQUE_BLOCK.length) {\n const end = Math.min(start + OPAQUE_BLOCK.length, alpha.length)\n if (alpha.compare(OPAQUE_BLOCK, 0, end - start, start, end) !== 0) return true\n }\n return false\n}\n\n/** 64 KB of fully opaque alpha — {@link hasTransparency}'s comparison block. */\nconst OPAQUE_BLOCK = Buffer.alloc(64 * 1024, 255)\n"],"mappings":"sHAkCA,MAAM,EAAkB,IAAI,IAAI,CAAC,gBAAiB,WAAW,CAAC,EAM9D,SAAgB,EAAmB,EAA2B,CAC5D,OAAO,EAAS,WAAW,QAAQ,GAAK,CAAC,EAAgB,IAAI,CAAQ,CACvE,CASA,eAAsB,EACpB,EACA,EACyB,CACzB,GAAM,CAAE,QAAO,UAAW,EAAkB,MAAM,EAAM,CAAM,CAAC,CAAC,SAAS,CAAC,EAEpE,EAAW,IAAI,IAEf,EAAU,OAAO,QAAQ,CAAM,EAiCrC,OAhCA,MAAM,QAAQ,IACZ,EAAQ,IAAI,MAAO,CAAC,EAAM,KAAW,CACnC,IAAM,EAAM,EAAM,QAAU,OACtB,EAAU,EAAM,SAAW,GAC3B,EAAM,EAAM,KAAO,QAcnB,CAAE,KAAM,EAAe,QAAS,MAPtB,EAAM,CAAM,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,CAC5C,MAAO,EAAM,MACb,OAAQ,EAAM,OACd,MACA,mBAAoB,EACtB,CAEkD,CAAC,CAAC,EAAI,CAAC,CAAE,SAAQ,CAAC,CAAC,CAAC,SAAS,CAC7E,kBAAmB,EACrB,CAAC,EAED,EAAS,IAAI,EAAM,CACjB,OAAQ,EACR,OAAQ,EACR,SAAU,SAAS,IACnB,MAAO,EAAK,MACZ,OAAQ,EAAK,MACf,CAAC,CACH,CAAC,CACH,EAEO,CAAE,QAAO,SAAQ,UAAS,CACnC,CAiDA,eAAsB,EACpB,EACA,EACA,EACsB,CACtB,GAAM,CAAE,QAAO,UAAW,EAAkB,MAAM,EAAM,CAAM,CAAC,CAAC,SAAS,CAAC,EACpE,EAAO,EAAa,CAAE,QAAO,QAAO,EAAG,EAAQ,CAAK,EAE1D,MAAO,CAAE,QAAO,SAAQ,SAAA,MADD,EAAsB,EAAQ,EAAK,QAAQ,EAChC,QAAS,EAAK,OAAQ,CAC1D,CAOA,eAAsB,EACpB,EACA,EAC0B,CAK1B,IAAM,EAAW,IAAI,IACrB,OAAO,EAAW,EAAS,EAAoB,KAAO,IAAY,CAChE,IAAM,EAAS,KAAK,UAAU,CAC5B,EAAQ,QACR,EAAQ,MACR,EAAQ,OACR,EAAQ,QACR,EAAQ,MACV,CAAC,EACG,EAAU,EAAS,IAAI,CAAM,EAMjC,OALK,IACH,EAAU,EAAqB,EAAQ,CAAO,EAC9C,EAAS,IAAI,EAAQ,CAAO,GAGvB,CAAE,GAAG,MADQ,EACD,MAAO,EAAQ,MAAO,aAAc,EAAQ,KAAM,CACvE,CAAC,CACH,CAEA,eAAsB,EACpB,EACA,EACwB,CAOxB,GAAM,CAAE,OAAM,QAAS,MANN,EAAM,CAAM,CAAC,CAAC,OAAO,CAAC,CAAC,QAAQ,EAAQ,OAAO,CAAC,CAAC,OAAO,CACtE,MAAO,EAAQ,MACf,OAAQ,EAAQ,OAChB,IAAK,QACL,mBAAoB,EACtB,CACoC,CAAC,CAAC,EAAQ,OAAO,CAAC,CAAE,QAAS,EAAQ,OAAQ,CAAC,CAAC,CAAC,SAAS,CAC3F,kBAAmB,EACrB,CAAC,EACD,MAAO,CACL,OAAQ,EACR,OAAQ,EAAQ,OAChB,SAAU,SAAS,EAAQ,SAC3B,MAAO,EAAK,MACZ,OAAQ,EAAK,OACb,MAAO,EAAQ,MACf,aAAc,EAAQ,MACtB,YAAa,EAAQ,WACvB,CACF,CAGA,eAAe,EACb,EACA,EACA,EACc,CACd,IAAM,EAAc,MAAS,EAAM,MAAM,EACnC,EAAQ,EAAM,KAAK,EAAM,KAAW,CAAE,OAAM,OAAM,EAAE,EAGtD,EAAS,GAYb,OADA,MAAM,QAAQ,IAAI,MAAM,KAAK,CAAE,OAAQ,KAAK,IAAI,EAAO,EAAM,MAAM,CAAE,EAAG,SAV9B,CACxC,IAAK,IAAI,EAAM,EAAM,MAAM,EAAG,IAAQ,IAAA,IAAa,CAAC,EAAQ,EAAM,EAAM,MAAM,EAC5E,GAAI,CACF,EAAQ,EAAI,OAAS,MAAM,EAAG,EAAI,IAAI,CACxC,OAAS,EAAK,CAEZ,KADA,GAAS,GACH,CACR,CAEJ,CAC8E,CAAC,EACxE,CACT,CAMA,eAAsB,EACpB,EAC4C,CAC5C,OAAO,EAAkB,MAAM,EAAM,CAAM,CAAC,CAAC,SAAS,CAAC,CACzD,CAcA,SAAS,EAAkB,EAAyD,CAClF,MAAO,CACL,MAAO,EAAK,YAAY,OAAS,EAAK,OAAS,EAC/C,OAAQ,EAAK,YAAY,QAAU,EAAK,QAAU,CACpD,CACF,CAiEA,eAAsB,EAAiB,EAA4C,CACjF,GAAM,CAAE,OAAM,QAAS,MAAM,EAAM,CAAM,CAAC,CACvC,OAAO,CAAC,CACR,OAAO,IAAgB,IAAgB,CAAE,IAAK,SAAU,mBAAoB,EAAK,CAAC,CAAC,CACnF,IAAI,CAAC,CACL,SAAS,CAAE,kBAAmB,EAAK,CAAC,EACjC,CAAE,QAAO,SAAQ,YAAa,EACpC,GAAI,EAAQ,GAAsB,EAAS,EAAoB,OAAO,KAEtE,IAAM,EAAW,MAAM,EAAM,EAAM,CADrB,IAAK,CAAE,QAAO,SAAQ,UAAS,CACR,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAC/D,CAAC,EAAU,GAAW,MAAM,QAAQ,IAAI,CAC5C,EAAiB,EAAM,EAAO,EAAQ,CAAQ,EAC9C,EAAiB,EAAU,EAAO,EAAQ,CAAQ,CACpD,CAAC,EACD,GAAI,IAAa,MAAQ,IAAY,KAAM,OAAO,KAClD,IAAM,EAAI,EAAa,EAAS,EAAG,EAAI,EAAQ,CAAC,EAC1C,EAAI,EAAa,EAAS,EAAG,EAAI,EAAQ,CAAC,EAEhD,OADI,IAAM,MAAQ,IAAM,KAAa,KAC9B,CAAE,EAAG,EAAc,GAAK,EAAG,EAAG,EAAG,EAAc,GAAK,EAAG,CAAE,CAClE,CAGA,eAAe,EACb,EACA,EACA,EACA,EAC4B,CAC5B,IAAM,EAAY,GAAS,EACrB,CAAE,QAAS,MAAM,EAAM,EAAM,CAAE,IAAK,CAAE,QAAO,SAAQ,UAAS,CAAE,CAAC,CAAC,CACrE,OAAO,EAAY,EAAQ,EAAI,EAAO,EAAY,EAAS,EAAS,EAAG,CACtE,IAAK,QACL,SAAU,EAAM,SAAS,SAC3B,CAAC,CAAC,CACD,SAAS,CAAE,kBAAmB,EAAK,CAAC,EACjC,CAAE,aAAY,cAAe,EAEnC,OADI,IAAe,IAAA,IAAa,IAAe,IAAA,GAAkB,KAC1D,CAAE,EAAG,EAAa,EAAO,EAAG,EAAa,CAAO,CACzD,CAGA,SAAS,EAAa,EAAkB,EAAiC,CACvE,OAAO,KAAK,IAAI,EAAW,CAAQ,GAAK,KAAkB,EAAW,GAAY,EAAI,IACvF,CAGA,SAAS,EAAc,EAAuB,CAE5C,OADK,OAAO,SAAS,CAAK,EACnB,KAAK,IAAI,KAAK,IAAI,KAAK,MAAM,EAAQ,GAAK,EAAI,IAAO,CAAC,EAAG,CAAC,EAD7B,EAEtC,CAqDA,eAAsB,EAAoB,EAAiC,CACzE,GAAI,MAAM,EAAgB,CAAM,EAAG,MAAO,GAC1C,GAAM,CAAE,OAAM,QAAS,MAAM,EAAM,CAAM,CAAC,CACvC,OAAO,CAAC,CACR,OAAA,GAAA,GAA6B,CAAE,IAAK,SAAU,mBAAoB,EAAK,CAAC,CAAC,CACzE,IAAI,CAAE,MAAO,OAAQ,CAAC,CAAC,CACvB,SAAS,CAAE,kBAAmB,EAAK,CAAC,EACjC,CAAE,QAAO,SAAQ,YAAa,EACpC,GAAI,EAAQ,GAAK,EAAS,EAAG,MAAO,GAQpC,IAAM,EAAM,2BAA0B,MALhB,EAAM,EAAM,CAAE,IAAK,CAAE,QAAO,SAAQ,UAAS,CAAE,CAAC,CAAC,CACpE,YAAY,CAAC,CACb,KAAK,GAAU,CAAC,CAChB,KAAK,CAAE,QAAS,GAAc,OAAQ,CAAE,CAAC,CAAC,CAC1C,SAAS,EAAA,CACkC,SAAS,QAAQ,IAG/D,OAAO,EAAc,CAAG,EAAI,EAAM,EACpC,CAeA,eAAe,EAAgB,EAAkC,CAC/D,GAAI,EAAE,MAAM,EAAM,CAAM,CAAC,CAAC,SAAS,EAAA,CAAG,SAAU,MAAO,GACvD,IAAM,EAAQ,MAAM,EAAM,CAAM,CAAC,CAAC,eAAe,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAIzE,IAAK,IAAI,EAAQ,EAAG,EAAQ,EAAM,OAAQ,GAAS,EAAa,OAAQ,CACtE,IAAM,EAAM,KAAK,IAAI,EAAQ,EAAa,OAAQ,EAAM,MAAM,EAC9D,GAAI,EAAM,QAAQ,EAAc,EAAG,EAAM,EAAO,EAAO,CAAG,IAAM,EAAG,MAAO,EAC5E,CACA,MAAO,EACT,CAGA,MAAM,EAAe,OAAO,MAAM,GAAK,KAAM,GAAG"}
@@ -1,5 +1,5 @@
1
- import { a as ImageVocabulary, i as ImageStyle, n as ImageFormat, r as ImageShape } from "./types-6PZQ4Zq6.mjs";
2
- import { a as ResolvedShape, c as VariantConfig, i as NATURAL_SHAPE, l as buildVariantConfig, n as DEFAULT_IMAGE_SHAPES, o as ShapeAlias, r as DEFAULT_IMAGE_VOCABULARY, s as UnmappedStyle, t as DEFAULT_IMAGE_LADDER, u as resolveStyleName } from "./shapes-C51rXSDJ.mjs";
1
+ import { a as ImageVocabulary, i as ImageStyle, n as ImageFormat, r as ImageShape } from "./types-lobQhTow.mjs";
2
+ import { a as ResolvedShape, c as VariantConfig, i as NATURAL_SHAPE, l as buildVariantConfig, n as DEFAULT_IMAGE_SHAPES, o as ShapeAlias, r as DEFAULT_IMAGE_VOCABULARY, s as UnmappedStyle, t as DEFAULT_IMAGE_LADDER, u as resolveStyleName } from "./shapes-BQH9_3h8.mjs";
3
3
 
4
4
  //#region src/variant-plan.d.ts
5
5
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"processing.d.mts","names":[],"sources":["../src/variant-plan.ts","../src/jobs/variant-state.ts","../src/process-image.ts","../src/variant-key.ts"],"mappings":"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"}
1
+ {"version":3,"file":"processing.d.mts","names":[],"sources":["../src/variant-plan.ts","../src/jobs/variant-state.ts","../src/process-image.ts","../src/variant-key.ts"],"mappings":"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"}
@@ -1 +1 @@
1
- import{a as e,n as t,r as n,s as r,t as i,u as a}from"./shapes-CqppzFzb.mjs";import{a as o,i as s,n as c,o as l,t as u}from"./variant-plan-86tJKtVi.mjs";import{s as d}from"./variant-state-CBtqb9Cb.mjs";import{a as f,i as p,n as m,o as h,r as g}from"./process-image-wQwiRhyk.mjs";export{i as DEFAULT_IMAGE_LADDER,t as DEFAULT_IMAGE_SHAPES,n as DEFAULT_IMAGE_VOCABULARY,u as MAX_VARIANTS_PER_IMAGE,e as NATURAL_SHAPE,r as buildVariantConfig,s as deriveShapedVariantKey,o as deriveVariantKey,m as generateShapedVariants,g as getImageDimensions,p as isProcessableImage,c as planVariants,f as processImage,d as readVariantState,h as renderPlannedVariant,a as resolveStyleName,l as variantFingerprint};
1
+ import{a as e,n as t,r as n,s as r,t as i,u as a}from"./shapes-CqppzFzb.mjs";import{a as o,i as s,n as c,o as l,t as u}from"./variant-plan-86tJKtVi.mjs";import{s as d}from"./variant-state-CBtqb9Cb.mjs";import{a as f,i as p,o as m,r as h,s as g}from"./process-image-Cwa8LlGb.mjs";export{i as DEFAULT_IMAGE_LADDER,t as DEFAULT_IMAGE_SHAPES,n as DEFAULT_IMAGE_VOCABULARY,u as MAX_VARIANTS_PER_IMAGE,e as NATURAL_SHAPE,r as buildVariantConfig,s as deriveShapedVariantKey,o as deriveVariantKey,h as generateShapedVariants,p as getImageDimensions,f as isProcessableImage,c as planVariants,m as processImage,d as readVariantState,g as renderPlannedVariant,a as resolveStyleName,l as variantFingerprint};
@@ -119,6 +119,17 @@ interface PublicMediaProjection {
119
119
  * saved last year with nothing rewritten (D007).
120
120
  */
121
121
  focalPoint: FocalPoint;
122
+ /**
123
+ * The blurred preview the photograph shows while it loads — a data URL of a
124
+ * tiny image of it (images PR11), or `null` when it has none: a transparent
125
+ * source, a row the catch-up has not reached, or a stored value that failed
126
+ * validation. A render draws it as the `<img>`'s own `background-image`.
127
+ *
128
+ * VALIDATED before it is published (`readBlurDataUrl`): it lands in an inline
129
+ * `style` on public pages. Like `focalPoint`, it rides the PROJECTION and
130
+ * never the stored block ref (D007).
131
+ */
132
+ blurDataUrl: string | null;
122
133
  }
123
134
  /** The Media rows this resolver needs. Structural, so any read path can supply them. */
124
135
  interface PublicMediaRow {
@@ -133,6 +144,8 @@ interface PublicMediaRow {
133
144
  focalPoint?: unknown;
134
145
  /** The stored `media.crops` — the editor's saved windows, read the way the planner reads them. */
135
146
  crops?: unknown;
147
+ /** The stored `media.blurDataUrl` — untrusted here, read through `readBlurDataUrl`. */
148
+ blurDataUrl?: unknown;
136
149
  }
137
150
  interface PublicMediaResolverDeps {
138
151
  /**
@@ -244,6 +257,8 @@ interface CanvasImage {
244
257
  */
245
258
  readonly focalPoint: FocalPoint;
246
259
  readonly sources: Readonly<Record<string, readonly PublicMediaSource[]>>;
260
+ /** The site's preview too, so the canvas shows the same one while it loads (images PR11). */
261
+ readonly blurDataUrl: string | null;
247
262
  }
248
263
  /**
249
264
  * Media ids → images the block-editor CANVAS may draw: a BOUND `field.media()`
@@ -1 +1 @@
1
- {"version":3,"file":"public-resolver.d.mts","names":[],"sources":["../src/async-cache.ts","../src/public-resolver.ts"],"mappings":";;;;;;AA2BA;;;;;;;;;;;;;;;;AAAmF;;;;ACwBnF;;;iBDxBgB,uBAAA,GAAA,CAA2B,IAAA,QAAY,OAAA,CAAQ,CAAA,UAAW,OAAA,CAAQ,CAAA;;;;;;;;;;;UCwBjE,iBAAA;EACf,GAAA;EACA,KAAA;EACA,MAAA;AAAA;;;;;;;;;AA8EsB;AAIxB;;;;;;UAhEiB,qBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EAoEK;AAGP;;;;;;;;;;;;;;;;;;;;;;;EA9CE,QAAA,EAAU,MAAA;EAiEwC;;;;;;;;;;;AAUS;AAuG7D;;;;;EAhKE,OAAA,EAAS,MAAA,SAAe,iBAAA;EAoKvB;;;;;;;;;EA1JD,UAAA,EAAY,UAAA;AAAA;;UAIG,cAAA;EACf,EAAA;EACA,OAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EAyRc;EAvRd,UAAA;EAmR6B;EAjR7B,KAAA;AAAA;AAAA,UAGe,uBAAA;EAgRf;;;;;AAEgB;AA2BlB;;;EAnSE,SAAA,GAAY,GAAA,qBAAwB,MAAA,cAAoB,OAAA,CAAQ,cAAA;EAsSrD;;;;;;EA/RX,cAAA,GACE,IAAA,wBACG,OAAA,CAAQ,GAAA;IAAc,GAAA;IAAa,QAAA,EAAU,MAAA;EAAA;EA6RN;AAAA;AAiD9C;;;;;EAtUE,aAAA,GAAgB,IAAA,wBAA4B,OAAA,CAAQ,GAAA;EAkVlC;EAhVlB,MAAA;IAAW,IAAA,GAAO,GAAA,EAAK,MAAA,mBAAyB,GAAA;EAAA;AAAA;;;;;;;;;;AAgVoB;AAgCtE;;;;;;;;;;;iBAzQsB,kBAAA,CACpB,GAAA,qBACA,IAAA,EAAM,uBAAA,EACN,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,qBAAA;;AAwQW;AAUlC;;;;;;;;;;;;;;;AAE0B;;;;;;;;;;;;;iBA9IV,cAAA,GAAA,CACd,IAAA,QAAY,OAAA,CAAQ,CAAA,GACpB,KAAA,UACA,GAAA,wBACO,OAAA,CAAQ,CAAA;AAAA,iBA2BD,yBAAA,CAAA,IACd,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;UAiDR,WAAA;EAAA,SACN,GAAA;EAAA,SACA,GAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA;;;;;;;WAOA,UAAA,EAAY,UAAA;EAAA,SACZ,OAAA,EAAS,QAAA,CAAS,MAAA,kBAAwB,iBAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgC/B,mBAAA,CACpB,GAAA,qBACA,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,WAAA;;;;;;iBAUP,cAAA,CACd,KAAA,EAAO,WAAA,SAAoB,qBAAA,IAC1B,GAAA,SAAY,WAAA"}
1
+ {"version":3,"file":"public-resolver.d.mts","names":[],"sources":["../src/async-cache.ts","../src/public-resolver.ts"],"mappings":";;;;;;AA2BA;;;;;;;;;;;;;;;;AAAmF;;;;ACyBnF;;;iBDzBgB,uBAAA,GAAA,CAA2B,IAAA,QAAY,OAAA,CAAQ,CAAA,UAAW,OAAA,CAAQ,CAAA;;;;;;;;;;;UCyBjE,iBAAA;EACf,GAAA;EACA,KAAA;EACA,MAAA;AAAA;;;;;;;;;;AAyFW;AAIb;;;;;UA3EiB,qBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EAiFA;;AAAW;AAGb;;;;;;;;;;;;;;;;;;;;;EA3DE,QAAA,EAAU,MAAA;EA8EiB;;;;;;;;;;;;;AAUgC;AAuG7D;;;EA7KE,OAAA,EAAS,MAAA,SAAe,iBAAA;EAiLH;;;;;;;;;EAvKrB,UAAA,EAAY,UAAA;EAuKX;;;;AAAyC;AAuI5C;;;;;EAnSE,WAAA;AAAA;;UAIe,cAAA;EACf,EAAA;EACA,OAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EA4Re;EA1Rf,UAAA;EA0RgB;EAxRhB,KAAA;EAmTuC;EAjTvC,WAAA;AAAA;AAAA,UAGe,uBAAA;EAiTZ;;;;;;;;;EAvSH,SAAA,GAAY,GAAA,qBAAwB,MAAA,cAAoB,OAAA,CAAQ,cAAA;EAuSpB;AAiD9C;;;;;EAjVE,cAAA,GACE,IAAA,wBACG,OAAA,CAAQ,GAAA;IAAc,GAAA;IAAa,QAAA,EAAU,MAAA;EAAA;EAgVzC;;;;;;;EAxUT,aAAA,GAAgB,IAAA,wBAA4B,OAAA,CAAQ,GAAA;EAmVzB;EAjV3B,MAAA;IAAW,IAAA,GAAO,GAAA,EAAK,MAAA,mBAAyB,GAAA;EAAA;AAAA;AAmXlD;;;;;;;;;;;;;;;AAGkC;AAUlC;;;;;AAbA,iBA5QsB,kBAAA,CACpB,GAAA,qBACA,IAAA,EAAM,uBAAA,EACN,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;;;AAuRG;;;;;;;;;;;;;;;;;;;;;;;iBAhJV,cAAA,GAAA,CACd,IAAA,QAAY,OAAA,CAAQ,CAAA,GACpB,KAAA,UACA,GAAA,wBACO,OAAA,CAAQ,CAAA;AAAA,iBA2BD,yBAAA,CAAA,IACd,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;UAiDR,WAAA;EAAA,SACN,GAAA;EAAA,SACA,GAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA;;;;;;;WAOA,UAAA,EAAY,UAAA;EAAA,SACZ,OAAA,EAAS,QAAA,CAAS,MAAA,kBAAwB,iBAAA;;WAE1C,WAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCW,mBAAA,CACpB,GAAA,qBACA,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,WAAA;;;;;;iBAUP,cAAA,CACd,KAAA,EAAO,WAAA,SAAoB,qBAAA,IAC1B,GAAA,SAAY,WAAA"}
@@ -1,2 +1,2 @@
1
- import{s as e}from"./focal-point-BWhtJzef.mjs";import{a as t}from"./shapes-CqppzFzb.mjs";import{r as n}from"./variant-plan-86tJKtVi.mjs";import{t as r}from"./async-cache-C_Ycvs7b.mjs";import{s as i}from"./variant-state-CBtqb9Cb.mjs";function a(e){let t=e.lastIndexOf(`/`);return t===-1?null:e.slice(0,t+1)}function o(e){let t=[...e].sort((e,t)=>e.width-t.width);return t.filter((e,n)=>n===0||t[n-1]?.width!==e.width)}function s(e,t,r,i){let a=t[t.length-1],{width:o,height:s}=i;if(a===void 0||r===void 0||r.length===0||typeof o!=`number`||typeof s!=`number`)return!1;let c=n({name:e,ratio:a.width/a.height,widths:r.map(e=>e.width)},{width:o,height:s},{focalPoint:i.focalPoint,crops:i.crops});return c!==void 0&&a.width>=c*.98}async function c(n,r,c={}){let l=new Map,u=[...new Set(n)];if(u.length===0)return l;let d=await r.readMedia(u,c.locale),f=d.map(e=>e.fileKey).filter(e=>typeof e==`string`&&e!==``);if(f.length===0)return l;let p=await r.getPublicFiles(f),m=new Map,h=[];for(let e of d){let t=p.get(e.fileKey);if(t===void 0||m.has(e.fileKey))continue;let n=a(e.fileKey),o=t=>n!==null&&t!==e.fileKey&&t.startsWith(n),s=i(t.metadata),c=[...s.legacy].filter(([,e])=>o(e)),l=s.shaped.filter(e=>o(e.key)),u=c.slice(0,64),d=l.slice(0,64);(u.length<c.length||d.length<l.length)&&r.logger?.warn({mediaId:e.id,legacy:c.length,shaped:l.length,cap:64},`public media: a photograph records more variants than any pass writes — the excess is ignored`),m.set(e.fileKey,{legacy:u,shaped:d}),h.push(...u.map(([,e])=>e),...d.map(e=>e.key))}let g=h.length>0?await r.getPublicUrls(h):new Map;for(let n of d){let r=p.get(n.fileKey),i=m.get(n.fileKey);if(r===void 0||i===void 0)continue;let a=Object.fromEntries(i.legacy.flatMap(([e,t])=>{let n=g.get(t);return n===void 0?[]:[[e,n]]})),c=new Map;for(let e of i.shaped){let t=g.get(e.key);if(t===void 0)continue;let n=c.get(e.shape)??[];n.push({url:t,width:e.width,height:e.height}),c.set(e.shape,n)}let u=new Map([...c].map(([e,t])=>[e,o(t)])),d=u.get(t),f=Object.fromEntries([...u].filter(([e,t])=>e===`natural`||s(e,t,d,n)));l.set(n.id,{id:n.id,url:r.url,mimeType:n.mimeType??null,width:n.width??null,height:n.height??null,alt:n.alt??null,title:n.title??null,variants:a,sources:f,focalPoint:e(n.focalPoint)})}return l}function l(e,t,n=Date.now){let r=null;return async()=>{let i=n();if(r&&i-r.at<t)return r.value;let a=e();return r={value:a,at:i},a.catch(()=>{r?.value===a&&(r=null)}),a}}async function u(){try{let{getApp:e}=await import(`@murumets-ee/core`);return e().logger}catch{return}}function d(){return async(e,t={})=>{let[{createMediaQueryClient:n},{getSharedStorageClient:r}]=await Promise.all([import(`./query-client.mjs`),import(`./client.mjs`)]),[i,a,o]=await Promise.all([n(),r(),u()]);return c(e,{readMedia:async(e,t)=>{let{schemaRegistry:n}=await import(`@murumets-ee/db`),{getTableColumns:r,inArray:a}=await import(`drizzle-orm`),o=n.get(`media`);if(!o)return[];let s=r(o).id;return s?await i.findMany({where:a(s,[...e]),limit:e.length,...t!==void 0&&{locale:t}}):[]},getPublicFiles:e=>a.getPublicFiles(e),getPublicUrls:e=>a.getPublicUrls(e),logger:o},t)}}let f;async function p(e,t){return f??=d(),m(await f(e,t))}function m(e){let t=new Map;for(let[n,r]of e)r.mimeType?.startsWith(`image/`)===!0&&t.set(n,{url:r.url,alt:r.alt,width:r.width,height:r.height,focalPoint:r.focalPoint,sources:r.sources});return t}export{r as cacheOnceUnlessRejected,d as createPublicMediaResolver,l as memoiseWithTtl,p as resolveCanvasImages,c as resolvePublicMedia,m as toCanvasImages};
1
+ import{r as e}from"./blur-data-url-EtSndBso.mjs";import{s as t}from"./focal-point-BWhtJzef.mjs";import{a as n}from"./shapes-CqppzFzb.mjs";import{r}from"./variant-plan-86tJKtVi.mjs";import{t as i}from"./async-cache-C_Ycvs7b.mjs";import{s as a}from"./variant-state-CBtqb9Cb.mjs";function o(e){let t=e.lastIndexOf(`/`);return t===-1?null:e.slice(0,t+1)}function s(e){let t=[...e].sort((e,t)=>e.width-t.width);return t.filter((e,n)=>n===0||t[n-1]?.width!==e.width)}function c(e,t,n,i){let a=t[t.length-1],{width:o,height:s}=i;if(a===void 0||n===void 0||n.length===0||typeof o!=`number`||typeof s!=`number`)return!1;let c=r({name:e,ratio:a.width/a.height,widths:n.map(e=>e.width)},{width:o,height:s},{focalPoint:i.focalPoint,crops:i.crops});return c!==void 0&&a.width>=c*.98}async function l(r,i,l={}){let u=new Map,d=[...new Set(r)];if(d.length===0)return u;let f=await i.readMedia(d,l.locale),p=f.map(e=>e.fileKey).filter(e=>typeof e==`string`&&e!==``);if(p.length===0)return u;let m=await i.getPublicFiles(p),h=new Map,g=[];for(let e of f){let t=m.get(e.fileKey);if(t===void 0||h.has(e.fileKey))continue;let n=o(e.fileKey),r=t=>n!==null&&t!==e.fileKey&&t.startsWith(n),s=a(t.metadata),c=[...s.legacy].filter(([,e])=>r(e)),l=s.shaped.filter(e=>r(e.key)),u=c.slice(0,64),d=l.slice(0,64);(u.length<c.length||d.length<l.length)&&i.logger?.warn({mediaId:e.id,legacy:c.length,shaped:l.length,cap:64},`public media: a photograph records more variants than any pass writes — the excess is ignored`),h.set(e.fileKey,{legacy:u,shaped:d}),g.push(...u.map(([,e])=>e),...d.map(e=>e.key))}let _=g.length>0?await i.getPublicUrls(g):new Map;for(let r of f){let i=m.get(r.fileKey),a=h.get(r.fileKey);if(i===void 0||a===void 0)continue;let o=Object.fromEntries(a.legacy.flatMap(([e,t])=>{let n=_.get(t);return n===void 0?[]:[[e,n]]})),l=new Map;for(let e of a.shaped){let t=_.get(e.key);if(t===void 0)continue;let n=l.get(e.shape)??[];n.push({url:t,width:e.width,height:e.height}),l.set(e.shape,n)}let d=new Map([...l].map(([e,t])=>[e,s(t)])),f=d.get(n),p=Object.fromEntries([...d].filter(([e,t])=>e===`natural`||c(e,t,f,r)));u.set(r.id,{id:r.id,url:i.url,mimeType:r.mimeType??null,width:r.width??null,height:r.height??null,alt:r.alt??null,title:r.title??null,variants:o,sources:p,focalPoint:t(r.focalPoint),blurDataUrl:e(r.blurDataUrl)})}return u}function u(e,t,n=Date.now){let r=null;return async()=>{let i=n();if(r&&i-r.at<t)return r.value;let a=e();return r={value:a,at:i},a.catch(()=>{r?.value===a&&(r=null)}),a}}async function d(){try{let{getApp:e}=await import(`@murumets-ee/core`);return e().logger}catch{return}}function f(){return async(e,t={})=>{let[{createMediaQueryClient:n},{getSharedStorageClient:r}]=await Promise.all([import(`./query-client.mjs`),import(`./client.mjs`)]),[i,a,o]=await Promise.all([n(),r(),d()]);return l(e,{readMedia:async(e,t)=>{let{schemaRegistry:n}=await import(`@murumets-ee/db`),{getTableColumns:r,inArray:a}=await import(`drizzle-orm`),o=n.get(`media`);if(!o)return[];let s=r(o).id;return s?await i.findMany({where:a(s,[...e]),limit:e.length,...t!==void 0&&{locale:t}}):[]},getPublicFiles:e=>a.getPublicFiles(e),getPublicUrls:e=>a.getPublicUrls(e),logger:o},t)}}let p;async function m(e,t){return p??=f(),h(await p(e,t))}function h(e){let t=new Map;for(let[n,r]of e)r.mimeType?.startsWith(`image/`)===!0&&t.set(n,{url:r.url,alt:r.alt,width:r.width,height:r.height,focalPoint:r.focalPoint,sources:r.sources,blurDataUrl:r.blurDataUrl});return t}export{i as cacheOnceUnlessRejected,f as createPublicMediaResolver,u as memoiseWithTtl,m as resolveCanvasImages,l as resolvePublicMedia,h as toCanvasImages};
2
2
  //# sourceMappingURL=public-resolver.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"public-resolver.mjs","names":[],"sources":["../src/public-resolver.ts"],"sourcesContent":["/**\n * The PUBLIC media resolver — media as data, for a consumer that builds its own\n * markup (plan/api phase 06, D021).\n *\n * **Why not `resolveMediaRefs`.** That helper parses `[media:type:id:variant]`\n * tags out of a text string and substitutes rendered HTML (`<img …>`, `<a\n * href=…>`) through per-type renderers. Right for a server-rendered page, wrong\n * for an HTTP API: a frontend needs `{url, width, height, alt}` so it can emit\n * its own `<picture>` with its own `srcset`, not a pre-baked tag it has to parse\n * back apart. Nothing here deprecates `resolveMediaRefs`; the API simply uses a\n * different, data-shaped path.\n *\n * **Why the entity cannot just be whitelisted.** `Media` is a real entity, but\n * it has no `url` field and cannot have one: what is stored is `fileKey`, and\n * the URL is DERIVED by the storage layer, which also decides whether it is a\n * direct public link or a time-limited signed one. Serving `media` as an\n * ordinary whitelisted entity would hand a consumer a storage key — useless for\n * rendering, and a gratuitous disclosure of storage layout.\n *\n * **This resolver can never emit a signed URL, structurally.** It asks storage\n * for PUBLIC urls (`getPublicUrls`) rather than asking for \"a url\" and\n * inspecting what came back. The second shape produces a signed URL on the\n * happy path and relies on a later check to suppress it — and a signed URL is a\n * bearer capability that outlives the permission check that minted it, on a\n * response the public role is deliberately allowed to cache. There is no branch\n * here to forget.\n *\n * D021 permits a permission-gated scoped URL for NON-public principals on\n * responses already forced to `no-store`. It is deliberately not built: no\n * consumer needs it, and an unexercised signed-URL branch is a check that\n * cannot fail. The shape is fixed so nobody re-derives it; the path waits for a\n * named consumer.\n */\n\nimport type { PgColumn } from 'drizzle-orm/pg-core'\n\nexport { cacheOnceUnlessRejected } from './async-cache.js'\n\nimport { type FocalPoint, readFocalPoint } from './crop/focal-point.js'\nimport { readVariantState } from './jobs/variant-state.js'\nimport { NATURAL_SHAPE } from './shapes.js'\nimport { MAX_VARIANTS_PER_IMAGE, plannedTopWidth } from './variant-plan.js'\n\n/**\n * One generated file of a photograph, as a render can use it in a `srcset`.\n *\n * `width`/`height` are what the FILE is — as sharp returned them when it was\n * written (images D003/F003) — never the ladder rung that was asked for. A `w`\n * descriptor that claims a width the file does not have makes the browser pick\n * the wrong candidate, silently.\n */\nexport interface PublicMediaSource {\n url: string\n width: number\n height: number\n}\n\n/**\n * One media item as it reaches an HTTP consumer.\n *\n * A STRUCTURAL match for `@murumets-ee/content-api`'s `MediaProjection`, not an\n * import of it: `content-api` is a leaf that this package's consumers wire into\n * by hand, exactly like `ContentApiResolution` mirrors content's\n * `ResolveResult`. The app that wires the two is where TypeScript checks they\n * agree.\n *\n * `width`/`height` ride along because a frontend needs them to reserve layout\n * space before the image loads; omitting them forces either a layout shift or a\n * second request, and the second request is the 4+N failure D001 exists to\n * prevent. `alt`/`title` are translatable on the Media entity, so this\n * projection is locale-dependent.\n */\nexport interface PublicMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n /**\n * Variant URLs by image-style name, INLINED as data rather than handed over\n * as a URL pattern to interpolate.\n *\n * Two reasons, and the second is a security property. D008's rule is that a\n * client derives nothing — a consumer that constructs URLs from a pattern\n * encodes an assumption the server never published, and nothing can detect\n * when it goes stale. And a URL pattern is necessarily public-SHAPED: the\n * moment a consumer can build its own URL, the server has lost the ability to\n * decide what that consumer may address, and the \"never a signed URL\"\n * guarantee becomes unenforceable because the consumer stopped asking.\n *\n * The payload multiplier is bounded by CONFIGURATION, not by content: the set\n * of image styles is operator-chosen and neither the caller nor the editor can\n * grow it. So this needs no `?populate=`-style opt-in — unlike reference\n * expansion, where the caller could otherwise drag arbitrarily large targets\n * in. A style whose variant file is missing or not public is simply absent.\n *\n * Read from what the photograph's ORIGINAL records it has (`metadata.variants`,\n * written by every pass since the pipeline existed), not re-derived from the\n * current style list (images F017) — so a style added a moment ago that the\n * catch-up has not reached yet is absent here, which a consumer already\n * handles as `variants[style] ?? url`.\n */\n variants: Record<string, string>\n /**\n * Every generated file, by SHAPE name, ascending by width — what a render\n * builds a `srcset` from (images D007, lumi-cms#1160).\n *\n * `natural` is the photograph's own ratio over the width ladder; every other\n * key is a vocabulary shape (`square`, `portrait`, …) or a legacy style that\n * became a one-rung shape, cropped around the focal point or the editor's\n * saved crop window for that shape. A shape the photograph has no files for\n * YET — the catch-up has not reached it — is absent, never an empty list.\n *\n * Only rungs that exist for THIS photograph are listed: a rung wider than the\n * source is never generated, and a tight crop may yield a single file. So a\n * reader takes what is here rather than assuming the configured ladder.\n *\n * Bounded by configuration exactly like `variants` — `MAX_VARIANTS_PER_IMAGE`\n * files per photograph, whatever the page holds.\n */\n sources: Record<string, PublicMediaSource[]>\n /**\n * Where the subject is, as `{ x, y }` fractions of the image (images D002).\n *\n * ALWAYS present: a row with no focal point, or one whose stored value is\n * unusable, projects as the centre, so no consumer branches on absence (D010).\n * It rides the PROJECTION and never the stored block ref — the resolver\n * substitutes fresh data on every read, so a point edited today reaches a page\n * saved last year with nothing rewritten (D007).\n */\n focalPoint: FocalPoint\n}\n\n/** The Media rows this resolver needs. Structural, so any read path can supply them. */\nexport interface PublicMediaRow {\n id: string\n fileKey: string\n mimeType?: string | null\n width?: number | null\n height?: number | null\n alt?: string | null\n title?: string | null\n /** The stored `media.focalPoint` — untrusted here, read through `readFocalPoint`. */\n focalPoint?: unknown\n /** The stored `media.crops` — the editor's saved windows, read the way the planner reads them. */\n crops?: unknown\n}\n\nexport interface PublicMediaResolverDeps {\n /**\n * Read media rows by id through a PUBLISHED-ONLY path.\n *\n * **The wiring must guarantee this** — pass a `MediaQueryClient`/`QueryClient`\n * read, never an `AdminClient` one. This module cannot enforce the choice (it\n * is injected), and an admin read would surface unpublished media on the\n * anonymous surface. Same posture, and same reason, as\n * `ContentApiResolution.resolvePath`.\n */\n readMedia: (ids: readonly string[], locale?: string) => Promise<PublicMediaRow[]>\n /**\n * Public URLs for a batch of ORIGINAL storage keys, each with the file's\n * stored `metadata` — `StorageClient.getPublicFiles`. The metadata is where a\n * photograph records which variants it has (F016/F017); it is read here and\n * never forwarded. Anything missing or non-public is ABSENT from the map.\n */\n getPublicFiles: (\n keys: readonly string[],\n ) => Promise<Map<string, { url: string; metadata: Record<string, unknown> | null }>>\n /**\n * Public URLs for a batch of VARIANT storage keys. Anything missing or\n * non-public is ABSENT from the map — see `StorageClient.getPublicUrls`, which\n * is the ask-for-public shape this whole module rests on. Asked per variant,\n * not inferred from the original's visibility: a variant is its own storage\n * row and can be made private on its own.\n */\n getPublicUrls: (keys: readonly string[]) => Promise<Map<string, string>>\n /** Where a truncated record is reported. Optional; nothing else logs. */\n logger?: { warn: (obj: Record<string, unknown>, msg: string) => void } | undefined\n}\n\n/**\n * The directory every variant of `fileKey` lives under — `<dir>/` for the\n * legacy `<dir>/<style>_<base>` files and the shaped `<dir>/variants/…` ones\n * alike (`variant-key.ts`).\n *\n * The variant list comes from the original's metadata, which is caller-shaped\n * JSON: an admin with storage access can write any key into it. Keys outside\n * the original's own directory are refused, so a crafted list can at most\n * point at this photograph's own files — never at another upload's.\n */\nfunction variantDirectory(fileKey: string): string | null {\n const lastSlash = fileKey.lastIndexOf('/')\n // No directory → no variants at all. The generator always writes originals\n // under `uploads/<y>/<m>/<id>/`; a caller-chosen key with no slash would\n // otherwise make the prefix `''`, and every public key would pass.\n return lastSlash === -1 ? null : fileKey.slice(0, lastSlash + 1)\n}\n\n/**\n * How many recorded entries of each kind one photograph may contribute to the\n * variant lookup. The metadata is caller-shaped JSON — an admin with storage\n * access can write a hundred thousand same-directory keys into it — and this\n * runs on the ANONYMOUS path, once per page view that embeds the photograph.\n * A pass never records more than `MAX_VARIANTS_PER_IMAGE` shaped files, and\n * the legacy styles are bounded the same way, so anything past it is not\n * something the generator wrote.\n */\nconst MAX_RECORDED_PER_KIND = MAX_VARIANTS_PER_IMAGE\n\n/** Sort ascending by width and keep the first file at each width. */\nfunction ladder(sources: PublicMediaSource[]): PublicMediaSource[] {\n const sorted = [...sources].sort((a, b) => a.width - b.width)\n return sorted.filter((source, index) => index === 0 || sorted[index - 1]?.width !== source.width)\n}\n\n/**\n * Is this recorded shaped list what a pass would have written for THIS\n * photograph's region of that shape?\n *\n * A render replaces the CSS-cropped original with a shaped list whose ratio\n * matches the slot, so a shaped list must never be a downgrade. Two recorded\n * lists would be:\n * - a one-rung shape narrower than the ladder (an operator's `card` edited to\n * 800×600 becomes one): a 4:3 slot over a 4000×3000 photograph could have\n * had 2560px files, and 800 would replace the original;\n * - a list a failed or partial pass left short.\n *\n * So the list is checked against the planner's OWN answer (`plannedTopWidth`):\n * the same region — the editor's saved crop for the shape, else the focal\n * window — and the same rung test, over the photograph's natural widths, which\n * ARE the ladder as it applies to this photograph. That is also what keeps a\n * ZOOMED saved crop: its region is small, the planner writes small files for\n * it, and those are complete for it — a check against the full region would\n * refuse exactly the crops an editor chose on purpose.\n *\n * A list that fails is left out of `sources`, so a render draws the natural\n * file cropped by CSS, exactly as before shaped files existed. No size or no\n * natural list means nothing to check against — left out too.\n */\nfunction isCompleteShapedList(\n shape: string,\n list: readonly PublicMediaSource[],\n natural: readonly PublicMediaSource[] | undefined,\n row: PublicMediaRow,\n): boolean {\n const largest = list[list.length - 1]\n const { width, height } = row\n if (largest === undefined || natural === undefined || natural.length === 0) return false\n if (typeof width !== 'number' || typeof height !== 'number') return false\n const top = plannedTopWidth(\n { name: shape, ratio: largest.width / largest.height, widths: natural.map((n) => n.width) },\n { width, height },\n { focalPoint: row.focalPoint, crops: row.crops },\n )\n // 2%: the recorded width is what sharp returned, and a file can be a pixel\n // narrower than the width it was planned at.\n return top !== undefined && largest.width >= top * 0.98\n}\n\n/**\n * Resolve media ids to public projections.\n *\n * Ids that do not exist, are unpublished for this caller, or whose file is not\n * public are ABSENT from the returned map — one absence, four causes, exactly\n * as phase 05's failure semantics require. The caller renders without them.\n *\n * Cost is THREE round trips at most, regardless of batch size: one entity read,\n * one storage read over the ORIGINALS (their public URLs and the variant set\n * each records), and one storage read over every VARIANT key those records\n * name (its visibility, checked per file). Emphatically NOT N×(styles+1), which\n * is what a per-item convenience method would have made it. Each storage read\n * is internally chunked when the key count exceeds what one query may carry,\n * so \"one storage read\" is `ceil(keys / 500)` in the extreme.\n *\n * (Until images PR06 the three were an image-style lookup, the entity read and\n * one storage read over keys DERIVED from the current styles. Deriving keys is\n * what F017 retired — a shaped key depends on the file's drawn size and a\n * fingerprint, so only the record knows it — and reading the record made the\n * style lookup unnecessary, so the count did not grow.)\n */\nexport async function resolvePublicMedia(\n ids: readonly string[],\n deps: PublicMediaResolverDeps,\n opts: { locale?: string | undefined } = {},\n): Promise<Map<string, PublicMediaProjection>> {\n const out = new Map<string, PublicMediaProjection>()\n const unique = [...new Set(ids)]\n if (unique.length === 0) return out\n\n const rows = await deps.readMedia(unique, opts.locale)\n const fileKeys = rows\n .map((row) => row.fileKey)\n .filter((key): key is string => typeof key === 'string' && key !== '')\n if (fileKeys.length === 0) return out\n\n // No public ORIGINAL → the item does not resolve at all, so its variants are\n // never even looked up. Not \"resolves with variants only\": the variants of a\n // private original are an accident of processing, not a sanctioned public\n // view of it.\n const originals = await deps.getPublicFiles(fileKeys)\n\n interface Recorded {\n readonly legacy: Array<[string, string]>\n readonly shaped: Array<{ shape: string; key: string; width: number; height: number }>\n }\n const recorded = new Map<string, Recorded>()\n const variantKeys: string[] = []\n for (const row of rows) {\n const original = originals.get(row.fileKey)\n if (original === undefined || recorded.has(row.fileKey)) continue\n const directory = variantDirectory(row.fileKey)\n const inDirectory = (key: string): boolean =>\n directory !== null && key !== row.fileKey && key.startsWith(directory)\n // `unconfirmedVariants` are deliberately not read: those are files a FAILED\n // pass uploaded, which the retry adopts or deletes (PR05). A reader serving\n // them would show a crop no pass vouched for.\n const state = readVariantState(original.metadata)\n const allLegacy = [...state.legacy].filter(([, key]) => inDirectory(key))\n const allShaped = state.shaped.filter((v) => inDirectory(v.key))\n const legacy = allLegacy.slice(0, MAX_RECORDED_PER_KIND)\n const shaped = allShaped.slice(0, MAX_RECORDED_PER_KIND)\n if (legacy.length < allLegacy.length || shaped.length < allShaped.length) {\n deps.logger?.warn(\n {\n mediaId: row.id,\n legacy: allLegacy.length,\n shaped: allShaped.length,\n cap: MAX_RECORDED_PER_KIND,\n },\n 'public media: a photograph records more variants than any pass writes — the excess is ignored',\n )\n }\n recorded.set(row.fileKey, { legacy, shaped })\n variantKeys.push(...legacy.map(([, key]) => key), ...shaped.map((v) => v.key))\n }\n\n const variantUrls =\n variantKeys.length > 0 ? await deps.getPublicUrls(variantKeys) : new Map<string, string>()\n\n for (const row of rows) {\n const original = originals.get(row.fileKey)\n const record = recorded.get(row.fileKey)\n if (original === undefined || record === undefined) continue\n\n // Built with `Object.fromEntries`, which defines OWN properties: a style\n // or shape named `__proto__` in the caller-shaped record is then an\n // ordinary key, never a prototype write on the object we hand out.\n const variants: Record<string, string> = Object.fromEntries(\n record.legacy.flatMap(([styleName, key]) => {\n const url = variantUrls.get(key)\n return url === undefined ? [] : [[styleName, url] as const]\n }),\n )\n\n const byShape = new Map<string, PublicMediaSource[]>()\n for (const variant of record.shaped) {\n const url = variantUrls.get(variant.key)\n if (url === undefined) continue\n const list = byShape.get(variant.shape) ?? []\n list.push({ url, width: variant.width, height: variant.height })\n byShape.set(variant.shape, list)\n }\n const laddered = new Map([...byShape].map(([shape, list]) => [shape, ladder(list)] as const))\n const natural = laddered.get(NATURAL_SHAPE)\n const sources: Record<string, PublicMediaSource[]> = Object.fromEntries(\n [...laddered].filter(\n ([shape, list]) =>\n shape === NATURAL_SHAPE || isCompleteShapedList(shape, list, natural, row),\n ),\n )\n\n out.set(row.id, {\n id: row.id,\n url: original.url,\n mimeType: row.mimeType ?? null,\n width: row.width ?? null,\n height: row.height ?? null,\n alt: row.alt ?? null,\n title: row.title ?? null,\n variants,\n sources,\n focalPoint: readFocalPoint(row.focalPoint),\n })\n }\n\n return out\n}\n\n/**\n * Wire {@link resolvePublicMedia} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveMedia })`.\n *\n * Every dependency is resolved through the PUBLIC read path:\n * `MediaQueryClient` (so the publish filter, the `view` gate and locale merging\n * all apply, and `alt`/`title` come back in the requested locale), and\n * `StorageClient.getPublicUrls` (which has no signed-URL branch at all).\n *\n * Imports are dynamic for the same reason `createMediaQueryClient`'s are: this\n * must be callable from a route module without dragging the storage/settings\n * graph into whatever bundle imports it.\n */\n/**\n * A single-value cache with a wall-clock TTL.\n *\n * Extracted rather than inlined so it can be tested with an injected clock:\n * the thing being asserted is time-dependent, and new stateful code on the\n * anonymous request path with no coverage is how a cache ends up serving the\n * wrong thing for a window nobody measured.\n *\n * Caches the PROMISE, not the settled value, so concurrent callers arriving\n * before the first load resolves share it. Caching only the value would let N\n * simultaneous requests each start their own read — the stampede this memo\n * exists to remove, on the one path where bursts are expected.\n *\n * A rejection is still NOT pinned for the TTL: the entry is dropped when the\n * promise rejects, so the next call retries rather than replaying a transient\n * outage for the rest of the window.\n */\nexport function memoiseWithTtl<T>(\n load: () => Promise<T>,\n ttlMs: number,\n now: () => number = Date.now,\n): () => Promise<T> {\n let cached: { value: Promise<T>; at: number } | null = null\n return async () => {\n const t = now()\n if (cached && t - cached.at < ttlMs) return cached.value\n const value = load()\n cached = { value, at: t }\n value.catch(() => {\n // Only clear OUR entry — a later call may already have replaced it.\n if (cached?.value === value) cached = null\n })\n return value\n }\n}\n\n/** The app's logger, when there is an app — this resolver still works without one. */\nasync function appLogger(): Promise<PublicMediaResolverDeps['logger']> {\n try {\n const { getApp } = await import('@murumets-ee/core')\n return getApp().logger\n } catch {\n // No initialised app (a script, a test): there is no logger to report a\n // truncated record through, and nothing else in this resolver needs one.\n return undefined\n }\n}\n\nexport function createPublicMediaResolver(): (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, PublicMediaProjection>> {\n return async (ids, opts = {}) => {\n const [{ createMediaQueryClient }, { getSharedStorageClient }] = await Promise.all([\n import('./query-client.js'),\n import('./client.js'),\n ])\n\n const [mediaQuery, storage, logger] = await Promise.all([\n createMediaQueryClient(),\n getSharedStorageClient(),\n appLogger(),\n ])\n\n return resolvePublicMedia(\n ids,\n {\n readMedia: async (mediaIds, locale) => {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { getTableColumns, inArray } = await import('drizzle-orm')\n const table = schemaRegistry.get('media')\n if (!table) return []\n // Column refs come from drizzle's own typed accessor, never from a bare\n // `table.id` — the registry hands back a dynamically-generated table\n // whose property access is untyped, so reaching through it would put an\n // `any` straight into the WHERE clause.\n const columns = getTableColumns(table) as Record<string, PgColumn>\n const idColumn = columns.id\n if (!idColumn) return []\n const rows = await mediaQuery.findMany({\n where: inArray(idColumn, [...mediaIds]),\n limit: mediaIds.length,\n ...(locale !== undefined && { locale }),\n })\n return rows as unknown as PublicMediaRow[]\n },\n getPublicFiles: (keys) => storage.getPublicFiles(keys),\n getPublicUrls: (keys) => storage.getPublicUrls(keys),\n logger,\n },\n opts,\n )\n }\n}\n\n/**\n * One image the CANVAS can draw for a media id — the shape `@murumets-ee/blocks`'\n * `CanvasImageResolver` asks for, declared structurally so this package does not\n * depend on blocks.\n */\nexport interface CanvasImage {\n readonly url: string\n readonly alt: string | null\n readonly width: number | null\n readonly height: number | null\n /**\n * The same two keys the site's projection carries (images D007, F010), so a\n * shaped slot on the canvas crops around the point and picks the same file\n * the published page does. Stripping them is how the canvas and the site\n * drifted on exactly the thing this plan exists to keep in step.\n */\n readonly focalPoint: FocalPoint\n readonly sources: Readonly<Record<string, readonly PublicMediaSource[]>>\n}\n\nlet canvasResolver: ReturnType<typeof createPublicMediaResolver> | undefined\n\n/**\n * Media ids → images the block-editor CANVAS may draw: a BOUND `field.media()`\n * column (frontend-delivery F112) and a collection card's picture.\n *\n * The ready-made `blocksPlugin({ resolveImages })` value, so an app wires one lazy\n * line instead of copying a resolver (frontend-delivery D025 — no builder's\n * manual):\n *\n * ```ts\n * blocksPlugin({\n * resolveImages: async (ids, opts) =>\n * (await import('@murumets-ee/media/public')).resolveCanvasImages(ids, opts),\n * })\n * ```\n *\n * Two properties are the point, not conveniences:\n *\n * - **The SAME public resolver content-api uses**, so the canvas cannot show an\n * image the published site would not (a private asset draws as empty on both).\n * - **Images only.** A `field.media({ kind: 'image' })` constrains only the\n * picker's list (editor F116), so a video or PDF can sit in the column; drawn\n * through an `<img>` it would be a broken image. Anything that is not\n * `image/*` is left out, and the canvas draws its empty placeholder instead.\n *\n * The resolver is built once per process — it memoises the image-style set, which\n * a fresh one per call would re-read.\n */\nexport async function resolveCanvasImages(\n ids: readonly string[],\n opts: { locale: string },\n): Promise<Map<string, CanvasImage>> {\n canvasResolver ??= createPublicMediaResolver()\n return toCanvasImages(await canvasResolver(ids, opts))\n}\n\n/**\n * The pure half of {@link resolveCanvasImages}: keep images, drop everything else,\n * project to what the canvas draws. Exported so the filter has a test that does\n * not need a database.\n */\nexport function toCanvasImages(\n found: ReadonlyMap<string, PublicMediaProjection>,\n): Map<string, CanvasImage> {\n const images = new Map<string, CanvasImage>()\n for (const [id, media] of found) {\n if (media.mimeType?.startsWith('image/') !== true) continue\n images.set(id, {\n url: media.url,\n alt: media.alt,\n width: media.width,\n height: media.height,\n focalPoint: media.focalPoint,\n sources: media.sources,\n })\n }\n return images\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"public-resolver.mjs","names":[],"sources":["../src/public-resolver.ts"],"sourcesContent":["/**\n * The PUBLIC media resolver — media as data, for a consumer that builds its own\n * markup (plan/api phase 06, D021).\n *\n * **Why not `resolveMediaRefs`.** That helper parses `[media:type:id:variant]`\n * tags out of a text string and substitutes rendered HTML (`<img …>`, `<a\n * href=…>`) through per-type renderers. Right for a server-rendered page, wrong\n * for an HTTP API: a frontend needs `{url, width, height, alt}` so it can emit\n * its own `<picture>` with its own `srcset`, not a pre-baked tag it has to parse\n * back apart. Nothing here deprecates `resolveMediaRefs`; the API simply uses a\n * different, data-shaped path.\n *\n * **Why the entity cannot just be whitelisted.** `Media` is a real entity, but\n * it has no `url` field and cannot have one: what is stored is `fileKey`, and\n * the URL is DERIVED by the storage layer, which also decides whether it is a\n * direct public link or a time-limited signed one. Serving `media` as an\n * ordinary whitelisted entity would hand a consumer a storage key — useless for\n * rendering, and a gratuitous disclosure of storage layout.\n *\n * **This resolver can never emit a signed URL, structurally.** It asks storage\n * for PUBLIC urls (`getPublicUrls`) rather than asking for \"a url\" and\n * inspecting what came back. The second shape produces a signed URL on the\n * happy path and relies on a later check to suppress it — and a signed URL is a\n * bearer capability that outlives the permission check that minted it, on a\n * response the public role is deliberately allowed to cache. There is no branch\n * here to forget.\n *\n * D021 permits a permission-gated scoped URL for NON-public principals on\n * responses already forced to `no-store`. It is deliberately not built: no\n * consumer needs it, and an unexercised signed-URL branch is a check that\n * cannot fail. The shape is fixed so nobody re-derives it; the path waits for a\n * named consumer.\n */\n\nimport type { PgColumn } from 'drizzle-orm/pg-core'\n\nexport { cacheOnceUnlessRejected } from './async-cache.js'\n\nimport { readBlurDataUrl } from './crop/blur-data-url.js'\nimport { type FocalPoint, readFocalPoint } from './crop/focal-point.js'\nimport { readVariantState } from './jobs/variant-state.js'\nimport { NATURAL_SHAPE } from './shapes.js'\nimport { MAX_VARIANTS_PER_IMAGE, plannedTopWidth } from './variant-plan.js'\n\n/**\n * One generated file of a photograph, as a render can use it in a `srcset`.\n *\n * `width`/`height` are what the FILE is — as sharp returned them when it was\n * written (images D003/F003) — never the ladder rung that was asked for. A `w`\n * descriptor that claims a width the file does not have makes the browser pick\n * the wrong candidate, silently.\n */\nexport interface PublicMediaSource {\n url: string\n width: number\n height: number\n}\n\n/**\n * One media item as it reaches an HTTP consumer.\n *\n * A STRUCTURAL match for `@murumets-ee/content-api`'s `MediaProjection`, not an\n * import of it: `content-api` is a leaf that this package's consumers wire into\n * by hand, exactly like `ContentApiResolution` mirrors content's\n * `ResolveResult`. The app that wires the two is where TypeScript checks they\n * agree.\n *\n * `width`/`height` ride along because a frontend needs them to reserve layout\n * space before the image loads; omitting them forces either a layout shift or a\n * second request, and the second request is the 4+N failure D001 exists to\n * prevent. `alt`/`title` are translatable on the Media entity, so this\n * projection is locale-dependent.\n */\nexport interface PublicMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n /**\n * Variant URLs by image-style name, INLINED as data rather than handed over\n * as a URL pattern to interpolate.\n *\n * Two reasons, and the second is a security property. D008's rule is that a\n * client derives nothing — a consumer that constructs URLs from a pattern\n * encodes an assumption the server never published, and nothing can detect\n * when it goes stale. And a URL pattern is necessarily public-SHAPED: the\n * moment a consumer can build its own URL, the server has lost the ability to\n * decide what that consumer may address, and the \"never a signed URL\"\n * guarantee becomes unenforceable because the consumer stopped asking.\n *\n * The payload multiplier is bounded by CONFIGURATION, not by content: the set\n * of image styles is operator-chosen and neither the caller nor the editor can\n * grow it. So this needs no `?populate=`-style opt-in — unlike reference\n * expansion, where the caller could otherwise drag arbitrarily large targets\n * in. A style whose variant file is missing or not public is simply absent.\n *\n * Read from what the photograph's ORIGINAL records it has (`metadata.variants`,\n * written by every pass since the pipeline existed), not re-derived from the\n * current style list (images F017) — so a style added a moment ago that the\n * catch-up has not reached yet is absent here, which a consumer already\n * handles as `variants[style] ?? url`.\n */\n variants: Record<string, string>\n /**\n * Every generated file, by SHAPE name, ascending by width — what a render\n * builds a `srcset` from (images D007, lumi-cms#1160).\n *\n * `natural` is the photograph's own ratio over the width ladder; every other\n * key is a vocabulary shape (`square`, `portrait`, …) or a legacy style that\n * became a one-rung shape, cropped around the focal point or the editor's\n * saved crop window for that shape. A shape the photograph has no files for\n * YET — the catch-up has not reached it — is absent, never an empty list.\n *\n * Only rungs that exist for THIS photograph are listed: a rung wider than the\n * source is never generated, and a tight crop may yield a single file. So a\n * reader takes what is here rather than assuming the configured ladder.\n *\n * Bounded by configuration exactly like `variants` — `MAX_VARIANTS_PER_IMAGE`\n * files per photograph, whatever the page holds.\n */\n sources: Record<string, PublicMediaSource[]>\n /**\n * Where the subject is, as `{ x, y }` fractions of the image (images D002).\n *\n * ALWAYS present: a row with no focal point, or one whose stored value is\n * unusable, projects as the centre, so no consumer branches on absence (D010).\n * It rides the PROJECTION and never the stored block ref — the resolver\n * substitutes fresh data on every read, so a point edited today reaches a page\n * saved last year with nothing rewritten (D007).\n */\n focalPoint: FocalPoint\n /**\n * The blurred preview the photograph shows while it loads — a data URL of a\n * tiny image of it (images PR11), or `null` when it has none: a transparent\n * source, a row the catch-up has not reached, or a stored value that failed\n * validation. A render draws it as the `<img>`'s own `background-image`.\n *\n * VALIDATED before it is published (`readBlurDataUrl`): it lands in an inline\n * `style` on public pages. Like `focalPoint`, it rides the PROJECTION and\n * never the stored block ref (D007).\n */\n blurDataUrl: string | null\n}\n\n/** The Media rows this resolver needs. Structural, so any read path can supply them. */\nexport interface PublicMediaRow {\n id: string\n fileKey: string\n mimeType?: string | null\n width?: number | null\n height?: number | null\n alt?: string | null\n title?: string | null\n /** The stored `media.focalPoint` — untrusted here, read through `readFocalPoint`. */\n focalPoint?: unknown\n /** The stored `media.crops` — the editor's saved windows, read the way the planner reads them. */\n crops?: unknown\n /** The stored `media.blurDataUrl` — untrusted here, read through `readBlurDataUrl`. */\n blurDataUrl?: unknown\n}\n\nexport interface PublicMediaResolverDeps {\n /**\n * Read media rows by id through a PUBLISHED-ONLY path.\n *\n * **The wiring must guarantee this** — pass a `MediaQueryClient`/`QueryClient`\n * read, never an `AdminClient` one. This module cannot enforce the choice (it\n * is injected), and an admin read would surface unpublished media on the\n * anonymous surface. Same posture, and same reason, as\n * `ContentApiResolution.resolvePath`.\n */\n readMedia: (ids: readonly string[], locale?: string) => Promise<PublicMediaRow[]>\n /**\n * Public URLs for a batch of ORIGINAL storage keys, each with the file's\n * stored `metadata` — `StorageClient.getPublicFiles`. The metadata is where a\n * photograph records which variants it has (F016/F017); it is read here and\n * never forwarded. Anything missing or non-public is ABSENT from the map.\n */\n getPublicFiles: (\n keys: readonly string[],\n ) => Promise<Map<string, { url: string; metadata: Record<string, unknown> | null }>>\n /**\n * Public URLs for a batch of VARIANT storage keys. Anything missing or\n * non-public is ABSENT from the map — see `StorageClient.getPublicUrls`, which\n * is the ask-for-public shape this whole module rests on. Asked per variant,\n * not inferred from the original's visibility: a variant is its own storage\n * row and can be made private on its own.\n */\n getPublicUrls: (keys: readonly string[]) => Promise<Map<string, string>>\n /** Where a truncated record is reported. Optional; nothing else logs. */\n logger?: { warn: (obj: Record<string, unknown>, msg: string) => void } | undefined\n}\n\n/**\n * The directory every variant of `fileKey` lives under — `<dir>/` for the\n * legacy `<dir>/<style>_<base>` files and the shaped `<dir>/variants/…` ones\n * alike (`variant-key.ts`).\n *\n * The variant list comes from the original's metadata, which is caller-shaped\n * JSON: an admin with storage access can write any key into it. Keys outside\n * the original's own directory are refused, so a crafted list can at most\n * point at this photograph's own files — never at another upload's.\n */\nfunction variantDirectory(fileKey: string): string | null {\n const lastSlash = fileKey.lastIndexOf('/')\n // No directory → no variants at all. The generator always writes originals\n // under `uploads/<y>/<m>/<id>/`; a caller-chosen key with no slash would\n // otherwise make the prefix `''`, and every public key would pass.\n return lastSlash === -1 ? null : fileKey.slice(0, lastSlash + 1)\n}\n\n/**\n * How many recorded entries of each kind one photograph may contribute to the\n * variant lookup. The metadata is caller-shaped JSON — an admin with storage\n * access can write a hundred thousand same-directory keys into it — and this\n * runs on the ANONYMOUS path, once per page view that embeds the photograph.\n * A pass never records more than `MAX_VARIANTS_PER_IMAGE` shaped files, and\n * the legacy styles are bounded the same way, so anything past it is not\n * something the generator wrote.\n */\nconst MAX_RECORDED_PER_KIND = MAX_VARIANTS_PER_IMAGE\n\n/** Sort ascending by width and keep the first file at each width. */\nfunction ladder(sources: PublicMediaSource[]): PublicMediaSource[] {\n const sorted = [...sources].sort((a, b) => a.width - b.width)\n return sorted.filter((source, index) => index === 0 || sorted[index - 1]?.width !== source.width)\n}\n\n/**\n * Is this recorded shaped list what a pass would have written for THIS\n * photograph's region of that shape?\n *\n * A render replaces the CSS-cropped original with a shaped list whose ratio\n * matches the slot, so a shaped list must never be a downgrade. Two recorded\n * lists would be:\n * - a one-rung shape narrower than the ladder (an operator's `card` edited to\n * 800×600 becomes one): a 4:3 slot over a 4000×3000 photograph could have\n * had 2560px files, and 800 would replace the original;\n * - a list a failed or partial pass left short.\n *\n * So the list is checked against the planner's OWN answer (`plannedTopWidth`):\n * the same region — the editor's saved crop for the shape, else the focal\n * window — and the same rung test, over the photograph's natural widths, which\n * ARE the ladder as it applies to this photograph. That is also what keeps a\n * ZOOMED saved crop: its region is small, the planner writes small files for\n * it, and those are complete for it — a check against the full region would\n * refuse exactly the crops an editor chose on purpose.\n *\n * A list that fails is left out of `sources`, so a render draws the natural\n * file cropped by CSS, exactly as before shaped files existed. No size or no\n * natural list means nothing to check against — left out too.\n */\nfunction isCompleteShapedList(\n shape: string,\n list: readonly PublicMediaSource[],\n natural: readonly PublicMediaSource[] | undefined,\n row: PublicMediaRow,\n): boolean {\n const largest = list[list.length - 1]\n const { width, height } = row\n if (largest === undefined || natural === undefined || natural.length === 0) return false\n if (typeof width !== 'number' || typeof height !== 'number') return false\n const top = plannedTopWidth(\n { name: shape, ratio: largest.width / largest.height, widths: natural.map((n) => n.width) },\n { width, height },\n { focalPoint: row.focalPoint, crops: row.crops },\n )\n // 2%: the recorded width is what sharp returned, and a file can be a pixel\n // narrower than the width it was planned at.\n return top !== undefined && largest.width >= top * 0.98\n}\n\n/**\n * Resolve media ids to public projections.\n *\n * Ids that do not exist, are unpublished for this caller, or whose file is not\n * public are ABSENT from the returned map — one absence, four causes, exactly\n * as phase 05's failure semantics require. The caller renders without them.\n *\n * Cost is THREE round trips at most, regardless of batch size: one entity read,\n * one storage read over the ORIGINALS (their public URLs and the variant set\n * each records), and one storage read over every VARIANT key those records\n * name (its visibility, checked per file). Emphatically NOT N×(styles+1), which\n * is what a per-item convenience method would have made it. Each storage read\n * is internally chunked when the key count exceeds what one query may carry,\n * so \"one storage read\" is `ceil(keys / 500)` in the extreme.\n *\n * (Until images PR06 the three were an image-style lookup, the entity read and\n * one storage read over keys DERIVED from the current styles. Deriving keys is\n * what F017 retired — a shaped key depends on the file's drawn size and a\n * fingerprint, so only the record knows it — and reading the record made the\n * style lookup unnecessary, so the count did not grow.)\n */\nexport async function resolvePublicMedia(\n ids: readonly string[],\n deps: PublicMediaResolverDeps,\n opts: { locale?: string | undefined } = {},\n): Promise<Map<string, PublicMediaProjection>> {\n const out = new Map<string, PublicMediaProjection>()\n const unique = [...new Set(ids)]\n if (unique.length === 0) return out\n\n const rows = await deps.readMedia(unique, opts.locale)\n const fileKeys = rows\n .map((row) => row.fileKey)\n .filter((key): key is string => typeof key === 'string' && key !== '')\n if (fileKeys.length === 0) return out\n\n // No public ORIGINAL → the item does not resolve at all, so its variants are\n // never even looked up. Not \"resolves with variants only\": the variants of a\n // private original are an accident of processing, not a sanctioned public\n // view of it.\n const originals = await deps.getPublicFiles(fileKeys)\n\n interface Recorded {\n readonly legacy: Array<[string, string]>\n readonly shaped: Array<{ shape: string; key: string; width: number; height: number }>\n }\n const recorded = new Map<string, Recorded>()\n const variantKeys: string[] = []\n for (const row of rows) {\n const original = originals.get(row.fileKey)\n if (original === undefined || recorded.has(row.fileKey)) continue\n const directory = variantDirectory(row.fileKey)\n const inDirectory = (key: string): boolean =>\n directory !== null && key !== row.fileKey && key.startsWith(directory)\n // `unconfirmedVariants` are deliberately not read: those are files a FAILED\n // pass uploaded, which the retry adopts or deletes (PR05). A reader serving\n // them would show a crop no pass vouched for.\n const state = readVariantState(original.metadata)\n const allLegacy = [...state.legacy].filter(([, key]) => inDirectory(key))\n const allShaped = state.shaped.filter((v) => inDirectory(v.key))\n const legacy = allLegacy.slice(0, MAX_RECORDED_PER_KIND)\n const shaped = allShaped.slice(0, MAX_RECORDED_PER_KIND)\n if (legacy.length < allLegacy.length || shaped.length < allShaped.length) {\n deps.logger?.warn(\n {\n mediaId: row.id,\n legacy: allLegacy.length,\n shaped: allShaped.length,\n cap: MAX_RECORDED_PER_KIND,\n },\n 'public media: a photograph records more variants than any pass writes — the excess is ignored',\n )\n }\n recorded.set(row.fileKey, { legacy, shaped })\n variantKeys.push(...legacy.map(([, key]) => key), ...shaped.map((v) => v.key))\n }\n\n const variantUrls =\n variantKeys.length > 0 ? await deps.getPublicUrls(variantKeys) : new Map<string, string>()\n\n for (const row of rows) {\n const original = originals.get(row.fileKey)\n const record = recorded.get(row.fileKey)\n if (original === undefined || record === undefined) continue\n\n // Built with `Object.fromEntries`, which defines OWN properties: a style\n // or shape named `__proto__` in the caller-shaped record is then an\n // ordinary key, never a prototype write on the object we hand out.\n const variants: Record<string, string> = Object.fromEntries(\n record.legacy.flatMap(([styleName, key]) => {\n const url = variantUrls.get(key)\n return url === undefined ? [] : [[styleName, url] as const]\n }),\n )\n\n const byShape = new Map<string, PublicMediaSource[]>()\n for (const variant of record.shaped) {\n const url = variantUrls.get(variant.key)\n if (url === undefined) continue\n const list = byShape.get(variant.shape) ?? []\n list.push({ url, width: variant.width, height: variant.height })\n byShape.set(variant.shape, list)\n }\n const laddered = new Map([...byShape].map(([shape, list]) => [shape, ladder(list)] as const))\n const natural = laddered.get(NATURAL_SHAPE)\n const sources: Record<string, PublicMediaSource[]> = Object.fromEntries(\n [...laddered].filter(\n ([shape, list]) =>\n shape === NATURAL_SHAPE || isCompleteShapedList(shape, list, natural, row),\n ),\n )\n\n out.set(row.id, {\n id: row.id,\n url: original.url,\n mimeType: row.mimeType ?? null,\n width: row.width ?? null,\n height: row.height ?? null,\n alt: row.alt ?? null,\n title: row.title ?? null,\n variants,\n sources,\n focalPoint: readFocalPoint(row.focalPoint),\n blurDataUrl: readBlurDataUrl(row.blurDataUrl),\n })\n }\n\n return out\n}\n\n/**\n * Wire {@link resolvePublicMedia} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveMedia })`.\n *\n * Every dependency is resolved through the PUBLIC read path:\n * `MediaQueryClient` (so the publish filter, the `view` gate and locale merging\n * all apply, and `alt`/`title` come back in the requested locale), and\n * `StorageClient.getPublicUrls` (which has no signed-URL branch at all).\n *\n * Imports are dynamic for the same reason `createMediaQueryClient`'s are: this\n * must be callable from a route module without dragging the storage/settings\n * graph into whatever bundle imports it.\n */\n/**\n * A single-value cache with a wall-clock TTL.\n *\n * Extracted rather than inlined so it can be tested with an injected clock:\n * the thing being asserted is time-dependent, and new stateful code on the\n * anonymous request path with no coverage is how a cache ends up serving the\n * wrong thing for a window nobody measured.\n *\n * Caches the PROMISE, not the settled value, so concurrent callers arriving\n * before the first load resolves share it. Caching only the value would let N\n * simultaneous requests each start their own read — the stampede this memo\n * exists to remove, on the one path where bursts are expected.\n *\n * A rejection is still NOT pinned for the TTL: the entry is dropped when the\n * promise rejects, so the next call retries rather than replaying a transient\n * outage for the rest of the window.\n */\nexport function memoiseWithTtl<T>(\n load: () => Promise<T>,\n ttlMs: number,\n now: () => number = Date.now,\n): () => Promise<T> {\n let cached: { value: Promise<T>; at: number } | null = null\n return async () => {\n const t = now()\n if (cached && t - cached.at < ttlMs) return cached.value\n const value = load()\n cached = { value, at: t }\n value.catch(() => {\n // Only clear OUR entry — a later call may already have replaced it.\n if (cached?.value === value) cached = null\n })\n return value\n }\n}\n\n/** The app's logger, when there is an app — this resolver still works without one. */\nasync function appLogger(): Promise<PublicMediaResolverDeps['logger']> {\n try {\n const { getApp } = await import('@murumets-ee/core')\n return getApp().logger\n } catch {\n // No initialised app (a script, a test): there is no logger to report a\n // truncated record through, and nothing else in this resolver needs one.\n return undefined\n }\n}\n\nexport function createPublicMediaResolver(): (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, PublicMediaProjection>> {\n return async (ids, opts = {}) => {\n const [{ createMediaQueryClient }, { getSharedStorageClient }] = await Promise.all([\n import('./query-client.js'),\n import('./client.js'),\n ])\n\n const [mediaQuery, storage, logger] = await Promise.all([\n createMediaQueryClient(),\n getSharedStorageClient(),\n appLogger(),\n ])\n\n return resolvePublicMedia(\n ids,\n {\n readMedia: async (mediaIds, locale) => {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { getTableColumns, inArray } = await import('drizzle-orm')\n const table = schemaRegistry.get('media')\n if (!table) return []\n // Column refs come from drizzle's own typed accessor, never from a bare\n // `table.id` — the registry hands back a dynamically-generated table\n // whose property access is untyped, so reaching through it would put an\n // `any` straight into the WHERE clause.\n const columns = getTableColumns(table) as Record<string, PgColumn>\n const idColumn = columns.id\n if (!idColumn) return []\n const rows = await mediaQuery.findMany({\n where: inArray(idColumn, [...mediaIds]),\n limit: mediaIds.length,\n ...(locale !== undefined && { locale }),\n })\n return rows as unknown as PublicMediaRow[]\n },\n getPublicFiles: (keys) => storage.getPublicFiles(keys),\n getPublicUrls: (keys) => storage.getPublicUrls(keys),\n logger,\n },\n opts,\n )\n }\n}\n\n/**\n * One image the CANVAS can draw for a media id — the shape `@murumets-ee/blocks`'\n * `CanvasImageResolver` asks for, declared structurally so this package does not\n * depend on blocks.\n */\nexport interface CanvasImage {\n readonly url: string\n readonly alt: string | null\n readonly width: number | null\n readonly height: number | null\n /**\n * The same two keys the site's projection carries (images D007, F010), so a\n * shaped slot on the canvas crops around the point and picks the same file\n * the published page does. Stripping them is how the canvas and the site\n * drifted on exactly the thing this plan exists to keep in step.\n */\n readonly focalPoint: FocalPoint\n readonly sources: Readonly<Record<string, readonly PublicMediaSource[]>>\n /** The site's preview too, so the canvas shows the same one while it loads (images PR11). */\n readonly blurDataUrl: string | null\n}\n\nlet canvasResolver: ReturnType<typeof createPublicMediaResolver> | undefined\n\n/**\n * Media ids → images the block-editor CANVAS may draw: a BOUND `field.media()`\n * column (frontend-delivery F112) and a collection card's picture.\n *\n * The ready-made `blocksPlugin({ resolveImages })` value, so an app wires one lazy\n * line instead of copying a resolver (frontend-delivery D025 — no builder's\n * manual):\n *\n * ```ts\n * blocksPlugin({\n * resolveImages: async (ids, opts) =>\n * (await import('@murumets-ee/media/public')).resolveCanvasImages(ids, opts),\n * })\n * ```\n *\n * Two properties are the point, not conveniences:\n *\n * - **The SAME public resolver content-api uses**, so the canvas cannot show an\n * image the published site would not (a private asset draws as empty on both).\n * - **Images only.** A `field.media({ kind: 'image' })` constrains only the\n * picker's list (editor F116), so a video or PDF can sit in the column; drawn\n * through an `<img>` it would be a broken image. Anything that is not\n * `image/*` is left out, and the canvas draws its empty placeholder instead.\n *\n * The resolver is built once per process — it memoises the image-style set, which\n * a fresh one per call would re-read.\n */\nexport async function resolveCanvasImages(\n ids: readonly string[],\n opts: { locale: string },\n): Promise<Map<string, CanvasImage>> {\n canvasResolver ??= createPublicMediaResolver()\n return toCanvasImages(await canvasResolver(ids, opts))\n}\n\n/**\n * The pure half of {@link resolveCanvasImages}: keep images, drop everything else,\n * project to what the canvas draws. Exported so the filter has a test that does\n * not need a database.\n */\nexport function toCanvasImages(\n found: ReadonlyMap<string, PublicMediaProjection>,\n): Map<string, CanvasImage> {\n const images = new Map<string, CanvasImage>()\n for (const [id, media] of found) {\n if (media.mimeType?.startsWith('image/') !== true) continue\n images.set(id, {\n url: media.url,\n alt: media.alt,\n width: media.width,\n height: media.height,\n focalPoint: media.focalPoint,\n sources: media.sources,\n blurDataUrl: media.blurDataUrl,\n })\n }\n return images\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { l as MediaRecord, m as Media } from "./types-6PZQ4Zq6.mjs";
1
+ import { l as MediaRecord, m as Media } from "./types-lobQhTow.mjs";
2
2
  import { CountOptions, FindByIdOptions, FindManyOptions, QueryClient } from "@murumets-ee/entity/query";
3
3
 
4
4
  //#region src/query-client.d.ts
@@ -1,2 +1,2 @@
1
- var e=class{query;constructor(e){this.query=e.query}async findById(e,t){return this.query.findById(e,t)}async findMany(e){return this.query.findMany(e)}async count(e){return this.query.count(e)}};async function t(){let{createQueryClient:t}=await import(`@murumets-ee/core/clients`),{Media:n}=await import(`./entity-CsDdjKz6.mjs`).then(e=>e.n);return new e({query:t(n)})}export{e as MediaQueryClient,t as createMediaQueryClient};
1
+ var e=class{query;constructor(e){this.query=e.query}async findById(e,t){return this.query.findById(e,t)}async findMany(e){return this.query.findMany(e)}async count(e){return this.query.count(e)}};async function t(){let{createQueryClient:t}=await import(`@murumets-ee/core/clients`),{Media:n}=await import(`./entity-CSrPWVJu.mjs`).then(e=>e.n);return new e({query:t(n)})}export{e as MediaQueryClient,t as createMediaQueryClient};
2
2
  //# sourceMappingURL=query-client.mjs.map
@@ -1,2 +1,2 @@
1
- import{createVariantDeps as e}from"./deps-DaGjHO16.mjs";import{generateMediaVariants as t}from"./generate-variants-Dpy4aT2o.mjs";import"server-only";async function n(n){let{app:r,storage:i,logger:a}=n,{AdminClient:o}=await import(`@murumets-ee/entity/admin`),{Media:s}=await import(`./entity-CsDdjKz6.mjs`).then(e=>e.n),{schemaRegistry:c}=await import(`@murumets-ee/db`),{and:l,asc:u,eq:d,gt:f}=await import(`drizzle-orm`),p=new o({entity:s,db:r.db.readWrite,logger:a,contextResolver:n.contextResolver}),m=c.get(`media`);if(!m)throw Error(`Media schema not registered`);let h=await e(r,{media:p,storage:i}),g={total:0,processed:0,skipped:0,errors:0},_=null;for(a?.info(`Starting variant regeneration (inline — no queue in this app)`);;){let e=await p.findMany({where:_?l(d(m.mediaType,`image`),f(m.id,_)):d(m.mediaType,`image`),orderBy:u(m.id),limit:100}),n=e[e.length-1];if(!n)break;_=n.id,g.total+=e.length;for(let n of e)try{let e=await t(n.id,h);e.status===`generated`||e.status===`stale`?g.processed++:g.skipped++}catch(e){g.errors++,a?.error({id:n.id,error:e},`Failed to regenerate variants for media record`)}if(e.length<100)break}return a?.info(g,`Variant regeneration complete`),g}export{n as regenerateAllVariants};
2
- //# sourceMappingURL=regenerate-variants-RwV_AQSo.mjs.map
1
+ import{createVariantDeps as e}from"./deps-BMOCedBq.mjs";import{generateMediaVariants as t}from"./generate-variants-CJ1oDbdp.mjs";import"server-only";async function n(n){let{app:r,storage:i,logger:a}=n,{AdminClient:o}=await import(`@murumets-ee/entity/admin`),{Media:s}=await import(`./entity-CSrPWVJu.mjs`).then(e=>e.n),{schemaRegistry:c}=await import(`@murumets-ee/db`),{and:l,asc:u,eq:d,gt:f}=await import(`drizzle-orm`),p=new o({entity:s,db:r.db.readWrite,logger:a,contextResolver:n.contextResolver}),m=c.get(`media`);if(!m)throw Error(`Media schema not registered`);let h=await e(r,{media:p,storage:i}),g={total:0,processed:0,skipped:0,errors:0},_=null;for(a?.info(`Starting variant regeneration (inline — no queue in this app)`);;){let e=await p.findMany({where:_?l(d(m.mediaType,`image`),f(m.id,_)):d(m.mediaType,`image`),orderBy:u(m.id),limit:100}),n=e[e.length-1];if(!n)break;_=n.id,g.total+=e.length;for(let n of e)try{let e=await t(n.id,h);e.status===`generated`||e.status===`stale`?g.processed++:g.skipped++}catch(e){g.errors++,a?.error({id:n.id,error:e},`Failed to regenerate variants for media record`)}if(e.length<100)break}return a?.info(g,`Variant regeneration complete`),g}export{n as regenerateAllVariants};
2
+ //# sourceMappingURL=regenerate-variants-BpCAq5U5.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"regenerate-variants-RwV_AQSo.mjs","names":[],"sources":["../src/regenerate-variants.ts"],"sourcesContent":["/**\n * Synchronous library regeneration — the path for an app WITHOUT the queue\n * plugin only.\n *\n * With a queue, the regenerate button queues `media:variants-backfill` instead\n * (`jobs/backfill.ts`): bounded, resumable, off the request, with the site\n * serving throughout. This loop is what a queueless app keeps, so it does not\n * silently lose the button — it runs inside the request, as it always has.\n *\n * It runs the SAME per-photograph pass the worker does (`generateMediaVariants`),\n * so it inherits its order (images F002): new files first, then the pointer,\n * then — only then — deletion of what they superseded. The previous version of\n * this file deleted every variant of a photograph before generating any, so a\n * removed style lost its files with nothing replacing them and every variant\n * URL 404'd for as long as the run took.\n *\n * Per-image errors are logged and counted, never stop the loop.\n */\n\nimport 'server-only'\n\nimport type { Logger, ToolkitApp } from '@murumets-ee/core'\nimport type { ContextResolver } from '@murumets-ee/entity'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { createVariantDeps } from './jobs/deps.js'\nimport { generateMediaVariants } from './jobs/generate-variants.js'\n\nconst BATCH_SIZE = 100\n\nexport interface RegenerateOptions {\n /** Toolkit app (provides db + the variant configuration). */\n app: ToolkitApp\n storage: StorageClient\n logger?: Logger\n /** Security context resolver — passed through to AdminClient. */\n contextResolver?: ContextResolver\n}\n\nexport interface RegenerateResult {\n /** Total image media records found */\n total: number\n /** Passes that ran (including ones that found nothing to do) */\n processed: number\n /** Skipped (non-processable mimeType, original missing, row gone) */\n skipped: number\n /** Failed with errors */\n errors: number\n}\n\n/** Regenerate variants for every image, in keyset pages of {@link BATCH_SIZE}. */\nexport async function regenerateAllVariants(options: RegenerateOptions): Promise<RegenerateResult> {\n const { app, storage, logger } = options\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n const { Media } = await import('./entity.js')\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, eq, gt } = await import('drizzle-orm')\n\n const admin = new AdminClient<typeof Media.allFields>({\n entity: Media,\n db: app.db.readWrite,\n logger,\n contextResolver: options.contextResolver,\n })\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered')\n\n const deps = await createVariantDeps(app, { media: admin, storage })\n const result: RegenerateResult = { total: 0, processed: 0, skipped: 0, errors: 0 }\n let cursor: string | null = null\n\n logger?.info('Starting variant regeneration (inline — no queue in this app)')\n\n for (;;) {\n const batch = await admin.findMany({\n where: cursor\n ? and(eq(table.mediaType, 'image'), gt(table.id, cursor))\n : eq(table.mediaType, 'image'),\n orderBy: asc(table.id),\n limit: BATCH_SIZE,\n })\n const last = batch[batch.length - 1]\n if (!last) break\n cursor = last.id\n result.total += batch.length\n\n for (const record of batch) {\n try {\n const outcome = await generateMediaVariants(record.id, deps)\n if (outcome.status === 'generated' || outcome.status === 'stale') result.processed++\n else result.skipped++\n } catch (err) {\n result.errors++\n logger?.error(\n { id: record.id, error: err },\n 'Failed to regenerate variants for media record',\n )\n }\n }\n\n if (batch.length < BATCH_SIZE) break\n }\n\n logger?.info(result, 'Variant regeneration complete')\n return result\n}\n"],"mappings":"qJAkDA,eAAsB,EAAsB,EAAuD,CACjG,GAAM,CAAE,MAAK,UAAS,UAAW,EAC3B,CAAE,eAAgB,MAAM,OAAO,6BAC/B,CAAE,SAAU,MAAM,OAAO,wBAAc,CAAA,KAAA,GAAA,EAAA,CAAA,EACvC,CAAE,kBAAmB,MAAM,OAAO,mBAClC,CAAE,MAAK,MAAK,KAAI,MAAO,MAAM,OAAO,eAEpC,EAAQ,IAAI,EAAoC,CACpD,OAAQ,EACR,GAAI,EAAI,GAAG,UACX,SACA,gBAAiB,EAAQ,eAC3B,CAAC,EACK,EAAQ,EAAe,IAAI,OAAO,EACxC,GAAI,CAAC,EAAO,MAAU,MAAM,6BAA6B,EAEzD,IAAM,EAAO,MAAM,EAAkB,EAAK,CAAE,MAAO,EAAO,SAAQ,CAAC,EAC7D,EAA2B,CAAE,MAAO,EAAG,UAAW,EAAG,QAAS,EAAG,OAAQ,CAAE,EAC7E,EAAwB,KAI5B,IAFA,GAAQ,KAAK,+DAA+D,IAEnE,CACP,IAAM,EAAQ,MAAM,EAAM,SAAS,CACjC,MAAO,EACH,EAAI,EAAG,EAAM,UAAW,OAAO,EAAG,EAAG,EAAM,GAAI,CAAM,CAAC,EACtD,EAAG,EAAM,UAAW,OAAO,EAC/B,QAAS,EAAI,EAAM,EAAE,EACrB,MAAO,GACT,CAAC,EACK,EAAO,EAAM,EAAM,OAAS,GAClC,GAAI,CAAC,EAAM,MACX,EAAS,EAAK,GACd,EAAO,OAAS,EAAM,OAEtB,IAAK,IAAM,KAAU,EACnB,GAAI,CACF,IAAM,EAAU,MAAM,EAAsB,EAAO,GAAI,CAAI,EACvD,EAAQ,SAAW,aAAe,EAAQ,SAAW,QAAS,EAAO,YACpE,EAAO,SACd,OAAS,EAAK,CACZ,EAAO,SACP,GAAQ,MACN,CAAE,GAAI,EAAO,GAAI,MAAO,CAAI,EAC5B,gDACF,CACF,CAGF,GAAI,EAAM,OAAS,IAAY,KACjC,CAGA,OADA,GAAQ,KAAK,EAAQ,+BAA+B,EAC7C,CACT"}
1
+ {"version":3,"file":"regenerate-variants-BpCAq5U5.mjs","names":[],"sources":["../src/regenerate-variants.ts"],"sourcesContent":["/**\n * Synchronous library regeneration — the path for an app WITHOUT the queue\n * plugin only.\n *\n * With a queue, the regenerate button queues `media:variants-backfill` instead\n * (`jobs/backfill.ts`): bounded, resumable, off the request, with the site\n * serving throughout. This loop is what a queueless app keeps, so it does not\n * silently lose the button — it runs inside the request, as it always has.\n *\n * It runs the SAME per-photograph pass the worker does (`generateMediaVariants`),\n * so it inherits its order (images F002): new files first, then the pointer,\n * then — only then — deletion of what they superseded. The previous version of\n * this file deleted every variant of a photograph before generating any, so a\n * removed style lost its files with nothing replacing them and every variant\n * URL 404'd for as long as the run took.\n *\n * Per-image errors are logged and counted, never stop the loop.\n */\n\nimport 'server-only'\n\nimport type { Logger, ToolkitApp } from '@murumets-ee/core'\nimport type { ContextResolver } from '@murumets-ee/entity'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { createVariantDeps } from './jobs/deps.js'\nimport { generateMediaVariants } from './jobs/generate-variants.js'\n\nconst BATCH_SIZE = 100\n\nexport interface RegenerateOptions {\n /** Toolkit app (provides db + the variant configuration). */\n app: ToolkitApp\n storage: StorageClient\n logger?: Logger\n /** Security context resolver — passed through to AdminClient. */\n contextResolver?: ContextResolver\n}\n\nexport interface RegenerateResult {\n /** Total image media records found */\n total: number\n /** Passes that ran (including ones that found nothing to do) */\n processed: number\n /** Skipped (non-processable mimeType, original missing, row gone) */\n skipped: number\n /** Failed with errors */\n errors: number\n}\n\n/** Regenerate variants for every image, in keyset pages of {@link BATCH_SIZE}. */\nexport async function regenerateAllVariants(options: RegenerateOptions): Promise<RegenerateResult> {\n const { app, storage, logger } = options\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n const { Media } = await import('./entity.js')\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, eq, gt } = await import('drizzle-orm')\n\n const admin = new AdminClient<typeof Media.allFields>({\n entity: Media,\n db: app.db.readWrite,\n logger,\n contextResolver: options.contextResolver,\n })\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered')\n\n const deps = await createVariantDeps(app, { media: admin, storage })\n const result: RegenerateResult = { total: 0, processed: 0, skipped: 0, errors: 0 }\n let cursor: string | null = null\n\n logger?.info('Starting variant regeneration (inline — no queue in this app)')\n\n for (;;) {\n const batch = await admin.findMany({\n where: cursor\n ? and(eq(table.mediaType, 'image'), gt(table.id, cursor))\n : eq(table.mediaType, 'image'),\n orderBy: asc(table.id),\n limit: BATCH_SIZE,\n })\n const last = batch[batch.length - 1]\n if (!last) break\n cursor = last.id\n result.total += batch.length\n\n for (const record of batch) {\n try {\n const outcome = await generateMediaVariants(record.id, deps)\n if (outcome.status === 'generated' || outcome.status === 'stale') result.processed++\n else result.skipped++\n } catch (err) {\n result.errors++\n logger?.error(\n { id: record.id, error: err },\n 'Failed to regenerate variants for media record',\n )\n }\n }\n\n if (batch.length < BATCH_SIZE) break\n }\n\n logger?.info(result, 'Variant regeneration complete')\n return result\n}\n"],"mappings":"qJAkDA,eAAsB,EAAsB,EAAuD,CACjG,GAAM,CAAE,MAAK,UAAS,UAAW,EAC3B,CAAE,eAAgB,MAAM,OAAO,6BAC/B,CAAE,SAAU,MAAM,OAAO,wBAAc,CAAA,KAAA,GAAA,EAAA,CAAA,EACvC,CAAE,kBAAmB,MAAM,OAAO,mBAClC,CAAE,MAAK,MAAK,KAAI,MAAO,MAAM,OAAO,eAEpC,EAAQ,IAAI,EAAoC,CACpD,OAAQ,EACR,GAAI,EAAI,GAAG,UACX,SACA,gBAAiB,EAAQ,eAC3B,CAAC,EACK,EAAQ,EAAe,IAAI,OAAO,EACxC,GAAI,CAAC,EAAO,MAAU,MAAM,6BAA6B,EAEzD,IAAM,EAAO,MAAM,EAAkB,EAAK,CAAE,MAAO,EAAO,SAAQ,CAAC,EAC7D,EAA2B,CAAE,MAAO,EAAG,UAAW,EAAG,QAAS,EAAG,OAAQ,CAAE,EAC7E,EAAwB,KAI5B,IAFA,GAAQ,KAAK,+DAA+D,IAEnE,CACP,IAAM,EAAQ,MAAM,EAAM,SAAS,CACjC,MAAO,EACH,EAAI,EAAG,EAAM,UAAW,OAAO,EAAG,EAAG,EAAM,GAAI,CAAM,CAAC,EACtD,EAAG,EAAM,UAAW,OAAO,EAC/B,QAAS,EAAI,EAAM,EAAE,EACrB,MAAO,GACT,CAAC,EACK,EAAO,EAAM,EAAM,OAAS,GAClC,GAAI,CAAC,EAAM,MACX,EAAS,EAAK,GACd,EAAO,OAAS,EAAM,OAEtB,IAAK,IAAM,KAAU,EACnB,GAAI,CACF,IAAM,EAAU,MAAM,EAAsB,EAAO,GAAI,CAAI,EACvD,EAAQ,SAAW,aAAe,EAAQ,SAAW,QAAS,EAAO,YACpE,EAAO,SACd,OAAS,EAAK,CACZ,EAAO,SACP,GAAQ,MACN,CAAE,GAAI,EAAO,GAAI,MAAO,CAAI,EAC5B,gDACF,CACF,CAGF,GAAI,EAAM,OAAS,IAAY,KACjC,CAGA,OADA,GAAQ,KAAK,EAAQ,+BAA+B,EAC7C,CACT"}
@@ -1,2 +1,2 @@
1
- import{c as e,i as t,l as n,s as r,t as i}from"./definitions-LJpgrdxd.mjs";import{n as a}from"./slot-CY4isqmN.mjs";import{runAsCli as o}from"@murumets-ee/core";async function s(s){if(!s.plugins.has(`@murumets-ee/queue`))return;let{defineJob:c,registerJob:l}=await import(`@murumets-ee/queue/client`),u=c({name:i,description:`Generate one photograph's image variants (every shape × width, plus fixed styles), record them on the original, then delete the ones they supersede.`,schema:e,idempotencyKey:r}),d=c({name:t,description:`Walk the image library and queue a variant pass for every photograph — after a shape, width or style changes. Bounded and resumable; the site keeps serving throughout.`,schema:n,defaultRetries:2});l(u,async t=>{let{mediaId:n}=e.parse(t.payload);await o(async()=>{let{createJobVariantDeps:e}=await import(`./deps-DaGjHO16.mjs`),{generateMediaVariants:t}=await import(`./generate-variants-Dpy4aT2o.mjs`),r=await t(n,await e(s));s.logger.info(r,`media variants: pass finished`)})}),l(d,async e=>{n.parse(e.payload),await o(async()=>{let{runVariantsBackfill:t}=await import(`./backfill-B1StTnxg.mjs`),n=await t(s,u,{walkJobId:e.id,onProgress:t=>e.updateProgress(t)});s.logger.info(n,`media variants: backfill queued`)})}),a({generateVariants:u,variantsBackfill:d})}export{s as registerMediaJobs};
2
- //# sourceMappingURL=register-CJKjNl2w.mjs.map
1
+ import{c as e,i as t,l as n,s as r,t as i}from"./definitions-LJpgrdxd.mjs";import{n as a}from"./slot-CY4isqmN.mjs";import{runAsCli as o}from"@murumets-ee/core";async function s(s){if(!s.plugins.has(`@murumets-ee/queue`))return;let{defineJob:c,registerJob:l}=await import(`@murumets-ee/queue/client`),u=c({name:i,description:`Generate one photograph's image variants (every shape × width, plus fixed styles), record them on the original, then delete the ones they supersede.`,schema:e,idempotencyKey:r}),d=c({name:t,description:`Walk the image library and queue a variant pass for every photograph — after a shape, width or style changes. Bounded and resumable; the site keeps serving throughout.`,schema:n,defaultRetries:2});l(u,async t=>{let{mediaId:n}=e.parse(t.payload);await o(async()=>{let{createJobVariantDeps:e}=await import(`./deps-BMOCedBq.mjs`),{generateMediaVariants:t}=await import(`./generate-variants-CJ1oDbdp.mjs`),r=await t(n,await e(s));s.logger.info(r,`media variants: pass finished`)})}),l(d,async e=>{n.parse(e.payload),await o(async()=>{let{runVariantsBackfill:t}=await import(`./backfill-BpcWvL5P.mjs`),n=await t(s,u,{walkJobId:e.id,onProgress:t=>e.updateProgress(t)});s.logger.info(n,`media variants: backfill queued`)})}),a({generateVariants:u,variantsBackfill:d})}export{s as registerMediaJobs};
2
+ //# sourceMappingURL=register-CJAkO7a2.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"register-CJKjNl2w.mjs","names":[],"sources":["../src/jobs/register.ts"],"sourcesContent":["/**\n * Registers the media queue jobs, and holds the handles every enqueuer uses.\n *\n * ⚠️ Imperative on purpose, and known debt: `defineJob` + `registerJob` from a\n * plugin's `server.init` is the ONE pattern the queue offers today — the same\n * one `packages/blocks/src/server/jobs/*`, `packages/commerce/src/<area>/jobs.ts`\n * and `packages/content/src/paths/jobs.ts` follow. It sits against CLAUDE.md's\n * declarative-contributions rule and is tracked as lumi-cms#497 (a declarative\n * `Plugin.jobs` slot). Do not invent a second pattern here; lift all of them\n * together when #497 lands.\n *\n * `@murumets-ee/queue/client` is imported DYNAMICALLY so an app without the\n * queue plugin (`apps/web-astro`) never loads it — it gets inline generation\n * instead (`schedule.ts`).\n */\n\nimport { runAsCli, type ToolkitApp } from '@murumets-ee/core'\nimport {\n GENERATE_VARIANTS_JOB_NAME,\n type GenerateVariantsPayload,\n generateVariantsJobKey,\n generateVariantsPayloadSchema,\n QUEUE_PLUGIN_NAME,\n VARIANTS_BACKFILL_JOB_NAME,\n type VariantsBackfillPayload,\n variantsBackfillPayloadSchema,\n} from './definitions.js'\nimport { setMediaJobs } from './slot.js'\n\n/**\n * Define and register `media:generate-variants` and `media:variants-backfill`.\n * A no-op when the app has no queue plugin. Sync-registry only — no DB — so it\n * is safe in every context `init` runs in (the Next server, `next build`, CLI).\n */\nexport async function registerMediaJobs(app: ToolkitApp): Promise<void> {\n if (!app.plugins.has(QUEUE_PLUGIN_NAME)) return\n const { defineJob, registerJob } = await import('@murumets-ee/queue/client')\n\n const generateVariants = defineJob<GenerateVariantsPayload>({\n name: GENERATE_VARIANTS_JOB_NAME,\n description:\n \"Generate one photograph's image variants (every shape × width, plus fixed styles), \" +\n 'record them on the original, then delete the ones they supersede.',\n schema: generateVariantsPayloadSchema,\n idempotencyKey: generateVariantsJobKey,\n })\n const variantsBackfill = defineJob<VariantsBackfillPayload>({\n name: VARIANTS_BACKFILL_JOB_NAME,\n description:\n 'Walk the image library and queue a variant pass for every photograph — after a shape, ' +\n 'width or style changes. Bounded and resumable; the site keeps serving throughout.',\n schema: variantsBackfillPayloadSchema,\n // Rerunning a backfill is always meaningful (the configuration may have\n // changed since), so it has no idempotency key; the passes it queues do.\n defaultRetries: 2,\n })\n\n registerJob(generateVariants, async (ctx) => {\n const { mediaId } = generateVariantsPayloadSchema.parse(ctx.payload)\n // A worker has no request, so no user: the pass reads and writes the media\n // row as the synthetic CLI admin, the same wrapping every entity-touching\n // job in this repo uses. What it writes is derived from the file, never\n // from a payload field (the payload is an id and a hash).\n await runAsCli(async () => {\n const { createJobVariantDeps } = await import('./deps.js')\n const { generateMediaVariants } = await import('./generate-variants.js')\n const outcome = await generateMediaVariants(mediaId, await createJobVariantDeps(app))\n app.logger.info(outcome, 'media variants: pass finished')\n })\n })\n\n registerJob(variantsBackfill, async (ctx) => {\n variantsBackfillPayloadSchema.parse(ctx.payload)\n await runAsCli(async () => {\n const { runVariantsBackfill } = await import('./backfill.js')\n const outcome = await runVariantsBackfill(app, generateVariants, {\n walkJobId: ctx.id,\n onProgress: (p) => ctx.updateProgress(p),\n })\n app.logger.info(outcome, 'media variants: backfill queued')\n })\n })\n\n setMediaJobs({ generateVariants, variantsBackfill })\n}\n"],"mappings":"gKAkCA,eAAsB,EAAkB,EAAgC,CACtE,GAAI,CAAC,EAAI,QAAQ,IAAA,oBAAqB,EAAG,OACzC,GAAM,CAAE,YAAW,eAAgB,MAAM,OAAO,6BAE1C,EAAmB,EAAmC,CAC1D,KAAM,EACN,YACE,uJAEF,OAAQ,EACR,eAAgB,CAClB,CAAC,EACK,EAAmB,EAAmC,CAC1D,KAAM,EACN,YACE,0KAEF,OAAQ,EAGR,eAAgB,CAClB,CAAC,EAED,EAAY,EAAkB,KAAO,IAAQ,CAC3C,GAAM,CAAE,WAAY,EAA8B,MAAM,EAAI,OAAO,EAKnE,MAAM,EAAS,SAAY,CACzB,GAAM,CAAE,wBAAyB,MAAM,OAAO,uBACxC,CAAE,yBAA0B,MAAM,OAAO,oCACzC,EAAU,MAAM,EAAsB,EAAS,MAAM,EAAqB,CAAG,CAAC,EACpF,EAAI,OAAO,KAAK,EAAS,+BAA+B,CAC1D,CAAC,CACH,CAAC,EAED,EAAY,EAAkB,KAAO,IAAQ,CAC3C,EAA8B,MAAM,EAAI,OAAO,EAC/C,MAAM,EAAS,SAAY,CACzB,GAAM,CAAE,uBAAwB,MAAM,OAAO,2BACvC,EAAU,MAAM,EAAoB,EAAK,EAAkB,CAC/D,UAAW,EAAI,GACf,WAAa,GAAM,EAAI,eAAe,CAAC,CACzC,CAAC,EACD,EAAI,OAAO,KAAK,EAAS,iCAAiC,CAC5D,CAAC,CACH,CAAC,EAED,EAAa,CAAE,mBAAkB,kBAAiB,CAAC,CACrD"}
1
+ {"version":3,"file":"register-CJAkO7a2.mjs","names":[],"sources":["../src/jobs/register.ts"],"sourcesContent":["/**\n * Registers the media queue jobs, and holds the handles every enqueuer uses.\n *\n * ⚠️ Imperative on purpose, and known debt: `defineJob` + `registerJob` from a\n * plugin's `server.init` is the ONE pattern the queue offers today — the same\n * one `packages/blocks/src/server/jobs/*`, `packages/commerce/src/<area>/jobs.ts`\n * and `packages/content/src/paths/jobs.ts` follow. It sits against CLAUDE.md's\n * declarative-contributions rule and is tracked as lumi-cms#497 (a declarative\n * `Plugin.jobs` slot). Do not invent a second pattern here; lift all of them\n * together when #497 lands.\n *\n * `@murumets-ee/queue/client` is imported DYNAMICALLY so an app without the\n * queue plugin (`apps/web-astro`) never loads it — it gets inline generation\n * instead (`schedule.ts`).\n */\n\nimport { runAsCli, type ToolkitApp } from '@murumets-ee/core'\nimport {\n GENERATE_VARIANTS_JOB_NAME,\n type GenerateVariantsPayload,\n generateVariantsJobKey,\n generateVariantsPayloadSchema,\n QUEUE_PLUGIN_NAME,\n VARIANTS_BACKFILL_JOB_NAME,\n type VariantsBackfillPayload,\n variantsBackfillPayloadSchema,\n} from './definitions.js'\nimport { setMediaJobs } from './slot.js'\n\n/**\n * Define and register `media:generate-variants` and `media:variants-backfill`.\n * A no-op when the app has no queue plugin. Sync-registry only — no DB — so it\n * is safe in every context `init` runs in (the Next server, `next build`, CLI).\n */\nexport async function registerMediaJobs(app: ToolkitApp): Promise<void> {\n if (!app.plugins.has(QUEUE_PLUGIN_NAME)) return\n const { defineJob, registerJob } = await import('@murumets-ee/queue/client')\n\n const generateVariants = defineJob<GenerateVariantsPayload>({\n name: GENERATE_VARIANTS_JOB_NAME,\n description:\n \"Generate one photograph's image variants (every shape × width, plus fixed styles), \" +\n 'record them on the original, then delete the ones they supersede.',\n schema: generateVariantsPayloadSchema,\n idempotencyKey: generateVariantsJobKey,\n })\n const variantsBackfill = defineJob<VariantsBackfillPayload>({\n name: VARIANTS_BACKFILL_JOB_NAME,\n description:\n 'Walk the image library and queue a variant pass for every photograph — after a shape, ' +\n 'width or style changes. Bounded and resumable; the site keeps serving throughout.',\n schema: variantsBackfillPayloadSchema,\n // Rerunning a backfill is always meaningful (the configuration may have\n // changed since), so it has no idempotency key; the passes it queues do.\n defaultRetries: 2,\n })\n\n registerJob(generateVariants, async (ctx) => {\n const { mediaId } = generateVariantsPayloadSchema.parse(ctx.payload)\n // A worker has no request, so no user: the pass reads and writes the media\n // row as the synthetic CLI admin, the same wrapping every entity-touching\n // job in this repo uses. What it writes is derived from the file, never\n // from a payload field (the payload is an id and a hash).\n await runAsCli(async () => {\n const { createJobVariantDeps } = await import('./deps.js')\n const { generateMediaVariants } = await import('./generate-variants.js')\n const outcome = await generateMediaVariants(mediaId, await createJobVariantDeps(app))\n app.logger.info(outcome, 'media variants: pass finished')\n })\n })\n\n registerJob(variantsBackfill, async (ctx) => {\n variantsBackfillPayloadSchema.parse(ctx.payload)\n await runAsCli(async () => {\n const { runVariantsBackfill } = await import('./backfill.js')\n const outcome = await runVariantsBackfill(app, generateVariants, {\n walkJobId: ctx.id,\n onProgress: (p) => ctx.updateProgress(p),\n })\n app.logger.info(outcome, 'media variants: backfill queued')\n })\n })\n\n setMediaJobs({ generateVariants, variantsBackfill })\n}\n"],"mappings":"gKAkCA,eAAsB,EAAkB,EAAgC,CACtE,GAAI,CAAC,EAAI,QAAQ,IAAA,oBAAqB,EAAG,OACzC,GAAM,CAAE,YAAW,eAAgB,MAAM,OAAO,6BAE1C,EAAmB,EAAmC,CAC1D,KAAM,EACN,YACE,uJAEF,OAAQ,EACR,eAAgB,CAClB,CAAC,EACK,EAAmB,EAAmC,CAC1D,KAAM,EACN,YACE,0KAEF,OAAQ,EAGR,eAAgB,CAClB,CAAC,EAED,EAAY,EAAkB,KAAO,IAAQ,CAC3C,GAAM,CAAE,WAAY,EAA8B,MAAM,EAAI,OAAO,EAKnE,MAAM,EAAS,SAAY,CACzB,GAAM,CAAE,wBAAyB,MAAM,OAAO,uBACxC,CAAE,yBAA0B,MAAM,OAAO,oCACzC,EAAU,MAAM,EAAsB,EAAS,MAAM,EAAqB,CAAG,CAAC,EACpF,EAAI,OAAO,KAAK,EAAS,+BAA+B,CAC1D,CAAC,CACH,CAAC,EAED,EAAY,EAAkB,KAAO,IAAQ,CAC3C,EAA8B,MAAM,EAAI,OAAO,EAC/C,MAAM,EAAS,SAAY,CACzB,GAAM,CAAE,uBAAwB,MAAM,OAAO,2BACvC,EAAU,MAAM,EAAoB,EAAK,EAAkB,CAC/D,UAAW,EAAI,GACf,WAAa,GAAM,EAAI,eAAe,CAAC,CACzC,CAAC,EACD,EAAI,OAAO,KAAK,EAAS,iCAAiC,CAC5D,CAAC,CACH,CAAC,EAED,EAAa,CAAE,mBAAkB,kBAAiB,CAAC,CACrD"}
@@ -0,0 +1,2 @@
1
+ import{r as e}from"./blur-data-url-EtSndBso.mjs";import{s as t}from"./focal-point-BWhtJzef.mjs";import{t as n}from"./media-config-DkbnXIjC.mjs";import{combineAdminRoutes as r,defineAdminRoute as i,runBounded as a,safeAudit as o,safeAuditMany as s}from"@murumets-ee/core";const c=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function l(e){return e!==void 0&&c.test(e)}const u=[`image`,`video`,`audio`,`document`,`other`];function d(e){return u.includes(e)}function f(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function p(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function m(e,t){return p({error:e},t)}const h={clientPromise:null};async function g(){if(!h.clientPromise){let e=(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{createStorageClient:t}=await import(`@murumets-ee/storage`),{getStorageConfig:n}=await import(`@murumets-ee/storage/plugin`),r=e();return t(n(),{app:r})})();e.catch(()=>{h.clientPromise===e&&(h.clientPromise=null)}),h.clientPromise=e}return h.clientPromise}async function _(){let{createAdminClient:e}=await import(`@murumets-ee/core/clients`),{MediaClient:t}=await import(`./client.mjs`),{Media:n}=await import(`./entity-CSrPWVJu.mjs`).then(e=>e.n),r=await g();return new t({admin:e(n),storage:r})}const v=async(n,r)=>{let{isStorageConfigured:i,getStorageConfigReason:a}=await import(`@murumets-ee/storage`),o=r.segments;if(o.length===2&&o[1]===`usage`){let e=o[0];if(!l(e))return m(`Invalid media ID format`,400);let{findMediaUsages:t}=await import(`./usage.mjs`),{getApp:n}=await import(`@murumets-ee/core`);return p({usages:await t(e,n())})}if(o.length>=2)return m(`Not found`,404);let s=o.length>0?o[0]:void 0;if(s!==void 0&&!l(s))return m(`Invalid media ID format`,400);if(!i()){let e=a()??`Storage not configured`;return o.length>0?p({error:e,configured:!1,reason:e},503):p({items:[],total:0,configured:!1,reason:e})}let c=await _();if(s!==void 0){let e=await c.findById(s);if(!e)return m(`Media not found`,404);let t=await c.getUrl(s);return p({...e,url:t})}let u=new URL(n.url),h=u.searchParams.get(`search`)??void 0,g=u.searchParams.get(`mediaType`),v=g!==null&&d(g)?g:void 0,y=Math.min(Math.max(f(u.searchParams.get(`limit`),24),1),100),b=Math.min(f(u.searchParams.get(`offset`),0),1e5),x=await c.findMany({...h!==void 0&&{search:h},...v!==void 0&&{mediaType:v},limit:y,offset:b}),S=x.items.map(e=>e.id),C=x.items.filter(e=>e.mediaType===`image`).map(e=>e.id),[w,T]=await Promise.all([c.getUrls(S),c.getVariantUrls(C,`thumbnail`)]);return p({items:x.items.map(n=>{let r=n.mediaType===`image`?T.get(n.id):void 0;return{id:n.id,title:n.title??null,alt:n.alt??null,filename:n.filename,mimeType:n.mimeType,size:n.size,mediaType:n.mediaType,url:w.get(n.id)??``,...r!==void 0&&{thumbnailUrl:r},width:n.width??null,height:n.height??null,...n.mediaType===`image`&&{focalPoint:t(n.focalPoint),blurDataUrl:e(n.blurDataUrl)}}}),total:x.total})},y=async(r,i)=>{let{isStorageConfigured:a,getStorageConfigReason:s,detectMimeType:c,isMimeTypeAllowed:l,readBoundedMultipart:u,tooLarge:d}=await import(`@murumets-ee/storage`);if(i.segments.length>0)return m(`Not found`,404);if(!a())return m(s()??`Storage not configured`,503);let{maxUploadSize:f,acceptedTypes:h}=n(),g=await u(r,f);if(!g.ok)return m(g.error,g.status);let v=g.value,y=v.get(`file`);if(!(y instanceof File)||y.size===0)return m(`No file provided`,400);if(y.size>f){let e=d(f);return m(e.error,e.status)}let b=await _(),x=Buffer.from(await y.arrayBuffer()),{mimeType:S,mismatch:C}=await c(x,y.type||`application/octet-stream`);if(C)return m(`File content doesn't match declared type: claimed ${y.type}, detected ${S}`,400);if(!l(S,h))return m(`File type ${S} is not accepted`,415);let w=v.get(`visibility`);if(w!==null&&w!==`public`&&w!==`private`)return m(`Field 'visibility' must be 'public' or 'private'`,400);let T=await b.upload(x,{filename:y.name,mimeType:S,size:y.size,uploadedBy:i.user.id,...w!==null&&{visibility:w}}),E={id:T.media.id,title:T.media.title??null,alt:T.media.alt??null,filename:T.media.filename,mimeType:T.media.mimeType,size:T.media.size,mediaType:T.media.mediaType,url:T.url,width:T.media.width??null,height:T.media.height??null,...T.media.mediaType===`image`&&{focalPoint:t(T.media.focalPoint),blurDataUrl:e(T.media.blurDataUrl)}};return o(i,{action:`media.upload`,entityType:`media`,entityId:T.media.id,userId:i.user.id,...i.user.name!==void 0&&{userName:i.user.name},changes:{filename:T.media.filename,mimeType:T.media.mimeType,size:T.media.size,mediaType:T.media.mediaType}}),p(E,201)},b=async(e,t)=>{let{isStorageConfigured:n,getStorageConfigReason:r}=await import(`@murumets-ee/storage`);if(t.segments.length!==1)return m(`Not found`,404);if(!n())return m(r()??`Storage not configured`,503);let{getApp:i,getContext:a}=await import(`@murumets-ee/core`),{getMediaJobs:s}=await import(`./slot-CY4isqmN.mjs`).then(e=>e.r),c=i(),l=s();if(l){let{enqueueVariantsBackfill:e}=await import(`./backfill-trigger-BFL1L7_j.mjs`),{jobId:n,alreadyRunning:r}=await e(c,l.variantsBackfill);o(t,{action:`media.regenerate_variants`,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},metadata:{mode:`queued`,jobId:n,alreadyRunning:r}});let{getVariantBacklog:i}=await import(`./backlog-D-H5VUQA.mjs`).then(e=>e.t);return p({mode:`queued`,jobId:n,alreadyRunning:r,backlog:await i(c).catch(()=>null)},202)}let{regenerateAllVariants:u}=await import(`./regenerate-variants-BpCAq5U5.mjs`),{createStorageClient:d}=await import(`@murumets-ee/storage`),{getStorageConfig:f}=await import(`@murumets-ee/storage/plugin`),h=await u({app:c,storage:d(f(),{app:c}),logger:c.logger.child({media:!0}),contextResolver:()=>{let e=a();if(!(!e?.user||!e?.checker))return{user:e.user,checker:e.checker,...e.scope!==void 0&&{scope:e.scope}}}});return o(t,{action:`media.regenerate_variants`,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},metadata:{mode:`inline`,total:h.total,processed:h.processed,errors:h.errors}}),p({mode:`inline`,...h})},x=16*1024;async function S(e){let t=Number(e.headers.get(`content-length`));if(Number.isFinite(t)&&t>x)return{error:`Request body exceeds the maximum size`,status:413};let n=e.body,r;if(n===null){if(r=await e.text(),Buffer.byteLength(r,`utf8`)>x)return{error:`Request body exceeds the maximum size`,status:413}}else{let e=n.getReader(),t=[],i=0;try{for(;;){let{done:n,value:r}=await e.read();if(n)break;if(r!==void 0){if(i+=r.byteLength,i>x)return await e.cancel().catch(()=>void 0),{error:`Request body exceeds the maximum size`,status:413};t.push(r)}}}finally{e.releaseLock()}r=Buffer.concat(t).toString(`utf8`)}let i;try{i=JSON.parse(r)}catch{return{error:`Invalid JSON body`,status:400}}if(typeof i!=`object`||!i||Array.isArray(i))return{error:`Body must be a JSON object`,status:400};let a=i.ids;if(!Array.isArray(a)||a.length===0)return{error:`Body must contain "ids" array`,status:400};if(a.length>100)return{error:`Bulk delete limited to 100 items per request`,status:400};let o=[];for(let e of a){if(typeof e!=`string`||!l(e))return{error:`Body "ids" must contain valid media IDs`,status:400};o.push(e)}return{ids:o}}const C=async(e,t)=>{let n=t.segments;if(n.length===0){let n=await S(e);if(`error`in n)return m(n.error,n.status);let{isStorageConfigured:r,getStorageConfigReason:i}=await import(`@murumets-ee/storage`);if(!r())return m(i()??`Storage not configured`,503);let o=await _(),c=await a(n.ids,async e=>{try{return await o.delete(e),{status:`fulfilled`,id:e}}catch(t){return{status:`rejected`,id:e,reason:t}}}),l=c.filter(e=>e.status===`fulfilled`).map(e=>e.id);await s(t,l.map(e=>({action:`media.delete`,entityType:`media`,entityId:e,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},metadata:{bulk:!0}})));let u=c.find(e=>e.status===`rejected`);if(u&&u.status===`rejected`)throw u.reason;return p({deleted:l.length})}if(n.length>1)return m(`Not found`,404);let r=n[0];if(!l(r))return m(`Invalid media ID format`,400);let{isStorageConfigured:i,getStorageConfigReason:c}=await import(`@murumets-ee/storage`);return i()?(await(await _()).delete(r),o(t,{action:`media.delete`,entityType:`media`,entityId:r,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name}}),p({deleted:1})):m(c()??`Storage not configured`,503)},w=[`admin`,`editor`,`agent`,`viewer`],T=[`admin`,`editor`];function E(){return r([i({prefix:`media`,path:``,method:`GET`,permission:`media:view`,defaultRoles:w,matchAnyPath:!0,description:`Read media — list, single item, or referencing-entity usage report.`,handler:v}),i({prefix:`media`,path:``,method:`POST`,permission:`media:create`,defaultRoles:T,description:"Upload a media file (multipart FormData with `file` field).",handler:y}),i({prefix:`media`,path:`regenerate-variants`,method:`POST`,permission:`media:create`,defaultRoles:T,description:`Catch every image up with the current shapes, widths and styles — queued as a background backfill (synchronous only when the app has no queue).`,handler:b}),i({prefix:`media`,path:``,method:`DELETE`,permission:`media:delete`,defaultRoles:T,matchAnyPath:!0,description:`Delete a media record + its storage object.`,handler:C})])}export{E as t};
2
+ //# sourceMappingURL=routes-Dk_oVBmE.mjs.map