@murumets-ee/media 0.72.0 → 0.74.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 (94) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/backfill-B1StTnxg.mjs +2 -0
  4. package/dist/{backfill-D2Ubblh3.mjs.map → backfill-B1StTnxg.mjs.map} +1 -1
  5. package/dist/client.d.mts +16 -6
  6. package/dist/client.d.mts.map +1 -1
  7. package/dist/client.mjs +1 -1
  8. package/dist/client.mjs.map +1 -1
  9. package/dist/crop-editor.d.mts +6 -5
  10. package/dist/crop-editor.d.mts.map +1 -1
  11. package/dist/crop-editor.mjs +1 -1
  12. package/dist/crop-rect-B-AJ2pWm.mjs.map +1 -1
  13. package/dist/crop-rect-C-SbjoxK.d.mts.map +1 -1
  14. package/dist/{deps-CzHEhyJK.mjs → deps-DaGjHO16.mjs} +2 -2
  15. package/dist/{deps-CzHEhyJK.mjs.map → deps-DaGjHO16.mjs.map} +1 -1
  16. package/dist/en-Duj_Pp-O.mjs +2 -0
  17. package/dist/en-Duj_Pp-O.mjs.map +1 -0
  18. package/dist/et-BFdSZnb5.mjs +2 -0
  19. package/dist/et-BFdSZnb5.mjs.map +1 -0
  20. package/dist/generate-variants-Dpy4aT2o.mjs +2 -0
  21. package/dist/{generate-variants-zyPjaGcy.mjs.map → generate-variants-Dpy4aT2o.mjs.map} +1 -1
  22. package/dist/i18n.mjs +1 -1
  23. package/dist/image-styles-settings.d.mts +1 -1
  24. package/dist/image-styles-settings.d.mts.map +1 -1
  25. package/dist/image-styles-settings.mjs +1 -1
  26. package/dist/image-styles-settings.mjs.map +1 -1
  27. package/dist/image-styles.d.mts +1 -1
  28. package/dist/image-styles.mjs +1 -1
  29. package/dist/image-styles.mjs.map +1 -1
  30. package/dist/index.d.mts +38 -4
  31. package/dist/index.d.mts.map +1 -1
  32. package/dist/index.mjs +1 -1
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/{media-config-DmcTxuDM.mjs → media-config-DkbnXIjC.mjs} +2 -2
  35. package/dist/{media-config-DmcTxuDM.mjs.map → media-config-DkbnXIjC.mjs.map} +1 -1
  36. package/dist/picker.d.mts +1 -1
  37. package/dist/plugin.d.mts +1 -1
  38. package/dist/plugin.mjs +1 -1
  39. package/dist/process-image-wQwiRhyk.mjs +2 -0
  40. package/dist/process-image-wQwiRhyk.mjs.map +1 -0
  41. package/dist/processing.d.mts +2 -2
  42. package/dist/processing.d.mts.map +1 -1
  43. package/dist/processing.mjs +1 -1
  44. package/dist/public-resolver.d.mts +80 -16
  45. package/dist/public-resolver.d.mts.map +1 -1
  46. package/dist/public-resolver.mjs +1 -1
  47. package/dist/public-resolver.mjs.map +1 -1
  48. package/dist/query-client.d.mts +1 -1
  49. package/dist/{regenerate-variants-Cp3sNHkT.mjs → regenerate-variants-RwV_AQSo.mjs} +2 -2
  50. package/dist/{regenerate-variants-Cp3sNHkT.mjs.map → regenerate-variants-RwV_AQSo.mjs.map} +1 -1
  51. package/dist/{register-BSGkELj0.mjs → register-CJKjNl2w.mjs} +2 -2
  52. package/dist/{register-BSGkELj0.mjs.map → register-CJKjNl2w.mjs.map} +1 -1
  53. package/dist/{resolve-image-styles-pf7Nd3Zf.mjs → resolve-image-styles-YExI329D.mjs} +2 -2
  54. package/dist/{resolve-image-styles-pf7Nd3Zf.mjs.map → resolve-image-styles-YExI329D.mjs.map} +1 -1
  55. package/dist/routes-DaE0M8h8.mjs +2 -0
  56. package/dist/routes-DaE0M8h8.mjs.map +1 -0
  57. package/dist/ru-ojvshRJy.mjs +2 -0
  58. package/dist/ru-ojvshRJy.mjs.map +1 -0
  59. package/dist/{schedule-DREjg2ji.mjs → schedule-JlvfUEVA.mjs} +2 -2
  60. package/dist/{schedule-DREjg2ji.mjs.map → schedule-JlvfUEVA.mjs.map} +1 -1
  61. package/dist/shapes-BpOEJeLx.mjs +2 -0
  62. package/dist/shapes-BpOEJeLx.mjs.map +1 -0
  63. package/dist/{shapes-40dHtTb-.d.mts → shapes-C51rXSDJ.d.mts} +2 -2
  64. package/dist/{shapes-40dHtTb-.d.mts.map → shapes-C51rXSDJ.d.mts.map} +1 -1
  65. package/dist/shapes-CqppzFzb.mjs +2 -0
  66. package/dist/shapes-CqppzFzb.mjs.map +1 -0
  67. package/dist/{types-CgkJF5dc.d.mts → types-6PZQ4Zq6.d.mts} +8 -11
  68. package/dist/{types-CgkJF5dc.d.mts.map → types-6PZQ4Zq6.d.mts.map} +1 -1
  69. package/dist/{types-DwzblZfW.d.mts → types-Lj3SkZQ_.d.mts} +11 -1
  70. package/dist/types-Lj3SkZQ_.d.mts.map +1 -0
  71. package/dist/variant-plan-86tJKtVi.mjs +2 -0
  72. package/dist/variant-plan-86tJKtVi.mjs.map +1 -0
  73. package/dist/variant-state-CBtqb9Cb.mjs +2 -0
  74. package/dist/variant-state-CBtqb9Cb.mjs.map +1 -0
  75. package/package.json +9 -9
  76. package/dist/backfill-D2Ubblh3.mjs +0 -2
  77. package/dist/en-A8YzaZ51.mjs +0 -2
  78. package/dist/en-A8YzaZ51.mjs.map +0 -1
  79. package/dist/et-CXvu8U31.mjs +0 -2
  80. package/dist/et-CXvu8U31.mjs.map +0 -1
  81. package/dist/generate-variants-zyPjaGcy.mjs +0 -2
  82. package/dist/process-image-Deedtkh4.mjs +0 -2
  83. package/dist/process-image-Deedtkh4.mjs.map +0 -1
  84. package/dist/routes-BJ23Cp0g.mjs +0 -2
  85. package/dist/routes-BJ23Cp0g.mjs.map +0 -1
  86. package/dist/ru-Dj-ax8s_.mjs +0 -2
  87. package/dist/ru-Dj-ax8s_.mjs.map +0 -1
  88. package/dist/shapes-BlKW-0C1.mjs +0 -2
  89. package/dist/shapes-BlKW-0C1.mjs.map +0 -1
  90. package/dist/types-DwzblZfW.d.mts.map +0 -1
  91. package/dist/variant-key-Cki4n-42.mjs +0 -2
  92. package/dist/variant-key-Cki4n-42.mjs.map +0 -1
  93. package/dist/variant-plan-DV8hsMdz.mjs +0 -2
  94. package/dist/variant-plan-DV8hsMdz.mjs.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"variant-state-CBtqb9Cb.mjs","names":[],"sources":["../src/jobs/variant-state.ts"],"sourcesContent":["/**\n * What a photograph's ORIGINAL file records about the variants that exist for\n * it — the single source a reader resolves variants from (images F016/F017).\n *\n * Stored in the original's `toolkit_files.metadata`, beside the `variants` map\n * the legacy path has always written there:\n *\n * ```jsonc\n * {\n * \"variants\": { \"thumbnail\": \"<dir>/thumbnail_photo.webp\" }, // legacy, unchanged\n * \"legacyRecipes\": { \"thumbnail\": \"200x200|cover|webp|q80\" }, // new\n * \"shaped\": [ // new\n * { \"shape\": \"square\", \"rung\": 320, \"width\": 320, \"height\": 320,\n * \"format\": \"webp\", \"key\": \"<dir>/variants/photo_square_320w_1k2x9a0.webp\" }\n * ],\n * \"unconfirmedVariants\": [\"<dir>/variants/…\"] // new, usually absent\n * }\n * ```\n *\n * 🔴 A shaped entry records `width`/`height` as sharp RETURNED them, never the\n * rung that was asked for (F003). The rung names the key; the recorded size is\n * what a `w` descriptor may claim. A reader must never re-derive keys from the\n * media row: the row's dimensions can differ from the file's (F017).\n *\n * `unconfirmedVariants` are files a FAILED pass uploaded before giving up. They\n * are not in `shaped` — so a reader never sees two crops for one (shape, rung)\n * — but they are recorded, so deleting the photograph deletes them too, and\n * the retry either adopts them or deletes them.\n *\n * Pure — no sharp, no storage, no database.\n */\n\nimport type { VariantConfig } from '../shapes.js'\nimport type { ImageStyle } from '../types.js'\nimport { deriveShapedVariantKey, deriveVariantKey } from '../variant-key.js'\nimport { planVariants, type VariantPlanInput } from '../variant-plan.js'\n\n/**\n * Set on the original's metadata when the pass looked for a focal point and\n * found nothing to guess about (`detectFocalPoint` → `null`: a strip, a flat\n * image). The row keeps `focalPoint: null` — the centre — and the backfill\n * stops treating that as \"never seeded\", which would queue it on every walk.\n */\nexport const FOCAL_SEED_TRIED = 'focalPointSeedTried'\n\n/** Did a pass already look for this photograph's focal point and find none? */\nexport function focalSeedTried(metadata: unknown): boolean {\n return isRecord(metadata) && metadata[FOCAL_SEED_TRIED] === true\n}\n\n/** One generated (shape × rung) variant, as recorded. */\nexport interface StoredShapedVariant {\n readonly shape: string\n /** The rung the variant was PLANNED at — part of its key. */\n readonly rung: number\n /** What the file actually is (F003). */\n readonly width: number\n readonly height: number\n readonly format: string\n readonly key: string\n}\n\n/** Everything the original's metadata says about its variants. */\nexport interface VariantState {\n /** Legacy style name → key (`metadata.variants`). */\n readonly legacy: ReadonlyMap<string, string>\n /** Legacy style name → the {@link legacyRecipe} its file was made with. */\n readonly legacyRecipes: ReadonlyMap<string, string>\n readonly shaped: readonly StoredShapedVariant[]\n /** Keys a failed pass uploaded that no reader should resolve yet — see the module doc. */\n readonly unconfirmed: readonly string[]\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction stringEntries(value: unknown): Map<string, string> {\n const out = new Map<string, string>()\n if (!isRecord(value)) return out\n for (const [k, v] of Object.entries(value)) if (typeof v === 'string') out.set(k, v)\n return out\n}\n\nfunction isPositiveInt(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value > 0\n}\n\nfunction readShapedEntry(value: unknown): StoredShapedVariant | null {\n if (!isRecord(value)) return null\n const { shape, rung, width, height, format, key } = value\n if (typeof shape !== 'string' || typeof format !== 'string' || typeof key !== 'string') {\n return null\n }\n if (!isPositiveInt(rung) || !isPositiveInt(width) || !isPositiveInt(height)) return null\n return { shape, rung, width, height, format, key }\n}\n\n/**\n * Read the variant state off an original's metadata. Lenient: the column is\n * caller-shaped JSON, so a malformed entry is dropped rather than trusted, and\n * a file that predates this module reads as its legacy map alone.\n */\nexport function readVariantState(metadata: unknown): VariantState {\n const meta = isRecord(metadata) ? metadata : {}\n const shaped = Array.isArray(meta.shaped)\n ? meta.shaped.map(readShapedEntry).filter((v): v is StoredShapedVariant => v !== null)\n : []\n const unconfirmed = Array.isArray(meta.unconfirmedVariants)\n ? meta.unconfirmedVariants.filter((k): k is string => typeof k === 'string')\n : []\n return {\n legacy: stringEntries(meta.variants),\n legacyRecipes: stringEntries(meta.legacyRecipes),\n shaped,\n unconfirmed,\n }\n}\n\n/**\n * `metadata` with `state` written into it — every other key the original's\n * metadata carries is kept as it was.\n */\nexport function writeVariantState(metadata: unknown, state: VariantState): Record<string, unknown> {\n const { unconfirmedVariants: _dropped, ...base } = isRecord(metadata) ? metadata : {}\n return {\n ...base,\n variants: Object.fromEntries(state.legacy),\n legacyRecipes: Object.fromEntries(state.legacyRecipes),\n shaped: state.shaped.map((v) => ({ ...v })),\n ...(state.unconfirmed.length > 0 && { unconfirmedVariants: [...state.unconfirmed] }),\n }\n}\n\n/**\n * Every variant file the original's metadata accounts for — legacy, shaped and\n * unconfirmed. What deleting the photograph must delete: a public photograph an\n * editor deleted must not stay reachable at its variant URLs.\n */\nexport function allVariantKeys(state: VariantState): string[] {\n return [\n ...new Set([...state.legacy.values(), ...state.shaped.map((v) => v.key), ...state.unconfirmed]),\n ]\n}\n\n/**\n * Everything that determines a LEGACY style file's bytes, as one string.\n *\n * A legacy key names the style and nothing else (`thumbnail_photo.webp`), so\n * without this a regeneration cannot tell \"the operator changed thumbnail to\n * 320×320\" from \"nothing changed\" — and re-rendering every legacy file on every\n * pass is exactly the all-or-nothing cost this module exists to remove.\n * Defaults are applied, so `{width: 200}` and `{width: 200, fit: 'cover'}` are\n * the same recipe, as they produce the same file.\n */\nexport function legacyRecipe(style: ImageStyle): string {\n return [\n `${style.width ?? ''}x${style.height ?? ''}`,\n style.fit ?? 'cover',\n style.format ?? 'webp',\n `q${style.quality ?? 80}`,\n ].join('|')\n}\n\n/**\n * Does the recorded state already say exactly what a pass would produce now?\n *\n * The backfill asks this per photograph from its metadata alone — no download,\n * no decode — and queues a pass only when the answer is no. That is what makes\n * a walk incremental (WordPress's `--only-missing`, R014): a library that is\n * already caught up costs one metadata read per photograph, and a walk that\n * stopped at its cap reaches further the next time.\n *\n * \"Exactly\" in both directions: a planned key that is not recorded needs\n * generating, and a recorded key that is not planned needs deleting (a removed\n * shape, a re-crop). Same for the legacy styles and their recipes.\n */\nexport function isUpToDate(\n state: VariantState,\n plannedKeys: ReadonlySet<string>,\n legacy: ReadonlyMap<string, { readonly key: string; readonly recipe: string }>,\n): boolean {\n // Leftovers of a failed pass are work: the next pass adopts or deletes them.\n if (state.unconfirmed.length > 0) return false\n if (state.shaped.length !== plannedKeys.size) return false\n for (const v of state.shaped) if (!plannedKeys.has(v.key)) return false\n if (state.legacy.size !== legacy.size) return false\n for (const [name, want] of legacy) {\n if (state.legacy.get(name) !== want.key || state.legacyRecipes.get(name) !== want.recipe) {\n return false\n }\n }\n return true\n}\n\n/**\n * The keys a pass would leave recorded for this photograph under this\n * configuration — the other half of {@link isUpToDate}. Planned from `drawn`,\n * which the pass takes from the FILE and the backfill from the row (a row\n * whose size disagrees with its file plans different keys, so it is queued,\n * and its pass corrects the row — F014).\n */\nexport function expectedVariants(\n fileKey: string,\n drawn: { readonly width: number; readonly height: number },\n config: VariantConfig,\n styles: Readonly<Record<string, ImageStyle>>,\n input: VariantPlanInput,\n): {\n plannedKeys: Set<string>\n legacy: Map<string, { key: string; recipe: string }>\n} {\n const plannedKeys = new Set(\n planVariants(drawn, config, input).variants.map((p) =>\n deriveShapedVariantKey(fileKey, {\n shape: p.shape,\n width: p.width,\n fingerprint: p.fingerprint,\n format: p.format,\n }),\n ),\n )\n const legacy = new Map(\n Object.entries(styles).map(([name, style]) => [\n name,\n { key: deriveVariantKey(fileKey, name, style.format ?? 'webp'), recipe: legacyRecipe(style) },\n ]),\n )\n return { plannedKeys, legacy }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/media",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -74,14 +74,14 @@
74
74
  "sharp": "^0.34.5",
75
75
  "tailwind-merge": "^2.6.0",
76
76
  "zod": "^3.24.1",
77
- "@murumets-ee/core": "0.72.0",
78
- "@murumets-ee/db": "0.72.0",
79
- "@murumets-ee/entity": "0.72.0",
80
- "@murumets-ee/logging": "0.72.0",
81
- "@murumets-ee/queue": "0.72.0",
82
- "@murumets-ee/settings": "0.72.0",
83
- "@murumets-ee/storage": "0.72.0",
84
- "@murumets-ee/ui": "0.72.0"
77
+ "@murumets-ee/core": "0.74.0",
78
+ "@murumets-ee/db": "0.74.0",
79
+ "@murumets-ee/entity": "0.74.0",
80
+ "@murumets-ee/logging": "0.74.0",
81
+ "@murumets-ee/queue": "0.74.0",
82
+ "@murumets-ee/settings": "0.74.0",
83
+ "@murumets-ee/storage": "0.74.0",
84
+ "@murumets-ee/ui": "0.74.0"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "lucide-react": ">=0.400.0 <1",
@@ -1,2 +0,0 @@
1
- import{t as e}from"./entity-CsDdjKz6.mjs";import{a as t,c as n}from"./definitions-LJpgrdxd.mjs";import{d as r,f as i,i as a,m as o,u as s}from"./process-image-Deedtkh4.mjs";import{n as c,t as l}from"./resolve-image-styles-pf7Nd3Zf.mjs";const u=1e5;async function d(e,t){let n=await t.readMetadata(e.fileKey);if(n===void 0)return`no-original`;if((e.focalPoint===null||e.focalPoint===void 0)&&!r(n))return`yes`;let a={width:e.width??0,height:e.height??0},c=s(e.fileKey,a,t.config,t.styles,e);return i(o(n),c.plannedKeys,c.legacy)?`no`:`yes`}async function f(e,t=()=>{},n=u){let r={phase:`scanning`,scanned:0,queued:0,upToDate:0,alreadyWaiting:0,skipped:0,truncated:!1},i=null;for(;;){let o=await e.listImages(i,200),s=o[o.length-1];if(!s)break;i=s.id;let c=0,l=0,u=[];for(let t of o){if(!a(t.mimeType)){c++;continue}let n=await d(t,e);n===`no-original`?c++:n===`no`?l++:u.push({mediaId:t.id,epoch:e.epoch})}let f=await e.waitingMediaIds(u.map(e=>e.mediaId)),p=0,m=0,h=!1;for(let t of u){if(f.has(t.mediaId)){m++;continue}if(r.queued+p>=n){h=!0;break}await e.enqueue(t),p++}if(r={...r,scanned:r.scanned+o.length,queued:r.queued+p,upToDate:r.upToDate+l,alreadyWaiting:r.alreadyWaiting+m,skipped:r.skipped+c,truncated:h},t(r),h||o.length<200)break}return r={...r,phase:`done`},t(r),r}async function p(r,i,a){let{QueueClient:o}=await import(`@murumets-ee/queue/client`),{jobsTable:s}=await import(`@murumets-ee/queue/admin`),{schemaRegistry:d}=await import(`@murumets-ee/db`),{createStorageClient:p}=await import(`@murumets-ee/storage`),{getStorageConfig:m}=await import(`@murumets-ee/storage/plugin`),{and:h,asc:g,eq:_,gt:v}=await import(`drizzle-orm`),y=d.get(`media`);if(!y)throw Error(`Media schema not registered. Is the media() plugin loaded?`);let b=r.getClient(e),x=p(m(),{app:r}),S=new o({app:r,logger:r.logger}),C=s.makeClient(r.db.readWrite),[w,T]=await Promise.all([c(r,r.logger,{strict:!0}),l(r,r.logger,{strict:!0})]),E=await f({epoch:t(a.walkJobId),listImages:(e,t)=>b.findMany({where:e?h(_(y.mediaType,`image`),v(y.id,e)):_(y.mediaType,`image`),orderBy:g(y.id),limit:t}),readMetadata:async e=>{let t=await x.getMetadata(e);return t?t.metadata??{}:void 0},waitingMediaIds:async e=>{if(e.length===0)return new Set;let t=await C.findMany({where:{type:i.name,status:{in:[`pending`,`processing`]},"payload.mediaId":{in:[...e]}},limit:Math.min(e.length*4,1e3)}),r=new Set;for(let e of t){let t=n.safeParse(e.payload);t.success&&r.add(t.data.mediaId)}return r},enqueue:async e=>{await S.enqueue(i,e)},config:w,styles:T},a.onProgress);return E.truncated&&r.logger.warn({cap:u,queued:E.queued},`media variants: backfill stopped at its cap — run it again once these have drained`),E}export{p as runVariantsBackfill};
2
- //# sourceMappingURL=backfill-D2Ubblh3.mjs.map
@@ -1,2 +0,0 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";var t=e({Media:()=>r,MediaCropEditor:()=>i,PluginNav:()=>n,default:()=>a}),n={media:`Media`},r={imageVocabulary:{description:`Every photograph is generated in each shape below, at each width. A slot that names a shape draws these files; a slot with its own ratio is cropped in the browser instead.`,usingDefaults:`Nothing is saved here yet. Shown are the toolkit defaults; until you save, the site uses them — or the shapes configured in its code, if it has any.`,widthsLabel:`Widths (px)`,widthsHelp:`Ascending, separated by commas. A photograph is never enlarged — a width it cannot supply is skipped.`,widthsInvalid:`Use up to {count} ascending whole numbers between {min} and {max}.`,shapesLabel:`Shapes`,naturalNote:`Plus “natural” — each photograph at its own shape, over the same widths.`,columnName:`Name`,columnRatio:`Ratio (W:H)`,columnWidths:`Own widths`,ownWidthsPlaceholder:`All widths`,ratioInvalid:`Write the ratio as W:H, for example 3:2.`,nameInvalid:`Lowercase letters, digits, - and _, starting with a letter — and not “natural”.`,nameTaken:`A shape named “{name}” already exists.`,tooMany:`At most {count} shapes.`,add:`Add shape`,remove:`Remove {name}`,tooManyFiles:`That makes {count} files for every photograph; the most is {max}. Remove a shape or a width first.`},variantBacklog:{waiting:`Images waiting for their sizes: {count}`,workerRunning:`The queue worker is generating them. Until it finishes, those images are shown at full size.`,workerMissing:`No queue worker is running, so these images are shown at full size — slower pages, especially on phones. Image sizes are made by the queue worker, a separate process that has to run beside the site (pnpm worker). Ask whoever hosts this site to start it.`,waitingSince:`The oldest was queued {since}.`,failed:`Size generation failed {count} times and was given up on — the Queue page shows why.`,catchUp:`A catch-up of the whole library is queued: every image is checked against the current shapes and styles.`,catchUpWorkerMissing:`No queue worker is running, so it has not started. Image sizes are made by the queue worker, a separate process that has to run beside the site (pnpm worker). Ask whoever hosts this site to start it.`,openQueue:`Open the queue`},variantGeneration:{workerRequired:`Image sizes are generated in the background by the queue worker — a separate process that has to run beside the site (pnpm worker). Without it, the site still works, but new and changed images are shown at full size.`,regenerate:`Catch up all images`,regenerating:`Starting…`,queued:`Started. The queue worker catches the library up in the background; the site keeps serving meanwhile. Follow it on the Queue page.`,alreadyRunning:`A catch-up is already running — follow it on the Queue page.`,inlineDone:`Processed {processed} of {total}; skipped {skipped}; errors {errors}.`,failed:`Could not start the catch-up: {reason}`}},i={tabListLabel:`Crop shapes`,description:`Drag to position the photograph, scroll or pinch to zoom. Every slot using this shape will show this crop.`,xLabel:`Left (%)`,yLabel:`Top (%)`,widthLabel:`Width (%)`,heightLabel:`Height (%)`,clearShape:`Clear this crop`,noShapes:`No configured image style has a fixed ratio yet, so there is nothing to crop against.`,hasCropSuffix:`— custom crop saved`},a={PluginNav:n,Media:r,MediaCropEditor:i};export{t as n,a as t};
2
- //# sourceMappingURL=en-A8YzaZ51.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"en-A8YzaZ51.mjs","names":[],"sources":["../src/messages/en.json"],"sourcesContent":[""],"mappings":""}
@@ -1,2 +0,0 @@
1
- var e={PluginNav:{media:`Meedia`},Media:{imageVocabulary:{description:`Iga foto genereeritakse igas allolevas kujus ja igas laiuses. Pesa, mis nimetab kuju, kasutab neid faile; oma kuvasuhtega pesa kärbitakse hoopis brauseris.`,usingDefaults:`Siin pole veel midagi salvestatud. Näidatud on tööriista vaikeväärtused; kuni salvestad, kasutab sait neid — või koodis seadistatud kujusid, kui neid on.`,widthsLabel:`Laiused (px)`,widthsHelp:`Kasvavas järjekorras, komadega eraldatud. Fotot ei suurendata kunagi — laius, mida see ei suuda pakkuda, jäetakse vahele.`,widthsInvalid:`Kasuta kuni {count} kasvavat täisarvu vahemikus {min}–{max}.`,shapesLabel:`Kujud`,naturalNote:`Lisaks „natural“ — iga foto oma kujus, samades laiustes.`,columnName:`Nimi`,columnRatio:`Kuvasuhe (L:K)`,columnWidths:`Oma laiused`,ownWidthsPlaceholder:`Kõik laiused`,ratioInvalid:`Kirjuta kuvasuhe kujul L:K, näiteks 3:2.`,nameInvalid:`Väiketähed, numbrid, - ja _, algab tähega — ja mitte „natural“.`,nameTaken:`Kuju nimega „{name}“ on juba olemas.`,tooMany:`Kõige rohkem {count} kuju.`,add:`Lisa kuju`,remove:`Eemalda {name}`,tooManyFiles:`See teeks iga foto kohta {count} faili; lubatud on kõige rohkem {max}. Eemalda enne kuju või laius.`},variantBacklog:{waiting:`Suuruste loomist ootavaid pilte: {count}`,workerRunning:`Järjekorratöötleja loob neid praegu. Kuni see pole valmis, näidatakse neid pilte täissuuruses.`,workerMissing:`Ükski järjekorratöötleja ei tööta, seega näidatakse neid pilte täissuuruses — lehed on aeglasemad, eriti telefonis. Pildisuurused loob järjekorratöötleja, eraldi protsess, mis peab töötama saidi kõrval (pnpm worker). Palu saidi haldajal see käivitada.`,waitingSince:`Vanim lisati järjekorda {since}.`,failed:`Suuruste loomine ebaõnnestus {count} korda ja jäeti pooleli — põhjuse leiad järjekorra lehelt.`,catchUp:`Kogu meediakogu uuendamine on järjekorras: iga pilti võrreldakse praeguste kujude ja stiilidega.`,catchUpWorkerMissing:`Ükski järjekorratöötleja ei tööta, seega pole see alanud. Pildisuurused loob järjekorratöötleja, eraldi protsess, mis peab töötama saidi kõrval (pnpm worker). Palu saidi haldajal see käivitada.`,openQueue:`Ava järjekord`},variantGeneration:{workerRequired:`Pildisuurused luuakse taustal järjekorratöötleja poolt — see on eraldi protsess, mis peab töötama saidi kõrval (pnpm worker). Ilma selleta sait töötab, kuid uusi ja muudetud pilte näidatakse täissuuruses.`,regenerate:`Uuenda kõik pildid`,regenerating:`Käivitan…`,queued:`Käivitatud. Järjekorratöötleja uuendab meediakogu taustal; sait töötab samal ajal edasi. Jälgi seda järjekorra lehel.`,alreadyRunning:`Uuendamine juba käib — jälgi seda järjekorra lehel.`,inlineDone:`Töödeldud {processed} / {total}; vahele jäetud {skipped}; vigu {errors}.`,failed:`Uuendamist ei õnnestunud käivitada: {reason}`}},MediaCropEditor:{tabListLabel:`Kärpimise kujud`,description:`Lohista foto paigutamiseks, kerimine või näpuliigutus suumib. Iga seda kuju kasutav koht näitab seda kärbet.`,xLabel:`Vasakult (%)`,yLabel:`Ülevalt (%)`,widthLabel:`Laius (%)`,heightLabel:`Kõrgus (%)`,clearShape:`Tühista see kärbe`,noShapes:`Ühelgi seadistatud pildistiilil pole veel kindlat proportsiooni, seega pole midagi kärpida.`,hasCropSuffix:`— kohandatud kärbe salvestatud`}};export{e as default};
2
- //# sourceMappingURL=et-CXvu8U31.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"et-CXvu8U31.mjs","names":[],"sources":["../src/messages/et.json"],"sourcesContent":[""],"mappings":""}
@@ -1,2 +0,0 @@
1
- import{n as e,t}from"./variant-key-Cki4n-42.mjs";import{n}from"./variant-plan-DV8hsMdz.mjs";import{t as r}from"./owned-delete-Cn_KFFjS.mjs";import{a as i,c as a,h as o,i as ee,m as s,p as c,r as te,s as ne,t as l}from"./process-image-Deedtkh4.mjs";var u=class extends Error{failed;constructor(e,t){super(`media ${e}: ${t} variant(s) failed to upload; the pass will be retried`),this.name=`VariantUploadError`,this.failed=t}};function d(e,t,n={}){return{mediaId:e,status:t,uploaded:0,reused:0,deleted:0,dropped:0,dimensionsCorrected:!1,focalPointSeeded:!1,...n}}async function re(e){if(Buffer.isBuffer(e))return e;let t=[],n=e.getReader();for(;;){let{done:e,value:r}=await n.read();if(e)break;r&&t.push(r)}return Buffer.concat(t)}async function f(e,t,n){let r=[...e];await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=r.shift();e!==void 0;e=r.shift())await n(e)}))}function p(e,t){return JSON.stringify(e??null)===JSON.stringify(t??null)}function m(e,n){return t(e,{shape:n.shape,width:n.width,fingerprint:n.fingerprint,format:n.format})}function ie(e,t,n){let{width:r,height:i}=e.metadata??{};return typeof r!=`number`||typeof i!=`number`||r<=0||i<=0?null:{shape:t.shape,rung:t.width,width:r,height:i,format:t.format,key:n}}async function h(t,h){let{media:y,storage:b,config:x,styles:ae,logger:S}=h,C=await y.findById(t);if(!C)return d(t,`missing`);let w=C;if(w.mediaType!==`image`||!ee(w.mimeType))return d(t,`skipped`);let T=await b.getMetadata(w.fileKey);if(!T)return S?.warn({mediaId:t},`media variants: original file record is missing; nothing to do`),d(t,`skipped`);let E=await re((await b.download(w.fileKey)).body),D=!1,O=await te(E),k={};if(O.width>0&&(w.width!==O.width||w.height!==O.height)&&(k.width=O.width,k.height=O.height),w.focalPoint===null||w.focalPoint===void 0){let e=await l(E).catch(e=>(S?.warn({mediaId:t,err:e},`media variants: focal-point detection failed; left unset`),null));e?k.focalPoint=e:D=!0}if(`focalPoint`in k){let e=await y.findById(t);if(!e)return d(t,`missing`);e.focalPoint!==null&&e.focalPoint!==void 0&&(delete k.focalPoint,w={...w,focalPoint:e.focalPoint,crops:e.crops})}Object.keys(k).length>0&&(await y.updateInternal(t,k),w={...w,...k});let A=n(O,x,{focalPoint:w.focalPoint,crops:w.crops});A.dropped>0&&S?.warn({mediaId:t,dropped:A.dropped},`media variants: the per-image cap cut the largest variants off this photograph`);let j=s(T.metadata),oe=new Map(j.shaped.map(e=>[e.key,e])),M=[],N=[];for(let e of A.variants){let t=m(w.fileKey,e),n=oe.get(t);if(n){M.push({...n,shape:e.shape,rung:e.width});continue}let r=await b.getMetadata(t),i=r?ie(r,e,t):null;i?M.push(i):N.push({planned:e,key:t,replace:r!==null})}let se=M.length,P=0,F=0,I=[],L=[],ce=await ne(E,N.map(e=>e.planned));await f(N.map((e,t)=>({...e,variant:ce[t]})),4,async({planned:e,key:n,replace:r,variant:i})=>{if(i)try{r&&await b.delete(n),await b.upload(i.buffer,{key:n,filename:`${e.shape}_${e.width}w_${w.filename}`,mimeType:i.mimeType,size:i.buffer.byteLength,visibility:T.visibility,metadata:{variantOf:w.fileKey,shape:e.shape,rung:e.width,width:i.width,height:i.height}}),M.push({shape:e.shape,rung:e.width,width:i.width,height:i.height,format:e.format,key:n}),I.push(n),P++}catch(e){F++,S?.warn({mediaId:t,key:n,err:e},`media variants: shaped variant upload failed`)}});let R=new Map,z=new Map,B={},V=new Set;for(let[t,n]of Object.entries(ae)){let r=e(w.fileKey,t,n.format??`webp`),i=c(n);j.legacy.get(t)===r&&j.legacyRecipes.get(t)===i?(R.set(t,r),z.set(t,i)):B[t]=n}Object.keys(B).length>0&&await f([...(await i(E,B)).variants],4,async([n,r])=>{let i=B[n];if(!i)return;let a=e(w.fileKey,n,r.format),o=!1;try{await b.getMetadata(a)&&(await b.delete(a),o=!0),await b.upload(r.buffer,{key:a,filename:`${n}_${w.filename}`,mimeType:r.mimeType,size:r.buffer.byteLength,visibility:T.visibility,metadata:{variantOf:w.fileKey,style:n}}),R.set(n,a),z.set(n,c(i)),L.push(a),P++}catch(e){F++,o&&V.add(n),S?.warn({mediaId:t,key:a,err:e},`media variants: legacy style upload failed`)}});let H=await y.findById(t);if(!H)return await r(b,w.fileKey,[...I,...L],S),d(t,`missing`,{uploaded:P});let U=!p(H.focalPoint,w.focalPoint)||!p(H.crops,w.crops),W=await b.getMetadata(w.fileKey)??T,G=s(W.metadata),K=_(g(G.legacy,R),V),q=_(g(G.legacyRecipes,z),V),le=[...G.legacy].filter(([e,t])=>K.has(e)&&K.get(e)!==t).map(([,e])=>e),J=U?new Set(n(O,x,{focalPoint:H.focalPoint,crops:H.crops}).variants.map(e=>m(w.fileKey,e))):null,Y=(e,t)=>{let n=new Set(e.map(e=>e.key));return[...new Set([...G.unconfirmed,...t,...le])].filter(e=>!n.has(e))},X;if(U){let e=I.filter(e=>J?.has(e));X={legacy:K,legacyRecipes:q,shaped:G.shaped,unconfirmed:Y(G.shaped,e)}}else if(F>0){let e=new Set(G.shaped.map(e=>`${e.shape}@${e.rung}`)),t=M.filter(t=>!e.has(`${t.shape}@${t.rung}`)),n=v([...G.shaped,...t]);X={legacy:K,legacyRecipes:q,shaped:n,unconfirmed:Y(n,M.map(e=>e.key))}}else X={legacy:R,legacyRecipes:z,shaped:v(M),unconfirmed:[]};let Z=o(W.metadata,X);await b.updateMetadata(w.fileKey,{metadata:D?{...Z,[a]:!0}:Z});let Q=new Set;if(U){let e=new Set(G.shaped.map(e=>e.key));for(let t of I)!J?.has(t)&&!e.has(t)&&Q.add(t)}else if(F===0){let e=new Set([...M.map(e=>e.key),...R.values()]);for(let t of G.shaped)e.has(t.key)||Q.add(t.key);for(let t of G.legacy.values())e.has(t)||Q.add(t);for(let t of G.unconfirmed)e.has(t)||Q.add(t)}let ue=await r(b,w.fileKey,Q,S),$=d(t,U?`stale`:`generated`,{uploaded:P,reused:se,deleted:ue,dropped:A.dropped,dimensionsCorrected:`width`in k,focalPointSeeded:`focalPoint`in k});if(F>0&&!U)throw new u(t,F);return S?.debug($,`media variants: pass complete`),$}function g(e,t){return new Map([...e,...t])}function _(e,t){for(let n of t)e.delete(n);return e}function v(e){return[...e].sort((e,t)=>e.shape===t.shape?e.rung-t.rung||e.width-t.width:e.shape<t.shape?-1:1)}export{h as generateMediaVariants};
2
- //# sourceMappingURL=generate-variants-zyPjaGcy.mjs.map
@@ -1,2 +0,0 @@
1
- import{n as e,t}from"./variant-key-Cki4n-42.mjs";import{n}from"./variant-plan-DV8hsMdz.mjs";import r from"sharp";const i=`focalPointSeedTried`;function a(e){return o(e)&&e.focalPointSeedTried===!0}function o(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function s(e){let t=new Map;if(!o(e))return t;for(let[n,r]of Object.entries(e))typeof r==`string`&&t.set(n,r);return t}function c(e){return typeof e==`number`&&Number.isInteger(e)&&e>0}function l(e){if(!o(e))return null;let{shape:t,rung:n,width:r,height:i,format:a,key:s}=e;return typeof t!=`string`||typeof a!=`string`||typeof s!=`string`||!c(n)||!c(r)||!c(i)?null:{shape:t,rung:n,width:r,height:i,format:a,key:s}}function u(e){let t=o(e)?e:{},n=Array.isArray(t.shaped)?t.shaped.map(l).filter(e=>e!==null):[],r=Array.isArray(t.unconfirmedVariants)?t.unconfirmedVariants.filter(e=>typeof e==`string`):[];return{legacy:s(t.variants),legacyRecipes:s(t.legacyRecipes),shaped:n,unconfirmed:r}}function d(e,t){let{unconfirmedVariants:n,...r}=o(e)?e:{};return{...r,variants:Object.fromEntries(t.legacy),legacyRecipes:Object.fromEntries(t.legacyRecipes),shaped:t.shaped.map(e=>({...e})),...t.unconfirmed.length>0&&{unconfirmedVariants:[...t.unconfirmed]}}}function f(e){return[...new Set([...e.legacy.values(),...e.shaped.map(e=>e.key),...e.unconfirmed])]}function p(e){return[`${e.width??``}x${e.height??``}`,e.fit??`cover`,e.format??`webp`,`q${e.quality??80}`].join(`|`)}function m(e,t,n){if(e.unconfirmed.length>0||e.shaped.length!==t.size)return!1;for(let n of e.shaped)if(!t.has(n.key))return!1;if(e.legacy.size!==n.size)return!1;for(let[t,r]of n)if(e.legacy.get(t)!==r.key||e.legacyRecipes.get(t)!==r.recipe)return!1;return!0}function h(r,i,a,o,s){return{plannedKeys:new Set(n(i,a,s).variants.map(e=>t(r,{shape:e.shape,width:e.width,fingerprint:e.fingerprint,format:e.format}))),legacy:new Map(Object.entries(o).map(([t,n])=>[t,{key:e(r,t,n.format??`webp`),recipe:p(n)}]))}}const g=new Set([`image/svg+xml`,`image/gif`]);function _(e){return e.startsWith(`image/`)&&!g.has(e)}async function v(e,t){let{width:n,height:i}=w(await r(e).metadata()),a=new Map,o=Object.entries(t);return await Promise.all(o.map(async([t,n])=>{let i=n.format??`webp`,o=n.quality??80,s=n.fit??`cover`,{data:c,info:l}=await r(e).rotate().resize({width:n.width,height:n.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:n,height:i,variants:a}}async function y(e,t,i){let{width:a,height:o}=w(await r(e).metadata()),s=n({width:a,height:o},t,i);return{width:a,height:o,variants:await b(e,s.variants),dropped:s.dropped}}async function b(e,t){let n=new Map;return S(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=x(e,t),n.set(r,i)),{...await i,shape:t.shape,plannedWidth:t.width}})}async function x(e,t){let{data:n,info:i}=await r(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:n,format:t.format,mimeType:`image/${t.format}`,width:i.width,height:i.height,shape:t.shape,plannedWidth:t.width,fingerprint:t.fingerprint}}async function S(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 C(e){return w(await r(e).metadata())}function w(e){return{width:e.autoOrient?.width??e.width??0,height:e.autoOrient?.height??e.height??0}}async function T(e){let{data:t,info:n}=await r(e).rotate().resize(512,512,{fit:`inside`,withoutEnlargement:!0}).raw().toBuffer({resolveWithObject:!0}),{width:i,height:a,channels:o}=n;if(i<8||a<8)return null;let s=await r(t,{raw:{width:i,height:a,channels:o}}).flop().flip().raw().toBuffer(),[c,l]=await Promise.all([E(t,i,a,o),E(s,i,a,o)]);if(c===null||l===null)return null;let u=D(c.x,1-l.x),d=D(c.y,1-l.y);return u===null&&d===null?null:{x:O(u??.5),y:O(d??.5)}}async function E(e,t,n,i){let a=t>=n,{info:o}=await r(e,{raw:{width:t,height:n,channels:i}}).resize(a?t-1:t,a?n:n-1,{fit:`cover`,position:r.strategy.attention}).toBuffer({resolveWithObject:!0}),{attentionX:s,attentionY:c}=o;return s===void 0||c===void 0?null:{x:s/t,y:c/n}}function D(e,t){return Math.abs(e-t)<=.25?(e+t)/2:null}function O(e){return Number.isFinite(e)?Math.min(Math.max(Math.round(e*1e4)/1e4,0),1):.5}export{v as a,i as c,a as d,m as f,d as h,_ as i,f as l,u as m,y as n,x as o,p,C as r,b as s,T as t,h as u};
2
- //# sourceMappingURL=process-image-Deedtkh4.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"process-image-Deedtkh4.mjs","names":[],"sources":["../src/jobs/variant-state.ts","../src/process-image.ts"],"sourcesContent":["/**\n * What a photograph's ORIGINAL file records about the variants that exist for\n * it — the single source a reader resolves variants from (images F016/F017).\n *\n * Stored in the original's `toolkit_files.metadata`, beside the `variants` map\n * the legacy path has always written there:\n *\n * ```jsonc\n * {\n * \"variants\": { \"thumbnail\": \"<dir>/thumbnail_photo.webp\" }, // legacy, unchanged\n * \"legacyRecipes\": { \"thumbnail\": \"200x200|cover|webp|q80\" }, // new\n * \"shaped\": [ // new\n * { \"shape\": \"square\", \"rung\": 320, \"width\": 320, \"height\": 320,\n * \"format\": \"webp\", \"key\": \"<dir>/variants/photo_square_320w_1k2x9a0.webp\" }\n * ],\n * \"unconfirmedVariants\": [\"<dir>/variants/…\"] // new, usually absent\n * }\n * ```\n *\n * 🔴 A shaped entry records `width`/`height` as sharp RETURNED them, never the\n * rung that was asked for (F003). The rung names the key; the recorded size is\n * what a `w` descriptor may claim. A reader must never re-derive keys from the\n * media row: the row's dimensions can differ from the file's (F017).\n *\n * `unconfirmedVariants` are files a FAILED pass uploaded before giving up. They\n * are not in `shaped` — so a reader never sees two crops for one (shape, rung)\n * — but they are recorded, so deleting the photograph deletes them too, and\n * the retry either adopts them or deletes them.\n *\n * Pure — no sharp, no storage, no database.\n */\n\nimport type { VariantConfig } from '../shapes.js'\nimport type { ImageStyle } from '../types.js'\nimport { deriveShapedVariantKey, deriveVariantKey } from '../variant-key.js'\nimport { planVariants, type VariantPlanInput } from '../variant-plan.js'\n\n/**\n * Set on the original's metadata when the pass looked for a focal point and\n * found nothing to guess about (`detectFocalPoint` → `null`: a strip, a flat\n * image). The row keeps `focalPoint: null` — the centre — and the backfill\n * stops treating that as \"never seeded\", which would queue it on every walk.\n */\nexport const FOCAL_SEED_TRIED = 'focalPointSeedTried'\n\n/** Did a pass already look for this photograph's focal point and find none? */\nexport function focalSeedTried(metadata: unknown): boolean {\n return isRecord(metadata) && metadata[FOCAL_SEED_TRIED] === true\n}\n\n/** One generated (shape × rung) variant, as recorded. */\nexport interface StoredShapedVariant {\n readonly shape: string\n /** The rung the variant was PLANNED at — part of its key. */\n readonly rung: number\n /** What the file actually is (F003). */\n readonly width: number\n readonly height: number\n readonly format: string\n readonly key: string\n}\n\n/** Everything the original's metadata says about its variants. */\nexport interface VariantState {\n /** Legacy style name → key (`metadata.variants`). */\n readonly legacy: ReadonlyMap<string, string>\n /** Legacy style name → the {@link legacyRecipe} its file was made with. */\n readonly legacyRecipes: ReadonlyMap<string, string>\n readonly shaped: readonly StoredShapedVariant[]\n /** Keys a failed pass uploaded that no reader should resolve yet — see the module doc. */\n readonly unconfirmed: readonly string[]\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction stringEntries(value: unknown): Map<string, string> {\n const out = new Map<string, string>()\n if (!isRecord(value)) return out\n for (const [k, v] of Object.entries(value)) if (typeof v === 'string') out.set(k, v)\n return out\n}\n\nfunction isPositiveInt(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value > 0\n}\n\nfunction readShapedEntry(value: unknown): StoredShapedVariant | null {\n if (!isRecord(value)) return null\n const { shape, rung, width, height, format, key } = value\n if (typeof shape !== 'string' || typeof format !== 'string' || typeof key !== 'string') {\n return null\n }\n if (!isPositiveInt(rung) || !isPositiveInt(width) || !isPositiveInt(height)) return null\n return { shape, rung, width, height, format, key }\n}\n\n/**\n * Read the variant state off an original's metadata. Lenient: the column is\n * caller-shaped JSON, so a malformed entry is dropped rather than trusted, and\n * a file that predates this module reads as its legacy map alone.\n */\nexport function readVariantState(metadata: unknown): VariantState {\n const meta = isRecord(metadata) ? metadata : {}\n const shaped = Array.isArray(meta.shaped)\n ? meta.shaped.map(readShapedEntry).filter((v): v is StoredShapedVariant => v !== null)\n : []\n const unconfirmed = Array.isArray(meta.unconfirmedVariants)\n ? meta.unconfirmedVariants.filter((k): k is string => typeof k === 'string')\n : []\n return {\n legacy: stringEntries(meta.variants),\n legacyRecipes: stringEntries(meta.legacyRecipes),\n shaped,\n unconfirmed,\n }\n}\n\n/**\n * `metadata` with `state` written into it — every other key the original's\n * metadata carries is kept as it was.\n */\nexport function writeVariantState(metadata: unknown, state: VariantState): Record<string, unknown> {\n const { unconfirmedVariants: _dropped, ...base } = isRecord(metadata) ? metadata : {}\n return {\n ...base,\n variants: Object.fromEntries(state.legacy),\n legacyRecipes: Object.fromEntries(state.legacyRecipes),\n shaped: state.shaped.map((v) => ({ ...v })),\n ...(state.unconfirmed.length > 0 && { unconfirmedVariants: [...state.unconfirmed] }),\n }\n}\n\n/**\n * Every variant file the original's metadata accounts for — legacy, shaped and\n * unconfirmed. What deleting the photograph must delete: a public photograph an\n * editor deleted must not stay reachable at its variant URLs.\n */\nexport function allVariantKeys(state: VariantState): string[] {\n return [\n ...new Set([...state.legacy.values(), ...state.shaped.map((v) => v.key), ...state.unconfirmed]),\n ]\n}\n\n/**\n * Everything that determines a LEGACY style file's bytes, as one string.\n *\n * A legacy key names the style and nothing else (`thumbnail_photo.webp`), so\n * without this a regeneration cannot tell \"the operator changed thumbnail to\n * 320×320\" from \"nothing changed\" — and re-rendering every legacy file on every\n * pass is exactly the all-or-nothing cost this module exists to remove.\n * Defaults are applied, so `{width: 200}` and `{width: 200, fit: 'cover'}` are\n * the same recipe, as they produce the same file.\n */\nexport function legacyRecipe(style: ImageStyle): string {\n return [\n `${style.width ?? ''}x${style.height ?? ''}`,\n style.fit ?? 'cover',\n style.format ?? 'webp',\n `q${style.quality ?? 80}`,\n ].join('|')\n}\n\n/**\n * Does the recorded state already say exactly what a pass would produce now?\n *\n * The backfill asks this per photograph from its metadata alone — no download,\n * no decode — and queues a pass only when the answer is no. That is what makes\n * a walk incremental (WordPress's `--only-missing`, R014): a library that is\n * already caught up costs one metadata read per photograph, and a walk that\n * stopped at its cap reaches further the next time.\n *\n * \"Exactly\" in both directions: a planned key that is not recorded needs\n * generating, and a recorded key that is not planned needs deleting (a removed\n * shape, a re-crop). Same for the legacy styles and their recipes.\n */\nexport function isUpToDate(\n state: VariantState,\n plannedKeys: ReadonlySet<string>,\n legacy: ReadonlyMap<string, { readonly key: string; readonly recipe: string }>,\n): boolean {\n // Leftovers of a failed pass are work: the next pass adopts or deletes them.\n if (state.unconfirmed.length > 0) return false\n if (state.shaped.length !== plannedKeys.size) return false\n for (const v of state.shaped) if (!plannedKeys.has(v.key)) return false\n if (state.legacy.size !== legacy.size) return false\n for (const [name, want] of legacy) {\n if (state.legacy.get(name) !== want.key || state.legacyRecipes.get(name) !== want.recipe) {\n return false\n }\n }\n return true\n}\n\n/**\n * The keys a pass would leave recorded for this photograph under this\n * configuration — the other half of {@link isUpToDate}. Planned from `drawn`,\n * which the pass takes from the FILE and the backfill from the row (a row\n * whose size disagrees with its file plans different keys, so it is queued,\n * and its pass corrects the row — F014).\n */\nexport function expectedVariants(\n fileKey: string,\n drawn: { readonly width: number; readonly height: number },\n config: VariantConfig,\n styles: Readonly<Record<string, ImageStyle>>,\n input: VariantPlanInput,\n): {\n plannedKeys: Set<string>\n legacy: Map<string, { key: string; recipe: string }>\n} {\n const plannedKeys = new Set(\n planVariants(drawn, config, input).variants.map((p) =>\n deriveShapedVariantKey(fileKey, {\n shape: p.shape,\n width: p.width,\n fingerprint: p.fingerprint,\n format: p.format,\n }),\n ),\n )\n const legacy = new Map(\n Object.entries(styles).map(([name, style]) => [\n name,\n { key: deriveVariantKey(fileKey, name, style.format ?? 'webp'), recipe: legacyRecipe(style) },\n ]),\n )\n return { plannedKeys, legacy }\n}\n","/**\n * Server-side image processing via Sharp.\n *\n * Pure processing module — no DB or storage dependencies.\n * Takes a buffer and image styles, returns metadata + variant buffers.\n */\n\nimport sharp from 'sharp'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport type { VariantConfig } from './shapes.js'\nimport type { ImageStyle } from './types.js'\nimport { type PlannedVariant, planVariants, type VariantPlanInput } from './variant-plan.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n // `.rotate()` first: without it an EXIF-rotated phone photo's variant is\n // written SIDEWAYS — the output format drops the orientation tag — while\n // the row's width/height (`displayDimensions`) and its focal point are in\n // the orientation a browser draws. Keys are unchanged; only the pixels\n // of a rotated original change, and they change to what was intended.\n const resized = sharp(buffer).rotate().resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/** One generated (shape × rung) variant. */\nexport interface ShapedVariant extends ProcessedVariant {\n readonly shape: string\n /** The width the plan ASKED for. It names the key; it is not a claim about the file. */\n readonly plannedWidth: number\n /** Goes into the key — see `deriveShapedVariantKey`. */\n readonly fingerprint: string\n}\n\n/** A photograph's shaped variants. */\nexport interface ShapedImage {\n /** The image as a browser draws it (post-EXIF orientation). */\n width: number\n height: number\n variants: ShapedVariant[]\n /** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`) — for the caller to log. */\n dropped: number\n}\n\n/**\n * How many sharp pipelines one image runs at once. Each decodes the original,\n * so a 24-megapixel JPEG costs ~70 MB of pixels per pipeline in flight; the\n * seeded vocabulary plans 30 of them. libvips already parallelises inside each\n * pipeline, so a small number here loses little throughput.\n */\nconst SHAPED_CONCURRENCY = 2\n\n/**\n * Generate every (shape × rung) variant `config` calls for (images D003).\n *\n * The region comes from {@link planVariants} — `crops[shape]`, else the focal\n * point's window for that ratio, else the whole image — and sharp runs\n * `rotate() → extract() → resize()` in that order:\n *\n * - `rotate()` first, so `extract()` works in the coordinates a browser DRAWS\n * (measured, R013). The crop and the focal point are stored in that space.\n * - `extract()` BEFORE `resize()` — what sharp documents for pre-resize\n * extraction, and the only way to express a rect at all: `resize({position})`\n * takes nine gravities and two strategies and throws on coordinates (C5).\n *\n * 🔴 The variant records `info.width`/`info.height` — what sharp RETURNED —\n * never the rung it asked for (F003). `withoutEnlargement` silently returns a\n * smaller file than requested, and a `640w` descriptor on a 400px file makes a\n * browser's `srcset` choice wrong in the direction it never corrects (R007).\n * The plan never asks for more than the region supplies, so today the two\n * agree; recording the real one is what keeps them agreeing when they don't.\n */\nexport async function generateShapedVariants(\n buffer: Buffer,\n config: VariantConfig,\n input: VariantPlanInput,\n): Promise<ShapedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n const plan = planVariants({ width, height }, config, input)\n const variants = await renderPlannedVariants(buffer, plan.variants)\n return { width, height, variants, dropped: plan.dropped }\n}\n\n/**\n * Render `planned` — a whole plan, or the part of one a caller does not already\n * have (the variant job renders only keys that are missing) — at most\n * {@link SHAPED_CONCURRENCY} sharp pipelines at a time, results in input order.\n */\nexport async function renderPlannedVariants(\n buffer: Buffer,\n planned: readonly PlannedVariant[],\n): Promise<ShapedVariant[]> {\n // Two shapes can plan the SAME recipe — `wide` on a 16:9 photograph is the\n // whole image, exactly as `natural` is — and encoding it twice buys nothing.\n // Keyed by the full recipe, never by the fingerprint: a 32-bit hash that\n // collided here would hand one shape another shape's picture.\n const rendered = new Map<string, Promise<ShapedVariant>>()\n return mapBounded(planned, SHAPED_CONCURRENCY, async (planned) => {\n const recipe = JSON.stringify([\n planned.extract,\n planned.width,\n planned.height,\n planned.quality,\n planned.format,\n ])\n let pending = rendered.get(recipe)\n if (!pending) {\n pending = renderPlannedVariant(buffer, planned)\n rendered.set(recipe, pending)\n }\n const first = await pending\n return { ...first, shape: planned.shape, plannedWidth: planned.width }\n })\n}\n\nexport async function renderPlannedVariant(\n buffer: Buffer,\n planned: PlannedVariant,\n): Promise<ShapedVariant> {\n const pipeline = sharp(buffer).rotate().extract(planned.extract).resize({\n width: planned.width,\n height: planned.height,\n fit: 'cover',\n withoutEnlargement: true,\n })\n const { data, info } = await pipeline[planned.format]({ quality: planned.quality }).toBuffer({\n resolveWithObject: true,\n })\n return {\n buffer: data,\n format: planned.format,\n mimeType: `image/${planned.format}`,\n width: info.width,\n height: info.height,\n shape: planned.shape,\n plannedWidth: planned.width,\n fingerprint: planned.fingerprint,\n }\n}\n\n/** `Promise.all(items.map(fn))` with at most `limit` in flight, results in input order. */\nasync function mapBounded<T, R>(\n items: readonly T[],\n limit: number,\n fn: (item: T) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length)\n const queue = items.map((item, index) => ({ item, index }))\n // Once one job has failed the whole call rejects, so the other workers stop\n // taking jobs rather than running sharp for a result nobody will read.\n let failed = false\n const worker = async (): Promise<void> => {\n for (let job = queue.shift(); job !== undefined && !failed; job = queue.shift()) {\n try {\n results[job.index] = await fn(job.item)\n } catch (err) {\n failed = true\n throw err\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))\n return results\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n return displayDimensions(await sharp(buffer).metadata())\n}\n\n/**\n * The size a browser DRAWS the image at — after EXIF orientation — not the\n * sensor's.\n *\n * `metadata().width/height` are the stored pixel grid, so a phone portrait\n * tagged orientation 6 reports 400×200 while every browser draws it 200×400\n * (`image-orientation: from-image` is the default). Those numbers become the\n * media row's `width`/`height`, which are the `<img>`'s CLS reservation AND the\n * image ratio the focal-point crop is computed against (images PR02) — a\n * swapped ratio positions the crop on the wrong axis. `autoOrient` is sharp's\n * own post-orientation size (0.33+).\n */\nfunction displayDimensions(meta: sharp.Metadata): { width: number; height: number } {\n return {\n width: meta.autoOrient?.width ?? meta.width ?? 0,\n height: meta.autoOrient?.height ?? meta.height ?? 0,\n }\n}\n\n/**\n * The longest edge the attention pass looks at. libvips scores saliency on a\n * ~32px grid, so a larger buffer buys no precision — it only costs decode time.\n */\nconst ATTENTION_EDGE = 512\n\n/** Below this on either side there is nothing to guess about — see {@link detectFocalPoint}. */\nconst MIN_ATTENTION_EDGE = 8\n\n/**\n * How far the two attention passes may disagree on an axis and still be the\n * same subject. libvips places its hotspot on a ~32-cell grid (1/32 ≈ 0.03), so\n * a real subject agrees to within a cell or two (measured ≤ 0.07); a FLAT axis\n * disagrees by the whole image (0 in one pass, 1 in the mirrored one).\n */\nconst AXIS_AGREEMENT = 0.25\n\n/**\n * Guess where the subject is, with sharp's `attention` strategy — the seed for\n * a new photograph's focal point, so an editor who opens nothing still gets\n * something other than a blind centre crop (images D002).\n *\n * ## Two passes, one of them mirrored\n *\n * When the saliency map is flat along an axis — a white product background, a\n * sky, a horizontal band, a plain texture — libvips reports the FIRST cell of\n * it: 0. Stored, that pins every shaped slot to the left or top edge, which is\n * worse than no guess. But a subject genuinely near the edge also reports 0 (a\n * disc at 5% from the corner does, measured), so \"0 means flat\" is not a rule.\n *\n * So the same buffer is scored twice: as-is, and flipped on both axes. A real\n * subject moves with the flip and the two answers agree once the second is\n * mirrored back; a flat axis reports 0 both times, i.e. 0 and 1 once mirrored.\n * Per axis: agree → their mean; disagree → that axis says nothing → 0.5. Both\n * axes flat → `null` (the caller stores it; it renders as the centre, D010).\n * The second pass runs on the already-decoded ≤512px raw buffer, so it costs a\n * saliency pass over ~0.25 MP, not a second decode.\n *\n * Also `null`: an image under {@link MIN_ATTENTION_EDGE} on either side — a\n * strip or an icon has no subject worth cropping around.\n *\n * ## Why not read `info.attentionX` off an ordinary resize\n *\n * Because it is WRONG whenever the same call resizes. sharp converts the\n * libvips hotspot with `attention_x * jpegShrinkOnLoad / scale`\n * (`src/pipeline.cc:505`, 0.34.5 and 0.35.3 alike), and those terms do not\n * describe the image smartcrop ran on: a disc drawn at x = 0.80 reported 0.50\n * from a JPEG and 0.25 from a PNG (images R012). The crop OFFSET beside it is\n * right; only the reported point is not.\n *\n * So each pass runs at SCALE 1: decode once to a bounded raw buffer, then crop\n * that buffer to its own size minus one pixel on the long axis. No\n * shrink-on-load and no scale, so the reported point is in that buffer's own\n * pixels and dividing by its size is the answer. A square image has no long\n * axis, but trimming either one still runs the strategy, and libvips takes the\n * hotspot over the whole saliency map whichever axis it crops.\n *\n * `.rotate()` first, so the point is in the orientation a browser DRAWS — an\n * EXIF-rotated phone photo would otherwise be seeded in sensor coordinates.\n *\n * ⚠️ Attention scores luminance frequency, saturation and skin tones. It is a\n * guess, not detection, and on a product shot the product can win (R012).\n */\nexport async function detectFocalPoint(buffer: Buffer): Promise<FocalPoint | null> {\n const { data, info } = await sharp(buffer)\n .rotate()\n .resize(ATTENTION_EDGE, ATTENTION_EDGE, { fit: 'inside', withoutEnlargement: true })\n .raw()\n .toBuffer({ resolveWithObject: true })\n const { width, height, channels } = info\n if (width < MIN_ATTENTION_EDGE || height < MIN_ATTENTION_EDGE) return null\n const raw = { raw: { width, height, channels } }\n const mirrored = await sharp(data, raw).flop().flip().raw().toBuffer()\n const [straight, flipped] = await Promise.all([\n attentionHotspot(data, width, height, channels),\n attentionHotspot(mirrored, width, height, channels),\n ])\n if (straight === null || flipped === null) return null\n const x = agreeingAxis(straight.x, 1 - flipped.x)\n const y = agreeingAxis(straight.y, 1 - flipped.y)\n if (x === null && y === null) return null\n return { x: clampFraction(x ?? 0.5), y: clampFraction(y ?? 0.5) }\n}\n\n/** One attention pass at scale 1, as fractions of the buffer. */\nasync function attentionHotspot(\n data: Buffer,\n width: number,\n height: number,\n channels: 1 | 2 | 3 | 4,\n): Promise<FocalPoint | null> {\n const landscape = width >= height\n const { info } = await sharp(data, { raw: { width, height, channels } })\n .resize(landscape ? width - 1 : width, landscape ? height : height - 1, {\n fit: 'cover',\n position: sharp.strategy.attention,\n })\n .toBuffer({ resolveWithObject: true })\n const { attentionX, attentionY } = info\n if (attentionX === undefined || attentionY === undefined) return null\n return { x: attentionX / width, y: attentionY / height }\n}\n\n/** The two passes' answer for one axis, or `null` when they disagree (a flat axis). */\nfunction agreeingAxis(straight: number, mirrored: number): number | null {\n return Math.abs(straight - mirrored) <= AXIS_AGREEMENT ? (straight + mirrored) / 2 : null\n}\n\n/** Round to four decimals and keep inside `[0, 1]`, so the stored value passes `isFocalPoint`. */\nfunction clampFraction(value: number): number {\n if (!Number.isFinite(value)) return 0.5\n return Math.min(Math.max(Math.round(value * 10000) / 10000, 0), 1)\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- import{t as e}from"./media-config-DmcTxuDM.mjs";import{combineAdminRoutes as t,defineAdminRoute as n,runBounded as r,safeAudit as i,safeAuditMany as a}from"@murumets-ee/core";const o=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function s(e){return e!==void 0&&o.test(e)}const c=[`image`,`video`,`audio`,`document`,`other`];function l(e){return c.includes(e)}function u(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function d(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function f(e,t){return d({error:e},t)}const p={clientPromise:null};async function m(){if(!p.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(()=>{p.clientPromise===e&&(p.clientPromise=null)}),p.clientPromise=e}return p.clientPromise}async function h(){let{createAdminClient:e}=await import(`@murumets-ee/core/clients`),{MediaClient:t}=await import(`./client.mjs`),{Media:n}=await import(`./entity-CsDdjKz6.mjs`).then(e=>e.n),r=await m();return new t({admin:e(n),storage:r})}const g=async(e,t)=>{let{isStorageConfigured:n,getStorageConfigReason:r}=await import(`@murumets-ee/storage`),i=t.segments;if(i.length===2&&i[1]===`usage`){let e=i[0];if(!s(e))return f(`Invalid media ID format`,400);let{findMediaUsages:t}=await import(`./usage.mjs`),{getApp:n}=await import(`@murumets-ee/core`);return d({usages:await t(e,n())})}if(i.length>=2)return f(`Not found`,404);let a=i.length>0?i[0]:void 0;if(a!==void 0&&!s(a))return f(`Invalid media ID format`,400);if(!n()){let e=r()??`Storage not configured`;return i.length>0?d({error:e,configured:!1,reason:e},503):d({items:[],total:0,configured:!1,reason:e})}let o=await h();if(a!==void 0){let e=await o.findById(a);if(!e)return f(`Media not found`,404);let t=await o.getUrl(a);return d({...e,url:t})}let c=new URL(e.url),p=c.searchParams.get(`search`)??void 0,m=c.searchParams.get(`mediaType`),g=m!==null&&l(m)?m:void 0,_=Math.min(Math.max(u(c.searchParams.get(`limit`),24),1),100),v=Math.min(u(c.searchParams.get(`offset`),0),1e5),y=await o.findMany({...p!==void 0&&{search:p},...g!==void 0&&{mediaType:g},limit:_,offset:v}),b=y.items.map(e=>e.id),x=y.items.filter(e=>e.mediaType===`image`).map(e=>e.id),[S,C]=await Promise.all([o.getUrls(b),o.getVariantUrls(x,`thumbnail`)]);return d({items:y.items.map(e=>{let t=e.mediaType===`image`?C.get(e.id):void 0;return{id:e.id,title:e.title??null,alt:e.alt??null,filename:e.filename,mimeType:e.mimeType,size:e.size,mediaType:e.mediaType,url:S.get(e.id)??``,...t!==void 0&&{thumbnailUrl:t},width:e.width??null,height:e.height??null}}),total:y.total})},_=async(t,n)=>{let{isStorageConfigured:r,getStorageConfigReason:a,detectMimeType:o,isMimeTypeAllowed:s,readBoundedMultipart:c,tooLarge:l}=await import(`@murumets-ee/storage`);if(n.segments.length>0)return f(`Not found`,404);if(!r())return f(a()??`Storage not configured`,503);let{maxUploadSize:u,acceptedTypes:p}=e(),m=await c(t,u);if(!m.ok)return f(m.error,m.status);let g=m.value,_=g.get(`file`);if(!(_ instanceof File)||_.size===0)return f(`No file provided`,400);if(_.size>u){let e=l(u);return f(e.error,e.status)}let v=await h(),y=Buffer.from(await _.arrayBuffer()),{mimeType:b,mismatch:x}=await o(y,_.type||`application/octet-stream`);if(x)return f(`File content doesn't match declared type: claimed ${_.type}, detected ${b}`,400);if(!s(b,p))return f(`File type ${b} is not accepted`,415);let S=g.get(`visibility`);if(S!==null&&S!==`public`&&S!==`private`)return f(`Field 'visibility' must be 'public' or 'private'`,400);let C=await v.upload(y,{filename:_.name,mimeType:b,size:_.size,uploadedBy:n.user.id,...S!==null&&{visibility:S}}),w={id:C.media.id,title:C.media.title??null,alt:C.media.alt??null,filename:C.media.filename,mimeType:C.media.mimeType,size:C.media.size,mediaType:C.media.mediaType,url:C.url,width:C.media.width??null,height:C.media.height??null};return i(n,{action:`media.upload`,entityType:`media`,entityId:C.media.id,userId:n.user.id,...n.user.name!==void 0&&{userName:n.user.name},changes:{filename:C.media.filename,mimeType:C.media.mimeType,size:C.media.size,mediaType:C.media.mediaType}}),d(w,201)},v=async(e,t)=>{let{isStorageConfigured:n,getStorageConfigReason:r}=await import(`@murumets-ee/storage`);if(t.segments.length!==1)return f(`Not found`,404);if(!n())return f(r()??`Storage not configured`,503);let{getApp:a,getContext:o}=await import(`@murumets-ee/core`),{getMediaJobs:s}=await import(`./slot-CY4isqmN.mjs`).then(e=>e.r),c=a(),l=s();if(l){let{enqueueVariantsBackfill:e}=await import(`./backfill-trigger-BFL1L7_j.mjs`),{jobId:n,alreadyRunning:r}=await e(c,l.variantsBackfill);i(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:a}=await import(`./backlog-D-H5VUQA.mjs`).then(e=>e.t);return d({mode:`queued`,jobId:n,alreadyRunning:r,backlog:await a(c).catch(()=>null)},202)}let{regenerateAllVariants:u}=await import(`./regenerate-variants-Cp3sNHkT.mjs`),{createStorageClient:p}=await import(`@murumets-ee/storage`),{getStorageConfig:m}=await import(`@murumets-ee/storage/plugin`),h=await u({app:c,storage:p(m(),{app:c}),logger:c.logger.child({media:!0}),contextResolver:()=>{let e=o();if(!(!e?.user||!e?.checker))return{user:e.user,checker:e.checker,...e.scope!==void 0&&{scope:e.scope}}}});return i(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}}),d({mode:`inline`,...h})},y=16*1024;async function b(e){let t=Number(e.headers.get(`content-length`));if(Number.isFinite(t)&&t>y)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`)>y)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>y)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`||!s(e))return{error:`Body "ids" must contain valid media IDs`,status:400};o.push(e)}return{ids:o}}const x=async(e,t)=>{let n=t.segments;if(n.length===0){let n=await b(e);if(`error`in n)return f(n.error,n.status);let{isStorageConfigured:i,getStorageConfigReason:o}=await import(`@murumets-ee/storage`);if(!i())return f(o()??`Storage not configured`,503);let s=await h(),c=await r(n.ids,async e=>{try{return await s.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 a(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 d({deleted:l.length})}if(n.length>1)return f(`Not found`,404);let o=n[0];if(!s(o))return f(`Invalid media ID format`,400);let{isStorageConfigured:c,getStorageConfigReason:l}=await import(`@murumets-ee/storage`);return c()?(await(await h()).delete(o),i(t,{action:`media.delete`,entityType:`media`,entityId:o,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name}}),d({deleted:1})):f(l()??`Storage not configured`,503)},S=[`admin`,`editor`,`agent`,`viewer`],C=[`admin`,`editor`];function w(){return t([n({prefix:`media`,path:``,method:`GET`,permission:`media:view`,defaultRoles:S,matchAnyPath:!0,description:`Read media — list, single item, or referencing-entity usage report.`,handler:g}),n({prefix:`media`,path:``,method:`POST`,permission:`media:create`,defaultRoles:C,description:"Upload a media file (multipart FormData with `file` field).",handler:_}),n({prefix:`media`,path:`regenerate-variants`,method:`POST`,permission:`media:create`,defaultRoles:C,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:v}),n({prefix:`media`,path:``,method:`DELETE`,permission:`media:delete`,defaultRoles:C,matchAnyPath:!0,description:`Delete a media record + its storage object.`,handler:x})])}export{w as t};
2
- //# sourceMappingURL=routes-BJ23Cp0g.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"routes-BJ23Cp0g.mjs","names":[],"sources":["../src/admin/routes.ts"],"sourcesContent":["/**\n * Media admin routes — one static `defineAdminRoute` per HTTP method.\n *\n * **URL surface served by this module:**\n *\n * - GET /api/admin/media — list with search/filter + URLs\n * - GET /api/admin/media/<id> — single record with URL\n * - GET /api/admin/media/<id>/usage — entities referencing this media\n * - POST /api/admin/media — upload (multipart FormData)\n * - POST /api/admin/media/regenerate-variants — queue the library variant backfill\n * (synchronous only in a queueless app)\n * - DELETE /api/admin/media/<id> — delete + storage cleanup\n * - DELETE /api/admin/media — bulk delete (body.ids[], ≤100)\n *\n * Standard entity CRUD (PATCH with translations) falls through to the\n * generic entity handler — add `Media` to `entities` in your handler config.\n *\n * **Why per-method `defineAdminRoute`** (vs. the previous top-level\n * `resource: 'media'` + auto-mapped permission):\n *\n * - The wrapper (`guardedHandler` from `@murumets-ee/core`) owns the\n * permission gate, `permission.denied` audit, and 403 body shape\n * uniformly with every other migrated plugin. The previous shape\n * relied on `METHOD_TO_ACTION` auto-mapping at the api-handler\n * dispatch level which couldn't emit per-route audit metadata.\n * - Inline `if (!checkPermission('media', 'create')) ...` defenses\n * at the regenerate-variants / upload / delete branches are now\n * redundant — the wrapper enforces the permission BEFORE the\n * handler runs.\n *\n * **`matchAnyPath` rationale:** media's URL surface predates the\n * static-second-segment convention every other plugin follows\n * (`/taxonomy/<vocab>/<id>`, `/settings/<namespace>/<rest>`). The\n * second segment under `/media` is the media id itself — a runtime\n * UUID — which the dispatcher's literal `segments[0]` match would\n * miss for every request. The catch-all flag declares \"this entry\n * claims every (prefix, method) sub-path no other entry matched\" so\n * the existing URL contract works under the per-method dispatch\n * model. Static POST sub-paths (`regenerate-variants`) take\n * precedence over the catch-all entry on POST. See\n * `DefineAdminRouteSpec.matchAnyPath` in `@murumets-ee/core` for the\n * full contract.\n *\n * **Default roles:** broad for view (`['admin', 'editor', 'agent',\n * 'viewer']`), narrower for writes (`['admin', 'editor']`).\n * Conservative starting point — apps can grant more via the\n * Permissions UI.\n *\n * @example\n * ```typescript\n * import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'\n * import { mediaRoutes } from '@murumets-ee/media/admin'\n * import { Media } from '@murumets-ee/media'\n *\n * const handler = createAdminApiHandler({\n * authenticate: async (req) => { ... },\n * entities: [Article, Media],\n * routes: [...mediaRoutes()],\n * })\n * ```\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n runBounded,\n safeAudit,\n safeAuditMany,\n} from '@murumets-ee/core'\nimport type { MediaClient } from '../client.js'\nimport { readMediaConfig } from '../media-config.js'\nimport type { MediaPickerItem, MediaPickerListResult } from '../picker/types.js'\n\n// ---------------------------------------------------------------------------\n// Validation\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isValidUuid(value: string | undefined): value is string {\n return value !== undefined && UUID_RE.test(value)\n}\n\n/**\n * Closed enum of allowed `mediaType` filter values, in lockstep with the\n * `mediaType` field on the Media entity. The list MUST match\n * `MediaListOptions.mediaType` (declared in `../types.js`) — TypeScript\n * doesn't link the two automatically, so the small drift risk is\n * accepted in exchange for not pulling a runtime dep on the entity\n * schema package just to read its enum.\n *\n * Used as both the runtime allowlist (`isAllowedMediaType`) and the\n * source of truth for the narrowed type the guard produces — keeping\n * `as` casts off the handler's hot path.\n */\nconst ALLOWED_MEDIA_TYPES = ['image', 'video', 'audio', 'document', 'other'] as const\ntype AllowedMediaType = (typeof ALLOWED_MEDIA_TYPES)[number]\n\nfunction isAllowedMediaType(value: string): value is AllowedMediaType {\n // Cast to readonly string[] so the typed-tuple `includes` check works\n // with an arbitrary string input — `Array.prototype.includes`'s\n // signature on a typed tuple only accepts members of the tuple\n // (TS 4.9+ literal-narrowing), which is exactly what we want to\n // sidestep here.\n return (ALLOWED_MEDIA_TYPES as readonly string[]).includes(value)\n}\n\n/**\n * Hard cap on the `offset` query param. The dispatcher's allowed-pages\n * model is `limit ∈ [1, 100]`; even with the max page size that's\n * 1000 pages before hitting MAX_OFFSET, which is well beyond any\n * realistic admin-UI scroll depth. Forces slow-discard offset attacks\n * (`?offset=999999999`) to revert to a sane upper bound. Cursor-based\n * pagination would let this go higher, but the media list is\n * offset-based by convention with the entity-list shell.\n */\nconst MAX_OFFSET = 100_000\n\n/**\n * Strict non-negative integer parser for query params.\n *\n * `Number(raw)` silently coerces blanks (`'' → 0`), hex (`'0x05' → 5`),\n * exponent notation (`'1e2' → 100`), and signed (`'+5'/'-5'`) values —\n * all of which pass a downstream `Number.isInteger(n) && n >= 0` check.\n * Pre-filtering with `/^\\d+$/` keeps the contract honest: \"if you don't\n * send a pure non-negative integer string, you get the default.\" Same\n * pattern as `packages/taxonomy/src/admin/routes.ts:handleGet` tree\n * branch from PR-C-taxonomy.\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n // The regex above stops hex / exponent / signed / whitespace / blank\n // inputs, but a long digit-only string still slips past — `Number(...)`\n // returns `Infinity` past ~309 digits, and precision is lost past\n // 2^53. Both would propagate through `client.findMany`'s\n // `offset: parsed` into a Postgres query that either crashes the\n // driver (`Infinity` is not a valid bigint) or silently rounds (the\n // 2^53 precision-loss boundary). Reject via `Number.isSafeInteger`\n // so overflowing inputs revert to `fallback` like every other\n // malformed value.\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction jsonError(message: string, status: number): Response {\n return jsonResponse({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory + cached storage client\n// ---------------------------------------------------------------------------\n//\n// MediaClient and its AdminClient MUST be built per-request — AdminClient's\n// context resolver is captured eagerly at construction (see\n// buildContextResolver in @murumets-ee/core/clients). A module-level singleton\n// would bake the first request's user + tenant scope into every subsequent\n// request's writes — a cross-request permission/scope leak.\n//\n// Storage config is process-global and safe to cache once.\n\ntype StorageClient = Awaited<ReturnType<typeof import('@murumets-ee/storage').createStorageClient>>\n\ninterface StorageState {\n clientPromise: Promise<StorageClient> | null\n}\n\n// Module-level state. `null` once eviction fires on a rejection so the next\n// request gets a fresh resolution attempt (see `getStorage`'s `.catch`).\nconst storageState: StorageState = { clientPromise: null }\n\n/**\n * Resolve the process-wide storage client. The pending-promise slot is\n * cleared on rejection so a transient `createStorageClient` failure\n * (DB hiccup, misconfig) doesn't poison every subsequent media request\n * for the lifetime of the Node process. Same pattern as\n * `packages/taxonomy/src/admin/routes.ts:getClient` from PR-C-taxonomy.\n */\nasync function getStorage(): Promise<StorageClient> {\n if (!storageState.clientPromise) {\n const pending = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n })()\n // Clear the slot on rejection so a transient failure doesn't poison\n // every subsequent request. `pending.catch(handler)` attaches a\n // synchronous rejection listener that resets the cached slot — the\n // handler returns `undefined`, NOT a re-throw — so the new\n // `.catch`-returned promise resolves, but we never await or hold\n // that promise. The ORIGINAL `pending` (stored in\n // `storageState.clientPromise`) is what callers `await`, and it's\n // still in its rejected state — so the caller observing this\n // resolution sees the underlying error. The `.catch` also marks\n // the rejection as \"handled\" for Node's unhandled-rejection\n // tracker. `storageState.clientPromise` holds the original `pending`\n // so concurrent in-flight callers share one resolution.\n pending.catch(() => {\n if (storageState.clientPromise === pending) storageState.clientPromise = null\n })\n storageState.clientPromise = pending\n }\n return storageState.clientPromise\n}\n\nasync function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { MediaClient } = await import('../client.js')\n const { Media } = await import('../entity.js')\n const storage = await getStorage()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleGet: AdminRouteHandler = async (req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n const segments = ctx.segments\n\n // GET /media/<id>/usage — DB-only lookup, safe even when storage is\n // unconfigured. Validate the UUID first so a malformed id 400s\n // uniformly with the storage-configured branch below.\n if (segments.length === 2 && segments[1] === 'usage') {\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { findMediaUsages } = await import('../usage.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const usages = await findMediaUsages(id, app)\n return jsonResponse({ usages })\n }\n\n // Reject unknown sub-paths explicitly. The wrapper-gate has already\n // passed (caller has `media:view`); a handler-level 404 here matches\n // the dispatcher's miss-shape so unknown URLs don't fall through to\n // the single-id branch and 400/500 with a misleading reason. The\n // only valid 2-segment URL is `<id>/usage`, which was already\n // claimed by the early-return above — anything else with >=2\n // segments is not a recognized media route.\n if (segments.length >= 2) return jsonError('Not found', 404)\n\n // GET /media/<id> — validate UUID up front so a bad request still\n // 400s when storage happens to be unconfigured (matches DELETE).\n const singleId = segments.length > 0 ? segments[0] : undefined\n if (singleId !== undefined && !isValidUuid(singleId)) {\n return jsonError('Invalid media ID format', 400)\n }\n\n // Everything below needs a working storage client. If env isn't\n // wired, return a structured \"disabled\" response rather than 500 —\n // the media list page renders a banner and new admins can finish\n // onboarding without being blocked on a crash loop.\n if (!isStorageConfigured()) {\n const reason = getStorageConfigReason() ?? 'Storage not configured'\n if (segments.length > 0) {\n // Single item — treat as not-found-ish to avoid leaking\n // existence; include reason so the UI can surface it.\n return jsonResponse({ error: reason, configured: false, reason }, 503)\n }\n // GET /media — empty list + disabled flag for the picker's banner.\n const response: MediaPickerListResult & { configured: false; reason: string } = {\n items: [],\n total: 0,\n configured: false,\n reason,\n }\n return jsonResponse(response)\n }\n\n const client = await getMediaClient()\n\n // GET /media/<id> — single item with URL (full record for EntityForm + picker)\n if (singleId !== undefined) {\n const record = await client.findById(singleId)\n if (!record) return jsonError('Media not found', 404)\n\n const url = await client.getUrl(singleId)\n return jsonResponse({ ...record, url })\n }\n\n // GET /media — list with search/filter + batch URL resolution\n const url = new URL(req.url)\n const search = url.searchParams.get('search') ?? undefined\n // mediaType MUST be validated against the entity's closed enum before\n // it reaches `client.findMany` — the previous shape force-cast the raw\n // query value with `as 'image' | ...` which is a type-system lie. A\n // bogus value like `?mediaType=bogus` would parameterize into\n // `WHERE media_type = 'bogus'` (no SQL injection — Drizzle is\n // parameterized — but the row count is silently zero, which is\n // confusing). Empty string from `?mediaType=` is also passed-through\n // by the prior `?? undefined` shape since empty string is not null.\n // Allowlist + type guard drops bogus / empty values cleanly so the\n // filter is omitted instead of applied with a no-match value.\n const rawMediaType = url.searchParams.get('mediaType')\n const mediaType =\n rawMediaType !== null && isAllowedMediaType(rawMediaType) ? rawMediaType : undefined\n // Strict parsing keeps the contract honest — see `parseUintParam`\n // JSDoc. Cap limit to [1, 100] and offset to [0, MAX_OFFSET] so a\n // request can't ask for an unboundedly large page nor force a\n // slow-discard scan with `?offset=99999999`.\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 24), 1), 100)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n const result = await client.findMany({\n ...(search !== undefined && { search }),\n ...(mediaType !== undefined && { mediaType }),\n limit,\n offset,\n })\n\n // Resolve original URLs for all items, but thumbnail variants ONLY for\n // images — a video/audio/document/other id will never have a `thumbnail`\n // variant, so asking `getVariantUrls` to resolve one is pure waste (a\n // storage lookup that falls through to the original, then gets discarded\n // below anyway). `media-list-page.tsx` filters the same way.\n const ids = result.items.map((item) => item.id)\n const imageIds = result.items.filter((item) => item.mediaType === 'image').map((item) => item.id)\n const [urlMap, thumbMap] = await Promise.all([\n client.getUrls(ids),\n client.getVariantUrls(imageIds, 'thumbnail'),\n ])\n\n const items: (MediaPickerItem & { thumbnailUrl?: string })[] = result.items.map((item) => {\n // F115: `getVariantUrls` falls back to the ORIGINAL asset URL for any id\n // with no `thumbnail` variant — correct for an image that just hasn't\n // been processed yet, wrong for a video/audio/document/other row, which\n // will never have one. Gating on `mediaType` keeps `thumbnailUrl` ABSENT\n // rather than pointing a consumer's `<img src>` at a `.mp4`.\n const thumbnailUrl = item.mediaType === 'image' ? thumbMap.get(item.id) : undefined\n return {\n id: item.id,\n title: item.title ?? null,\n alt: item.alt ?? null,\n filename: item.filename,\n mimeType: item.mimeType,\n size: item.size,\n mediaType: item.mediaType,\n url: urlMap.get(item.id) ?? '',\n ...(thumbnailUrl !== undefined && { thumbnailUrl }),\n width: item.width ?? null,\n height: item.height ?? null,\n }\n })\n\n const response: MediaPickerListResult = { items, total: result.total }\n return jsonResponse(response)\n}\n\nconst handleUpload: AdminRouteHandler = async (req, ctx) => {\n const {\n isStorageConfigured,\n getStorageConfigReason,\n detectMimeType,\n isMimeTypeAllowed,\n readBoundedMultipart,\n tooLarge,\n } = await import('@murumets-ee/storage')\n\n // Upload is registered at `path: ''` (NOT matchAnyPath) — the\n // dispatcher only invokes this handler when segments=[]. The api-\n // handler ALSO rejects empty/whitespace-only first segments with a\n // 400 before any plugin dispatcher runs (see\n // `packages/admin-ui/src/server/api-handler/index.ts` segment-\n // validation block). So this guard is defense-in-depth — per HANDOFF\n // §\"Defense-in-depth `&& segments[i]` guards are fine\" — protecting\n // against a future framework invariant change that lets a non-empty\n // segment reach a `path: ''` static entry.\n if (ctx.segments.length > 0) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // The plugin's `maxUploadSize` / `acceptedTypes`, from the registry the\n // merge step filled before any init — see `media-config.ts` (F007).\n const { maxUploadSize, acceptedTypes } = readMediaConfig()\n\n // F007: bound the body BEFORE `formData()` makes it resident — and before\n // building a MediaClient for a request that is about to be refused.\n const envelope = await readBoundedMultipart(req, maxUploadSize)\n if (!envelope.ok) return jsonError(envelope.error, envelope.status)\n const formData = envelope.value\n\n const file = formData.get('file')\n // FormData `get` returns string | File | null. Reject anything that\n // isn't a File so the typed `file.size`/`file.arrayBuffer()` below\n // is safe without a runtime cast.\n if (!(file instanceof File) || file.size === 0) {\n return jsonError('No file provided', 400)\n }\n\n // The envelope bound covers the whole body; this covers the file, before\n // `arrayBuffer()` copies it.\n if (file.size > maxUploadSize) {\n const refused = tooLarge(maxUploadSize)\n return jsonError(refused.error, refused.status)\n }\n\n const client = await getMediaClient()\n\n const buffer = Buffer.from(await file.arrayBuffer())\n\n // Detect actual MIME type from file content (prevents spoofing).\n const { mimeType, mismatch } = await detectMimeType(\n buffer,\n file.type || 'application/octet-stream',\n )\n if (mismatch) {\n return jsonError(\n `File content doesn't match declared type: claimed ${file.type}, detected ${mimeType}`,\n 400,\n )\n }\n // `acceptedTypes` against the DETECTED type, so a renamed file cannot\n // claim its way past the allowlist.\n if (!isMimeTypeAllowed(mimeType, acceptedTypes)) {\n return jsonError(`File type ${mimeType} is not accepted`, 415)\n }\n\n // Per-upload visibility, DEFAULTING TO PRIVATE — the caller opts in.\n //\n // This route serves two populations with opposite needs. The media picker\n // uploads publishable web assets: a page's hero image MUST be public or the\n // public site resolves it to `null` and renders no images at all, with a 200\n // on every page and one warn log (F030). `packages/rent-ui`'s inspection\n // wizard posts to this SAME route with handover and return photos — vehicle\n // and apartment condition shots, plates, interiors, occasionally documents —\n // which must stay behind time-limited signed urls.\n //\n // Deny-by-default decides the direction, and the asymmetry is the argument:\n // forget the opt-in and an image is invisible, which is annoying, visible and\n // fixable; default to public and forget an opt-out and those photos become\n // permanently world-readable. A missed opt-in cannot leak. So the default is\n // unchanged (`private` via StorageClient's own fallback) and rent's caller is\n // untouched — it sends nothing and behaves exactly as before.\n const visibilityRaw = formData.get('visibility')\n if (visibilityRaw !== null && visibilityRaw !== 'public' && visibilityRaw !== 'private') {\n return jsonError(\"Field 'visibility' must be 'public' or 'private'\", 400)\n }\n const result = await client.upload(buffer, {\n filename: file.name,\n mimeType,\n size: file.size,\n uploadedBy: ctx.user.id,\n ...(visibilityRaw !== null && { visibility: visibilityRaw }),\n })\n\n const item: MediaPickerItem = {\n id: result.media.id,\n title: result.media.title ?? null,\n alt: result.media.alt ?? null,\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n url: result.url,\n width: result.media.width ?? null,\n height: result.media.height ?? null,\n }\n\n safeAudit(ctx, {\n action: 'media.upload',\n entityType: 'media',\n entityId: result.media.id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n changes: {\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n },\n })\n\n return jsonResponse(item, 201)\n}\n\nconst handleRegenerateVariants: AdminRouteHandler = async (_req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n\n // Static POST path='regenerate-variants' — the dispatcher only invokes\n // this handler when `segments[0] === 'regenerate-variants'`. Trailing\n // segments (e.g. POST /media/regenerate-variants/extra) aren't a\n // supported sub-route.\n if (ctx.segments.length !== 1) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const { getApp, getContext } = await import('@murumets-ee/core')\n const { getMediaJobs } = await import('../jobs/slot.js')\n const app = getApp()\n\n // With a queue: queue the library walk and return at once (images D004, S6).\n // The walk queues one pass per photograph; the Queue page and the media\n // library count them down while the site keeps serving.\n const jobs = getMediaJobs()\n if (jobs) {\n const { enqueueVariantsBackfill } = await import('../jobs/backfill-trigger.js')\n const { jobId, alreadyRunning } = await enqueueVariantsBackfill(app, jobs.variantsBackfill)\n safeAudit(ctx, {\n action: 'media.regenerate_variants',\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { mode: 'queued', jobId, alreadyRunning },\n })\n const { getVariantBacklog } = await import('../jobs/backlog.js')\n const backlog = await getVariantBacklog(app).catch(() => null)\n return jsonResponse({ mode: 'queued', jobId, alreadyRunning, backlog }, 202)\n }\n\n // No queue in this app: the synchronous loop it always had, now additive.\n const { regenerateAllVariants } = await import('../regenerate-variants.js')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const storage = createStorageClient(getStorageConfig(), { app })\n\n const result = await regenerateAllVariants({\n app,\n storage,\n logger: app.logger.child({ media: true }),\n contextResolver: () => {\n const requestCtx = getContext()\n if (!requestCtx?.user || !requestCtx?.checker) return undefined\n return {\n user: requestCtx.user,\n checker: requestCtx.checker,\n ...(requestCtx.scope !== undefined && { scope: requestCtx.scope }),\n }\n },\n })\n\n safeAudit(ctx, {\n action: 'media.regenerate_variants',\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: {\n mode: 'inline',\n total: result.total,\n processed: result.processed,\n errors: result.errors,\n },\n })\n\n return jsonResponse({ mode: 'inline', ...result })\n}\n\n/**\n * Cap on a bulk delete — the same number the generic entity CRUD route's own\n * bulk delete uses (`entity-crud.ts`'s `MAX_BULK_DELETE`), so a caller sees\n * one consistent limit regardless of which admin list it is deleting from.\n */\nconst MAX_BULK_DELETE = 100\n\n/**\n * Bound on the bulk-delete request body. A body here is `{ ids: string[] }`\n * of short UUID strings — generous headroom for {@link MAX_BULK_DELETE} of\n * them (each ~38 bytes quoted-and-comma'd).\n */\nconst MAX_BULK_DELETE_BODY_BYTES = 16 * 1024\n\n/** A validated bulk-delete request, or the error response to send instead. */\ntype BulkDeleteBody = { ids: string[] } | { error: string; status: number }\n\n/**\n * Read and parse the bulk-delete body, refusing anything past\n * {@link MAX_BULK_DELETE_BODY_BYTES}.\n *\n * `content-length` alone is NOT a bound — it is client-supplied, so a\n * chunked request (no header at all) or a spoofed small one sails through a\n * header-only check straight into an unbounded `req.json()` buffering\n * whatever the caller sends (review finding, entity-table PR06). Streamed\n * and byte-counted instead, same shape `entity-crud.ts`'s `readJsonBody`\n * uses for arbitrary write bodies — this one is sized for a much smaller,\n * closed shape, not copied wholesale, because `@murumets-ee/media` must not\n * depend on `@murumets-ee/admin-ui` (wrong dependency direction).\n */\nasync function readBulkDeleteBody(req: Request): Promise<BulkDeleteBody> {\n const declared = Number(req.headers.get('content-length'))\n if (Number.isFinite(declared) && declared > MAX_BULK_DELETE_BODY_BYTES) {\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n\n const body = req.body\n let text: string\n if (body === null) {\n text = await req.text()\n if (Buffer.byteLength(text, 'utf8') > MAX_BULK_DELETE_BODY_BYTES) {\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n } else {\n const reader = body.getReader()\n const chunks: Uint8Array[] = []\n let total = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (value === undefined) continue\n total += value.byteLength\n if (total > MAX_BULK_DELETE_BODY_BYTES) {\n // Stop pulling — without the cancel the peer keeps sending into a\n // reader nobody drains, which is the connection this refusal\n // exists to end.\n await reader.cancel().catch(() => undefined)\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n chunks.push(value)\n }\n } finally {\n reader.releaseLock()\n }\n text = Buffer.concat(chunks).toString('utf8')\n }\n\n let raw: unknown\n try {\n raw = JSON.parse(text)\n } catch {\n return { error: 'Invalid JSON body', status: 400 }\n }\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {\n return { error: 'Body must be a JSON object', status: 400 }\n }\n\n const ids = (raw as Record<string, unknown>).ids\n if (!Array.isArray(ids) || ids.length === 0) {\n return { error: 'Body must contain \"ids\" array', status: 400 }\n }\n if (ids.length > MAX_BULK_DELETE) {\n return { error: `Bulk delete limited to ${MAX_BULK_DELETE} items per request`, status: 400 }\n }\n\n // Reject anything that isn't a well-formed media id before any mutation —\n // `client.delete()` would otherwise throw mid-batch, leaving a partially\n // deleted set for a request that was malformed from the start.\n //\n // `Array.isArray` narrows to `any[]` (its lib.es5.d.ts signature is\n // `arg is any[]`), so without the cast below every read of the loop\n // variable counts as an `any` against `verify:types` — `as unknown[]`\n // resets it to a type `typeof`/`isValidUuid` still narrow correctly, one\n // step later than the guard that actually proves it.\n const validated: string[] = []\n for (const id of ids as unknown[]) {\n if (typeof id !== 'string' || !isValidUuid(id)) {\n return { error: 'Body \"ids\" must contain valid media IDs', status: 400 }\n }\n validated.push(id)\n }\n return { ids: validated }\n}\n\nconst handleDelete: AdminRouteHandler = async (req, ctx) => {\n const segments = ctx.segments\n\n // DELETE /media — no id supplied. Bulk delete (body: { ids: string[] }),\n // matching the generic entity CRUD route's own bulk shape — `EntityList`'s\n // \"Delete selected\" always sends this, and Media's `matchAnyPath` DELETE\n // entry claims the whole `/media` prefix, so without this branch that\n // request lands here and gets refused with \"Media ID required\" (found via\n // browser verification, entity-table PR06 — S8's demonstration on a real\n // Media list is what surfaced it: nothing before this PR had exercised a\n // bulk delete against the live Media admin page).\n if (segments.length === 0) {\n const parsed = await readBulkDeleteBody(req)\n if ('error' in parsed) return jsonError(parsed.error, parsed.status)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const client = await getMediaClient()\n // MAX_BULK_DELETE bounds the TOTAL; `runBounded` bounds the CONCURRENCY —\n // each `delete()` does entity DB work AND storage object/metadata work,\n // so 100 ids fired via a bare `Promise.all` would open up to 100 of both\n // at once, which is exactly the shared-pool-contention shape CLAUDE.md's\n // \"No Unbounded Fan-Out\" rule exists to catch (CodeRabbit CLI finding,\n // entity-table PR06). The worker catches internally and returns a\n // settled-shaped result instead of letting `runBounded` see a rejection —\n // that keeps its own fail-fast behavior from firing, so this stays\n // allSettled-equivalent: every id gets attempted regardless of a sibling's\n // failure, and the audit trail for the ones that DID succeed survives one\n // that didn't (`safeAuditMany` below would otherwise be skipped entirely\n // on any rejection, auditing NONE of them). The first failure is still\n // rethrown after the audit write, so the response shape is unchanged —\n // no new 207-partial-success contract.\n const results = await runBounded(parsed.ids, async (id) => {\n try {\n await client.delete(id)\n return { status: 'fulfilled' as const, id }\n } catch (reason) {\n return { status: 'rejected' as const, id, reason }\n }\n })\n const deletedIds = results.filter((r) => r.status === 'fulfilled').map((r) => r.id)\n\n // ONE batched write, awaited — not N unawaited `safeAudit` calls racing\n // the connection pool after the response is already decided (review\n // finding, entity-table PR06; `safeAuditMany`'s own docblock names this\n // exact loop shape as the reason it exists).\n await safeAuditMany(\n ctx,\n deletedIds.map((id) => ({\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { bulk: true },\n })),\n )\n\n const failure = results.find((r) => r.status === 'rejected')\n if (failure && failure.status === 'rejected') throw failure.reason\n\n return jsonResponse({ deleted: deletedIds.length })\n }\n\n // Reject sub-paths beyond /<id> — DELETE /media/<id>/usage etc.\n if (segments.length > 1) return jsonError('Not found', 404)\n\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n // Can't safely delete — MediaClient needs storage to remove the\n // actual object, and partial delete (DB row gone, object orphaned)\n // would leak storage. Surface the reason so the UI can display it.\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // AdminClient.delete() checks entity_refs and throws\n // ReferencedEntityError if this media is still referenced. The\n // error bubbles to the caller's error handler which returns 409\n // with usage details.\n const client = await getMediaClient()\n await client.delete(id)\n\n safeAudit(ctx, {\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n })\n\n return jsonResponse({ deleted: 1 })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Default roles for view: broad reach across the admin shell.\n *\n * `BUILT_IN_ROLES` in `packages/auth/src/permissions.ts` is\n * `['admin', 'public', 'authenticated', 'agent', 'customer']` — `admin` is\n * hardcoded as an unconditional yes in `buildPermissionChecker`,\n * `agent` is the only non-admin built-in that flows through\n * `buildInitialRoleDefinitions()` + `upsertBuiltInRoles` with default\n * media access (`customer` seeds with zero permissions — see that\n * function's JSDoc). The `agent` entry in this list is what gives ops\n * agents read-only media access on first boot of a fresh install.\n *\n * `editor` and `viewer` are CONVENTIONAL app-defined roles —\n * apps that follow the scaffold pattern create them as part of\n * their own role catalog. Including them in `defaultRoles` doesn't\n * seed them (the seeder only iterates the built-ins above), but\n * the values DO flow into the process-local permission catalog\n * (see `registerPermission` in `@murumets-ee/core`), which the\n * forthcoming Permission Matrix UI (PR-D) reads to show \"this\n * route's recommended default grants\" against the app's actual\n * role set. The catalog is also the mechanism via which a future\n * upgrade of `upsertBuiltInRoles` could extend the seed surface\n * to app-defined roles without each plugin having to re-declare\n * its defaults.\n */\nconst VIEW_DEFAULT_ROLES = ['admin', 'editor', 'agent', 'viewer'] as const\n\n/** Default roles for create/delete — narrower than view. */\nconst WRITE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Build admin API routes for media management.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry per\n * `(prefix, method)` after grouping. Callers spread the result into\n * their top-level routes list: `routes: [...mediaRoutes()]`.\n *\n * Four `defineAdminRoute` entries:\n *\n * - GET matchAnyPath with `media:view` defaultRoles: VIEW_DEFAULT_ROLES\n * - POST path='' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - POST path='regenerate-variants' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - DELETE matchAnyPath with `media:delete` defaultRoles: WRITE_DEFAULT_ROLES\n *\n * Permissions are auto-registered by the entity catalog\n * (`buildResourceCatalog` in `@murumets-ee/auth`) AND by\n * `registerPermission` inside `defineAdminRoute`. Idempotent —\n * `registerPermission` unions `defaultRoles` on repeat registration,\n * so the framework's two pathways agree on the final grant set.\n *\n * Standard entity CRUD (PATCH with translations) is NOT registered\n * here — it falls through to the generic entity handler. Add `Media`\n * to the `entities` array in your handler config to enable it.\n */\nexport function mediaRoutes(): AdminRoute[] {\n const entries = [\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'GET',\n permission: 'media:view',\n defaultRoles: VIEW_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Read media — list, single item, or referencing-entity usage report.',\n handler: handleGet,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description: 'Upload a media file (multipart FormData with `file` field).',\n handler: handleUpload,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: 'regenerate-variants',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description:\n 'Catch every image up with the current shapes, widths and styles — queued as a background ' +\n 'backfill (synchronous only when the app has no queue).',\n handler: handleRegenerateVariants,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'DELETE',\n permission: 'media:delete',\n defaultRoles: WRITE_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Delete a media record + its storage object.',\n handler: handleDelete,\n }),\n ]\n\n return combineAdminRoutes(entries)\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- var e={PluginNav:{media:`Медиа`},Media:{imageVocabulary:{description:`Каждая фотография создаётся в каждой форме ниже и в каждой ширине. Слот, указывающий форму, использует эти файлы; слот со своим соотношением сторон обрезается в браузере.`,usingDefaults:`Здесь пока ничего не сохранено. Показаны значения по умолчанию; пока вы не сохраните, сайт использует их — или формы, заданные в его коде, если они есть.`,widthsLabel:`Ширины (px)`,widthsHelp:`По возрастанию, через запятую. Фотография никогда не увеличивается — ширина, которую она не может обеспечить, пропускается.`,widthsInvalid:`Укажите до {count} возрастающих целых чисел от {min} до {max}.`,shapesLabel:`Формы`,naturalNote:`А также «natural» — каждая фотография в своей форме, в тех же ширинах.`,columnName:`Название`,columnRatio:`Соотношение (Ш:В)`,columnWidths:`Свои ширины`,ownWidthsPlaceholder:`Все ширины`,ratioInvalid:`Запишите соотношение как Ш:В, например 3:2.`,nameInvalid:`Строчные буквы, цифры, - и _, начиная с буквы — и не «natural».`,nameTaken:`Форма с названием «{name}» уже существует.`,tooMany:`Не более {count} форм.`,add:`Добавить форму`,remove:`Удалить {name}`,tooManyFiles:`Это даёт {count} файлов на каждую фотографию; максимум — {max}. Сначала удалите форму или ширину.`},variantBacklog:{waiting:`Изображений, ожидающих создания размеров: {count}`,workerRunning:`Обработчик очереди сейчас их создаёт. Пока он не закончит, эти изображения показываются в полном размере.`,workerMissing:`Ни один обработчик очереди не запущен, поэтому эти изображения показываются в полном размере — страницы грузятся медленнее, особенно на телефонах. Размеры изображений создаёт обработчик очереди — отдельный процесс, который должен работать рядом с сайтом (pnpm worker). Попросите того, кто обслуживает сайт, запустить его.`,waitingSince:`Самое старое поставлено в очередь {since}.`,failed:`Создание размеров не удалось {count} раз(а), попытки прекращены — причина на странице очереди.`,catchUp:`Обновление всей медиатеки поставлено в очередь: каждое изображение сверяется с текущими формами и стилями.`,catchUpWorkerMissing:`Ни один обработчик очереди не запущен, поэтому оно не началось. Размеры изображений создаёт обработчик очереди — отдельный процесс, который должен работать рядом с сайтом (pnpm worker). Попросите того, кто обслуживает сайт, запустить его.`,openQueue:`Открыть очередь`},variantGeneration:{workerRequired:`Размеры изображений создаются в фоне обработчиком очереди — отдельным процессом, который должен работать рядом с сайтом (pnpm worker). Без него сайт работает, но новые и изменённые изображения показываются в полном размере.`,regenerate:`Обновить все изображения`,regenerating:`Запуск…`,queued:`Запущено. Обработчик очереди обновляет медиатеку в фоне; сайт при этом продолжает работать. Следите за ходом на странице очереди.`,alreadyRunning:`Обновление уже идёт — следите за ним на странице очереди.`,inlineDone:`Обработано {processed} из {total}; пропущено {skipped}; ошибок {errors}.`,failed:`Не удалось запустить обновление: {reason}`}},MediaCropEditor:{tabListLabel:`Формы обрезки`,description:`Перетащите, чтобы расположить фотографию, прокрутите или сведите пальцы для масштабирования. Каждое место с этой формой покажет эту обрезку.`,xLabel:`Слева (%)`,yLabel:`Сверху (%)`,widthLabel:`Ширина (%)`,heightLabel:`Высота (%)`,clearShape:`Очистить эту обрезку`,noShapes:`Ни один настроенный стиль изображения пока не имеет фиксированного соотношения сторон, поэтому обрезать нечего.`,hasCropSuffix:`— сохранена собственная обрезка`}};export{e as default};
2
- //# sourceMappingURL=ru-Dj-ax8s_.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ru-Dj-ax8s_.mjs","names":[],"sources":["../src/messages/ru.json"],"sourcesContent":[""],"mappings":""}
@@ -1,2 +0,0 @@
1
- const e=`natural`,t=Object.freeze([320,640,960,1280,1920,2560]),n=Object.freeze({square:Object.freeze({ratio:`1:1`}),landscape:Object.freeze({ratio:`3:2`}),wide:Object.freeze({ratio:`16:9`}),portrait:Object.freeze({ratio:`3:4`})}),r=Object.freeze({shapes:n,ladder:t}),i=`webp`,a=8192,o=/^[a-z][a-z0-9_-]*$/,s=/^([1-9]\d*):([1-9]\d*)$/;function c(e){if(typeof e!=`string`)return null;let t=s.exec(e);if(t===null)return null;let n=Number(t[1]),r=Number(t[2]);return n>1e4||r>1e4?null:n/r}function l(e){return e.ladder.length+Object.values(e.shapes).reduce((t,n)=>t+(n.widths??e.ladder).length,0)}const u=new Map([[`thumbnail`,{style:{width:200,height:200,fit:`cover`,format:`webp`,quality:80},shape:`square`}],[`card`,{style:{width:600,height:400,fit:`cover`,format:`webp`,quality:85},shape:`landscape`}],[`hero`,{style:{width:1920,height:800,fit:`cover`,format:`webp`,quality:85},shape:`wide`}],[`full`,{style:{width:2400,fit:`inside`,format:`webp`,quality:90},shape:e}]]);function d(e,t){return e.find(e=>e>=t)??e[e.length-1]}function f(e,t){return e<t?-1:+(e>t)}function p(e,t){return e.width===t.width&&e.height===t.height&&(e.fit??`cover`)===(t.fit??`cover`)&&(e.format??`webp`)===(t.format??`webp`)&&(e.quality??80)===(t.quality??80)}function m(e){return typeof e==`number`&&Number.isInteger(e)&&e>=16&&e<=8192}function h(e){return[...new Set(e.filter(m))].sort((e,t)=>e-t)}function g(e){return typeof e==`number`&&Number.isInteger(e)&&e>=1&&e<=100}const _=new Set([`webp`,`jpeg`,`png`,`avif`]);function v(e){return typeof e==`string`&&_.has(e)}function y(n){let r=[],a=new Map,s=h(Array.isArray(n.vocabulary.ladder)?n.vocabulary.ladder:[]).slice(0,12),l=s.length>0?s:[...t],m=Object.entries(n.vocabulary.shapes??{}).sort(([e],[t])=>f(e,t));for(let[e,t]of m){if(!o.test(e)||e===`natural`){r.push({name:e,reason:`not a usable shape name`});continue}if(a.size>=16){r.push({name:e,reason:`more than 16 shapes configured`});continue}let n=c(t?.ratio);if(n===null){r.push({name:e,reason:`ratio is not W:H in positive integers`});continue}let s=Array.isArray(t.widths)?h(t.widths):[];a.set(e,{name:e,ratio:n,widths:s.length>0?s.slice(0,12):l,format:v(t.format)?t.format:i,quality:g(t.quality)?t.quality:80,origin:`vocabulary`})}a.set(e,{name:e,ratio:null,widths:l,format:i,quality:80,origin:`natural`});let _=new Map,y=new Set,x=new Map(a),S=0,C=Object.entries(n.styles??{}).sort(([e],[t])=>f(e,t));for(let[e,t]of C){let n=u.get(e);if(n&&p(t,n.style)&&x.has(n.shape))continue;if(y.add(e),a.has(e)){r.push({name:e,reason:`a vocabulary shape has the same name`});continue}let i=b(e,t);`reason`in i?r.push(i):S>=16?r.push({name:e,reason:`more than 16 fixed styles configured`}):(a.set(e,i),S++)}for(let[e,t]of u){if(y.has(e)||a.has(e))continue;let n=x.get(t.shape),r=n&&t.style.width&&d(n.widths,t.style.width);n&&r&&_.set(e,{shape:n.name,width:r})}return{shapes:[...a.values()].sort((e,t)=>e.origin===`natural`?-1:t.origin===`natural`?1:f(e.name,t.name)),aliases:_,unmapped:r}}function b(e,t){if(!o.test(e))return{name:e,reason:`not a usable shape name`};let n=t.fit??`cover`,r=v(t.format)?t.format:i,a=g(t.quality)?t.quality:80;return m(t.width)?n===`cover`?m(t.height)?{name:e,ratio:t.width/t.height,widths:[t.width],format:r,quality:a,origin:`style`}:{name:e,reason:`a cover style needs a height`}:n===`inside`&&t.height===void 0?{name:e,ratio:null,widths:[t.width],format:r,quality:a,origin:`style`}:{name:e,reason:`fit '${n}' with this box has no single ratio and width`}:{name:e,reason:`no width, or a width outside the ladder bounds`}}function x(e,t){let n=e.aliases.get(t);if(n)return n;let r=e.shapes.find(e=>e.name===t&&e.origin===`style`),i=r?.widths[0];return r&&i!==void 0?{shape:r.name,width:i}:void 0}export{e as a,c,a as i,l,n,o,r,y as s,t,x as u};
2
- //# sourceMappingURL=shapes-BlKW-0C1.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"shapes-BlKW-0C1.mjs","names":[],"sources":["../src/shapes.ts"],"sourcesContent":["/**\n * The shape model — what a variant IS once size and shape are separate axes\n * (images D003).\n *\n * - A **shape** is a name carrying one ratio (`square` 1:1, `portrait` 3:4, …).\n * - The **ladder** is one global, ascending list of widths.\n * - A **variant** is (shape × rung), its height derived from the ratio.\n *\n * `ImageStyle` conflated the two — `card: {600×400}` is one ratio at one\n * width — which is why no `srcset` could ever come out of it: a `srcset` needs\n * several widths at ONE shape.\n *\n * ## Why this lives in the SAME settings namespace\n *\n * The vocabulary is a second key (`vocabulary`) of the existing\n * `media.imageStyles` namespace, not a namespace of its own (C7). So it inherits\n * the settings DB → plugin config → hardcoded waterfall, the auto-derived\n * `settings_media.imageStyles` permission resource, and the admin settings page.\n * The existing `imageStyles` key keeps its meaning — an operator's fixed styles\n * — and keeps generating exactly as before for every caller that reads it.\n *\n * ## How the existing styles map onto the model — read-time, never rewritten\n *\n * {@link buildVariantConfig} folds the stored styles into the model when it is\n * READ, so no stored row is migrated and nothing an operator saved is lost:\n *\n * - The four seeded defaults, UNMODIFIED, become **aliases** onto the vocabulary\n * (`thumbnail` → `square`@320, `card` → `landscape`@640, `hero` → `wide`@1920,\n * `full` → the natural ladder's top rung). A caller that still asks for\n * `'thumbnail'` by name is pointed at a variant the vocabulary generates\n * anyway, instead of commissioning a near-duplicate file.\n * - Any other style — an operator's own, or a default they edited — becomes a\n * **one-rung shape** under its own name. It keeps generating at the width they\n * chose, and now at the ratio they chose as a first-class shape.\n * - A style no shape can express (`contain`/`fill`/`outside` fits, a height-only\n * box, an `inside` box bounded on both axes) is reported as **unmapped**,\n * with the reason. It is not dropped from anything: it still generates through\n * the legacy path. It simply has no place in a ratio-and-width model.\n *\n * ## Bounded by configuration\n *\n * Nothing here takes a ratio from markup. A render site asking for a raw ratio\n * (`'1007/577'`) gets CSS cropping and the natural ladder, and no generated\n * variants (D003, N1) — which is what keeps the variant matrix bounded by what\n * an operator configured rather than by what a page happens to contain.\n *\n * Pure — no DB, no sharp, no settings client. Unit-tested on its own.\n */\n\nimport type { ImageFormat, ImageShape, ImageStyle, ReadonlyImageVocabulary } from './types.js'\n\n/**\n * The implicit shape every configuration has: the photograph's own ratio, over\n * the ladder. It is what a slot that declares no shape draws, so it is what\n * that slot's `srcset` is made of. Reserved — no vocabulary shape may use it.\n */\nexport const NATURAL_SHAPE = 'natural'\n\n/**\n * The default width ladder. Six rungs, because this is a LOCAL sharp on\n * somebody's VPS: Astro ships a shorter list for local services than for\n * remote ones, and says why — every rung is a generated file (R006).\n */\nexport const DEFAULT_IMAGE_LADDER: readonly number[] = Object.freeze([\n 320, 640, 960, 1280, 1920, 2560,\n])\n\n/**\n * The seeded shape vocabulary.\n *\n * ⚠️ Portrait is **3:4, not 4:5**. 4:5 is folk wisdom with no design-system\n * backing anywhere in the survey; 3:4 has eBay's and the UAE design system's\n * (R009). Re-read R009 before changing it.\n */\nexport const DEFAULT_IMAGE_SHAPES: Readonly<Record<string, Readonly<ImageShape>>> = Object.freeze({\n square: Object.freeze({ ratio: '1:1' }),\n landscape: Object.freeze({ ratio: '3:2' }),\n wide: Object.freeze({ ratio: '16:9' }),\n portrait: Object.freeze({ ratio: '3:4' }),\n})\n\n/**\n * The seeded vocabulary + ladder — what a site that configured nothing gets.\n * Frozen: it is shared by every reader, so a caller that wants to edit it\n * copies it first (the settings editor and `resolveMediaConfig` both do).\n */\nexport const DEFAULT_IMAGE_VOCABULARY: ReadonlyImageVocabulary = Object.freeze({\n shapes: DEFAULT_IMAGE_SHAPES,\n ladder: DEFAULT_IMAGE_LADDER,\n})\n\nexport const DEFAULT_VARIANT_FORMAT: ImageFormat = 'webp'\nexport const DEFAULT_VARIANT_QUALITY = 80\n\n/**\n * Bounds on the vocabulary. Every shape and every rung is a file per image, so\n * these are what keep one upload from commissioning an unbounded amount of\n * sharp work (CLAUDE.md \"No unbounded fan-out\"). The planner applies a hard\n * per-image cap on top — `MAX_VARIANTS_PER_IMAGE` in `variant-plan.ts` —\n * because legacy styles, which predate these bounds, also become shapes.\n */\nexport const MAX_SHAPES = 16\n/**\n * How many legacy fixed styles become one-rung shapes; the rest are reported\n * as unmapped. Legacy styles predate every bound here, and the planner deals\n * rungs round-robin, so this is what guarantees EVERY shape its first variant\n * under `MAX_VARIANTS_PER_IMAGE`: `MAX_SHAPES + MAX_STYLE_SHAPES + 1` (natural)\n * is 33, well under 64. A style past it is not resolvable by name either — it\n * is in no shape, so `resolveStyleName` answers `undefined` for it.\n */\nexport const MAX_STYLE_SHAPES = 16\nexport const MAX_LADDER_RUNGS = 12\nexport const MIN_VARIANT_WIDTH = 16\nexport const MAX_VARIANT_WIDTH = 8192\n/** The largest term a `W:H` ratio may carry. `1:10000` is already absurd. */\nexport const MAX_RATIO_TERM = 10000\n\n/** Shape and style names — the same rule the style-name validator has always had. */\nexport const SHAPE_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/\n\nconst RATIO_PATTERN = /^([1-9]\\d*):([1-9]\\d*)$/\n\n/**\n * `'3:2'` → `1.5` (width / height), or `null` when the value is not a ratio\n * this model accepts: two positive integers, neither above\n * {@link MAX_RATIO_TERM}.\n */\nexport function parseShapeRatio(ratio: unknown): number | null {\n if (typeof ratio !== 'string') return null\n const match = RATIO_PATTERN.exec(ratio)\n if (match === null) return null\n const w = Number(match[1])\n const h = Number(match[2])\n if (w > MAX_RATIO_TERM || h > MAX_RATIO_TERM) return null\n return w / h\n}\n\n/**\n * How many files a vocabulary plans per photograph, at most: every shape at its\n * widths (its own, else the ladder) plus `natural` over the ladder. The ONE\n * count both the settings schema and the editor check against the per-image\n * cap, so the editor cannot build what the route will refuse.\n */\nexport function plannedFileCount(vocabulary: {\n readonly shapes: Readonly<Record<string, { readonly widths?: readonly number[] | undefined }>>\n readonly ladder: readonly number[]\n}): number {\n return (\n vocabulary.ladder.length +\n Object.values(vocabulary.shapes).reduce(\n (sum, shape) => sum + (shape.widths ?? vocabulary.ladder).length,\n 0,\n )\n )\n}\n\nfunction gcd(a: number, b: number): number {\n let x = a\n let y = b\n while (y !== 0) {\n const t = y\n y = x % y\n x = t\n }\n return x\n}\n\n/** `(600, 400)` → `'3:2'`. Both arguments positive integers. */\nexport function reduceRatio(width: number, height: number): string {\n const d = gcd(width, height)\n return `${width / d}:${height / d}`\n}\n\n/** One shape the generator will produce variants for, fully defaulted. */\nexport interface ResolvedShape {\n readonly name: string\n /** width / height, or `null` for the photograph's own ratio. */\n readonly ratio: number | null\n /** Ascending, distinct. The rungs this shape is generated at, before the per-image filter. */\n readonly widths: readonly number[]\n readonly format: ImageFormat\n readonly quality: number\n /** Where it came from — the vocabulary, the implicit natural shape, or a legacy fixed style. */\n readonly origin: 'vocabulary' | 'natural' | 'style'\n}\n\n/**\n * A legacy style name answered by a (shape, rung) of the vocabulary.\n *\n * The rung is one the shape is CONFIGURED at. A given photograph may still not\n * have it — a source narrower than the rung plans nothing that wide. That is a\n * CONTRACT for the reader, which does not exist yet (images PR06 resolves\n * variants for a render): resolving an alias for one photograph must take the\n * nearest variant that photograph has, as it would for a shape asked by name.\n */\nexport interface ShapeAlias {\n readonly shape: string\n readonly width: number\n}\n\n/** A legacy style the shape model cannot express, and why. */\nexport interface UnmappedStyle {\n readonly name: string\n readonly reason: string\n}\n\n/** The whole variant configuration, resolved. */\nexport interface VariantConfig {\n /** `natural` first, then sorted by name. */\n readonly shapes: readonly ResolvedShape[]\n /** Legacy style name → the (shape, rung) that now answers it. */\n readonly aliases: ReadonlyMap<string, ShapeAlias>\n /** Legacy styles and vocabulary entries that have no place in the model. */\n readonly unmapped: readonly UnmappedStyle[]\n}\n\n/**\n * The four seeded defaults, exactly as `defaultImageStyles` has always defined\n * them, and the vocabulary shape each one maps to (images PR04 acceptance).\n *\n * The RUNG is not stored: it is the smallest of the shape's widths that is at\n * least the style's own width, else its largest — so on the seeded ladder\n * `thumbnail` (200) → `square`@320, `card` (600) → `landscape`@640, `hero`\n * (1920) → `wide`@1920 and `full` (2400) → `natural`@2560, and on an\n * operator's own ladder it lands on a rung that ladder actually has.\n *\n * A stored style is aliased only when it is STILL this default. An operator who\n * edited `card` to 800×600 meant 800×600; that becomes a one-rung shape.\n */\nexport const LEGACY_STYLE_ALIASES: ReadonlyMap<\n string,\n { readonly style: ImageStyle; readonly shape: string }\n> = new Map([\n [\n 'thumbnail',\n {\n style: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n shape: 'square',\n },\n ],\n [\n 'card',\n {\n style: { width: 600, height: 400, fit: 'cover', format: 'webp', quality: 85 },\n shape: 'landscape',\n },\n ],\n [\n 'hero',\n {\n style: { width: 1920, height: 800, fit: 'cover', format: 'webp', quality: 85 },\n shape: 'wide',\n },\n ],\n [\n 'full',\n { style: { width: 2400, fit: 'inside', format: 'webp', quality: 90 }, shape: NATURAL_SHAPE },\n ],\n])\n\n/** The rung an alias answers with — see {@link LEGACY_STYLE_ALIASES}. */\nfunction aliasRung(widths: readonly number[], wanted: number): number | undefined {\n return widths.find((w) => w >= wanted) ?? widths[widths.length - 1]\n}\n\n/**\n * Code-unit order, not `localeCompare`: the order decides nothing a reader\n * sees, but it must be the same on every process that plans a key, and a\n * collation depends on the ICU data and locale the process happens to have.\n */\nfunction compareNames(a: string, b: string): number {\n return a < b ? -1 : a > b ? 1 : 0\n}\n\nfunction sameStyle(a: ImageStyle, b: ImageStyle): boolean {\n return (\n a.width === b.width &&\n a.height === b.height &&\n (a.fit ?? 'cover') === (b.fit ?? 'cover') &&\n (a.format ?? DEFAULT_VARIANT_FORMAT) === (b.format ?? DEFAULT_VARIANT_FORMAT) &&\n (a.quality ?? DEFAULT_VARIANT_QUALITY) === (b.quality ?? DEFAULT_VARIANT_QUALITY)\n )\n}\n\nfunction isWidth(value: unknown): value is number {\n return (\n typeof value === 'number' &&\n Number.isInteger(value) &&\n value >= MIN_VARIANT_WIDTH &&\n value <= MAX_VARIANT_WIDTH\n )\n}\n\n/** Ascending and distinct, invalid entries dropped. */\nfunction normaliseWidths(widths: readonly unknown[]): number[] {\n return [...new Set(widths.filter(isWidth))].sort((a, b) => a - b)\n}\n\nfunction isQuality(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 100\n}\n\nconst FORMATS: ReadonlySet<string> = new Set<ImageFormat>(['webp', 'jpeg', 'png', 'avif'])\n\nfunction isFormat(value: unknown): value is ImageFormat {\n return typeof value === 'string' && FORMATS.has(value)\n}\n\n/**\n * Fold a vocabulary and the stored fixed styles into one {@link VariantConfig}.\n *\n * LENIENT, because it is a READ: the settings route validates what it stores\n * (`image-styles-settings.ts`), but plugin config is code nobody validated, and\n * a bad entry must cost that entry — reported in `unmapped` — never every\n * upload's variants. Deterministic: the same input always yields the same\n * config, in the same order, which the variant keys depend on.\n */\nexport function buildVariantConfig(input: {\n readonly vocabulary: ReadonlyImageVocabulary\n readonly styles: Readonly<Record<string, ImageStyle>>\n}): VariantConfig {\n const unmapped: UnmappedStyle[] = []\n const byName = new Map<string, ResolvedShape>()\n\n const ladder = normaliseWidths(\n Array.isArray(input.vocabulary.ladder) ? input.vocabulary.ladder : [],\n ).slice(0, MAX_LADDER_RUNGS)\n const effectiveLadder = ladder.length > 0 ? ladder : [...DEFAULT_IMAGE_LADDER]\n\n const vocabularyEntries = Object.entries(input.vocabulary.shapes ?? {}).sort(([a], [b]) =>\n compareNames(a, b),\n )\n for (const [name, shape] of vocabularyEntries) {\n if (!SHAPE_NAME_PATTERN.test(name) || name === NATURAL_SHAPE) {\n unmapped.push({ name, reason: 'not a usable shape name' })\n continue\n }\n if (byName.size >= MAX_SHAPES) {\n unmapped.push({ name, reason: `more than ${MAX_SHAPES} shapes configured` })\n continue\n }\n const ratio = parseShapeRatio(shape?.ratio)\n if (ratio === null) {\n unmapped.push({ name, reason: 'ratio is not W:H in positive integers' })\n continue\n }\n const own = Array.isArray(shape.widths) ? normaliseWidths(shape.widths) : []\n byName.set(name, {\n name,\n ratio,\n widths: own.length > 0 ? own.slice(0, MAX_LADDER_RUNGS) : effectiveLadder,\n format: isFormat(shape.format) ? shape.format : DEFAULT_VARIANT_FORMAT,\n quality: isQuality(shape.quality) ? shape.quality : DEFAULT_VARIANT_QUALITY,\n origin: 'vocabulary',\n })\n }\n\n byName.set(NATURAL_SHAPE, {\n name: NATURAL_SHAPE,\n ratio: null,\n widths: effectiveLadder,\n format: DEFAULT_VARIANT_FORMAT,\n quality: DEFAULT_VARIANT_QUALITY,\n origin: 'natural',\n })\n\n // A Map, not an object literal: every lookup below is by an operator-chosen\n // NAME, and `constructor` passes the name rule — on a plain object it would\n // find `Object.prototype.constructor` instead of nothing.\n const aliases = new Map<string, ShapeAlias>()\n const claimed = new Set<string>()\n // What an alias may point at: the vocabulary and `natural` — fixed BEFORE the\n // styles are read, so an operator's own style never becomes an alias target\n // (a 3:1 style called `square` must not answer for `thumbnail`), and the\n // answer does not depend on which style names sort first.\n const aliasTargets = new Map(byName)\n let styleShapes = 0\n\n const styleEntries = Object.entries(input.styles ?? {}).sort(([a], [b]) => compareNames(a, b))\n for (const [name, style] of styleEntries) {\n const legacy = LEGACY_STYLE_ALIASES.get(name)\n // Still the default AND its shape still exists → an alias, below. With the\n // shape gone the style stands on its own as a one-rung shape, rather than\n // being neither a shape, an alias nor unmapped.\n if (legacy && sameStyle(style, legacy.style) && aliasTargets.has(legacy.shape)) continue\n claimed.add(name)\n if (byName.has(name)) {\n unmapped.push({ name, reason: 'a vocabulary shape has the same name' })\n continue\n }\n const converted = styleAsShape(name, style)\n if ('reason' in converted) unmapped.push(converted)\n else if (styleShapes >= MAX_STYLE_SHAPES) {\n unmapped.push({ name, reason: `more than ${MAX_STYLE_SHAPES} fixed styles configured` })\n } else {\n byName.set(name, converted)\n styleShapes++\n }\n }\n\n for (const [name, legacy] of LEGACY_STYLE_ALIASES) {\n // A name an operator reused for their own style, or one the vocabulary\n // itself defines, answers as itself — never as the alias.\n if (claimed.has(name) || byName.has(name)) continue\n const target = aliasTargets.get(legacy.shape)\n const width = target && legacy.style.width && aliasRung(target.widths, legacy.style.width)\n if (target && width) aliases.set(name, { shape: target.name, width })\n }\n\n // `natural` first: it is what every slot that names no shape draws, so when\n // the per-image cap has to cut, it must not be the shape that loses.\n const shapes = [...byName.values()].sort((a, b) =>\n a.origin === 'natural' ? -1 : b.origin === 'natural' ? 1 : compareNames(a.name, b.name),\n )\n return { shapes, aliases, unmapped }\n}\n\n/** A legacy fixed style as a one-rung shape, or the reason it cannot be one. */\nfunction styleAsShape(name: string, style: ImageStyle): ResolvedShape | UnmappedStyle {\n if (!SHAPE_NAME_PATTERN.test(name)) return { name, reason: 'not a usable shape name' }\n const fit = style.fit ?? 'cover'\n const format = isFormat(style.format) ? style.format : DEFAULT_VARIANT_FORMAT\n const quality = isQuality(style.quality) ? style.quality : DEFAULT_VARIANT_QUALITY\n if (!isWidth(style.width)) {\n return { name, reason: 'no width, or a width outside the ladder bounds' }\n }\n if (fit === 'cover') {\n if (!isWidth(style.height)) return { name, reason: 'a cover style needs a height' }\n return {\n name,\n ratio: style.width / style.height,\n widths: [style.width],\n format,\n quality,\n origin: 'style',\n }\n }\n if (fit === 'inside' && style.height === undefined) {\n return { name, ratio: null, widths: [style.width], format, quality, origin: 'style' }\n }\n return { name, reason: `fit '${fit}' with this box has no single ratio and width` }\n}\n\n/**\n * What a legacy style NAME refers to in the shape model: the (shape, rung) of\n * its alias, or — for a style that became a one-rung shape — that shape at its\n * one width. `undefined` for a name the model does not know.\n */\nexport function resolveStyleName(config: VariantConfig, name: string): ShapeAlias | undefined {\n const alias = config.aliases.get(name)\n if (alias) return alias\n const shape = config.shapes.find((s) => s.name === name && s.origin === 'style')\n const width = shape?.widths[0]\n return shape && width !== undefined ? { shape: shape.name, width } : undefined\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"types-DwzblZfW.d.mts","names":[],"sources":["../src/picker/types.ts"],"mappings":";;;;UAGiB,eAAA;EACf,EAAA;EACA,KAAA;EACA,GAAA;EACA,QAAA;EACA,QAAA;EACA,IAAA;EACA,KAAA;EACA,MAAA;EACA,SAAA;EACA,GAAA;AAAA;;UAIe,qBAAA;EACf,KAAA,EAAO,eAAe;EACtB,KAAA;EANG;AAIL;;;;EAQE,UAAA;EAPO;EASP,MAAA;AAAA;;UAIe,oBAAA;EAJT;EAMN,UAAA,GAAa,OAAA;IACX,MAAA;IACA,SAAA;IACA,KAAA;IACA,MAAA;EAAA,MACI,OAAA,CAAQ,qBAAA;EAGuB;EAArC,WAAA,GAAc,IAAA,EAAM,IAAA,KAAS,OAAA,CAAQ,eAAA;AAAA;;KAI3B,eAAA;;UAGK,qBAAA;EACf,OAAA;EACA,OAAA;EACA,MAAA;EACA,KAAA;EACA,OAAA;EACA,WAAA;EACA,UAAA;EACA,IAAA;EACA,IAAA;EACA,YAAA;EACA,SAAA;EACA,SAAA;EACA,UAAA;EACA,gBAAA;EACA,MAAA;EACA,aAAA;EACA,YAAA;EACA,OAAA;EACA,KAAA;AAAA;;UAIe,gBAAA;EArBf;EAuBA,IAAA;EArBA;EAuBA,YAAA,GAAe,IAAA;EArBf;EAuBA,QAAA,GAAW,KAAA,EAAO,eAAA;EArBlB;EAuBA,IAAA,GAAO,eAAA;EArBP;EAuBA,MAAA;EArBA;EAuBA,SAAA;EArBA;EAuBA,SAAA;EArBA;EAuBA,WAAA;EArBA;EAuBA,KAAA;EAtBK;EAwBL,WAAA;EApBe;EAsBf,UAAA,GAAa,qBAAA;;EAEb,SAAA;EAhBO;EAkBP,QAAA,GAAW,SAAA;AAAA"}
@@ -1,2 +0,0 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";var t=e({deriveShapedVariantKey:()=>a,deriveVariantKey:()=>n,fnv1a36:()=>i,variantFingerprint:()=>r});function n(e,t,n=`webp`){let r=e.lastIndexOf(`/`);return`${e.substring(0,r)}/${t}_${e.substring(r+1).replace(/\.[^.]+$/,``)}.${n}`}function r(e){let{extract:t}=e;return i(`${t.left},${t.top},${t.width},${t.height}>${e.width}x${e.height}q${e.quality}`)}function i(e){let t=2166136261;for(let n=0;n<e.length;n++)t^=e.charCodeAt(n),t=Math.imul(t,16777619);return(t>>>0).toString(36).padStart(7,`0`)}function a(e,t){let n=e.lastIndexOf(`/`);return`${n===-1?``:`${e.substring(0,n)}/`}variants/${e.substring(n+1).replace(/\.[^.]+$/,``)}_${t.shape}_${t.width}w_${t.fingerprint}.${t.format}`}export{t as i,n,r,a as t};
2
- //# sourceMappingURL=variant-key-Cki4n-42.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"variant-key-Cki4n-42.mjs","names":[],"sources":["../src/variant-key.ts"],"sourcesContent":["/**\n * Variant key derivation — where a generated variant lives in storage.\n *\n * Two key shapes coexist (images D005):\n *\n * **Legacy** — one file per image STYLE, keyed by the style name alone:\n *\n * Original: uploads/2026/02/{uuid}/photo.jpg\n * Variant: uploads/2026/02/{uuid}/thumbnail_photo.webp\n *\n * Sound only while nothing about a variant can change. It stays exactly as it\n * is: every row that has not been regenerated under the shape model still has\n * its files there, and ticketing derives its thumbnails with it.\n *\n * **Shaped** — one file per (shape × rung), carrying a FINGERPRINT of the\n * recipe that produced it:\n *\n * Variant: uploads/2026/02/{uuid}/variants/photo_portrait_640w_1k2x9a0.webp\n *\n * The moment an editor can re-crop, a name-only key serves different bytes at\n * the same URL, and every cache in front of it — the CDN, the browser, the\n * content-api response that inlined the URL — keeps the old picture. So the\n * fingerprint is of the EFFECTIVE recipe: the integral pixel rect actually\n * extracted, the output box and the quality (R013). Same recipe → same key → a\n * backfill that re-derives it uploads nothing. Different recipe → different key\n * → every cache misses correctly, because it is a different file. No\n * timestamps, no salt: determinism is what makes the backfill idempotent.\n *\n * ⚠️ A query-string cache-buster is not an alternative. Storage keys are what\n * `getPublicUrls` resolves, so a query string is not part of the identity and\n * the stale file is still the file (D005 (d); Craft's issue #3685 is that\n * failure in a shipping product).\n *\n * The shaped keys live in a `variants/` directory beside the original, which is\n * what makes the two shapes impossible to confuse: a legacy key is always a\n * direct child of the original's directory (a style name cannot contain `/`),\n * and both stay under the original's own prefix — which ticketing's\n * prefix-ownership check relies on.\n */\n\n/**\n * Derive a LEGACY variant storage key from the original key + style name.\n *\n * @param originalKey - The original file's storage key\n * @param styleName - The image style name (e.g., 'thumbnail', 'medium')\n * @param format - The variant output format (default: 'webp')\n * @returns The derived variant key\n */\nexport function deriveVariantKey(originalKey: string, styleName: string, format = 'webp'): string {\n const lastSlash = originalKey.lastIndexOf('/')\n const dir = originalKey.substring(0, lastSlash)\n const filename = originalKey.substring(lastSlash + 1)\n const baseName = filename.replace(/\\.[^.]+$/, '')\n return `${dir}/${styleName}_${baseName}.${format}`\n}\n\n/** Everything that determines a shaped variant's bytes, apart from the source file itself. */\nexport interface VariantRecipe {\n /** The region `extract()` takes, in integral pixels of the image as DRAWN (post-EXIF). */\n readonly extract: {\n readonly left: number\n readonly top: number\n readonly width: number\n readonly height: number\n }\n /** The box the region is resized to. */\n readonly width: number\n readonly height: number\n readonly quality: number\n}\n\n/**\n * A short deterministic fingerprint of a {@link VariantRecipe}: 32-bit FNV-1a\n * over a canonical string, as 7 base-36 characters.\n *\n * Not cryptographic and not meant to be — it only has to differ between two\n * recipes for the SAME file, shape and width, and nothing an attacker controls\n * chooses between colliding recipes. Pure JS rather than `node:crypto` so the\n * key can be derived in any realm that needs to name a variant.\n */\nexport function variantFingerprint(recipe: VariantRecipe): string {\n const { extract: e } = recipe\n return fnv1a36(\n `${e.left},${e.top},${e.width},${e.height}>${recipe.width}x${recipe.height}q${recipe.quality}`,\n )\n}\n\n/** 32-bit FNV-1a of `canonical`, as 7 base-36 characters — see {@link variantFingerprint}. */\nexport function fnv1a36(canonical: string): string {\n let hash = 0x811c9dc5\n for (let i = 0; i < canonical.length; i++) {\n hash ^= canonical.charCodeAt(i)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0).toString(36).padStart(7, '0')\n}\n\n/** The parts a shaped variant key is made of. */\nexport interface ShapedVariantKeyParts {\n /** The shape name (`square`, `natural`, …). */\n readonly shape: string\n /** The rung the variant was PLANNED at — part of the name, not a claim about the file. */\n readonly width: number\n /** {@link variantFingerprint} of the recipe. */\n readonly fingerprint: string\n /** Output format — the extension. */\n readonly format: string\n}\n\n/**\n * Derive a SHAPED variant storage key (images D005).\n *\n * `photo.jpg` + `{portrait, 640, 1k2x9a0, webp}` →\n * `<dir>/variants/photo_portrait_640w_1k2x9a0.webp`.\n */\nexport function deriveShapedVariantKey(originalKey: string, parts: ShapedVariantKeyParts): string {\n const lastSlash = originalKey.lastIndexOf('/')\n const dir = lastSlash === -1 ? '' : `${originalKey.substring(0, lastSlash)}/`\n const filename = originalKey.substring(lastSlash + 1)\n const baseName = filename.replace(/\\.[^.]+$/, '')\n return `${dir}variants/${baseName}_${parts.shape}_${parts.width}w_${parts.fingerprint}.${parts.format}`\n}\n"],"mappings":"0JAgDA,SAAgB,EAAiB,EAAqB,EAAmB,EAAS,OAAgB,CAChG,IAAM,EAAY,EAAY,YAAY,GAAG,EAI7C,MAAO,GAHK,EAAY,UAAU,EAAG,CAGzB,EAAE,GAAG,EAAU,GAFV,EAAY,UAAU,EAAY,CAC3B,CAAC,CAAC,QAAQ,WAAY,EACT,EAAE,GAAG,GAC5C,CA0BA,SAAgB,EAAmB,EAA+B,CAChE,GAAM,CAAE,QAAS,GAAM,EACvB,OAAO,EACL,GAAG,EAAE,KAAK,GAAG,EAAE,IAAI,GAAG,EAAE,MAAM,GAAG,EAAE,OAAO,GAAG,EAAO,MAAM,GAAG,EAAO,OAAO,GAAG,EAAO,SACvF,CACF,CAGA,SAAgB,EAAQ,EAA2B,CACjD,IAAI,EAAO,WACX,IAAK,IAAI,EAAI,EAAG,EAAI,EAAU,OAAQ,IACpC,GAAQ,EAAU,WAAW,CAAC,EAC9B,EAAO,KAAK,KAAK,EAAM,QAAU,EAEnC,OAAQ,IAAS,EAAA,CAAG,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,CAClD,CAoBA,SAAgB,EAAuB,EAAqB,EAAsC,CAChG,IAAM,EAAY,EAAY,YAAY,GAAG,EAI7C,MAAO,GAHK,IAAc,GAAK,GAAK,GAAG,EAAY,UAAU,EAAG,CAAS,EAAE,GAG7D,WAFG,EAAY,UAAU,EAAY,CAC3B,CAAC,CAAC,QAAQ,WAAY,EACd,EAAE,GAAG,EAAM,MAAM,GAAG,EAAM,MAAM,IAAI,EAAM,YAAY,GAAG,EAAM,QACjG"}
@@ -1,2 +0,0 @@
1
- import{i as e}from"./crop-rect-B-AJ2pWm.mjs";import{n as t,s as n}from"./focal-point-BWhtJzef.mjs";import{r}from"./variant-key-Cki4n-42.mjs";const i=64;function a(e,t,n){return Math.min(Math.max(e,t),n)}function o(e,t,n){let r=a(Math.round(e.x*t),0,t-1),i=a(Math.round(e.y*n),0,n-1);return{left:r,top:i,width:a(Math.round(e.w*t),1,t-r),height:a(Math.round(e.h*n),1,n-i)}}function s(r,i,a){return r.ratio===null?{left:0,top:0,width:i.width,height:i.height}:o(e(a.crops).get(r.name)??t(n(a.focalPoint),i.width/i.height,r.ratio),i.width,i.height)}function c(e,t){let n=e.ratio??t.width/t.height,r=[];for(let i of e.widths){let e=Math.max(1,Math.round(i/n));i<=t.width&&e<=t.height&&r.push([i,e])}if(r.length>0)return r;let i=Math.max(1,Math.round(t.width/n));return i<=t.height?[[t.width,i]]:[[a(Math.round(t.height*n),1,t.width),t.height]]}function l(e,t,n){if(!(e.width>=1&&e.height>=1))return{variants:[],dropped:0};let i=t.shapes.map(t=>{let r=s(t,e,n);return{shape:t,region:r,sizes:c(t,r)}}),a=[],o=0,l=Math.max(0,...i.map(e=>e.sizes.length));for(let e=0;e<l;e++)for(let{shape:t,region:n,sizes:s}of i){let i=s[e];if(!i)continue;if(a.length>=64){o++;continue}let[c,l]=i,u={extract:n,width:c,height:l,quality:t.quality};a.push({shape:t.name,width:c,height:l,extract:n,format:t.format,quality:t.quality,fingerprint:r(u)})}return{variants:a,dropped:o}}export{l as n,i as t};
2
- //# sourceMappingURL=variant-plan-DV8hsMdz.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"variant-plan-DV8hsMdz.mjs","names":[],"sources":["../src/variant-plan.ts"],"sourcesContent":["/**\n * Planning a photograph's variants — which (shape × rung) files it gets, the\n * exact region each is cut from, and the fingerprint that names it.\n *\n * Pure: no sharp, no storage, no database. Planning is split from generation\n * so the same answer is available to a caller that must NAME a variant without\n * producing it — a queue job's idempotency key (images PR05), a resolver\n * deriving the key of a file that should already exist (images PR06) — and so\n * it can be unit-tested exhaustively without decoding a single image.\n *\n * ## The effective rect\n *\n * For a shape with a ratio, the region is `crops[shape]` when the editor drew\n * one, and otherwise the window the focal point implies for that ratio —\n * `focalCropRect`, the SAME function the browser's `object-position` comes\n * from, so the generated crop and the CSS crop cannot disagree (phase 03).\n * `natural` is the whole image. The rect is then denormalised to integral\n * pixels, because `extract()` takes nothing else (R001, C5).\n *\n * ## Never upscale\n *\n * A rung is planned only when the region can supply it on BOTH axes. When no\n * rung fits — a small source, or a tight crop — the shape still gets exactly\n * one variant, at the largest size the region supplies, so every shape has a\n * file and none of them is enlarged (R006: Astro filters the same way; unpic\n * cannot, because it does not know its originals).\n *\n * The planned width is what the generator asks for. What it RECORDS is what\n * sharp returned (F003) — see `generateShapedVariants`.\n */\n\nimport { readCrops } from './crop/crop-rect.js'\nimport { type FractionalRect, focalCropRect, readFocalPoint } from './crop/focal-point.js'\nimport type { ResolvedShape, VariantConfig } from './shapes.js'\nimport type { ImageFormat } from './types.js'\nimport { type VariantRecipe, variantFingerprint } from './variant-key.js'\n\n/**\n * The hard ceiling on variants per image, whatever the configuration says.\n *\n * The vocabulary is bounded (`MAX_SHAPES` × `MAX_LADDER_RUNGS`), but legacy\n * styles predate those bounds and each becomes a shape, so the product is not.\n * Every variant is a decode-extract-resize-encode on the generating process, so\n * this is the cap that keeps one upload from being an amplification vector. The\n * seeded vocabulary plans at most 30 (four shapes and `natural`, six rungs).\n * What it drops is COUNTED in {@link VariantPlan.dropped}, never silent.\n */\nexport const MAX_VARIANTS_PER_IMAGE = 64\n\n/** The region `extract()` takes, in integral pixels of the image as drawn. */\nexport interface PixelRect {\n readonly left: number\n readonly top: number\n readonly width: number\n readonly height: number\n}\n\n/** One variant to generate. */\nexport interface PlannedVariant {\n readonly shape: string\n /** The width to ask sharp for. Never above what {@link extract} supplies. */\n readonly width: number\n /** The height to ask sharp for — derived from the shape's ratio. */\n readonly height: number\n readonly extract: PixelRect\n readonly format: ImageFormat\n readonly quality: number\n /** {@link variantFingerprint} of this variant's recipe — goes into its key. */\n readonly fingerprint: string\n}\n\nexport interface VariantPlan {\n readonly variants: readonly PlannedVariant[]\n /** How many variants {@link MAX_VARIANTS_PER_IMAGE} cut off. */\n readonly dropped: number\n}\n\n/** What the plan needs from the media row. Untrusted — both are read leniently. */\nexport interface VariantPlanInput {\n readonly focalPoint?: unknown\n readonly crops?: unknown\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(Math.max(value, min), max)\n}\n\n/** A fractional rect as integral pixels, never empty and never outside the image. */\nexport function toPixelRect(rect: FractionalRect, width: number, height: number): PixelRect {\n const left = clamp(Math.round(rect.x * width), 0, width - 1)\n const top = clamp(Math.round(rect.y * height), 0, height - 1)\n return {\n left,\n top,\n width: clamp(Math.round(rect.w * width), 1, width - left),\n height: clamp(Math.round(rect.h * height), 1, height - top),\n }\n}\n\n/**\n * The region a shape is cut from — its crop, else its focal window, else the\n * whole image.\n *\n * A stored crop is used as drawn even when its ratio is not quite the shape's\n * (`isCropRect` checks bounds, not ratio): the resize is `cover`, so the drift\n * is trimmed evenly from the region's long axis. The crop editor constrains the\n * window to the shape, so in practice the drift is sub-pixel rounding.\n */\nexport function effectiveRect(\n shape: ResolvedShape,\n source: { readonly width: number; readonly height: number },\n input: VariantPlanInput,\n): PixelRect {\n if (shape.ratio === null) return { left: 0, top: 0, width: source.width, height: source.height }\n const crop = readCrops(input.crops).get(shape.name)\n const rect =\n crop ??\n focalCropRect(readFocalPoint(input.focalPoint), source.width / source.height, shape.ratio)\n return toPixelRect(rect, source.width, source.height)\n}\n\n/**\n * The (width, height) boxes a shape is generated at, from one region: every\n * rung the region supplies on both axes, or — when none does — one box at the\n * largest size it supplies.\n */\nfunction sizesFor(shape: ResolvedShape, region: PixelRect): Array<[number, number]> {\n const ratio = shape.ratio ?? region.width / region.height\n const sizes: Array<[number, number]> = []\n for (const rung of shape.widths) {\n const h = Math.max(1, Math.round(rung / ratio))\n if (rung <= region.width && h <= region.height) sizes.push([rung, h])\n }\n if (sizes.length > 0) return sizes\n // The whole region's width, unless the ratio makes that too tall — then the\n // whole region's height. Rounded, so a region that IS the shape (to the\n // pixel) is used whole rather than trimmed by a floor.\n const tall = Math.max(1, Math.round(region.width / ratio))\n if (tall <= region.height) return [[region.width, tall]]\n return [[clamp(Math.round(region.height * ratio), 1, region.width), region.height]]\n}\n\n/**\n * Plan every variant a photograph of `source` dimensions gets under `config`.\n *\n * `source` must be the DRAWN size (post-EXIF orientation) of the actual file —\n * `getImageDimensions`, not the row's client-measured `width`/`height` — since\n * the regions are cut from the file.\n */\nexport function planVariants(\n source: { readonly width: number; readonly height: number },\n config: VariantConfig,\n input: VariantPlanInput,\n): VariantPlan {\n if (!(source.width >= 1 && source.height >= 1)) return { variants: [], dropped: 0 }\n const perShape = config.shapes.map((shape) => {\n const region = effectiveRect(shape, source, input)\n return { shape, region, sizes: sizesFor(shape, region) }\n })\n // Round-robin by rung — every shape's smallest variant before any shape's\n // second — so if the cap cuts, it cuts the LARGEST files of the most shapes\n // rather than every file of whichever shapes sort last. `config.shapes` puts\n // `natural` first, so it is never the one left with nothing.\n const variants: PlannedVariant[] = []\n let dropped = 0\n const deepest = Math.max(0, ...perShape.map((p) => p.sizes.length))\n for (let rung = 0; rung < deepest; rung++) {\n for (const { shape, region, sizes } of perShape) {\n const size = sizes[rung]\n if (!size) continue\n if (variants.length >= MAX_VARIANTS_PER_IMAGE) {\n dropped++\n continue\n }\n const [width, height] = size\n const recipe: VariantRecipe = { extract: region, width, height, quality: shape.quality }\n variants.push({\n shape: shape.name,\n width,\n height,\n extract: region,\n format: shape.format,\n quality: shape.quality,\n fingerprint: variantFingerprint(recipe),\n })\n }\n }\n return { variants, dropped }\n}\n"],"mappings":"6IA+CA,MAAa,EAAyB,GAoCtC,SAAS,EAAM,EAAe,EAAa,EAAqB,CAC9D,OAAO,KAAK,IAAI,KAAK,IAAI,EAAO,CAAG,EAAG,CAAG,CAC3C,CAGA,SAAgB,EAAY,EAAsB,EAAe,EAA2B,CAC1F,IAAM,EAAO,EAAM,KAAK,MAAM,EAAK,EAAI,CAAK,EAAG,EAAG,EAAQ,CAAC,EACrD,EAAM,EAAM,KAAK,MAAM,EAAK,EAAI,CAAM,EAAG,EAAG,EAAS,CAAC,EAC5D,MAAO,CACL,OACA,MACA,MAAO,EAAM,KAAK,MAAM,EAAK,EAAI,CAAK,EAAG,EAAG,EAAQ,CAAI,EACxD,OAAQ,EAAM,KAAK,MAAM,EAAK,EAAI,CAAM,EAAG,EAAG,EAAS,CAAG,CAC5D,CACF,CAWA,SAAgB,EACd,EACA,EACA,EACW,CAMX,OALI,EAAM,QAAU,KAAa,CAAE,KAAM,EAAG,IAAK,EAAG,MAAO,EAAO,MAAO,OAAQ,EAAO,MAAO,EAKxF,EAJM,EAAU,EAAM,KAAK,CAAC,CAAC,IAAI,EAAM,IAEzC,GACH,EAAc,EAAe,EAAM,UAAU,EAAG,EAAO,MAAQ,EAAO,OAAQ,EAAM,KAAK,EAClE,EAAO,MAAO,EAAO,MAAM,CACtD,CAOA,SAAS,EAAS,EAAsB,EAA4C,CAClF,IAAM,EAAQ,EAAM,OAAS,EAAO,MAAQ,EAAO,OAC7C,EAAiC,CAAC,EACxC,IAAK,IAAM,KAAQ,EAAM,OAAQ,CAC/B,IAAM,EAAI,KAAK,IAAI,EAAG,KAAK,MAAM,EAAO,CAAK,CAAC,EAC1C,GAAQ,EAAO,OAAS,GAAK,EAAO,QAAQ,EAAM,KAAK,CAAC,EAAM,CAAC,CAAC,CACtE,CACA,GAAI,EAAM,OAAS,EAAG,OAAO,EAI7B,IAAM,EAAO,KAAK,IAAI,EAAG,KAAK,MAAM,EAAO,MAAQ,CAAK,CAAC,EAEzD,OADI,GAAQ,EAAO,OAAe,CAAC,CAAC,EAAO,MAAO,CAAI,CAAC,EAChD,CAAC,CAAC,EAAM,KAAK,MAAM,EAAO,OAAS,CAAK,EAAG,EAAG,EAAO,KAAK,EAAG,EAAO,MAAM,CAAC,CACpF,CASA,SAAgB,EACd,EACA,EACA,EACa,CACb,GAAI,EAAE,EAAO,OAAS,GAAK,EAAO,QAAU,GAAI,MAAO,CAAE,SAAU,CAAC,EAAG,QAAS,CAAE,EAClF,IAAM,EAAW,EAAO,OAAO,IAAK,GAAU,CAC5C,IAAM,EAAS,EAAc,EAAO,EAAQ,CAAK,EACjD,MAAO,CAAE,QAAO,SAAQ,MAAO,EAAS,EAAO,CAAM,CAAE,CACzD,CAAC,EAKK,EAA6B,CAAC,EAChC,EAAU,EACR,EAAU,KAAK,IAAI,EAAG,GAAG,EAAS,IAAK,GAAM,EAAE,MAAM,MAAM,CAAC,EAClE,IAAK,IAAI,EAAO,EAAG,EAAO,EAAS,IACjC,IAAK,GAAM,CAAE,QAAO,SAAQ,WAAW,EAAU,CAC/C,IAAM,EAAO,EAAM,GACnB,GAAI,CAAC,EAAM,SACX,GAAI,EAAS,QAAA,GAAkC,CAC7C,IACA,QACF,CACA,GAAM,CAAC,EAAO,GAAU,EAClB,EAAwB,CAAE,QAAS,EAAQ,QAAO,SAAQ,QAAS,EAAM,OAAQ,EACvF,EAAS,KAAK,CACZ,MAAO,EAAM,KACb,QACA,SACA,QAAS,EACT,OAAQ,EAAM,OACd,QAAS,EAAM,QACf,YAAa,EAAmB,CAAM,CACxC,CAAC,CACH,CAEF,MAAO,CAAE,WAAU,SAAQ,CAC7B"}