@murumets-ee/media 0.71.0 → 0.72.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 (134) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/backfill-D2Ubblh3.mjs +2 -0
  4. package/dist/backfill-D2Ubblh3.mjs.map +1 -0
  5. package/dist/backfill-trigger-BFL1L7_j.mjs +2 -0
  6. package/dist/backfill-trigger-BFL1L7_j.mjs.map +1 -0
  7. package/dist/backlog-D-H5VUQA.mjs +2 -0
  8. package/dist/backlog-D-H5VUQA.mjs.map +1 -0
  9. package/dist/client.d.mts +55 -7
  10. package/dist/client.d.mts.map +1 -1
  11. package/dist/client.mjs +1 -1
  12. package/dist/client.mjs.map +1 -1
  13. package/dist/crop-editor.d.mts +60 -0
  14. package/dist/crop-editor.d.mts.map +1 -0
  15. package/dist/crop-editor.mjs +2 -0
  16. package/dist/crop-editor.mjs.map +1 -0
  17. package/dist/crop-rect-B-AJ2pWm.mjs +2 -0
  18. package/dist/crop-rect-B-AJ2pWm.mjs.map +1 -0
  19. package/dist/crop-rect-C-SbjoxK.d.mts +59 -0
  20. package/dist/crop-rect-C-SbjoxK.d.mts.map +1 -0
  21. package/dist/crop.d.mts +2 -1
  22. package/dist/crop.mjs +1 -1
  23. package/dist/definitions-LJpgrdxd.mjs +2 -0
  24. package/dist/definitions-LJpgrdxd.mjs.map +1 -0
  25. package/dist/deps-CzHEhyJK.mjs +2 -0
  26. package/dist/deps-CzHEhyJK.mjs.map +1 -0
  27. package/dist/derive-media-type-C38ZA_Cl.mjs +2 -0
  28. package/dist/derive-media-type-C38ZA_Cl.mjs.map +1 -0
  29. package/dist/en-A8YzaZ51.mjs +2 -0
  30. package/dist/en-A8YzaZ51.mjs.map +1 -0
  31. package/dist/entity-CsDdjKz6.mjs +2 -0
  32. package/dist/entity-CsDdjKz6.mjs.map +1 -0
  33. package/dist/et-CXvu8U31.mjs +2 -0
  34. package/dist/et-CXvu8U31.mjs.map +1 -0
  35. package/dist/generate-variants-zyPjaGcy.mjs +2 -0
  36. package/dist/generate-variants-zyPjaGcy.mjs.map +1 -0
  37. package/dist/i18n.mjs +1 -1
  38. package/dist/i18n.mjs.map +1 -1
  39. package/dist/image-styles-settings.d.mts +8 -1
  40. package/dist/image-styles-settings.d.mts.map +1 -1
  41. package/dist/image-styles-settings.mjs +1 -1
  42. package/dist/image-styles-settings.mjs.map +1 -1
  43. package/dist/image-styles.d.mts +35 -21
  44. package/dist/image-styles.d.mts.map +1 -1
  45. package/dist/image-styles.mjs +1 -2
  46. package/dist/image-styles.mjs.map +1 -1
  47. package/dist/index.d.mts +16 -42
  48. package/dist/index.d.mts.map +1 -1
  49. package/dist/index.mjs +1 -1
  50. package/dist/index.mjs.map +1 -1
  51. package/dist/media-config-DmcTxuDM.mjs +2 -0
  52. package/dist/media-config-DmcTxuDM.mjs.map +1 -0
  53. package/dist/owned-delete-Cn_KFFjS.mjs +2 -0
  54. package/dist/owned-delete-Cn_KFFjS.mjs.map +1 -0
  55. package/dist/picker.d.mts +1 -94
  56. package/dist/picker.d.mts.map +1 -1
  57. package/dist/picker.mjs +1 -2
  58. package/dist/picker.mjs.map +1 -1
  59. package/dist/plugin.d.mts +2 -1
  60. package/dist/plugin.d.mts.map +1 -1
  61. package/dist/plugin.mjs +1 -1
  62. package/dist/plugin.mjs.map +1 -1
  63. package/dist/process-image-Deedtkh4.mjs +2 -0
  64. package/dist/process-image-Deedtkh4.mjs.map +1 -0
  65. package/dist/processing.d.mts +201 -7
  66. package/dist/processing.d.mts.map +1 -1
  67. package/dist/processing.mjs +1 -1
  68. package/dist/public-resolver.d.mts +1 -1
  69. package/dist/public-resolver.mjs +1 -1
  70. package/dist/public-resolver.mjs.map +1 -1
  71. package/dist/query-client.d.mts +1 -1
  72. package/dist/query-client.mjs +1 -1
  73. package/dist/regenerate-variants-Cp3sNHkT.mjs +2 -0
  74. package/dist/regenerate-variants-Cp3sNHkT.mjs.map +1 -0
  75. package/dist/register-BSGkELj0.mjs +2 -0
  76. package/dist/register-BSGkELj0.mjs.map +1 -0
  77. package/dist/resolve-image-styles-pf7Nd3Zf.mjs +2 -0
  78. package/dist/resolve-image-styles-pf7Nd3Zf.mjs.map +1 -0
  79. package/dist/routes-BJ23Cp0g.mjs +2 -0
  80. package/dist/routes-BJ23Cp0g.mjs.map +1 -0
  81. package/dist/ru-Dj-ax8s_.mjs +2 -0
  82. package/dist/ru-Dj-ax8s_.mjs.map +1 -0
  83. package/dist/schedule-DREjg2ji.mjs +2 -0
  84. package/dist/schedule-DREjg2ji.mjs.map +1 -0
  85. package/dist/shapes-40dHtTb-.d.mts +90 -0
  86. package/dist/shapes-40dHtTb-.d.mts.map +1 -0
  87. package/dist/shapes-BlKW-0C1.mjs +2 -0
  88. package/dist/shapes-BlKW-0C1.mjs.map +1 -0
  89. package/dist/slot-CY4isqmN.mjs +2 -0
  90. package/dist/slot-CY4isqmN.mjs.map +1 -0
  91. package/dist/{types-CnORVkLZ.d.mts → types-CgkJF5dc.d.mts} +85 -5
  92. package/dist/types-CgkJF5dc.d.mts.map +1 -0
  93. package/dist/types-DwzblZfW.d.mts +97 -0
  94. package/dist/types-DwzblZfW.d.mts.map +1 -0
  95. package/dist/variant-key-Cki4n-42.mjs +2 -0
  96. package/dist/variant-key-Cki4n-42.mjs.map +1 -0
  97. package/dist/variant-plan-DV8hsMdz.mjs +2 -0
  98. package/dist/variant-plan-DV8hsMdz.mjs.map +1 -0
  99. package/package.json +21 -8
  100. package/dist/client-CSOH7Zun.mjs +0 -2
  101. package/dist/client-CSOH7Zun.mjs.map +0 -1
  102. package/dist/en-0YnQ_Qz-.mjs +0 -2
  103. package/dist/en-0YnQ_Qz-.mjs.map +0 -1
  104. package/dist/entity-CNxAERYG.mjs +0 -2
  105. package/dist/entity-CNxAERYG.mjs.map +0 -1
  106. package/dist/entity-DKFCdQJT.mjs +0 -2
  107. package/dist/entity-DKFCdQJT.mjs.map +0 -1
  108. package/dist/et-BIxGYWVK.mjs +0 -2
  109. package/dist/et-BIxGYWVK.mjs.map +0 -1
  110. package/dist/image-styles-settings-DEnu-V2E.mjs +0 -2
  111. package/dist/image-styles-settings-DEnu-V2E.mjs.map +0 -1
  112. package/dist/plugin-B_ZtfiAc.mjs +0 -2
  113. package/dist/plugin-B_ZtfiAc.mjs.map +0 -1
  114. package/dist/process-image-WByEHTXQ.mjs +0 -2
  115. package/dist/process-image-WByEHTXQ.mjs.map +0 -1
  116. package/dist/regenerate-variants-2Lmo5kf8.mjs +0 -2
  117. package/dist/regenerate-variants-2Lmo5kf8.mjs.map +0 -1
  118. package/dist/regenerate-variants-B9AXXN6N.mjs +0 -2
  119. package/dist/regenerate-variants-B9AXXN6N.mjs.map +0 -1
  120. package/dist/resolve-image-styles-Cakg1Qpp.mjs +0 -2
  121. package/dist/resolve-image-styles-Cakg1Qpp.mjs.map +0 -1
  122. package/dist/resolve-image-styles-iN9JbZYf.mjs +0 -2
  123. package/dist/resolve-image-styles-iN9JbZYf.mjs.map +0 -1
  124. package/dist/routes-C7Fx-055.mjs +0 -2
  125. package/dist/routes-C7Fx-055.mjs.map +0 -1
  126. package/dist/ru-UUTBfsMF.mjs +0 -2
  127. package/dist/ru-UUTBfsMF.mjs.map +0 -1
  128. package/dist/types-CnORVkLZ.d.mts.map +0 -1
  129. package/dist/usage-CVAqkS6h.mjs +0 -2
  130. package/dist/usage-CVAqkS6h.mjs.map +0 -1
  131. package/dist/variant-key-JBTJXPL1.mjs +0 -2
  132. package/dist/variant-key-JBTJXPL1.mjs.map +0 -1
  133. package/dist/variant-key-RKke2RfS.mjs +0 -2
  134. package/dist/variant-key-RKke2RfS.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/routes.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2yBgB,WAAA,CAAA,GAAe,UAAU"}
1
+ {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/routes.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6zBgB,WAAA,CAAA,GAAe,UAAU"}
package/dist/admin.mjs CHANGED
@@ -1 +1 @@
1
- import{t as e}from"./routes-C7Fx-055.mjs";export{e as mediaRoutes};
1
+ import{t as e}from"./routes-BJ23Cp0g.mjs";export{e as mediaRoutes};
@@ -0,0 +1,2 @@
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backfill-D2Ubblh3.mjs","names":[],"sources":["../src/jobs/backfill.ts"],"sourcesContent":["/**\n * The library catch-up (images G5, S6) — the body of `media:variants-backfill`,\n * which replaces `regenerateAllVariants`'s synchronous loop inside one HTTP\n * request.\n *\n * It generates NOTHING itself. It pages through the image library and queues\n * one `media:generate-variants` pass per photograph, so:\n *\n * - **the site keeps serving** — nothing is deleted up front, and every pass\n * is additive (see `generate-variants.ts`);\n * - **progress is counted by the queue** — the operator watches passes drain\n * on the Queue page and the waiting count on the media library;\n * - **it is incremental** — a photograph whose recorded variants already\n * match the configuration is not queued (read from its metadata; nothing is\n * downloaded), so a walk over a caught-up library is cheap and a capped walk\n * reaches further the next time;\n * - **it never piles up** — a photograph that already has a pass waiting (from\n * an upload, an edit, or an earlier walk) is not queued again, because a\n * waiting pass reads the configuration when it RUNS. So repeated clicks do\n * not multiply work, and a walk retried after a crash re-queues nothing;\n * - **one bad photograph blocks nothing** — it fails its own pass, not the walk\n * (Craft moved off one batch job for exactly this, R014).\n *\n * Bounded (CLAUDE.md, no unbounded fan-out): keyset pages of {@link PAGE_SIZE}\n * rows, never the whole table in memory, one enqueue at a time, and a hard\n * ceiling of {@link MAX_BACKFILL_QUEUED} passes per walk — reported as\n * `truncated`, never silent. Runs only in the worker, off every request path.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { JobDefinition } from '@murumets-ee/queue/client'\nimport { Media } from '../entity.js'\nimport { isProcessableImage } from '../process-image.js'\nimport { resolveImageStyles, resolveVariantConfig } from '../resolve-image-styles.js'\nimport type { VariantConfig } from '../shapes.js'\nimport type { ImageStyle } from '../types.js'\nimport {\n backfillEpoch,\n type GenerateVariantsPayload,\n generateVariantsPayloadSchema,\n} from './definitions.js'\nimport { expectedVariants, focalSeedTried, isUpToDate, readVariantState } from './variant-state.js'\n\n/** Rows read per page. */\nexport const PAGE_SIZE = 200\n\n/**\n * Passes one walk will queue. A library needing more is caught up by running\n * the backfill again once these have drained: a photograph whose pass has\n * completed is up to date and is not queued again, so each walk reaches\n * further.\n */\nexport const MAX_BACKFILL_QUEUED = 100_000\n\nexport interface VariantsBackfillProgress {\n readonly phase: 'scanning' | 'done'\n /** Image rows read. */\n readonly scanned: number\n /** Passes queued by this walk. */\n readonly queued: number\n /** Photographs whose recorded variants already match the configuration. */\n readonly upToDate: number\n /** Photographs that already had a pass waiting — not queued twice. */\n readonly alreadyWaiting: number\n /** Image rows this pipeline does not process (SVG, GIF), or with no original file. */\n readonly skipped: number\n /** The walk stopped at {@link MAX_BACKFILL_QUEUED}. */\n readonly truncated: boolean\n}\n\n/** One image row, as much of it as the walk reads. */\nexport interface BackfillRow {\n readonly id: string\n readonly fileKey: string\n readonly mimeType: string\n readonly width?: number | null\n readonly height?: number | null\n readonly focalPoint?: unknown\n readonly crops?: unknown\n}\n\n/** What the walk needs, injected so it can be tested without a database. */\nexport interface BackfillDeps {\n /** This walk's epoch — `backfillEpoch(its job id)`. */\n readonly epoch: string\n /** Up to `limit` image rows with `id > afterId`, ascending by id. */\n listImages(afterId: string | null, limit: number): Promise<readonly BackfillRow[]>\n /** The original's metadata, or `undefined` when there is no original file. */\n readMetadata(fileKey: string): Promise<unknown>\n /** Which of these media ids already have a `media:generate-variants` pass waiting or running. */\n waitingMediaIds(ids: readonly string[]): Promise<ReadonlySet<string>>\n enqueue(payload: GenerateVariantsPayload): Promise<void>\n readonly config: VariantConfig\n readonly styles: Readonly<Record<string, ImageStyle>>\n}\n\n/**\n * Does this photograph need a pass? No original → nothing a pass could do. A\n * focal point never set → yes, so the pass seeds it (F014) — unless a pass\n * already looked and found nothing to guess about (`focalSeedTried`).\n * Otherwise: does its\n * recorded state already match what a pass would produce (`isUpToDate`)?\n */\nasync function needsPass(\n row: BackfillRow,\n deps: BackfillDeps,\n): Promise<'yes' | 'no' | 'no-original'> {\n const metadata = await deps.readMetadata(row.fileKey)\n if (metadata === undefined) return 'no-original'\n const unseeded = row.focalPoint === null || row.focalPoint === undefined\n if (unseeded && !focalSeedTried(metadata)) return 'yes'\n const drawn = { width: row.width ?? 0, height: row.height ?? 0 }\n const expected = expectedVariants(row.fileKey, drawn, deps.config, deps.styles, row)\n return isUpToDate(readVariantState(metadata), expected.plannedKeys, expected.legacy)\n ? 'no'\n : 'yes'\n}\n\n/** The walk itself. */\nexport async function walkVariantsBackfill(\n deps: BackfillDeps,\n onProgress: (p: VariantsBackfillProgress) => void = () => {},\n cap: number = MAX_BACKFILL_QUEUED,\n): Promise<VariantsBackfillProgress> {\n let progress: VariantsBackfillProgress = {\n phase: 'scanning',\n scanned: 0,\n queued: 0,\n upToDate: 0,\n alreadyWaiting: 0,\n skipped: 0,\n truncated: false,\n }\n let cursor: string | null = null\n for (;;) {\n const page = await deps.listImages(cursor, PAGE_SIZE)\n const last = page[page.length - 1]\n if (!last) break\n cursor = last.id\n\n let skipped = 0\n let upToDate = 0\n const due: GenerateVariantsPayload[] = []\n for (const row of page) {\n if (!isProcessableImage(row.mimeType)) {\n skipped++\n continue\n }\n const need = await needsPass(row, deps)\n if (need === 'no-original') skipped++\n else if (need === 'no') upToDate++\n else due.push({ mediaId: row.id, epoch: deps.epoch })\n }\n const waiting = await deps.waitingMediaIds(due.map((p) => p.mediaId))\n let queued = 0\n let alreadyWaiting = 0\n let truncated = false\n for (const payload of due) {\n if (waiting.has(payload.mediaId)) {\n alreadyWaiting++\n continue\n }\n if (progress.queued + queued >= cap) {\n truncated = true\n break\n }\n await deps.enqueue(payload)\n queued++\n }\n progress = {\n ...progress,\n scanned: progress.scanned + page.length,\n queued: progress.queued + queued,\n upToDate: progress.upToDate + upToDate,\n alreadyWaiting: progress.alreadyWaiting + alreadyWaiting,\n skipped: progress.skipped + skipped,\n truncated,\n }\n onProgress(progress)\n if (truncated || page.length < PAGE_SIZE) break\n }\n progress = { ...progress, phase: 'done' }\n onProgress(progress)\n return progress\n}\n\n/** The walk wired to the app's database and queue. Call inside `runAsCli`. */\nexport async function runVariantsBackfill(\n app: ToolkitApp,\n generateJob: JobDefinition<GenerateVariantsPayload>,\n options: { walkJobId: string; onProgress?: (p: VariantsBackfillProgress) => void },\n): Promise<VariantsBackfillProgress> {\n const { QueueClient } = await import('@murumets-ee/queue/client')\n const { jobsTable } = await import('@murumets-ee/queue/admin')\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const { and, asc, eq, gt } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n const admin = app.getClient(Media)\n const storage = createStorageClient(getStorageConfig(), { app })\n const queue = new QueueClient({ app, logger: app.logger })\n const jobs = jobsTable.makeClient(app.db.readWrite)\n // Strict, like the pass: a walk planned from the code defaults during a\n // settings outage would queue a pass for every photograph.\n const [config, styles] = await Promise.all([\n resolveVariantConfig(app, app.logger, { strict: true }),\n resolveImageStyles(app, app.logger, { strict: true }),\n ])\n\n const outcome = await walkVariantsBackfill(\n {\n epoch: backfillEpoch(options.walkJobId),\n listImages: (afterId, limit) =>\n admin.findMany({\n where: afterId\n ? and(eq(table.mediaType, 'image'), gt(table.id, afterId))\n : eq(table.mediaType, 'image'),\n orderBy: asc(table.id),\n limit,\n }),\n readMetadata: async (fileKey) => {\n const file = await storage.getMetadata(fileKey)\n return file ? (file.metadata ?? {}) : undefined\n },\n waitingMediaIds: async (ids) => {\n if (ids.length === 0) return new Set()\n // One query per page, on the payload's `mediaId` (a jsonb path the\n // where-builder validates and parameterizes). Bounded: a page is at\n // most PAGE_SIZE ids, and a photograph rarely has more than a pass or\n // two waiting — if the cap cuts, the cost is one redundant pass.\n const rows = await jobs.findMany({\n where: {\n type: generateJob.name,\n status: { in: ['pending', 'processing'] },\n 'payload.mediaId': { in: [...ids] },\n },\n limit: Math.min(ids.length * 4, 1000),\n })\n const found = new Set<string>()\n for (const row of rows) {\n const parsed = generateVariantsPayloadSchema.safeParse(row.payload)\n if (parsed.success) found.add(parsed.data.mediaId)\n }\n return found\n },\n enqueue: async (payload) => {\n await queue.enqueue(generateJob, payload)\n },\n config,\n styles,\n },\n options.onProgress,\n )\n if (outcome.truncated) {\n app.logger.warn(\n { cap: MAX_BACKFILL_QUEUED, queued: outcome.queued },\n 'media variants: backfill stopped at its cap — run it again once these have drained',\n )\n }\n return outcome\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ async function e(e,t){let{jobsTable:n}=await import(`@murumets-ee/queue/admin`),{QueueClient:r}=await import(`@murumets-ee/queue/client`),i=await n.makeClient(e.db.readWrite).findOne({type:t.name,status:{in:[`pending`,`processing`]}});return i?{jobId:i.id,alreadyRunning:!0}:{jobId:await new r({app:e,logger:e.logger}).enqueue(t,{}),alreadyRunning:!1}}export{e as enqueueVariantsBackfill};
2
+ //# sourceMappingURL=backfill-trigger-BFL1L7_j.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backfill-trigger-BFL1L7_j.mjs","names":[],"sources":["../src/jobs/backfill-trigger.ts"],"sourcesContent":["/**\n * Starting a library backfill from the admin — at most one WALK at a time.\n *\n * The regenerate button is behind `media:create`, which every editor holds, and\n * a walk reads the whole image table. A second press while a walk is waiting or\n * running returns that walk. A press after it has finished starts a new one —\n * which is what a changed configuration needs, since the earlier walk skipped\n * every photograph that was up to date under the OLD one. It cannot pile work\n * up: the walk skips any photograph that already has a pass waiting\n * (`backfill.ts`), and a waiting pass reads the configuration when it runs.\n *\n * Two presses in the same instant can still both see nothing running — a rare\n * race whose cost is one redundant walk, which queues nothing the other queued.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { JobDefinition } from '@murumets-ee/queue/client'\nimport type { VariantsBackfillPayload } from './definitions.js'\n\nexport async function enqueueVariantsBackfill(\n app: ToolkitApp,\n job: JobDefinition<VariantsBackfillPayload>,\n): Promise<{ jobId: string; alreadyRunning: boolean }> {\n const { jobsTable } = await import('@murumets-ee/queue/admin')\n const { QueueClient } = await import('@murumets-ee/queue/client')\n const jobs = jobsTable.makeClient(app.db.readWrite)\n const running = await jobs.findOne({\n type: job.name,\n status: { in: ['pending', 'processing'] },\n })\n if (running) return { jobId: running.id, alreadyRunning: true }\n const queue = new QueueClient({ app, logger: app.logger })\n return { jobId: await queue.enqueue(job, {}), alreadyRunning: false }\n}\n"],"mappings":"AAmBA,eAAsB,EACpB,EACA,EACqD,CACrD,GAAM,CAAE,aAAc,MAAM,OAAO,4BAC7B,CAAE,eAAgB,MAAM,OAAO,6BAE/B,EAAU,MADH,EAAU,WAAW,EAAI,GAAG,SAChB,CAAC,CAAC,QAAQ,CACjC,KAAM,EAAI,KACV,OAAQ,CAAE,GAAI,CAAC,UAAW,YAAY,CAAE,CAC1C,CAAC,EAGD,OAFI,EAAgB,CAAE,MAAO,EAAQ,GAAI,eAAgB,EAAK,EAEvD,CAAE,MAAO,MAAM,IADJ,EAAY,CAAE,MAAK,OAAQ,EAAI,MAAO,CAC9B,CAAC,CAAC,QAAQ,EAAK,CAAC,CAAC,EAAG,eAAgB,EAAM,CACtE"}
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t,t as n}from"./definitions-LJpgrdxd.mjs";var r=e({getVariantBacklog:()=>i});async function i(e){if(!e.plugins.has(`@murumets-ee/queue`))return{mode:`inline`};let{heartbeatsTable:r,jobsTable:i}=await import(`@murumets-ee/queue/admin`),{getQueueConfig:a}=await import(`@murumets-ee/queue/plugin`),o=e.db.readWrite,[s,c]=await Promise.all([i.makeClient(o).aggregate({select:{n:{fn:`count`},oldest:{fn:`min`,column:`createdAt`}},groupBy:[`type`,`status`],where:{type:{in:[n,t]},status:{in:[`pending`,`processing`,`dead`]}}}),r.makeClient(o).findMany({orderBy:[{column:`lastSeenAt`,dir:`desc`}],limit:1})]),l=0,u=0,d=null,f=!1;for(let e of s){let t=Number(e.n);if(e.type===`media:variants-backfill`){e.status!==`dead`&&t>0&&(f=!0);continue}if(e.status===`dead`){u+=t;continue}l+=t;let n=e.oldest===null?null:new Date(e.oldest);n&&(!d||n<d)&&(d=n)}let p=c[0],m=a().heartbeatStaleAfter,h=p?Date.now()-p.lastSeenAt.getTime()>m?`stale`:`running`:`none`;return{mode:`queued`,waiting:l,oldestWaitingSince:d,failed:u,catchUpPending:f,worker:h}}export{i as n,r as t};
2
+ //# sourceMappingURL=backlog-D-H5VUQA.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backlog-D-H5VUQA.mjs","names":[],"sources":["../src/jobs/backlog.ts"],"sourcesContent":["/**\n * How many photographs are waiting for their variants, and whether anything is\n * going to generate them — the operator signal images C6 requires.\n *\n * Moving generation to the queue created a failure mode that did not exist\n * before: a deployment with no worker process now gets NO variants, where\n * inline generation always ran. The site still works — every render falls back\n * to the original — which is exactly why nobody would notice. So the media\n * library says it, in the product, rather than leaving an operator to discover\n * it from a slow page.\n *\n * Three numbers and a verdict, all cheap: one aggregate over `toolkit_jobs`\n * (indexed on status) and one read of the worker heartbeats. The age of the\n * oldest waiting pass is there because a COUNT alone cannot tell \"the worker is\n * busy with a big backfill\" from \"nothing has run for a day\" — Action Scheduler\n * and Horizon both alert on age for that reason (R014).\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport {\n GENERATE_VARIANTS_JOB_NAME,\n QUEUE_PLUGIN_NAME,\n VARIANTS_BACKFILL_JOB_NAME,\n} from './definitions.js'\n\nexport type WorkerState =\n /** A worker heartbeat is fresh. */\n | 'running'\n /** Workers have run, but none has checked in within the stale window. */\n | 'stale'\n /** No worker has ever checked in. */\n | 'none'\n\nexport type VariantBacklog =\n /** No queue plugin: variants are generated inline at upload — nothing can wait. */\n | { readonly mode: 'inline' }\n | {\n readonly mode: 'queued'\n /** Passes waiting for, or being run by, a worker. */\n readonly waiting: number\n /** When the oldest of them was queued, or `null` when none is waiting. */\n readonly oldestWaitingSince: Date | null\n /**\n * Passes that failed every retry and were given up on (visible on the\n * Queue page, kept for the queue's dead-letter retention). A count of\n * PASSES, not photographs — a later pass may have succeeded — and the\n * copy says so.\n */\n readonly failed: number\n /**\n * A library catch-up is queued or walking. Counted apart from `waiting`:\n * until a worker runs the walk it has queued no passes, so without this a\n * catch-up requested with no worker running would show nothing at all.\n */\n readonly catchUpPending: boolean\n readonly worker: WorkerState\n }\n\n/** Read the backlog. Throws only if the database does; callers render nothing on error. */\nexport async function getVariantBacklog(app: ToolkitApp): Promise<VariantBacklog> {\n if (!app.plugins.has(QUEUE_PLUGIN_NAME)) return { mode: 'inline' }\n const { heartbeatsTable, jobsTable } = await import('@murumets-ee/queue/admin')\n const { getQueueConfig } = await import('@murumets-ee/queue/plugin')\n const db = app.db.readWrite\n\n const [counts, heartbeats] = await Promise.all([\n jobsTable\n .makeClient(db)\n .aggregate<{ type: string; status: string; n: number; oldest: Date | null }>({\n select: { n: { fn: 'count' }, oldest: { fn: 'min', column: 'createdAt' } },\n groupBy: ['type', 'status'],\n where: {\n type: { in: [GENERATE_VARIANTS_JOB_NAME, VARIANTS_BACKFILL_JOB_NAME] },\n status: { in: ['pending', 'processing', 'dead'] },\n },\n }),\n heartbeatsTable.makeClient(db).findMany({\n orderBy: [{ column: 'lastSeenAt', dir: 'desc' }],\n limit: 1,\n }),\n ])\n\n let waiting = 0\n let failed = 0\n let oldestWaitingSince: Date | null = null\n let catchUpPending = false\n for (const row of counts) {\n const n = Number(row.n)\n if (row.type === VARIANTS_BACKFILL_JOB_NAME) {\n if (row.status !== 'dead' && n > 0) catchUpPending = true\n continue\n }\n if (row.status === 'dead') {\n failed += n\n continue\n }\n waiting += n\n const oldest = row.oldest === null ? null : new Date(row.oldest)\n if (oldest && (!oldestWaitingSince || oldest < oldestWaitingSince)) oldestWaitingSince = oldest\n }\n\n const latest = heartbeats[0]\n const staleAfter = getQueueConfig().heartbeatStaleAfter\n const worker: WorkerState = !latest\n ? 'none'\n : Date.now() - latest.lastSeenAt.getTime() > staleAfter\n ? 'stale'\n : 'running'\n\n return { mode: 'queued', waiting, oldestWaitingSince, failed, catchUpPending, worker }\n}\n"],"mappings":"6IA2DA,eAAsB,EAAkB,EAA0C,CAChF,GAAI,CAAC,EAAI,QAAQ,IAAA,oBAAqB,EAAG,MAAO,CAAE,KAAM,QAAS,EACjE,GAAM,CAAE,kBAAiB,aAAc,MAAM,OAAO,4BAC9C,CAAE,kBAAmB,MAAM,OAAO,6BAClC,EAAK,EAAI,GAAG,UAEZ,CAAC,EAAQ,GAAc,MAAM,QAAQ,IAAI,CAC7C,EACG,WAAW,CAAE,CAAC,CACd,UAA4E,CAC3E,OAAQ,CAAE,EAAG,CAAE,GAAI,OAAQ,EAAG,OAAQ,CAAE,GAAI,MAAO,OAAQ,WAAY,CAAE,EACzE,QAAS,CAAC,OAAQ,QAAQ,EAC1B,MAAO,CACL,KAAM,CAAE,GAAI,CAAC,EAA4B,CAA0B,CAAE,EACrE,OAAQ,CAAE,GAAI,CAAC,UAAW,aAAc,MAAM,CAAE,CAClD,CACF,CAAC,EACH,EAAgB,WAAW,CAAE,CAAC,CAAC,SAAS,CACtC,QAAS,CAAC,CAAE,OAAQ,aAAc,IAAK,MAAO,CAAC,EAC/C,MAAO,CACT,CAAC,CACH,CAAC,EAEG,EAAU,EACV,EAAS,EACT,EAAkC,KAClC,EAAiB,GACrB,IAAK,IAAM,KAAO,EAAQ,CACxB,IAAM,EAAI,OAAO,EAAI,CAAC,EACtB,GAAI,EAAI,OAAA,0BAAqC,CACvC,EAAI,SAAW,QAAU,EAAI,IAAG,EAAiB,IACrD,QACF,CACA,GAAI,EAAI,SAAW,OAAQ,CACzB,GAAU,EACV,QACF,CACA,GAAW,EACX,IAAM,EAAS,EAAI,SAAW,KAAO,KAAO,IAAI,KAAK,EAAI,MAAM,EAC3D,IAAW,CAAC,GAAsB,EAAS,KAAqB,EAAqB,EAC3F,CAEA,IAAM,EAAS,EAAW,GACpB,EAAa,EAAe,CAAC,CAAC,oBAC9B,EAAuB,EAEzB,KAAK,IAAI,EAAI,EAAO,WAAW,QAAQ,EAAI,EACzC,QACA,UAHF,OAKJ,MAAO,CAAE,KAAM,SAAU,UAAS,qBAAoB,SAAQ,iBAAgB,QAAO,CACvF"}
package/dist/client.d.mts CHANGED
@@ -1,13 +1,44 @@
1
- import { a as MediaRecord, c as MediaUploadResult, l as Media, n as MediaListOptions, r as MediaListResult, s as MediaUploadOptions, t as ImageStyle } from "./types-CnORVkLZ.mjs";
1
+ import { d as MediaUploadOptions, f as MediaUploadResult, i as ImageStyle, l as MediaRecord, m as Media, o as MediaListOptions, s as MediaListResult } from "./types-CgkJF5dc.mjs";
2
+ import { ToolkitApp } from "@murumets-ee/core";
2
3
  import { AdminClient } from "@murumets-ee/entity/admin";
3
4
  import { StorageClient } from "@murumets-ee/storage";
4
5
 
6
+ //#region src/jobs/backlog.d.ts
7
+ type WorkerState = /** A worker heartbeat is fresh. */'running' /** Workers have run, but none has checked in within the stale window. */ | 'stale' /** No worker has ever checked in. */ | 'none';
8
+ type VariantBacklog = /** No queue plugin: variants are generated inline at upload — nothing can wait. */{
9
+ readonly mode: 'inline';
10
+ } | {
11
+ readonly mode: 'queued'; /** Passes waiting for, or being run by, a worker. */
12
+ readonly waiting: number; /** When the oldest of them was queued, or `null` when none is waiting. */
13
+ readonly oldestWaitingSince: Date | null;
14
+ /**
15
+ * Passes that failed every retry and were given up on (visible on the
16
+ * Queue page, kept for the queue's dead-letter retention). A count of
17
+ * PASSES, not photographs — a later pass may have succeeded — and the
18
+ * copy says so.
19
+ */
20
+ readonly failed: number;
21
+ /**
22
+ * A library catch-up is queued or walking. Counted apart from `waiting`:
23
+ * until a worker runs the walk it has queued no passes, so without this a
24
+ * catch-up requested with no worker running would show nothing at all.
25
+ */
26
+ readonly catchUpPending: boolean;
27
+ readonly worker: WorkerState;
28
+ };
29
+ /** Read the backlog. Throws only if the database does; callers render nothing on error. */
30
+ declare function getVariantBacklog(app: ToolkitApp): Promise<VariantBacklog>;
31
+ //#endregion
5
32
  //#region src/client.d.ts
6
33
  type MediaFields = typeof Media.allFields;
7
34
  interface MediaClientConfig {
8
35
  admin: AdminClient<MediaFields>;
9
36
  storage: StorageClient;
10
- /** Image styles to generate on upload. Loaded from plugin config if not provided. */
37
+ /**
38
+ * Image styles `getVariantUrl(s)` resolve legacy keys with. Loaded via the
39
+ * settings waterfall if not provided. (Generation reads the configuration
40
+ * itself — see `jobs/`.)
41
+ */
11
42
  imageStyles?: Record<string, ImageStyle>;
12
43
  }
13
44
  declare class MediaClient {
@@ -23,15 +54,32 @@ declare class MediaClient {
23
54
  private resolveImageStyles;
24
55
  /** Clear cached styles so next access re-reads from settings DB. */
25
56
  invalidateImageStylesCache(): void;
57
+ /**
58
+ * The configured image styles this instance would generate variants for —
59
+ * the vocabulary the crop editor's shape tabs are drawn from
60
+ * (`deriveCropShapes`, images PR03). Public wrapper over the private
61
+ * resolver so the media edit page doesn't need its own copy of the
62
+ * settings → config → hardcoded-default waterfall.
63
+ */
64
+ getImageStyles(): Promise<Record<string, ImageStyle>>;
26
65
  /**
27
66
  * Upload a file and create a media entity record in one step.
28
67
  * 1. Uploads original to storage (StorageClient)
29
- * 2. If image: extracts dimensions via Sharp + generates variants
68
+ * 2. If image: reads its drawn size and seeds a focal point — INLINE, cheap
30
69
  * 3. Creates media entity record (AdminClient)
31
- * 4. Returns the media record + URL
70
+ * 4. If image: requests its variants — QUEUED (images D004), inline only
71
+ * when the app has no queue plugin
72
+ * 5. Returns the media record + URL
73
+ *
74
+ * The split in 2 and 4 is the point. The row must have `width`, `height` and
75
+ * `focalPoint` the moment this returns — every CLS reservation depends on the
76
+ * size, and `sizes="auto"` makes it mandatory (images R007/R008). Variant
77
+ * generation is (shapes × widths) sharp passes and does NOT belong on the
78
+ * request: it goes to the worker, and the render falls back to the original
79
+ * until it lands (`variants[style] ?? url`).
32
80
  *
33
- * Rolls back storage upload if entity creation fails.
34
- * Variant generation failures are logged but don't fail the upload.
81
+ * Rolls back the storage upload if entity creation fails. A failed size
82
+ * read, focal guess or variant request never fails the upload.
35
83
  */
36
84
  upload(data: Buffer | ReadableStream<Uint8Array>, options: MediaUploadOptions): Promise<MediaUploadResult>;
37
85
  findById(id: string, options?: {
@@ -102,5 +150,5 @@ declare function getSharedStorageClient(): Promise<StorageClient>;
102
150
  */
103
151
  declare function getMediaClient(): Promise<MediaClient>;
104
152
  //#endregion
105
- export { MediaClient, MediaClientConfig, createMediaClient, getMediaClient, getSharedStorageClient };
153
+ export { MediaClient, MediaClientConfig, type VariantBacklog, type WorkerState, createMediaClient, getMediaClient, getSharedStorageClient, getVariantBacklog };
106
154
  //# sourceMappingURL=client.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.mts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;KA4BK,WAAA,UAAqB,KAAA,CAAM,SAAS;AAAA,UAExB,iBAAA;EACf,KAAA,EAAO,WAAA,CAAY,WAAA;EACnB,OAAA,EAAS,aAAA;EAEoB;EAA7B,WAAA,GAAc,MAAA,SAAe,UAAA;AAAA;AAAA,cAGlB,WAAA;EAAA,QACH,KAAA;EAAA,QACA,OAAA;EAAA,QACA,WAAA;cAEI,MAAA,EAAQ,iBAAA;EAVX;;;;;EAAA,QAqBK,kBAAA;EAhBH;EA0BX,0BAAA,CAAA;;;;;;;;;;;EAkBM,MAAA,CACJ,IAAA,EAAM,MAAA,GAAS,cAAA,CAAe,UAAA,GAC9B,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,iBAAA;EA2HL,QAAA,CAAS,EAAA,UAAY,OAAA;IAAY,MAAA;EAAA,IAAoB,OAAA,CAAQ,WAAA;EAI7D,QAAA,CAAS,OAAA,GAAU,gBAAA,GAAmB,OAAA,CAAQ,eAAA;EAwD9C,MAAA,CACJ,EAAA,UACA,IAAA;IAAQ,KAAA;IAAgB,GAAA;IAAc,WAAA;EAAA,IACrC,OAAA,CAAQ,WAAA;EA4HqD;;;EArH1D,MAAA,CAAO,EAAA,WAAa,OAAA;EA/OlB;;;;EAqRF,MAAA,CAAO,EAAA,WAAa,OAAA;EAjRd;;;;EA4RN,OAAA,CAAQ,GAAA,aAAgB,OAAA,CAAQ,GAAA;EApPrB;;;;;;;;EAsRX,aAAA,CAAc,EAAA,UAAY,SAAA,WAAoB,OAAA;EAzJb;;;;;;;;EA2LjC,cAAA,CAAe,GAAA,YAAe,SAAA,WAAoB,OAAA,CAAQ,GAAA;AAAA;;;;;iBA2D5C,iBAAA,CAAkB,OAAA,EAAS,aAAA,GAAgB,OAAA,CAAQ,WAAA;;;;;;;;;;iBA2CnD,sBAAA,CAAA,GAA0B,OAAO,CAAC,aAAA;;;;;;;;;;iBAiBlC,cAAA,CAAA,GAAkB,OAAO,CAAC,WAAA"}
1
+ {"version":3,"file":"client.d.mts","names":[],"sources":["../src/jobs/backlog.ts","../src/client.ts"],"mappings":";;;;;;KAyBY,WAAA;KAQA,cAAA,GAsBsB;EAAA,SApBnB,IAAA;AAAA;EAAA,SAEA,IAAA,YAsBmD;EAAA,SApBnD,OAAA,UAoBkD;EAAA,SAlBlD,kBAAA,EAAoB,IAAA;EAkBU;;;;;AAAmC;EAAnC,SAX9B,MAAA;;;ACpBI;;;WD0BJ,cAAA;EAAA,SACA,MAAA,EAAQ,WAAW;AAAA;;iBAIZ,iBAAA,CAAkB,GAAA,EAAK,UAAA,GAAa,OAAA,CAAQ,cAAA;;;KC5B7D,WAAA,UAAqB,KAAA,CAAM,SAAS;AAAA,UAExB,iBAAA;EACf,KAAA,EAAO,WAAA,CAAY,WAAA;EACnB,OAAA,EAAS,aAAA;EDEI;;;;;ECIb,WAAA,GAAc,MAAA,SAAe,UAAA;AAAA;AAAA,cAGlB,WAAA;EAAA,QACH,KAAA;EAAA,QACA,OAAA;EAAA,QACA,WAAA;cAEI,MAAA,EAAQ,iBAAA;;;;;;UAWN,kBAAA;EDD6B;ECW3C,0BAAA,CAAA;EDXwD;;;AAAsB;;;;ECsBxE,cAAA,CAAA,GAAkB,OAAA,CAAQ,MAAA,SAAe,UAAA;EAlDjC;;;AAAyB;AAEzC;;;;;;;;;;;;;;;EA2EQ,MAAA,CACJ,IAAA,EAAM,MAAA,GAAS,cAAA,CAAe,UAAA,GAC9B,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,iBAAA;EAwGL,QAAA,CAAS,EAAA,UAAY,OAAA;IAAY,MAAA;EAAA,IAAoB,OAAA,CAAQ,WAAA;EAI7D,QAAA,CAAS,OAAA,GAAU,gBAAA,GAAmB,OAAA,CAAQ,eAAA;EAwD9C,MAAA,CACJ,EAAA,UACA,IAAA;IAAQ,KAAA;IAAgB,GAAA;IAAc,WAAA;EAAA,IACrC,OAAA,CAAQ,WAAA;EArMqB;;;EA4M1B,MAAA,CAAO,EAAA,WAAa,OAAA;EAhLT;;;;EAkNX,MAAA,CAAO,EAAA,WAAa,OAAA;EAxGiC;;;;EAmHrD,OAAA,CAAQ,GAAA,aAAgB,OAAA,CAAQ,GAAA;EApDnC;;;;;;;;EAsFG,aAAA,CAAc,EAAA,UAAY,SAAA,WAAoB,OAAA;EAkCW;;;;;;;;EAAzD,cAAA,CAAe,GAAA,YAAe,SAAA,WAAoB,OAAA,CAAQ,GAAA;AAAA;;;;;iBA2D5C,iBAAA,CAAkB,OAAA,EAAS,aAAA,GAAgB,OAAA,CAAQ,WAAA;;;;;;;;;;iBA2CnD,sBAAA,CAAA,GAA0B,OAAO,CAAC,aAAA;;;;;;;;;;iBAiBlC,cAAA,CAAA,GAAkB,OAAO,CAAC,WAAA"}
package/dist/client.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{t as e}from"./entity-DKFCdQJT.mjs";import{t}from"./async-cache-C_Ycvs7b.mjs";import{i as n,r,t as i}from"./process-image-WByEHTXQ.mjs";import{t as a}from"./variant-key-JBTJXPL1.mjs";import"server-only";function o(e){return e.startsWith(`image/`)?`image`:e.startsWith(`video/`)?`video`:e.startsWith(`audio/`)?`audio`:e===`application/pdf`||e.startsWith(`application/msword`)||e.startsWith(`application/vnd.`)?`document`:`other`}var s=class{admin;storage;imageStyles;constructor(e){this.admin=e.admin,this.storage=e.storage,this.imageStyles=e.imageStyles??null}async resolveImageStyles(){if(this.imageStyles)return this.imageStyles;let{getApp:e}=await import(`@murumets-ee/core`),{resolveImageStyles:t}=await import(`./resolve-image-styles-iN9JbZYf.mjs`),n=e();return this.imageStyles=await t(n,n.logger),this.imageStyles}invalidateImageStylesCache(){this.imageStyles=null}async upload(e,t){let s=await this.storage.upload(e,{filename:t.filename,mimeType:t.mimeType,size:t.size,visibility:t.visibility,uploadedBy:t.uploadedBy}),l=t.width??null,u=t.height??null,d=null,f={};if(e instanceof Buffer&&r(t.mimeType))try{let r=await this.resolveImageStyles(),[o,c]=await Promise.all([n(e,r),i(e).catch(async e=>(await m(e,t.filename),null))]);d=c,l=o.width,u=o.height;let p=s.visibility;await Promise.all([...o.variants.entries()].map(async([e,n])=>{let r=a(s.key,e,n.format);try{await this.storage.upload(n.buffer,{key:r,filename:`${e}_${t.filename}`,mimeType:n.mimeType,size:n.buffer.byteLength,visibility:p,metadata:{variantOf:s.key,style:e},uploadedBy:t.uploadedBy}),f[e]=r}catch{}})),Object.keys(f).length>0&&await this.storage.updateMetadata(s.key,{metadata:{...s.metadata??{},variants:f}}).catch(()=>{})}catch{}let p=o(t.mimeType);try{return{media:await this.admin.create({title:t.title??c(t.filename),alt:t.alt??null,description:t.description??null,fileKey:s.key,filename:t.filename,mimeType:t.mimeType,size:t.size,width:l,height:u,focalPoint:d,mediaType:p}),url:await this.storage.getUrl(s.key)}}catch(e){for(let e of Object.values(f))await this.storage.delete(e).catch(()=>{});throw await this.storage.delete(s.key).catch(()=>{}),e}}async findById(e,t){return this.admin.findById(e,t)}async findMany(e){let{schemaRegistry:t}=await import(`@murumets-ee/db`),{and:n,asc:r,desc:i,eq:a,ilike:o,or:s,sql:c}=await import(`drizzle-orm`),l=t.get(`media`);if(!l)throw Error(`Media schema not registered. Is the media() plugin loaded?`);let u=[];if(e?.mediaType&&u.push(a(l.mediaType,e.mediaType)),e?.mimeTypePrefix){let t=e.mimeTypePrefix.replace(/[\\%_]/g,`\\$&`);u.push(o(l.mimeType,`${t}%`))}if(e?.search){let t=`%${e.search.replace(/[\\%_]/g,`\\$&`)}%`;u.push(s(o(l.filename,t),c`${l.fields} ->> 'title' ILIKE ${t}`))}let d=e?.limit??50,f=e?.offset??0,p=u.length>0?n(...u):void 0,m=await this.admin.count({where:p}),h=e?.orderBy===`filename`?l.filename:l.createdAt,g=(e?.orderDirection??`desc`)===`asc`?r:i;return{items:await this.admin.findMany({where:p,limit:d,offset:f,orderBy:g(h)}),total:m,limit:d,offset:f}}async update(e,t){return this.admin.update(e,t)}async delete(e){let t=await this.admin.findById(e);if(!t)throw Error(`Media not found: ${e}`);let n=t.fileKey;await this.admin.delete(e);let r=(await this.storage.getMetadata(n))?.metadata?.variants;if(r)for(let e of Object.values(r))await this.storage.delete(e).catch(()=>{});await this.storage.delete(n).catch(()=>{})}async getUrl(e){let t=await this.admin.findById(e);if(!t)throw Error(`Media not found: ${e}`);return this.storage.getUrl(t.fileKey)}async getUrls(e){if(e.length===0)return new Map;let{schemaRegistry:t}=await import(`@murumets-ee/db`),{inArray:n}=await import(`drizzle-orm`),r=t.get(`media`);if(!r)return new Map;let i=await this.admin.findMany({where:n(r.id,e),limit:e.length}),a=new Map;return await Promise.all(i.map(async e=>{let t=await this.storage.getUrl(e.fileKey);a.set(e.id,t)})),a}async getVariantUrl(e,t){let n=await this.admin.findById(e);if(!n)return null;let r=n.fileKey,i=(await this.resolveImageStyles())[t];if(i){let e=a(r,t,i.format??`webp`);try{return await this.storage.getUrl(e)}catch{}}try{return await this.storage.getUrl(r)}catch{return null}}async getVariantUrls(e,t){if(e.length===0)return new Map;let{schemaRegistry:n}=await import(`@murumets-ee/db`),{inArray:r}=await import(`drizzle-orm`),i=n.get(`media`);if(!i)return new Map;let o=await this.admin.findMany({where:r(i.id,e),limit:e.length}),s=(await this.resolveImageStyles())[t],c=new Map;return await Promise.all(o.map(async e=>{if(s){let n=a(e.fileKey,t,s.format??`webp`);try{let t=await this.storage.getUrl(n);c.set(e.id,t);return}catch{}}try{let t=await this.storage.getUrl(e.fileKey);c.set(e.id,t)}catch{}})),c}};function c(e){return e.replace(/\.[^.]+$/,``).replace(/[-_]/g,` `)}async function l(t){let{createAdminClient:n}=await import(`@murumets-ee/core/clients`);return new s({admin:n(e),storage:t})}const u=t(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})});async function d(){return f()}async function f(){return u()}async function p(){let{createAdminClient:t}=await import(`@murumets-ee/core/clients`),n=await f();return new s({admin:t(e),storage:n})}async function m(e,t){try{let{getApp:n}=await import(`@murumets-ee/core`);n().logger.warn({err:e,filename:t},`media: focal-point detection failed; stored as centre`)}catch(n){console.warn(`media: focal-point detection failed; stored as centre`,t,e,n)}}export{s as MediaClient,l as createMediaClient,p as getMediaClient,d as getSharedStorageClient};
1
+ import{t as e}from"./entity-CsDdjKz6.mjs";import{n as t}from"./variant-key-Cki4n-42.mjs";import{t as n}from"./async-cache-C_Ycvs7b.mjs";import{t as r}from"./derive-media-type-C38ZA_Cl.mjs";import{t as i}from"./owned-delete-Cn_KFFjS.mjs";import{i as a,l as o,m as s,r as c,t as l}from"./process-image-Deedtkh4.mjs";import{n as u}from"./backlog-D-H5VUQA.mjs";import"server-only";var d=class{admin;storage;imageStyles;constructor(e){this.admin=e.admin,this.storage=e.storage,this.imageStyles=e.imageStyles??null}async resolveImageStyles(){if(this.imageStyles)return this.imageStyles;let{getApp:e}=await import(`@murumets-ee/core`),{resolveImageStyles:t}=await import(`./resolve-image-styles-pf7Nd3Zf.mjs`).then(e=>e.r),n=e();return this.imageStyles=await t(n,n.logger),this.imageStyles}invalidateImageStylesCache(){this.imageStyles=null}async getImageStyles(){return this.resolveImageStyles()}async upload(e,t){let n=await this.storage.upload(e,{filename:t.filename,mimeType:t.mimeType,size:t.size,visibility:t.visibility,uploadedBy:t.uploadedBy}),i=t.width??null,o=t.height??null,s=null,u=null;if(e instanceof Buffer&&a(t.mimeType)){let[n,r]=await Promise.all([c(e).catch(async e=>(await v(e,t.filename,`size`),null)),l(e).catch(async e=>(await v(e,t.filename,`focal point`),null))]);n&&n.width>0&&n.height>0&&(u=n,i=n.width,o=n.height),s=r}let d=r(t.mimeType),p,m;try{m=await this.storage.getUrl(n.key),p=await this.admin.create({title:t.title??f(t.filename),alt:t.alt??null,description:t.description??null,fileKey:n.key,filename:t.filename,mimeType:t.mimeType,size:t.size,width:i,height:o,focalPoint:s,mediaType:d})}catch(e){throw await this.storage.delete(n.key).catch(()=>{}),e}if(u)try{let{getApp:e}=await import(`@murumets-ee/core`),{requestVariants:t}=await import(`./schedule-DREjg2ji.mjs`);await t(e(),p,{media:this.admin,storage:this.storage})}catch(e){console.warn(`media: variants were not requested for`,p.id,e)}return{media:p,url:m}}async findById(e,t){return this.admin.findById(e,t)}async findMany(e){let{schemaRegistry:t}=await import(`@murumets-ee/db`),{and:n,asc:r,desc:i,eq:a,ilike:o,or:s,sql:c}=await import(`drizzle-orm`),l=t.get(`media`);if(!l)throw Error(`Media schema not registered. Is the media() plugin loaded?`);let u=[];if(e?.mediaType&&u.push(a(l.mediaType,e.mediaType)),e?.mimeTypePrefix){let t=e.mimeTypePrefix.replace(/[\\%_]/g,`\\$&`);u.push(o(l.mimeType,`${t}%`))}if(e?.search){let t=`%${e.search.replace(/[\\%_]/g,`\\$&`)}%`;u.push(s(o(l.filename,t),c`${l.fields} ->> 'title' ILIKE ${t}`))}let d=e?.limit??50,f=e?.offset??0,p=u.length>0?n(...u):void 0,m=await this.admin.count({where:p}),h=e?.orderBy===`filename`?l.filename:l.createdAt,g=(e?.orderDirection??`desc`)===`asc`?r:i;return{items:await this.admin.findMany({where:p,limit:d,offset:f,orderBy:g(h)}),total:m,limit:d,offset:f}}async update(e,t){return this.admin.update(e,t)}async delete(e){let t=await this.admin.findById(e);if(!t)throw Error(`Media not found: ${e}`);let n=t.fileKey;await this.admin.delete(e);let r=o(s((await this.storage.getMetadata(n))?.metadata));await i(this.storage,n,r,await y()),await this.storage.delete(n).catch(()=>{})}async getUrl(e){let t=await this.admin.findById(e);if(!t)throw Error(`Media not found: ${e}`);return this.storage.getUrl(t.fileKey)}async getUrls(e){if(e.length===0)return new Map;let{schemaRegistry:t}=await import(`@murumets-ee/db`),{inArray:n}=await import(`drizzle-orm`),r=t.get(`media`);if(!r)return new Map;let i=await this.admin.findMany({where:n(r.id,e),limit:e.length}),a=new Map;return await Promise.all(i.map(async e=>{let t=await this.storage.getUrl(e.fileKey);a.set(e.id,t)})),a}async getVariantUrl(e,n){let r=await this.admin.findById(e);if(!r)return null;let i=r.fileKey,a=(await this.resolveImageStyles())[n];if(a){let e=t(i,n,a.format??`webp`);try{return await this.storage.getUrl(e)}catch{}}try{return await this.storage.getUrl(i)}catch{return null}}async getVariantUrls(e,n){if(e.length===0)return new Map;let{schemaRegistry:r}=await import(`@murumets-ee/db`),{inArray:i}=await import(`drizzle-orm`),a=r.get(`media`);if(!a)return new Map;let o=await this.admin.findMany({where:i(a.id,e),limit:e.length}),s=(await this.resolveImageStyles())[n],c=new Map;return await Promise.all(o.map(async e=>{if(s){let r=t(e.fileKey,n,s.format??`webp`);try{let t=await this.storage.getUrl(r);c.set(e.id,t);return}catch{}}try{let t=await this.storage.getUrl(e.fileKey);c.set(e.id,t)}catch{}})),c}};function f(e){return e.replace(/\.[^.]+$/,``).replace(/[-_]/g,` `)}async function p(t){let{createAdminClient:n}=await import(`@murumets-ee/core/clients`);return new d({admin:n(e),storage:t})}const m=n(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})});async function h(){return g()}async function g(){return m()}async function _(){let{createAdminClient:t}=await import(`@murumets-ee/core/clients`),n=await g();return new d({admin:t(e),storage:n})}async function v(e,t,n){let r=`media: reading the image's ${n} failed; stored without it`;try{let{getApp:n}=await import(`@murumets-ee/core`);n().logger.warn({err:e,filename:t},r)}catch(n){console.warn(r,t,e,n)}}async function y(){try{let{getApp:e}=await import(`@murumets-ee/core`);return e().logger}catch{return}}export{d as MediaClient,p as createMediaClient,_ as getMediaClient,h as getSharedStorageClient,u as getVariantBacklog};
2
2
  //# sourceMappingURL=client.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../src/derive-media-type.ts","../src/client.ts"],"sourcesContent":["import type { MediaType } from './types.js'\n\n/**\n * Classify a MIME type into the coarse `MediaType` an upload is stored under.\n *\n * Pure and dependency-free, and in its own module on purpose: `client.ts`\n * (`import 'server-only'`) needs it to set `mediaType` at upload, and the admin\n * list's MIME-type cell (`picker/media-type-icon.ts`, a CLIENT entry) needs the\n * same answer to pick an icon. One rule in one file means the icon a row shows\n * cannot disagree with the type it was stored as — which two copies would, the\n * first time someone taught one of them about a new document type.\n */\nexport function deriveMediaType(mimeType: string): MediaType {\n if (mimeType.startsWith('image/')) return 'image'\n if (mimeType.startsWith('video/')) return 'video'\n if (mimeType.startsWith('audio/')) return 'audio'\n if (\n mimeType === 'application/pdf' ||\n mimeType.startsWith('application/msword') ||\n mimeType.startsWith('application/vnd.')\n ) {\n return 'document'\n }\n return 'other'\n}\n","/**\n * MediaClient — wraps AdminClient + StorageClient for media management.\n *\n * Usage:\n * import { createMediaClient } from '@murumets-ee/media/client'\n * const media = await createMediaClient(storageClient)\n * const result = await media.upload(buffer, { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 12345 })\n */\n\nimport 'server-only'\n\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { cacheOnceUnlessRejected } from './async-cache.js'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport { deriveMediaType } from './derive-media-type.js'\nimport { Media } from './entity.js'\nimport { detectFocalPoint, isProcessableImage, processImage } from './process-image.js'\nimport type {\n ImageStyle,\n MediaListOptions,\n MediaListResult,\n MediaRecord,\n MediaUploadOptions,\n MediaUploadResult,\n} from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\ntype MediaFields = typeof Media.allFields\n\nexport interface MediaClientConfig {\n admin: AdminClient<MediaFields>\n storage: StorageClient\n /** Image styles to generate on upload. Loaded from plugin config if not provided. */\n imageStyles?: Record<string, ImageStyle>\n}\n\nexport class MediaClient {\n private admin: AdminClient<MediaFields>\n private storage: StorageClient\n private imageStyles: Record<string, ImageStyle> | null\n\n constructor(config: MediaClientConfig) {\n this.admin = config.admin\n this.storage = config.storage\n this.imageStyles = config.imageStyles ?? null\n }\n\n /**\n * Resolve image styles via the shared waterfall (settings DB → plugin\n * config → hardcoded defaults) and cache the result on this instance.\n * Call `invalidateImageStylesCache()` after a settings update.\n */\n private async resolveImageStyles(): Promise<Record<string, ImageStyle>> {\n if (this.imageStyles) return this.imageStyles\n const { getApp } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('./resolve-image-styles.js')\n const app = getApp()\n this.imageStyles = await resolveImageStyles(app, app.logger)\n return this.imageStyles\n }\n\n /** Clear cached styles so next access re-reads from settings DB. */\n invalidateImageStylesCache(): void {\n this.imageStyles = null\n }\n\n // ---------------------------------------------------------------\n // Upload — the key convenience method\n // ---------------------------------------------------------------\n\n /**\n * Upload a file and create a media entity record in one step.\n * 1. Uploads original to storage (StorageClient)\n * 2. If image: extracts dimensions via Sharp + generates variants\n * 3. Creates media entity record (AdminClient)\n * 4. Returns the media record + URL\n *\n * Rolls back storage upload if entity creation fails.\n * Variant generation failures are logged but don't fail the upload.\n */\n async upload(\n data: Buffer | ReadableStream<Uint8Array>,\n options: MediaUploadOptions,\n ): Promise<MediaUploadResult> {\n // 1. Upload original file to storage\n const fileRecord = await this.storage.upload(data, {\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n visibility: options.visibility,\n uploadedBy: options.uploadedBy,\n })\n\n // 2. Image processing — extract dimensions + generate variants\n let width = options.width ?? null\n let height = options.height ?? null\n // `null` renders as the centre (images D010), so a failed or skipped guess\n // is exactly today's crop rather than an error.\n let focalPoint: FocalPoint | null = null\n const variantKeys: Record<string, string> = {}\n\n if (data instanceof Buffer && isProcessableImage(options.mimeType)) {\n try {\n const styles = await this.resolveImageStyles()\n // The focal-point seed runs beside the variants rather than after them —\n // two independent sharp passes over the same buffer. Its own failure is\n // swallowed here so a photograph the attention pass cannot read still\n // gets its variants.\n const [processed, seeded] = await Promise.all([\n processImage(data, styles),\n detectFocalPoint(data).catch(async (err: unknown) => {\n await warnFocalPointFailed(err, options.filename)\n return null\n }),\n ])\n focalPoint = seeded\n\n // Set dimensions from Sharp metadata\n width = processed.width\n height = processed.height\n\n // Upload each variant to storage\n const visibility = fileRecord.visibility\n await Promise.all(\n [...processed.variants.entries()].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(fileRecord.key, styleName, variant.format)\n try {\n await this.storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${options.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: fileRecord.key, style: styleName },\n uploadedBy: options.uploadedBy,\n })\n variantKeys[styleName] = vKey\n } catch {\n // Variant upload failure is non-fatal — original is saved\n }\n }),\n )\n\n // Store variant keys in original file's metadata\n if (Object.keys(variantKeys).length > 0) {\n await this.storage\n .updateMetadata(fileRecord.key, {\n metadata: {\n ...(fileRecord.metadata ?? {}),\n variants: variantKeys,\n },\n })\n .catch(() => {\n // Metadata update failure is non-fatal\n })\n }\n } catch {\n // Image processing failure is non-fatal — original is saved\n }\n }\n\n // 3. Create media entity record\n const mediaType = deriveMediaType(options.mimeType)\n\n try {\n const entityRecord = await this.admin.create({\n title: options.title ?? deriveTitle(options.filename),\n alt: options.alt ?? null,\n description: options.description ?? null,\n fileKey: fileRecord.key,\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n width,\n height,\n focalPoint,\n mediaType,\n })\n\n // 4. Get URL\n const url = await this.storage.getUrl(fileRecord.key)\n\n return {\n media: entityRecord,\n url,\n }\n } catch (error) {\n // Rollback: delete variants + original if entity creation fails\n\n // Delete variants first (best-effort)\n for (const vKey of Object.values(variantKeys)) {\n await this.storage.delete(vKey).catch(() => {})\n }\n\n // Delete original\n await this.storage.delete(fileRecord.key).catch(() => {\n // Storage rollback failure is already being handled — propagate original error\n })\n throw error\n }\n }\n\n // ---------------------------------------------------------------\n // CRUD delegation\n // ---------------------------------------------------------------\n\n async findById(id: string, options?: { locale?: string }): Promise<MediaRecord | null> {\n return this.admin.findById(id, options)\n }\n\n async findMany(options?: MediaListOptions): Promise<MediaListResult> {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, desc, eq, ilike, or, sql } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n\n // Build where conditions\n const conditions = []\n\n if (options?.mediaType) {\n conditions.push(eq(table.mediaType, options.mediaType))\n }\n if (options?.mimeTypePrefix) {\n // Escape ILIKE wildcards (%, _) in user input to prevent pattern injection\n const escaped = options.mimeTypePrefix.replace(/[\\\\%_]/g, '\\\\$&')\n conditions.push(ilike(table.mimeType, `${escaped}%`))\n }\n if (options?.search) {\n // Escape ILIKE wildcards in user input, then wrap with %...%\n const escaped = options.search.replace(/[\\\\%_]/g, '\\\\$&')\n const pattern = `%${escaped}%`\n conditions.push(\n or(\n ilike(table.filename, pattern),\n sql`${table.fields} ->> 'title' ILIKE ${pattern}`,\n )!,\n )\n }\n\n const limit = options?.limit ?? 50\n const offset = options?.offset ?? 0\n const whereClause = conditions.length > 0 ? and(...conditions) : undefined\n\n // Count total via AdminClient\n const total = await this.admin.count({ where: whereClause })\n\n // Fetch items via AdminClient for proper DTO shaping\n const orderField = options?.orderBy === 'filename' ? table.filename : table.createdAt\n const orderFn = (options?.orderDirection ?? 'desc') === 'asc' ? asc : desc\n\n const items = await this.admin.findMany({\n where: whereClause,\n limit,\n offset,\n orderBy: orderFn(orderField),\n })\n\n return {\n items,\n total,\n limit,\n offset,\n }\n }\n\n async update(\n id: string,\n data: { title?: string; alt?: string; description?: string },\n ): Promise<MediaRecord> {\n return this.admin.update(id, data)\n }\n\n /**\n * Delete a media entity, its variants, and its original file in storage.\n */\n async delete(id: string): Promise<void> {\n const record = await this.admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n const fileKey = record.fileKey\n\n // 1. Delete entity first (ref checking via entity_refs happens here)\n await this.admin.delete(id)\n\n // 2. Look up original file record for variant metadata\n const fileRecord = await this.storage.getMetadata(fileKey)\n const variants = (fileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n\n // 3. Delete variants (best-effort)\n if (variants) {\n for (const vKey of Object.values(variants)) {\n await this.storage.delete(vKey).catch(() => {\n // Variant deletion failure is non-fatal\n })\n }\n }\n\n // 4. Delete original file (best-effort)\n await this.storage.delete(fileKey).catch(() => {\n // Storage deletion failure is non-fatal — entity is already deleted\n })\n }\n\n // ---------------------------------------------------------------\n // URL resolution\n // ---------------------------------------------------------------\n\n /**\n * Get URL for a media entity by its ID.\n * Resolves entity -> fileKey -> storage URL.\n */\n async getUrl(id: string): Promise<string> {\n const record = await this.admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n return this.storage.getUrl(record.fileKey)\n }\n\n /**\n * Get URLs for multiple media entities (batch).\n * Returns a Map of mediaId -> url.\n */\n async getUrls(ids: string[]): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await this.admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n records.map(async (record) => {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n }),\n )\n\n return urlMap\n }\n\n /**\n * Get variant URL for a specific image style.\n * Falls back to original URL if the variant doesn't exist.\n *\n * @param id - Media entity ID\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns The variant URL, or original URL as fallback, or null if media not found\n */\n async getVariantUrl(id: string, styleName: string): Promise<string | null> {\n const record = await this.admin.findById(id)\n if (!record) return null\n\n const fileKey = record.fileKey\n\n // Try variant key first\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n if (style) {\n const vKey = deriveVariantKey(fileKey, styleName, style.format ?? 'webp')\n try {\n return await this.storage.getUrl(vKey)\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n return await this.storage.getUrl(fileKey)\n } catch {\n return null\n }\n }\n\n /**\n * Get variant URLs for multiple media entities (batch).\n * Falls back to original URL per item if the variant doesn't exist.\n *\n * @param ids - Media entity IDs\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns Map of mediaId -> variant URL (or original URL as fallback)\n */\n async getVariantUrls(ids: string[], styleName: string): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await this.admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n records.map(async (record) => {\n // Try variant URL\n if (style) {\n const vKey = deriveVariantKey(record.fileKey, styleName, style.format ?? 'webp')\n try {\n const url = await this.storage.getUrl(vKey)\n urlMap.set(record.id, url)\n return\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n } catch {\n // Skip — no URL available\n }\n }),\n )\n\n return urlMap\n }\n}\n\n// ---------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------\n\nfunction deriveTitle(filename: string): string {\n const withoutExt = filename.replace(/\\.[^.]+$/, '')\n return withoutExt.replace(/[-_]/g, ' ')\n}\n\n/**\n * Factory — creates a MediaClient with an explicit StorageClient.\n * Must be called after createApp().\n */\nexport async function createMediaClient(storage: StorageClient): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory\n// ---------------------------------------------------------------------------\n//\n// NOTE: Storage config is process-global and safe to cache.\n// MediaClient / AdminClient must be built per-request — they carry a\n// context resolver tied to the calling request's user + permissions.\n// A singleton would leak one request's security context across others.\n\n/**\n * The storage singleton, which does NOT pin a failed initialisation.\n *\n * `_storagePromise ??= (...)()` used to cache the rejected promise too, so a\n * transient failure during storage init (config not yet loaded, a DB blip)\n * broke every subsequent caller until the process restarted. That mattered\n * little while this served admin operations; `plan/api` PR 5 puts it on the\n * ANONYMOUS request path via `getSharedStorageClient`, where the symptom\n * becomes every image on the site failing permanently with no recovery short of\n * a redeploy. See `cacheOnceUnlessRejected`, which is unit-tested.\n */\nconst _storageOnce = cacheOnceUnlessRejected<StorageClient>(async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n})\n\n/**\n * The process-wide `StorageClient`, built once from the storage plugin config.\n *\n * Exported so the PUBLIC media resolver (`./public-resolver.ts`) reaches the\n * same instance rather than constructing a second one from a copy of these two\n * lines. Safe to share, and unlike `MediaClient`/`AdminClient` it MUST NOT be\n * rebuilt per request: it carries no security context — the read-side gate\n * lives in whichever client resolves the media rows, not here.\n */\nexport async function getSharedStorageClient(): Promise<StorageClient> {\n return getStorageSingleton()\n}\n\nasync function getStorageSingleton(): Promise<StorageClient> {\n return _storageOnce()\n}\n\n/**\n * Returns a fresh MediaClient wired to the current request's context.\n * Must be called after createApp(), inside a request context\n * (withAdminContext, runAsCli, etc.).\n *\n * Despite the name, this is NOT cached — the storage config is cached\n * internally but the MediaClient and its AdminClient are rebuilt per call\n * so the security context resolver attaches to the correct request.\n */\nexport async function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const storage = await getStorageSingleton()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n/**\n * A failed focal-point guess is not an upload failure — the row is stored with\n * `null`, which renders as the centre (D010) — but it is not silent either: a\n * decoder that cannot read a whole format would otherwise leave every such\n * photograph centred with nothing saying why.\n */\nasync function warnFocalPointFailed(err: unknown, filename: string): Promise<void> {\n try {\n const { getApp } = await import('@murumets-ee/core')\n getApp().logger.warn({ err, filename }, 'media: focal-point detection failed; stored as centre')\n } catch (loggerErr) {\n // No app (a script, a test) — fall back to the console rather than lose it.\n console.warn('media: focal-point detection failed; stored as centre', filename, err, loggerErr)\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * MediaClient — wraps AdminClient + StorageClient for media management.\n *\n * Usage:\n * import { createMediaClient } from '@murumets-ee/media/client'\n * const media = await createMediaClient(storageClient)\n * const result = await media.upload(buffer, { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 12345 })\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { cacheOnceUnlessRejected } from './async-cache.js'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport { deriveMediaType } from './derive-media-type.js'\nimport { deleteOwnedVariants } from './jobs/owned-delete.js'\nimport { allVariantKeys, readVariantState } from './jobs/variant-state.js'\nimport { Media } from './entity.js'\nimport { detectFocalPoint, getImageDimensions, isProcessableImage } from './process-image.js'\nimport type {\n ImageStyle,\n MediaListOptions,\n MediaListResult,\n MediaRecord,\n MediaUploadOptions,\n MediaUploadResult,\n} from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\ntype MediaFields = typeof Media.allFields\n\nexport interface MediaClientConfig {\n admin: AdminClient<MediaFields>\n storage: StorageClient\n /**\n * Image styles `getVariantUrl(s)` resolve legacy keys with. Loaded via the\n * settings waterfall if not provided. (Generation reads the configuration\n * itself — see `jobs/`.)\n */\n imageStyles?: Record<string, ImageStyle>\n}\n\nexport class MediaClient {\n private admin: AdminClient<MediaFields>\n private storage: StorageClient\n private imageStyles: Record<string, ImageStyle> | null\n\n constructor(config: MediaClientConfig) {\n this.admin = config.admin\n this.storage = config.storage\n this.imageStyles = config.imageStyles ?? null\n }\n\n /**\n * Resolve image styles via the shared waterfall (settings DB → plugin\n * config → hardcoded defaults) and cache the result on this instance.\n * Call `invalidateImageStylesCache()` after a settings update.\n */\n private async resolveImageStyles(): Promise<Record<string, ImageStyle>> {\n if (this.imageStyles) return this.imageStyles\n const { getApp } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('./resolve-image-styles.js')\n const app = getApp()\n this.imageStyles = await resolveImageStyles(app, app.logger)\n return this.imageStyles\n }\n\n /** Clear cached styles so next access re-reads from settings DB. */\n invalidateImageStylesCache(): void {\n this.imageStyles = null\n }\n\n /**\n * The configured image styles this instance would generate variants for —\n * the vocabulary the crop editor's shape tabs are drawn from\n * (`deriveCropShapes`, images PR03). Public wrapper over the private\n * resolver so the media edit page doesn't need its own copy of the\n * settings → config → hardcoded-default waterfall.\n */\n async getImageStyles(): Promise<Record<string, ImageStyle>> {\n return this.resolveImageStyles()\n }\n\n // ---------------------------------------------------------------\n // Upload — the key convenience method\n // ---------------------------------------------------------------\n\n /**\n * Upload a file and create a media entity record in one step.\n * 1. Uploads original to storage (StorageClient)\n * 2. If image: reads its drawn size and seeds a focal point — INLINE, cheap\n * 3. Creates media entity record (AdminClient)\n * 4. If image: requests its variants — QUEUED (images D004), inline only\n * when the app has no queue plugin\n * 5. Returns the media record + URL\n *\n * The split in 2 and 4 is the point. The row must have `width`, `height` and\n * `focalPoint` the moment this returns — every CLS reservation depends on the\n * size, and `sizes=\"auto\"` makes it mandatory (images R007/R008). Variant\n * generation is (shapes × widths) sharp passes and does NOT belong on the\n * request: it goes to the worker, and the render falls back to the original\n * until it lands (`variants[style] ?? url`).\n *\n * Rolls back the storage upload if entity creation fails. A failed size\n * read, focal guess or variant request never fails the upload.\n */\n async upload(\n data: Buffer | ReadableStream<Uint8Array>,\n options: MediaUploadOptions,\n ): Promise<MediaUploadResult> {\n // 1. Upload original file to storage\n const fileRecord = await this.storage.upload(data, {\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n visibility: options.visibility,\n uploadedBy: options.uploadedBy,\n })\n\n // 2. The inline half — the size a browser draws, and a focal-point guess.\n let width = options.width ?? null\n let height = options.height ?? null\n // `null` renders as the centre (images D010), so a failed or skipped guess\n // is exactly today's crop rather than an error.\n let focalPoint: FocalPoint | null = null\n let drawn: { width: number; height: number } | null = null\n\n if (data instanceof Buffer && isProcessableImage(options.mimeType)) {\n // Two independent sharp reads of the same buffer. Neither failure fails\n // the upload: a file sharp cannot read keeps the client-measured size and\n // the centre, and gets no variants.\n const [dimensions, seeded] = await Promise.all([\n getImageDimensions(data).catch(async (err: unknown) => {\n await warnImageReadFailed(err, options.filename, 'size')\n return null\n }),\n detectFocalPoint(data).catch(async (err: unknown) => {\n await warnImageReadFailed(err, options.filename, 'focal point')\n return null\n }),\n ])\n if (dimensions && dimensions.width > 0 && dimensions.height > 0) {\n drawn = dimensions\n width = dimensions.width\n height = dimensions.height\n }\n focalPoint = seeded\n }\n\n // 3. Create media entity record\n const mediaType = deriveMediaType(options.mimeType)\n\n let entityRecord: MediaRecord\n let url: string\n try {\n // Resolve the URL BEFORE creating the entity (images PR03's drive-by\n // fix): the entity carries no URL of its own, only `fileKey`. Resolving\n // it last meant a `getUrl` failure threw after the row existed and\n // outside this `catch`, so the rollback deleted the file the new row\n // still pointed at and the row leaked. Failing before creation keeps the\n // rollback below (storage only) sufficient.\n url = await this.storage.getUrl(fileRecord.key)\n\n entityRecord = await this.admin.create({\n title: options.title ?? deriveTitle(options.filename),\n alt: options.alt ?? null,\n description: options.description ?? null,\n fileKey: fileRecord.key,\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n width,\n height,\n focalPoint,\n mediaType,\n })\n } catch (error) {\n // Rollback: no variants exist yet (they are generated after the row), so\n // the original is the only file to remove — whether the URL resolution\n // or the create failed.\n await this.storage.delete(fileRecord.key).catch(() => {\n // Storage rollback failure is already being handled — propagate original error\n })\n throw error\n }\n\n // 4. The variants. Queued: this awaits the enqueue (one INSERT) and not the\n // generation. Only an app with no queue plugin generates them here.\n if (drawn) {\n try {\n const { getApp } = await import('@murumets-ee/core')\n const { requestVariants } = await import('./jobs/schedule.js')\n await requestVariants(getApp(), entityRecord, {\n media: this.admin,\n storage: this.storage,\n })\n } catch (err) {\n // `requestVariants` logs its own failures; this is only \"no app\" (a\n // script that built a MediaClient by hand) — the original still serves.\n console.warn('media: variants were not requested for', entityRecord.id, err)\n }\n }\n\n return {\n media: entityRecord,\n url,\n }\n }\n\n // ---------------------------------------------------------------\n // CRUD delegation\n // ---------------------------------------------------------------\n\n async findById(id: string, options?: { locale?: string }): Promise<MediaRecord | null> {\n return this.admin.findById(id, options)\n }\n\n async findMany(options?: MediaListOptions): Promise<MediaListResult> {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, desc, eq, ilike, or, sql } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n\n // Build where conditions\n const conditions = []\n\n if (options?.mediaType) {\n conditions.push(eq(table.mediaType, options.mediaType))\n }\n if (options?.mimeTypePrefix) {\n // Escape ILIKE wildcards (%, _) in user input to prevent pattern injection\n const escaped = options.mimeTypePrefix.replace(/[\\\\%_]/g, '\\\\$&')\n conditions.push(ilike(table.mimeType, `${escaped}%`))\n }\n if (options?.search) {\n // Escape ILIKE wildcards in user input, then wrap with %...%\n const escaped = options.search.replace(/[\\\\%_]/g, '\\\\$&')\n const pattern = `%${escaped}%`\n conditions.push(\n or(\n ilike(table.filename, pattern),\n sql`${table.fields} ->> 'title' ILIKE ${pattern}`,\n )!,\n )\n }\n\n const limit = options?.limit ?? 50\n const offset = options?.offset ?? 0\n const whereClause = conditions.length > 0 ? and(...conditions) : undefined\n\n // Count total via AdminClient\n const total = await this.admin.count({ where: whereClause })\n\n // Fetch items via AdminClient for proper DTO shaping\n const orderField = options?.orderBy === 'filename' ? table.filename : table.createdAt\n const orderFn = (options?.orderDirection ?? 'desc') === 'asc' ? asc : desc\n\n const items = await this.admin.findMany({\n where: whereClause,\n limit,\n offset,\n orderBy: orderFn(orderField),\n })\n\n return {\n items,\n total,\n limit,\n offset,\n }\n }\n\n async update(\n id: string,\n data: { title?: string; alt?: string; description?: string },\n ): Promise<MediaRecord> {\n return this.admin.update(id, data)\n }\n\n /**\n * Delete a media entity, its variants, and its original file in storage.\n */\n async delete(id: string): Promise<void> {\n const record = await this.admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n const fileKey = record.fileKey\n\n // 1. Delete entity first (ref checking via entity_refs happens here)\n await this.admin.delete(id)\n\n // 2. Every variant the original's metadata accounts for — the legacy styles,\n // the shaped set and any a failed pass left unconfirmed (images PR05). A\n // public photograph an editor deleted must not stay served at its variant URLs.\n // The list comes from caller-writable metadata, so only files that are\n // variants OF this original are deleted (`deleteOwnedVariants`).\n const fileRecord = await this.storage.getMetadata(fileKey)\n const variantKeys = allVariantKeys(readVariantState(fileRecord?.metadata))\n\n // 3. Delete variants (best-effort, logged)\n await deleteOwnedVariants(this.storage, fileKey, variantKeys, await appLogger())\n\n // 4. Delete original file (best-effort)\n await this.storage.delete(fileKey).catch(() => {\n // Storage deletion failure is non-fatal — entity is already deleted\n })\n }\n\n // ---------------------------------------------------------------\n // URL resolution\n // ---------------------------------------------------------------\n\n /**\n * Get URL for a media entity by its ID.\n * Resolves entity -> fileKey -> storage URL.\n */\n async getUrl(id: string): Promise<string> {\n const record = await this.admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n return this.storage.getUrl(record.fileKey)\n }\n\n /**\n * Get URLs for multiple media entities (batch).\n * Returns a Map of mediaId -> url.\n */\n async getUrls(ids: string[]): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await this.admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n records.map(async (record) => {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n }),\n )\n\n return urlMap\n }\n\n /**\n * Get variant URL for a specific image style.\n * Falls back to original URL if the variant doesn't exist.\n *\n * @param id - Media entity ID\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns The variant URL, or original URL as fallback, or null if media not found\n */\n async getVariantUrl(id: string, styleName: string): Promise<string | null> {\n const record = await this.admin.findById(id)\n if (!record) return null\n\n const fileKey = record.fileKey\n\n // Try variant key first\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n if (style) {\n const vKey = deriveVariantKey(fileKey, styleName, style.format ?? 'webp')\n try {\n return await this.storage.getUrl(vKey)\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n return await this.storage.getUrl(fileKey)\n } catch {\n return null\n }\n }\n\n /**\n * Get variant URLs for multiple media entities (batch).\n * Falls back to original URL per item if the variant doesn't exist.\n *\n * @param ids - Media entity IDs\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns Map of mediaId -> variant URL (or original URL as fallback)\n */\n async getVariantUrls(ids: string[], styleName: string): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await this.admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n records.map(async (record) => {\n // Try variant URL\n if (style) {\n const vKey = deriveVariantKey(record.fileKey, styleName, style.format ?? 'webp')\n try {\n const url = await this.storage.getUrl(vKey)\n urlMap.set(record.id, url)\n return\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n } catch {\n // Skip — no URL available\n }\n }),\n )\n\n return urlMap\n }\n}\n\n// ---------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------\n\nfunction deriveTitle(filename: string): string {\n const withoutExt = filename.replace(/\\.[^.]+$/, '')\n return withoutExt.replace(/[-_]/g, ' ')\n}\n\n/**\n * Factory — creates a MediaClient with an explicit StorageClient.\n * Must be called after createApp().\n */\nexport async function createMediaClient(storage: StorageClient): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory\n// ---------------------------------------------------------------------------\n//\n// NOTE: Storage config is process-global and safe to cache.\n// MediaClient / AdminClient must be built per-request — they carry a\n// context resolver tied to the calling request's user + permissions.\n// A singleton would leak one request's security context across others.\n\n/**\n * The storage singleton, which does NOT pin a failed initialisation.\n *\n * `_storagePromise ??= (...)()` used to cache the rejected promise too, so a\n * transient failure during storage init (config not yet loaded, a DB blip)\n * broke every subsequent caller until the process restarted. That mattered\n * little while this served admin operations; `plan/api` PR 5 puts it on the\n * ANONYMOUS request path via `getSharedStorageClient`, where the symptom\n * becomes every image on the site failing permanently with no recovery short of\n * a redeploy. See `cacheOnceUnlessRejected`, which is unit-tested.\n */\nconst _storageOnce = cacheOnceUnlessRejected<StorageClient>(async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n})\n\n/**\n * The process-wide `StorageClient`, built once from the storage plugin config.\n *\n * Exported so the PUBLIC media resolver (`./public-resolver.ts`) reaches the\n * same instance rather than constructing a second one from a copy of these two\n * lines. Safe to share, and unlike `MediaClient`/`AdminClient` it MUST NOT be\n * rebuilt per request: it carries no security context — the read-side gate\n * lives in whichever client resolves the media rows, not here.\n */\nexport async function getSharedStorageClient(): Promise<StorageClient> {\n return getStorageSingleton()\n}\n\nasync function getStorageSingleton(): Promise<StorageClient> {\n return _storageOnce()\n}\n\n/**\n * Returns a fresh MediaClient wired to the current request's context.\n * Must be called after createApp(), inside a request context\n * (withAdminContext, runAsCli, etc.).\n *\n * Despite the name, this is NOT cached — the storage config is cached\n * internally but the MediaClient and its AdminClient are rebuilt per call\n * so the security context resolver attaches to the correct request.\n */\nexport async function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const storage = await getStorageSingleton()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n/**\n * A failed size read or focal-point guess is not an upload failure — the row\n * keeps the client-measured size, or `null` for the point, which renders as the\n * centre (D010) — but it is not silent either: a decoder that cannot read a\n * whole format would otherwise leave every such photograph unsized or centred\n * with nothing saying why.\n */\nasync function warnImageReadFailed(\n err: unknown,\n filename: string,\n what: 'size' | 'focal point',\n): Promise<void> {\n const message = `media: reading the image's ${what} failed; stored without it`\n try {\n const { getApp } = await import('@murumets-ee/core')\n getApp().logger.warn({ err, filename }, message)\n } catch (loggerErr) {\n // No app (a script, a test) — fall back to the console rather than lose it.\n console.warn(message, filename, err, loggerErr)\n }\n}\n\n// The variant backlog — the media library's \"are my images being sized?\"\n// signal (images PR05, C6). Server-only, like the rest of this entry.\nexport { getVariantBacklog, type VariantBacklog, type WorkerState } from './jobs/backlog.js'\n\n/** The app logger when there is an app, else nothing — a script may build a MediaClient by hand. */\nasync function appLogger(): Promise<Logger | undefined> {\n try {\n const { getApp } = await import('@murumets-ee/core')\n return getApp().logger\n } catch {\n // No app (a script, a test): deletes still happen, refusals go unlogged.\n return undefined\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,60 @@
1
+ import { t as CropShape } from "./types-CgkJF5dc.mjs";
2
+ import { n as FocalPoint, r as FractionalRect } from "./focal-point-C_0drV03.mjs";
3
+
4
+ //#region src/crop-editor/types.d.ts
5
+ interface CropEditorProps {
6
+ /** The photograph being cropped — the ORIGINAL, not a variant. */
7
+ readonly previewUrl: string;
8
+ readonly alt: string;
9
+ /**
10
+ * The shapes to offer a tab for, in display order — `deriveCropShapes`'
11
+ * output. Empty means no configured style declares a fixed ratio; the
12
+ * editor renders {@link CropEditorLabels.noShapes} rather than an empty tab
13
+ * strip.
14
+ */
15
+ readonly shapes: readonly CropShape[];
16
+ /** The stored crops map. `null`/`undefined` means no shape has an override. */
17
+ readonly value: Record<string, FractionalRect> | null | undefined;
18
+ /** Commits the WHOLE map — the same shape `media.crops` stores. */
19
+ readonly onChange: (value: Record<string, FractionalRect>) => void;
20
+ readonly readOnly?: boolean | undefined;
21
+ /** CSP nonce for react-easy-crop's injected `<style>` tag (D006). */
22
+ readonly nonce?: string | undefined;
23
+ /**
24
+ * The media row's own focal point — the fallback a shape with no saved
25
+ * crop derives its default window from (D002's resolution order). Pass
26
+ * `readFocalPoint(value)`'s result so an absent/malformed stored value
27
+ * reads as the centre, exactly like every other consumer.
28
+ */
29
+ readonly focalPoint: FocalPoint;
30
+ /**
31
+ * The SOURCE photograph's own width / height. `NaN` (or any non-finite,
32
+ * non-positive ratio) degrades to "nothing overflows" in
33
+ * `focalCropRect` — the same safe answer a caller-bug ratio gets
34
+ * everywhere else in `crop/focal-point.ts`.
35
+ */
36
+ readonly imageRatio: number;
37
+ }
38
+ //#endregion
39
+ //#region src/crop-editor/crop-editor.d.ts
40
+ declare function CropEditor({
41
+ previewUrl,
42
+ alt,
43
+ shapes,
44
+ value,
45
+ onChange,
46
+ readOnly,
47
+ nonce,
48
+ focalPoint,
49
+ imageRatio
50
+ }: CropEditorProps): import("react/jsx-runtime").JSX.Element;
51
+ //#endregion
52
+ //#region src/crop-editor/crop-editor-copy.d.ts
53
+ /** Every key the editor reads, relative to {@link CROP_EDITOR_NAMESPACE}. */
54
+ declare const CROP_EDITOR_KEYS: readonly ["tabListLabel", "description", "xLabel", "yLabel", "widthLabel", "heightLabel", "clearShape", "noShapes", "hasCropSuffix"];
55
+ type CropEditorKey = (typeof CROP_EDITOR_KEYS)[number];
56
+ /** Unscoped on purpose — see `useFocalPointCopy`. */
57
+ declare function useCropEditorCopy(): (key: CropEditorKey) => string;
58
+ //#endregion
59
+ export { CropEditor, type CropEditorProps, type CropShape, type FractionalRect, useCropEditorCopy };
60
+ //# sourceMappingURL=crop-editor.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crop-editor.d.mts","names":[],"sources":["../src/crop-editor/types.ts","../src/crop-editor/crop-editor.tsx","../src/crop-editor/crop-editor-copy.ts"],"mappings":";;;;UAMiB,eAAA;EAAe;EAAA,SAErB,UAAA;EAAA,SACA,GAAA;EASsB;;;;;;EAAA,SAFtB,MAAA,WAAiB,SAAA;EARjB;EAAA,SAUA,KAAA,EAAO,MAAA,SAAe,cAAA;EAFtB;EAAA,SAIA,QAAA,GAAW,KAAA,EAAO,MAAA,SAAe,cAAA;EAAA,SACjC,QAAA;EAHO;EAAA,SAKP,KAAA;EAHA;;;;;;EAAA,SAUA,UAAA,EAAY,UAAA;EAAA;;;AAOF;;;EAPE,SAOZ,UAAA;AAAA;;;iBC4HK,UAAA,CAAA;EACd,UAAA;EACA,GAAA;EACA,MAAA;EACA,KAAA;EACA,QAAA;EACA,QAAA;EACA,KAAA;EACA,UAAA;EACA;AAAA,GACC,eAAA,+BAAe,GAAA,CAAA,OAAA;;;;cC3JL,gBAAA;AAAA,KAYD,aAAA,WAAwB,gBAAgB;AFtBpD;AAAA,iBEiCgB,iBAAA,CAAA,IAAsB,GAAkB,EAAb,aAAa"}
@@ -0,0 +1,2 @@
1
+ "use client";import{t as e}from"./crop-rect-B-AJ2pWm.mjs";import{n as t}from"./focal-point-BWhtJzef.mjs";import{t as n}from"./en-A8YzaZ51.mjs";import{Fragment as r,jsx as i,jsxs as a}from"react/jsx-runtime";import{useCallback as o,useEffect as s,useId as c,useRef as l,useState as u}from"react";import{Button as d,Input as f,Label as p,cn as m}from"@murumets-ee/ui";import{createTranslator as h,useTranslations as g}from"next-intl";import _ from"react-easy-crop";const v=`MediaCropEditor`,y=h({locale:`en`,messages:n,namespace:v});function b(){let e=g();return o(t=>{let n=`${v}.${t}`;return e.has(n)?e(n):y(t)},[e])}function x(e){return{x:e.x/100,y:e.y/100,w:e.width/100,h:e.height/100}}function S(e){return{x:e.x*100,y:e.y*100,width:e.w*100,height:e.h*100}}function C(e){let t=e=>Math.round(e*1e3)/1e3,n=t(e.x),r=t(e.y);return{x:n,y:r,w:Math.min(t(e.w),t(1-n)),h:Math.min(t(e.h),t(1-r))}}function w({previewUrl:n,alt:o,shapes:f,value:p,onChange:h,readOnly:g,nonce:v,focalPoint:y,imageRatio:w}){let E=b(),D=c(),O=l([]),[ee,k]=u(0),[A,j]=u({x:0,y:0}),[M,N]=u(1),[P,F]=u(0),[I,L]=u(null),R=l(!1),z=l(null),B=l(null);if(s(()=>()=>{z.current!==null&&clearTimeout(z.current)},[]),f.length===0)return i(`p`,{className:`text-sm text-muted-foreground`,children:E(`noShapes`)});let V=Math.min(ee,f.length-1),H=f[V];if(!H)throw Error(`CropEditor: activeIndex out of range after clamping — cannot happen`);let U=H.name,W=p??{},G=W[U]??t(y,w,H.ratio),K=`${U}-${P}`;K!==I&&(L(K),R.current=!1,z.current!==null&&(clearTimeout(z.current),z.current=null),B.current=null);let q=e=>{g||h({...W,[U]:C(e)})},J=()=>{z.current!==null&&(clearTimeout(z.current),z.current=null),R.current&&B.current!==null&&q(B.current),B.current=null},Y=()=>{z.current!==null&&(clearTimeout(z.current),z.current=null),B.current=null},X=e=>{J(),k(e),F(e=>e+1)},Z=(t,n)=>{if(!Number.isFinite(n))return;let r={...R.current?B.current??G:G,[t]:n/100};if(Number.isFinite(w)&&w>0){if(t===`w`){let e=r.w*w/H.ratio;r={...r,h:e,y:Math.min(r.y,Math.max(0,1-e))}}else if(t===`h`){let e=r.h*H.ratio/w;r={...r,w:e,x:Math.min(r.x,Math.max(0,1-e))}}}Y(),q(e(r,t)),F(e=>e+1)},Q=(e,t)=>{let n=f.length-1,r=null;e.key===`ArrowRight`?r=t===n?0:t+1:e.key===`ArrowLeft`?r=t===0?n:t-1:e.key===`Home`?r=0:e.key===`End`&&(r=n),r!==null&&(e.preventDefault(),X(r),O.current[r]?.focus())},$=S(G);return a(`div`,{className:`space-y-3`,children:[i(`p`,{className:`text-sm text-muted-foreground`,children:E(`description`)}),i(`div`,{role:`tablist`,"aria-label":E(`tabListLabel`),id:D,className:`flex flex-wrap gap-1`,children:f.map((e,t)=>{let n=t===V;return a(`button`,{ref:e=>{O.current[t]=e},type:`button`,role:`tab`,id:`${D}-tab-${e.name}`,"aria-selected":n,"aria-controls":`${D}-panel-${e.name}`,tabIndex:n?0:-1,onClick:()=>X(t),onKeyDown:e=>Q(e,t),className:m(`rounded-md border px-3 py-1.5 font-mono text-sm`,n?`border-primary bg-primary text-primary-foreground`:`border-input bg-transparent text-foreground hover:bg-muted`),children:[e.name,W[e.name]&&a(r,{children:[i(`span`,{"aria-hidden":`true`,className:`ml-1.5 inline-block size-1.5 rounded-full bg-current`}),i(`span`,{className:`sr-only`,children:E(`hasCropSuffix`)})]})]},e.name)})}),a(`div`,{role:`tabpanel`,id:`${D}-panel-${H.name}`,"aria-labelledby":`${D}-tab-${H.name}`,className:`space-y-3`,children:[i(`div`,{className:`relative h-72 w-full overflow-hidden rounded-md bg-muted`,children:i(_,{image:n,crop:A,zoom:M,rotation:0,aspect:H.ratio,objectFit:`cover`,restrictPosition:!0,keyboardStep:1,minZoom:1,maxZoom:5,...v===void 0?{}:{nonce:v},onCropChange:j,onZoomChange:N,onInteractionStart:()=>{R.current=!0},onCropComplete:e=>{let t=x(e);B.current=t,z.current!==null&&clearTimeout(z.current),z.current=setTimeout(()=>{z.current=null,B.current=null,R.current&&q(t)},150)},initialCroppedAreaPercentages:$,mediaProps:{alt:o},cropperProps:{"aria-label":`${H.name} ${E(`description`)}`}},`${H.name}-${P}`)}),a(`div`,{className:`grid grid-cols-2 gap-3 sm:grid-cols-4`,children:[i(T,{id:`${D}-x`,label:E(`xLabel`),value:$.x,onCommit:e=>Z(`x`,e),readOnly:g}),i(T,{id:`${D}-y`,label:E(`yLabel`),value:$.y,onCommit:e=>Z(`y`,e),readOnly:g}),i(T,{id:`${D}-w`,label:E(`widthLabel`),value:$.width,onCommit:e=>Z(`w`,e),readOnly:g}),i(T,{id:`${D}-h`,label:E(`heightLabel`),value:$.height,onCommit:e=>Z(`h`,e),readOnly:g})]}),W[U]&&i(d,{type:`button`,variant:`outline`,size:`sm`,disabled:g,onClick:()=>{if(g)return;Y();let e={...W};delete e[U],h(e),F(e=>e+1)},children:E(`clearShape`)})]})]})}function T({id:e,label:t,value:n,onCommit:r,readOnly:o}){return a(`div`,{className:`space-y-1`,children:[i(p,{htmlFor:e,children:t}),i(f,{id:e,type:`number`,min:0,max:100,step:.1,disabled:o,value:Math.round(n*10)/10,onChange:e=>r(e.target.valueAsNumber)})]})}export{w as CropEditor,b as useCropEditorCopy};
2
+ //# sourceMappingURL=crop-editor.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crop-editor.mjs","names":["enMessages"],"sources":["../src/crop-editor/crop-editor-copy.ts","../src/crop-editor/crop-editor.tsx"],"sourcesContent":["'use client'\n\n/**\n * Copy for the media edit page's crop editor (images PR03).\n *\n * Same shape as `admin-ui`'s `focal-point-copy.ts`, one namespace over: an\n * UNSCOPED `useTranslations()` first, falling back to this package's own\n * English catalogue — never to the raw key — so the crop editor still reads\n * in an app that hasn't merged `@murumets-ee/media`'s messages.\n */\n\nimport { createTranslator, useTranslations } from 'next-intl'\nimport { useCallback } from 'react'\nimport enMessages from '../messages/en.json'\n\n/** Every key the editor reads, relative to {@link CROP_EDITOR_NAMESPACE}. */\nexport const CROP_EDITOR_KEYS = [\n 'tabListLabel',\n 'description',\n 'xLabel',\n 'yLabel',\n 'widthLabel',\n 'heightLabel',\n 'clearShape',\n 'noShapes',\n 'hasCropSuffix',\n] as const\n\nexport type CropEditorKey = (typeof CROP_EDITOR_KEYS)[number]\n\nexport const CROP_EDITOR_NAMESPACE = 'MediaCropEditor'\n\nconst englishFallback = createTranslator({\n locale: 'en',\n messages: enMessages,\n namespace: CROP_EDITOR_NAMESPACE,\n})\n\n/** Unscoped on purpose — see `useFocalPointCopy`. */\nexport function useCropEditorCopy(): (key: CropEditorKey) => string {\n const t = useTranslations()\n return useCallback(\n (key: CropEditorKey) => {\n const full = `${CROP_EDITOR_NAMESPACE}.${key}`\n return t.has(full) ? t(full) : englishFallback(key)\n },\n [t],\n )\n}\n","'use client'\n\n/**\n * The media edit page's crop editor (images PR03) — a fixed-aspect hole,\n * zoomed and dragged, one tab per configured shape.\n *\n * ## The control\n *\n * `react-easy-crop` (images D006/R004): a rectangle out, never a canvas — the\n * server re-derives every variant from the original (`.extract()` then\n * `.resize()`, images PR04). Its percentages are 0–100; this component\n * divides on the way in and out, because the stored model (`FractionalRect`)\n * is fractional (D002).\n *\n * ## Restoring the saved window (S4)\n *\n * `initialCroppedAreaPercentages` is read ONCE, on mount — it seeds `crop`\n * and `zoom`, and nothing after mount re-reads it. So switching tabs, or\n * committing a numeric fallback edit, works by CHANGING THE `key`: React\n * unmounts and remounts the `Cropper`, which re-seeds from the new stored\n * rect. `remountNonce` exists only to force that remount when a numeric\n * field commits a value the Cropper itself didn't produce.\n *\n * ## The always-available fallback (F015)\n *\n * The drag surface's own keyboard support (`keyboardStep`) does not help an\n * NVDA/JAWS user in browse mode — the underlying `<div tabIndex={0}>` carries\n * no ARIA role, so arrow keys go to the screen reader, not the crop (the same\n * gap PR02's focal-point picker had). Four plain `<input type=\"number\">`\n * fields — left/top/width/height, all percentages — are real form controls:\n * always focusable regardless of browse/focus mode, and a complete,\n * independent way to set the same `FractionalRect` the drag surface produces.\n * Typing in one commits through `clampFractionalRect` and forces the\n * Cropper's remount, so the drag surface reflects what was typed.\n *\n * ## The default for a shape with no saved crop\n *\n * D002's resolution order is `crops[shape]` → a rect DERIVED FROM THE FOCAL\n * POINT → centre — \"a missing crop is a fallback, never a hole.\" So a shape's\n * initial window is `focalCropRect(focalPoint, imageRatio, shape.ratio)`, the\n * SAME arithmetic the render spine uses for `object-position` (`crop/focal-point.ts`)\n * — never a naive full-frame rect. A portrait shape over a landscape photo has\n * no rect that shows 100% of both axes at once; the naive default silently\n * asks the Cropper to satisfy an impossible constraint; `focalCropRect`\n * already answers the same question `object-position` does today.\n *\n * ## Why the mount-report guard gates on INTERACTION, not the reported value\n *\n * A value comparison against the seed (\"does this report differ from what we\n * seeded with?\") looks like the obvious guard, and PR03 shipped with exactly\n * that — until live testing on a real photo showed a *saved, reloaded* crop\n * changing on its own, with no drag, no click, nothing. Traced by logging\n * every `onCropChange`/`onCropSizeChange`/`onCropComplete` through one mount\n * (`node_modules/react-easy-crop/index.module.mjs`): react-easy-crop's\n * `objectFit=\"cover\"` starts a fresh `Cropper` at `state.mediaObjectFit`\n * defaulted to `\"contain\"`, and only corrects it to `\"horizontal-cover\"` /\n * `\"vertical-cover\"` in a SECOND pass, once `componentDidUpdate` can measure\n * the mounted `<img>`. That correction runs `computeSizes` →\n * `recomputeCropPosition` → `this.props.onCropChange(newPosition)`\n * immediately followed, in the SAME synchronous call, by\n * `this.emitCropData()` — which reads `this.props.crop` to build the report.\n * A class component's `this.props` does not update until React commits the\n * re-render `onCropChange` just scheduled, so `emitCropData` reports the\n * \"contain\" pass's STALE crop position combined with the \"cover\" pass's NEW\n * `mediaSize`/`cropSize`: a real bug in react-easy-crop 6.2.3, not a\n * misreading of it. The captured trace: `{x:8,y:19.5}` (correct, equals\n * seed) → `{x:12.8,y:25.1}` (garbage, from exactly this stale combination) →\n * `{x:8,y:19.5}` again (the library's own next pass) → a THIRD `onMediaLoad`\n * (the native `<img>` `load` event, which still fires after the mount-time\n * `image.complete` check restarts the same two-pass dance) → settled, for\n * good, on `{x:12.8,y:25.1}` — the WRONG one. **The sequence does not\n * converge to the truth; it just stops**, on whichever phase it happened to\n * be in when no further `onMediaLoad` fired. A value comparison — against\n * the seed, or against \"the previous report,\" debounced or not — cannot\n * discriminate the garbage report from a genuine edit that happens to look\n * different from the seed, because BOTH shapes are \"differs from the seed.\"\n * This is not a race this component can win by comparing harder.\n *\n * So the guard does not look at the VALUE at all. It looks at whether the\n * user interacted — `onInteractionStart` (drag/touch/wheel/keyboard, the full\n * set react-easy-crop offers), a monotonic ref set once per mount and never\n * cleared until the next remount. A report that arrives before that flag\n * flips is mount noise regardless of what it says; a report that arrives\n * after is a real edit regardless of what it says.\n *\n * An earlier version tried gating on this flag directly inside\n * `onCropComplete` and was reverted (pr-review finding #2): `onWheel` calls\n * `setNewZoom` — which flows through `onZoomChange` → `componentDidUpdate` →\n * `recomputeCropPosition` → `onCropComplete`, all synchronous-ish within one\n * commit — BEFORE it raises `hasWheelJustStarted` in a `setState` CALLBACK,\n * which runs strictly after that commit. Checked synchronously, the FIRST\n * wheel tick's report always saw the flag still `false`. `SETTLE_DEBOUNCE_MS`\n * closes that gap for free: the flag only needs to be checked once the\n * debounce fires, tens of milliseconds later, and a `setState` callback never\n * takes anywhere near that long to run. The debounce was ALREADY required to\n * collapse the mount-settling burst into one write rather than several; it\n * turns out to be exactly what makes the interaction flag safe to trust too.\n * Measured settling completed within 43–51ms of mount; 150ms is a 3× margin,\n * still far under what a human perceives as lag — and it gates only the \"is\n * this now the saved crop\" bookkeeping, not the drag itself, which stays\n * fully live via the separate, undebounced `crop`/`zoom` state.\n *\n * ## The residual window this does NOT close\n *\n * `interactedRef` is monotonic for a mount's whole lifetime, never cleared\n * except on remount. If the ORIGINAL `<img>`'s native `load` event — the\n * \"THIRD `onMediaLoad`\" the trace above describes — fires LATE (a large,\n * uncached photograph on a slow connection) and a real interaction already\n * flipped the flag before it does, that late pass's own settling report\n * would be trusted and could commit its own stale-props garbage. This is\n * accepted, not engineered around: the crop editor's photograph is also\n * rendered by the focal-point picker on the SAME page (`media-form.tsx`),\n * which makes the browser's own HTTP cache warm by the time the crop\n * editor's `<img>` mounts in the one layout this ships in today, and closing\n * it unconditionally would mean tracking react-easy-crop's OWN settling\n * state (not just whether it settled) — substantially more machinery for a\n * window this narrow. If a future layout removes that warm-cache guarantee,\n * revisit this rather than assume it still holds.\n */\n\nimport { Button, cn, Input, Label } from '@murumets-ee/ui'\nimport { type KeyboardEvent, useEffect, useId, useRef, useState } from 'react'\nimport Cropper, { type Area } from 'react-easy-crop'\nimport { clampFractionalRect } from '../crop/crop-rect.js'\nimport { focalCropRect } from '../crop/focal-point.js'\nimport { useCropEditorCopy } from './crop-editor-copy.js'\nimport type { CropEditorProps, FractionalRect } from './types.js'\n\nfunction areaToRect(area: Area): FractionalRect {\n return { x: area.x / 100, y: area.y / 100, w: area.width / 100, h: area.height / 100 }\n}\n\nfunction rectToArea(rect: FractionalRect): Area {\n return { x: rect.x * 100, y: rect.y * 100, width: rect.w * 100, height: rect.h * 100 }\n}\n\n/**\n * A tenth of a percent is well under a pixel on any real photograph.\n *\n * Rounds x/y FIRST, then derives each extent's cap from the ROUNDED position\n * — rounding w/h independently, against the UNROUNDED position, can overshoot\n * the far edge even when the pre-rounding rect fit exactly. Reachable through\n * the ratio lock: `commitField` can set `y = 1 - h` exactly (e.g. `y=0.6625`,\n * `h=0.3375` — a 3:2 photo, 16:9 shape, width typed 40%), where the pair sums\n * to 1.0 before rounding but each independently rounds its .5-at-the-4th-decimal\n * UP, landing at `0.663 + 0.338 = 1.001` (CodeRabbit, PR #1178). `isCropsRecord`\n * then refuses the WHOLE map (not just this shape), so a save silently loses\n * every OTHER shape's crop too, and the server's own `cropsValidation` 400s the\n * request outright. `1 - x` is exact at this precision once `x` itself is\n * rounded, so capping the extent against it cannot overshoot.\n */\nfunction round(rect: FractionalRect): FractionalRect {\n const r = (n: number) => Math.round(n * 1000) / 1000\n const x = r(rect.x)\n const y = r(rect.y)\n return { x, y, w: Math.min(r(rect.w), r(1 - x)), h: Math.min(r(rect.h), r(1 - y)) }\n}\n\n/** See the module docblock — 3× the worst measured settling time (51ms). */\nconst SETTLE_DEBOUNCE_MS = 150\n\nexport function CropEditor({\n previewUrl,\n alt,\n shapes,\n value,\n onChange,\n readOnly,\n nonce,\n focalPoint,\n imageRatio,\n}: CropEditorProps) {\n const copy = useCropEditorCopy()\n const tabListId = useId()\n const tabRefs = useRef<Array<HTMLButtonElement | null>>([])\n const [activeIndex, setActiveIndex] = useState(0)\n const [crop, setCrop] = useState({ x: 0, y: 0 })\n const [zoom, setZoom] = useState(1)\n const [remountNonce, setRemountNonce] = useState(0)\n // Hooks must run unconditionally, so these are declared here, before the\n // shapes-empty early return, and reset once per mount — a\n // set-state-during-render `prev` tracker, not an effect.\n const [prevMountKey, setPrevMountKey] = useState<string | null>(null)\n // Has the user interacted with THIS Cropper mount — drag, wheel, or\n // keyboard? The mount-report guard below reads this, not the reported\n // value (see the module docblock). A ref: reading it inside a debounced\n // callback, not during render, is exactly what a ref is for.\n const interactedRef = useRef(false)\n // The pending debounced commit — see the module docblock's \"mount-report\n // guard\" section. Refs, not state: scheduling one is not something a\n // render should react to. `pendingCommitRef` holds the rect the timer\n // would commit if left to fire naturally, so a caller that needs to\n // pre-empt the timer (see `flushPendingCommit`/`discardPendingCommit`\n // below) has something to flush.\n const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n const pendingCommitRef = useRef<FractionalRect | null>(null)\n\n // Cancel a pending commit on full unmount (navigating away mid-settle) —\n // the only cleanup a bare setTimeout needs; nothing here is a\n // subscription, so nothing else is legitimate to put in this effect.\n useEffect(() => {\n return () => {\n if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current)\n }\n }, [])\n\n if (shapes.length === 0) {\n return <p className=\"text-sm text-muted-foreground\">{copy('noShapes')}</p>\n }\n\n const clampedIndex = Math.min(activeIndex, shapes.length - 1)\n const active = shapes[clampedIndex]\n if (!active)\n throw new Error('CropEditor: activeIndex out of range after clamping — cannot happen')\n\n const activeName = active.name\n const crops = value ?? {}\n const activeRect = crops[activeName] ?? focalCropRect(focalPoint, imageRatio, active.ratio)\n\n // `mountKey` changes exactly when `key={...}` below forces the Cropper to\n // remount — reset the interaction flag for the fresh mount. Every caller\n // that bumps `remountNonce` below ALSO settles any pending debounced\n // commit first, explicitly, via `flushPendingCommit`/`discardPendingCommit`\n // — which one is correct depends on WHY the remount is happening (see\n // those two), so this block is a defensive discard-only fallback for a\n // future caller that forgets to, not the primary mechanism. Discarding is\n // always safe here even when a flush would have been more correct: it\n // never commits into the wrong shape, it can only lose an edit that was\n // already supposed to be handled by its caller.\n const mountKey = `${activeName}-${remountNonce}`\n if (mountKey !== prevMountKey) {\n setPrevMountKey(mountKey)\n interactedRef.current = false\n if (commitTimerRef.current !== null) {\n clearTimeout(commitTimerRef.current)\n commitTimerRef.current = null\n }\n pendingCommitRef.current = null\n }\n\n const commit = (rect: FractionalRect) => {\n if (readOnly) return\n onChange({ ...crops, [activeName]: round(rect) })\n }\n\n // Commit whatever the pending debounced report would have committed, NOW,\n // before the remount that's about to happen tears down the mount it\n // belongs to. For a tab switch: the user's drag/wheel/keyboard edit on the\n // tab they're LEAVING hadn't settled yet (pr-review, live — discarding it\n // silently lost a just-finished drag on an ordinary switch-tabs-right-after\n // workflow). `interactedRef` still gates it — a not-yet-committed MOUNT\n // report is exactly as much noise here as anywhere else in this file.\n const flushPendingCommit = () => {\n if (commitTimerRef.current !== null) {\n clearTimeout(commitTimerRef.current)\n commitTimerRef.current = null\n }\n if (interactedRef.current && pendingCommitRef.current !== null) commit(pendingCommitRef.current)\n pendingCommitRef.current = null\n }\n\n // Cancel a pending debounced report WITHOUT committing it — for a caller\n // whose own fresh commit already supersedes it (a numeric-field edit, or\n // Clear), where flushing the stale drag/wheel report right before would\n // just have it immediately overwritten anyway, and — worse — its `commit`\n // closure's `crops` snapshot predates the fresh edit, so flushing here\n // would revert it.\n const discardPendingCommit = () => {\n if (commitTimerRef.current !== null) {\n clearTimeout(commitTimerRef.current)\n commitTimerRef.current = null\n }\n pendingCommitRef.current = null\n }\n\n const selectTab = (index: number) => {\n flushPendingCommit()\n setActiveIndex(index)\n setRemountNonce((n) => n + 1)\n }\n\n const commitField = (field: 'x' | 'y' | 'w' | 'h', percent: number) => {\n if (!Number.isFinite(percent)) return\n // Build on a PENDING drag/wheel rect when one exists, not `activeRect`\n // (the last COMMITTED value) — typing one field right after a drag, on\n // the same tab, before SETTLE_DEBOUNCE_MS elapses, is an ordinary\n // workflow. `discardPendingCommit()` below is correct to cancel the\n // TIMER (this commit supersedes it), but building `next` from the stale\n // pre-drag rect would still throw the other three dimensions the drag\n // just set away entirely (found live, second-round review).\n //\n // Gated on `interactedRef.current` too: `pendingCommitRef` is written by\n // EVERY `onCropComplete` report, including the mount-settling noise this\n // file's whole guard exists to ignore — a genuinely uninteracted mount's\n // still-pending noise report is not \"the pending drag,\" and basing a\n // numeric commit on it would inject that noise into the saved rect.\n const base = interactedRef.current ? (pendingCommitRef.current ?? activeRect) : activeRect\n let next: FractionalRect = { ...base, [field]: percent / 100 }\n // Keep the EXTRACTED region's pixel aspect ratio locked to the shape's\n // own ratio when an extent (not just a position) is hand-typed.\n // Un-locked, a numeric edit can produce a rect the drag surface's own\n // `aspect={active.ratio}` constraint cannot reproduce — react-easy-crop\n // then reinterprets it to the nearest ratio-respecting window the moment\n // the tab is next opened, silently rewriting what was saved (pr-review\n // finding #3, observed live: a hand-typed {x:0.3,w:0.4,h:1} against a\n // 4:3 photo and a 2:3 shape reopened as an unrelated rect). `w/h` (the\n // FRACTIONAL ratio) must scale by `imageRatio` to get the PIXEL ratio\n // `active.ratio` actually constrains — `w_px/h_px = (w·W)/(h·H) =\n // (w/h)·imageRatio`, so `h = w·imageRatio/active.ratio` keeps that equal\n // to `active.ratio`. Skipped when `imageRatio` is unknown (no width/\n // height on the row) — there is no pixel ratio to lock to.\n // `clampFractionalRect(next, field)` below only protects the field the\n // user actually typed — its sibling extent (h here, w there) is the\n // UNPINNED axis, which `resolveAxis` will silently shrink to fit\n // `1 - position` if the unchanged position doesn't leave room. Left\n // alone, that reintroduces exactly the ratio break this lock exists to\n // prevent whenever the existing position is far enough from 0 (pr-review\n // finding: x=0.25,y=0.3,w locked to h=0.8 against a 2:3 shape — the\n // unpinned y-axis clamp shrinks h to 0.7, off-ratio again). So the\n // sibling's POSITION yields here, the same way `resolveAxis` already\n // lets a PINNED extent's own position yield — computed before the pin,\n // so the pinned axis's own clamp sees a position that already fits.\n //\n // Sliding the position is not always enough: when the computed sibling\n // extent itself exceeds 1 — a wide-enough photo against a narrow-enough\n // shape, reachable at a fairly ordinary typed width, not just a\n // contrived one — `clampFractionalRect`'s own `clampUnit` caps it back\n // to 1 regardless, and the lock breaks again with no signal to the\n // editor. Pre-existing (unrelated to the position-shift above; the\n // unadjusted path clamped even harder here), and accepted rather than\n // fixed here: the right answer is a UI-level \"this shape and this photo\n // don't fit at that width\" signal, a different feature than this bound.\n if (Number.isFinite(imageRatio) && imageRatio > 0) {\n if (field === 'w') {\n const h = (next.w * imageRatio) / active.ratio\n next = { ...next, h, y: Math.min(next.y, Math.max(0, 1 - h)) }\n } else if (field === 'h') {\n const w = (next.h * active.ratio) / imageRatio\n next = { ...next, w, x: Math.min(next.x, Math.max(0, 1 - w)) }\n }\n }\n discardPendingCommit()\n commit(clampFractionalRect(next, field))\n setRemountNonce((n) => n + 1)\n }\n\n const onKeyDownTab = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n const last = shapes.length - 1\n let next: number | null = null\n if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1\n else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1\n else if (event.key === 'Home') next = 0\n else if (event.key === 'End') next = last\n if (next === null) return\n event.preventDefault()\n selectTab(next)\n tabRefs.current[next]?.focus()\n }\n\n const percentArea = rectToArea(activeRect)\n\n return (\n <div className=\"space-y-3\">\n <p className=\"text-sm text-muted-foreground\">{copy('description')}</p>\n\n <div\n role=\"tablist\"\n aria-label={copy('tabListLabel')}\n id={tabListId}\n className=\"flex flex-wrap gap-1\"\n >\n {shapes.map((shape, index) => {\n const selected = index === clampedIndex\n return (\n <button\n key={shape.name}\n ref={(el) => {\n tabRefs.current[index] = el\n }}\n type=\"button\"\n role=\"tab\"\n id={`${tabListId}-tab-${shape.name}`}\n aria-selected={selected}\n aria-controls={`${tabListId}-panel-${shape.name}`}\n tabIndex={selected ? 0 : -1}\n onClick={() => selectTab(index)}\n onKeyDown={(e) => onKeyDownTab(e, index)}\n className={cn(\n 'rounded-md border px-3 py-1.5 font-mono text-sm',\n selected\n ? 'border-primary bg-primary text-primary-foreground'\n : 'border-input bg-transparent text-foreground hover:bg-muted',\n )}\n >\n {shape.name}\n {crops[shape.name] && (\n <>\n <span\n aria-hidden=\"true\"\n className=\"ml-1.5 inline-block size-1.5 rounded-full bg-current\"\n />\n <span className=\"sr-only\">{copy('hasCropSuffix')}</span>\n </>\n )}\n </button>\n )\n })}\n </div>\n\n <div\n role=\"tabpanel\"\n id={`${tabListId}-panel-${active.name}`}\n aria-labelledby={`${tabListId}-tab-${active.name}`}\n className=\"space-y-3\"\n >\n <div className=\"relative h-72 w-full overflow-hidden rounded-md bg-muted\">\n <Cropper\n key={`${active.name}-${remountNonce}`}\n image={previewUrl}\n crop={crop}\n zoom={zoom}\n rotation={0}\n aspect={active.ratio}\n objectFit=\"cover\"\n restrictPosition\n keyboardStep={1}\n minZoom={1}\n maxZoom={5}\n {...(nonce !== undefined ? { nonce } : {})}\n onCropChange={setCrop}\n onZoomChange={setZoom}\n onInteractionStart={() => {\n interactedRef.current = true\n }}\n onCropComplete={(percentages) => {\n const next = areaToRect(percentages)\n pendingCommitRef.current = next\n if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current)\n commitTimerRef.current = setTimeout(() => {\n commitTimerRef.current = null\n pendingCommitRef.current = null\n if (interactedRef.current) commit(next)\n }, SETTLE_DEBOUNCE_MS)\n }}\n initialCroppedAreaPercentages={percentArea}\n mediaProps={{ alt }}\n cropperProps={{ 'aria-label': `${active.name} ${copy('description')}` }}\n />\n </div>\n\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-4\">\n <NumericField\n id={`${tabListId}-x`}\n label={copy('xLabel')}\n value={percentArea.x}\n onCommit={(v) => commitField('x', v)}\n readOnly={readOnly}\n />\n <NumericField\n id={`${tabListId}-y`}\n label={copy('yLabel')}\n value={percentArea.y}\n onCommit={(v) => commitField('y', v)}\n readOnly={readOnly}\n />\n <NumericField\n id={`${tabListId}-w`}\n label={copy('widthLabel')}\n value={percentArea.width}\n onCommit={(v) => commitField('w', v)}\n readOnly={readOnly}\n />\n <NumericField\n id={`${tabListId}-h`}\n label={copy('heightLabel')}\n value={percentArea.height}\n onCommit={(v) => commitField('h', v)}\n readOnly={readOnly}\n />\n </div>\n\n {crops[activeName] && (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n disabled={readOnly}\n onClick={() => {\n if (readOnly) return\n discardPendingCommit()\n const next = { ...crops }\n delete next[activeName]\n onChange(next)\n setRemountNonce((n) => n + 1)\n }}\n >\n {copy('clearShape')}\n </Button>\n )}\n </div>\n </div>\n )\n}\n\nfunction NumericField({\n id,\n label,\n value,\n onCommit,\n readOnly,\n}: {\n id: string\n label: string\n value: number\n onCommit: (percent: number) => void\n readOnly?: boolean | undefined\n}) {\n return (\n <div className=\"space-y-1\">\n <Label htmlFor={id}>{label}</Label>\n <Input\n id={id}\n type=\"number\"\n min={0}\n max={100}\n step={0.1}\n disabled={readOnly}\n value={Math.round(value * 10) / 10}\n onChange={(e) => onCommit(e.target.valueAsNumber)}\n />\n </div>\n )\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ const e=.01;function t(e){return typeof e==`number`&&Number.isFinite(e)&&e>=0&&e<=1}function n(n){if(typeof n!=`object`||!n||Array.isArray(n))return!1;let r=Object.keys(n);if(r.length!==4||![`x`,`y`,`w`,`h`].every(e=>r.includes(e)))return!1;let{x:i,y:a,w:o,h:s}=n;if(![i,a,o,s].every(t))return!1;let c={x:i,y:a,w:o,h:s};return c.w>=e&&c.h>=e&&c.x+c.w<=1.000001&&c.y+c.h<=1.000001}function r(e){if(typeof e!=`object`||!e||Array.isArray(e))return!1;let t=Object.entries(e);return t.length>64?!1:t.every(([e,t])=>e.length>0&&e.length<=64&&n(t))}function i(e){let t=new Map;if(typeof e!=`object`||!e||Array.isArray(e))return t;for(let[r,i]of Object.entries(e)){if(t.size>=64)break;r.length>0&&r.length<=64&&n(i)&&t.set(r,i)}return t}function a(e){return Math.min(Math.max(e,0),1)}function o(e){return Math.round(e*1e4)/1e4}function s(t,n,r){if(r){let r=Math.min(Math.max(n,e),1);return[Math.min(t,1-r),r]}let i=Math.min(t,1-e);return[i,Math.min(Math.max(n,e),1-i)]}function c(e,t){let n=a(e.x),r=a(e.y),i=a(e.w),c=a(e.h),[l,u]=s(n,i,t===`w`),[d,f]=s(r,c,t===`h`),p=o(l),m=o(d);return{x:p,y:m,w:Math.min(o(u),o(1-p)),h:Math.min(o(f),o(1-m))}}export{i,r as n,n as r,c as t};
2
+ //# sourceMappingURL=crop-rect-B-AJ2pWm.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crop-rect-B-AJ2pWm.mjs","names":[],"sources":["../src/crop/crop-rect.ts"],"sourcesContent":["/**\n * `FractionalRect` shape validation and clamping — the write guard for\n * `media.crops` and the client-side helper that keeps a hand-typed rect\n * inside the photograph.\n *\n * No DOM, no sharp, no database, no imports — sibling to `focal-point.ts` and\n * shared for the same reason: this module reaches the browser (the crop\n * editor's numeric fallback inputs, images PR03/F015) as well as the server\n * (`crops-validation.ts`), so it must stay import-free.\n */\n\nimport type { FractionalRect } from './focal-point.js'\n\nexport type { FractionalRect }\n\n/** How far x+w or y+h may exceed 1 and still count as \"the far edge\" — floating-point slop, not a design tolerance. */\nconst EDGE_EPSILON = 1e-6\n\n/** The smallest crop worth storing — anything thinner is not a rectangle an editor meant to draw. */\nconst MIN_EXTENT = 0.01\n\n/**\n * Shape names come from `deriveCropShapes` — configured `media.imageStyles`\n * keys, matched against `STYLE_NAME_RE` (`image-styles-settings.ts`) and in\n * practice a handful of characters (\"thumbnail\", \"portrait\"). Both bounds\n * below exist for the same reason: `isCropsRecord` guards a PATCH body an\n * authenticated editor controls directly, and every per-entry check up to\n * this point validates SHAPE, never SIZE — an unbounded key length or entry\n * count is still individually \"valid\" by every one of them. CLAUDE.md's\n * \"validate at the boundary… capped and sanitized,\" applied to the one write\n * path nothing else here bounds (pr-review finding #4).\n */\nconst MAX_SHAPE_NAME_LENGTH = 64\n/** Generous relative to any real vocabulary (images D003/PR04's shape count is operator-configured but small in practice) — a ceiling against abuse, not a design target. */\nconst MAX_CROPS_ENTRIES = 64\n\nfunction isUnitFraction(value: unknown): value is number {\n return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1\n}\n\n/**\n * Is `value` a storable crop rectangle — exactly `{x, y, w, h}`, fractional,\n * non-degenerate, and inside the unit square?\n *\n * STRICT, because it guards a WRITE (a `crops[shape]` entry). Mirrors\n * `isFocalPoint`'s reasoning: an extra key is refused rather than stripped.\n */\nexport function isFractionalRect(value: unknown): value is FractionalRect {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const keys = Object.keys(value)\n if (keys.length !== 4 || !['x', 'y', 'w', 'h'].every((k) => keys.includes(k))) return false\n const { x, y, w, h } = value as { x: unknown; y: unknown; w: unknown; h: unknown }\n if (![x, y, w, h].every(isUnitFraction)) return false\n const rect = { x, y, w, h } as FractionalRect\n return (\n rect.w >= MIN_EXTENT &&\n rect.h >= MIN_EXTENT &&\n rect.x + rect.w <= 1 + EDGE_EPSILON &&\n rect.y + rect.h <= 1 + EDGE_EPSILON\n )\n}\n\n/**\n * Is `value` a storable `crops` map — every value a {@link isFractionalRect},\n * keyed by shape name?\n *\n * Shape names are NOT validated against the configured vocabulary here: that\n * vocabulary is runtime operator data (`media.imageStyles`, C7) that can be\n * renamed or removed after a crop was saved against it. A stale key is inert\n * — nothing reads a shape name absent from the current vocabulary — so\n * refusing it here would only make renaming a style destroy data it need not\n * touch.\n */\nexport function isCropsRecord(value: unknown): value is Record<string, FractionalRect> {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const entries = Object.entries(value)\n if (entries.length > MAX_CROPS_ENTRIES) return false\n return entries.every(\n ([key, rect]) =>\n key.length > 0 && key.length <= MAX_SHAPE_NAME_LENGTH && isFractionalRect(rect),\n )\n}\n\n/**\n * The `crops` map, LENIENT — a read path (images PR04's variant-key\n * fingerprint, or any future consumer). Drops any entry that isn't a valid\n * {@link isFractionalRect} rather than refusing the whole value — a render\n * must not fail over one bad shape (mirrors `readFocalPoint`'s reasoning).\n *\n * Returns a `Map`, not a plain object, on purpose: a shape literally named\n * `\"__proto__\"` or `\"constructor\"` is a legal `field.json()` key today (shape\n * names are operator data, not a fixed enum — C7), and building a plain\n * accumulator with `result[shape] = …` from an attacker- or\n * accident-supplied shape name is exactly the classic prototype-pollution\n * shape. A `Map`'s keys never touch `Object.prototype`, so the question\n * cannot come up for any caller that reads through this rather than\n * re-deriving its own `Object.entries` walk.\n */\nexport function readCrops(value: unknown): ReadonlyMap<string, FractionalRect> {\n const map = new Map<string, FractionalRect>()\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return map\n for (const [key, rect] of Object.entries(value)) {\n if (map.size >= MAX_CROPS_ENTRIES) break\n if (key.length > 0 && key.length <= MAX_SHAPE_NAME_LENGTH && isFractionalRect(rect))\n map.set(key, rect)\n }\n return map\n}\n\nfunction clampUnit(value: number): number {\n return Math.min(Math.max(value, 0), 1)\n}\n\n/** Four decimals is well under a pixel on any real photograph, and keeps `1 - 0.9` from reporting `0.09999999999999998`. */\nfunction round(value: number): number {\n return Math.round(value * 10000) / 10000\n}\n\n/**\n * One axis — position + extent — resolved so the result ALWAYS satisfies\n * both halves of {@link isFractionalRect}'s bound: `extent >= MIN_EXTENT` AND\n * `position + extent <= 1`. Those two cannot both hold for a `position` above\n * `1 - MIN_EXTENT` no matter what `extent` is asked to be — a review of an\n * earlier version found exactly that gap: pinning position let it sit past\n * `1 - MIN_EXTENT`, and the MIN_EXTENT floor then pushed `position + extent`\n * over 1, producing a rect this module's OWN validator refused. The fix is to\n * treat \"leave room for at least one MIN_EXTENT\" as a bound on position\n * itself, not just on extent — so position silently yields BY THE MINIMUM\n * amount needed, only in that narrow band, rather than the whole rect\n * quietly becoming invalid.\n */\nfunction resolveAxis(position: number, extent: number, extentPinned: boolean): [number, number] {\n if (extentPinned) {\n const boundedExtent = Math.min(Math.max(extent, MIN_EXTENT), 1)\n return [Math.min(position, 1 - boundedExtent), boundedExtent]\n }\n const boundedPosition = Math.min(position, 1 - MIN_EXTENT)\n const boundedExtent = Math.min(Math.max(extent, MIN_EXTENT), 1 - boundedPosition)\n return [boundedPosition, boundedExtent]\n}\n\n/**\n * Keep a hand-edited rect inside the unit square.\n *\n * `pinned` names the field the caller just typed. On ITS axis, the pinned\n * field wins and its sibling (the extent, if position was pinned; the\n * position, if extent was pinned) yields — so typing `x` slides the window\n * rather than eating the `w` the editor deliberately set, and typing `w`\n * grows/shrinks in place rather than silently moving `x` — EXCEPT in the\n * narrow band where `position > 1 - MIN_EXTENT`, where position must yield by\n * the minimum needed to leave room for a non-degenerate window; see\n * {@link resolveAxis}. The other axis is untouched by this edit and is only\n * clamped defensively (position kept, extent shrunk to fit — the same rule a\n * position-pinned edit uses on its own axis). Used by the crop editor's\n * numeric fallback inputs (F015): each field commits through this rather\n * than the raw value.\n */\nexport function clampFractionalRect(\n rect: FractionalRect,\n pinned: 'x' | 'y' | 'w' | 'h',\n): FractionalRect {\n const x = clampUnit(rect.x)\n const y = clampUnit(rect.y)\n const w = clampUnit(rect.w)\n const h = clampUnit(rect.h)\n\n const [nx, nw] = resolveAxis(x, w, pinned === 'w')\n const [ny, nh] = resolveAxis(y, h, pinned === 'h')\n\n // `resolveAxis` guarantees `position + extent <= 1` at FULL float\n // precision, but rounding each independently to 4 decimals can still push\n // their ROUNDED sum past 1 — both values landing on a .00005-at-the-5th-\n // decimal boundary and each rounding up (CodeRabbit, PR #1178, found the\n // same class one caller up in `crop-editor.tsx`'s own 3-decimal `round`).\n // Deriving each extent's cap from its ALREADY-rounded position — exact at\n // this precision — closes it here too, before it ever reaches that caller.\n const rx = round(nx)\n const ry = round(ny)\n return {\n x: rx,\n y: ry,\n w: Math.min(round(nw), round(1 - rx)),\n h: Math.min(round(nh), round(1 - ry)),\n }\n}\n"],"mappings":"AAmBA,MAAM,EAAa,IAiBnB,SAAS,EAAe,EAAiC,CACvD,OAAO,OAAO,GAAU,UAAY,OAAO,SAAS,CAAK,GAAK,GAAS,GAAK,GAAS,CACvF,CASA,SAAgB,EAAiB,EAAyC,CACxE,GAAI,OAAO,GAAU,WAAY,GAAkB,MAAM,QAAQ,CAAK,EAAG,MAAO,GAChF,IAAM,EAAO,OAAO,KAAK,CAAK,EAC9B,GAAI,EAAK,SAAW,GAAK,CAAC,CAAC,IAAK,IAAK,IAAK,GAAG,CAAC,CAAC,MAAO,GAAM,EAAK,SAAS,CAAC,CAAC,EAAG,MAAO,GACtF,GAAM,CAAE,IAAG,IAAG,IAAG,KAAM,EACvB,GAAI,CAAC,CAAC,EAAG,EAAG,EAAG,CAAC,CAAC,CAAC,MAAM,CAAc,EAAG,MAAO,GAChD,IAAM,EAAO,CAAE,IAAG,IAAG,IAAG,GAAE,EAC1B,OACE,EAAK,GAAK,GACV,EAAK,GAAK,GACV,EAAK,EAAI,EAAK,GAAK,UACnB,EAAK,EAAI,EAAK,GAAK,QAEvB,CAaA,SAAgB,EAAc,EAAyD,CACrF,GAAI,OAAO,GAAU,WAAY,GAAkB,MAAM,QAAQ,CAAK,EAAG,MAAO,GAChF,IAAM,EAAU,OAAO,QAAQ,CAAK,EAEpC,OADI,EAAQ,OAAS,GAA0B,GACxC,EAAQ,OACZ,CAAC,EAAK,KACL,EAAI,OAAS,GAAK,EAAI,QAAU,IAAyB,EAAiB,CAAI,CAClF,CACF,CAiBA,SAAgB,EAAU,EAAqD,CAC7E,IAAM,EAAM,IAAI,IAChB,GAAI,OAAO,GAAU,WAAY,GAAkB,MAAM,QAAQ,CAAK,EAAG,OAAO,EAChF,IAAK,GAAM,CAAC,EAAK,KAAS,OAAO,QAAQ,CAAK,EAAG,CAC/C,GAAI,EAAI,MAAQ,GAAmB,MAC/B,EAAI,OAAS,GAAK,EAAI,QAAU,IAAyB,EAAiB,CAAI,GAChF,EAAI,IAAI,EAAK,CAAI,CACrB,CACA,OAAO,CACT,CAEA,SAAS,EAAU,EAAuB,CACxC,OAAO,KAAK,IAAI,KAAK,IAAI,EAAO,CAAC,EAAG,CAAC,CACvC,CAGA,SAAS,EAAM,EAAuB,CACpC,OAAO,KAAK,MAAM,EAAQ,GAAK,EAAI,GACrC,CAeA,SAAS,EAAY,EAAkB,EAAgB,EAAyC,CAC9F,GAAI,EAAc,CAChB,IAAM,EAAgB,KAAK,IAAI,KAAK,IAAI,EAAQ,CAAU,EAAG,CAAC,EAC9D,MAAO,CAAC,KAAK,IAAI,EAAU,EAAI,CAAa,EAAG,CAAa,CAC9D,CACA,IAAM,EAAkB,KAAK,IAAI,EAAU,EAAI,CAAU,EAEzD,MAAO,CAAC,EADc,KAAK,IAAI,KAAK,IAAI,EAAQ,CAAU,EAAG,EAAI,CAC5B,CAAC,CACxC,CAkBA,SAAgB,EACd,EACA,EACgB,CAChB,IAAM,EAAI,EAAU,EAAK,CAAC,EACpB,EAAI,EAAU,EAAK,CAAC,EACpB,EAAI,EAAU,EAAK,CAAC,EACpB,EAAI,EAAU,EAAK,CAAC,EAEpB,CAAC,EAAI,GAAM,EAAY,EAAG,EAAG,IAAW,GAAG,EAC3C,CAAC,EAAI,GAAM,EAAY,EAAG,EAAG,IAAW,GAAG,EAS3C,EAAK,EAAM,CAAE,EACb,EAAK,EAAM,CAAE,EACnB,MAAO,CACL,EAAG,EACH,EAAG,EACH,EAAG,KAAK,IAAI,EAAM,CAAE,EAAG,EAAM,EAAI,CAAE,CAAC,EACpC,EAAG,KAAK,IAAI,EAAM,CAAE,EAAG,EAAM,EAAI,CAAE,CAAC,CACtC,CACF"}