@murumets-ee/media 0.72.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/backfill-B1StTnxg.mjs +2 -0
  4. package/dist/{backfill-D2Ubblh3.mjs.map → backfill-B1StTnxg.mjs.map} +1 -1
  5. package/dist/client.d.mts +16 -6
  6. package/dist/client.d.mts.map +1 -1
  7. package/dist/client.mjs +1 -1
  8. package/dist/client.mjs.map +1 -1
  9. package/dist/crop-editor.d.mts +6 -5
  10. package/dist/crop-editor.d.mts.map +1 -1
  11. package/dist/crop-editor.mjs +1 -1
  12. package/dist/crop-rect-B-AJ2pWm.mjs.map +1 -1
  13. package/dist/crop-rect-C-SbjoxK.d.mts.map +1 -1
  14. package/dist/{deps-CzHEhyJK.mjs → deps-DaGjHO16.mjs} +2 -2
  15. package/dist/{deps-CzHEhyJK.mjs.map → deps-DaGjHO16.mjs.map} +1 -1
  16. package/dist/en-Duj_Pp-O.mjs +2 -0
  17. package/dist/en-Duj_Pp-O.mjs.map +1 -0
  18. package/dist/et-BFdSZnb5.mjs +2 -0
  19. package/dist/et-BFdSZnb5.mjs.map +1 -0
  20. package/dist/generate-variants-Dpy4aT2o.mjs +2 -0
  21. package/dist/{generate-variants-zyPjaGcy.mjs.map → generate-variants-Dpy4aT2o.mjs.map} +1 -1
  22. package/dist/i18n.mjs +1 -1
  23. package/dist/image-styles-settings.d.mts +1 -1
  24. package/dist/image-styles-settings.d.mts.map +1 -1
  25. package/dist/image-styles-settings.mjs +1 -1
  26. package/dist/image-styles-settings.mjs.map +1 -1
  27. package/dist/image-styles.d.mts +1 -1
  28. package/dist/image-styles.mjs +1 -1
  29. package/dist/image-styles.mjs.map +1 -1
  30. package/dist/index.d.mts +38 -4
  31. package/dist/index.d.mts.map +1 -1
  32. package/dist/index.mjs +1 -1
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/{media-config-DmcTxuDM.mjs → media-config-DkbnXIjC.mjs} +2 -2
  35. package/dist/{media-config-DmcTxuDM.mjs.map → media-config-DkbnXIjC.mjs.map} +1 -1
  36. package/dist/picker.d.mts +1 -1
  37. package/dist/plugin.d.mts +1 -1
  38. package/dist/plugin.mjs +1 -1
  39. package/dist/process-image-wQwiRhyk.mjs +2 -0
  40. package/dist/process-image-wQwiRhyk.mjs.map +1 -0
  41. package/dist/processing.d.mts +2 -2
  42. package/dist/processing.d.mts.map +1 -1
  43. package/dist/processing.mjs +1 -1
  44. package/dist/public-resolver.d.mts +80 -16
  45. package/dist/public-resolver.d.mts.map +1 -1
  46. package/dist/public-resolver.mjs +1 -1
  47. package/dist/public-resolver.mjs.map +1 -1
  48. package/dist/query-client.d.mts +1 -1
  49. package/dist/{regenerate-variants-Cp3sNHkT.mjs → regenerate-variants-RwV_AQSo.mjs} +2 -2
  50. package/dist/{regenerate-variants-Cp3sNHkT.mjs.map → regenerate-variants-RwV_AQSo.mjs.map} +1 -1
  51. package/dist/{register-BSGkELj0.mjs → register-CJKjNl2w.mjs} +2 -2
  52. package/dist/{register-BSGkELj0.mjs.map → register-CJKjNl2w.mjs.map} +1 -1
  53. package/dist/{resolve-image-styles-pf7Nd3Zf.mjs → resolve-image-styles-YExI329D.mjs} +2 -2
  54. package/dist/{resolve-image-styles-pf7Nd3Zf.mjs.map → resolve-image-styles-YExI329D.mjs.map} +1 -1
  55. package/dist/routes-DaE0M8h8.mjs +2 -0
  56. package/dist/routes-DaE0M8h8.mjs.map +1 -0
  57. package/dist/ru-ojvshRJy.mjs +2 -0
  58. package/dist/ru-ojvshRJy.mjs.map +1 -0
  59. package/dist/{schedule-DREjg2ji.mjs → schedule-JlvfUEVA.mjs} +2 -2
  60. package/dist/{schedule-DREjg2ji.mjs.map → schedule-JlvfUEVA.mjs.map} +1 -1
  61. package/dist/shapes-BpOEJeLx.mjs +2 -0
  62. package/dist/shapes-BpOEJeLx.mjs.map +1 -0
  63. package/dist/{shapes-40dHtTb-.d.mts → shapes-C51rXSDJ.d.mts} +2 -2
  64. package/dist/{shapes-40dHtTb-.d.mts.map → shapes-C51rXSDJ.d.mts.map} +1 -1
  65. package/dist/shapes-CqppzFzb.mjs +2 -0
  66. package/dist/shapes-CqppzFzb.mjs.map +1 -0
  67. package/dist/{types-CgkJF5dc.d.mts → types-6PZQ4Zq6.d.mts} +8 -11
  68. package/dist/{types-CgkJF5dc.d.mts.map → types-6PZQ4Zq6.d.mts.map} +1 -1
  69. package/dist/{types-DwzblZfW.d.mts → types-Lj3SkZQ_.d.mts} +11 -1
  70. package/dist/types-Lj3SkZQ_.d.mts.map +1 -0
  71. package/dist/variant-plan-86tJKtVi.mjs +2 -0
  72. package/dist/variant-plan-86tJKtVi.mjs.map +1 -0
  73. package/dist/variant-state-CBtqb9Cb.mjs +2 -0
  74. package/dist/variant-state-CBtqb9Cb.mjs.map +1 -0
  75. package/package.json +9 -9
  76. package/dist/backfill-D2Ubblh3.mjs +0 -2
  77. package/dist/en-A8YzaZ51.mjs +0 -2
  78. package/dist/en-A8YzaZ51.mjs.map +0 -1
  79. package/dist/et-CXvu8U31.mjs +0 -2
  80. package/dist/et-CXvu8U31.mjs.map +0 -1
  81. package/dist/generate-variants-zyPjaGcy.mjs +0 -2
  82. package/dist/process-image-Deedtkh4.mjs +0 -2
  83. package/dist/process-image-Deedtkh4.mjs.map +0 -1
  84. package/dist/routes-BJ23Cp0g.mjs +0 -2
  85. package/dist/routes-BJ23Cp0g.mjs.map +0 -1
  86. package/dist/ru-Dj-ax8s_.mjs +0 -2
  87. package/dist/ru-Dj-ax8s_.mjs.map +0 -1
  88. package/dist/shapes-BlKW-0C1.mjs +0 -2
  89. package/dist/shapes-BlKW-0C1.mjs.map +0 -1
  90. package/dist/types-DwzblZfW.d.mts.map +0 -1
  91. package/dist/variant-key-Cki4n-42.mjs +0 -2
  92. package/dist/variant-key-Cki4n-42.mjs.map +0 -1
  93. package/dist/variant-plan-DV8hsMdz.mjs +0 -2
  94. package/dist/variant-plan-DV8hsMdz.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/routes.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6zBgB,WAAA,CAAA,GAAe,UAAU"}
1
+ {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/routes.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBAg0BgB,WAAA,CAAA,GAAe,UAAU"}
package/dist/admin.mjs CHANGED
@@ -1 +1 @@
1
- import{t as e}from"./routes-BJ23Cp0g.mjs";export{e as mediaRoutes};
1
+ import{t as e}from"./routes-DaE0M8h8.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{a as r,i,r as a,s as o}from"./variant-state-CBtqb9Cb.mjs";import{i as s}from"./process-image-wQwiRhyk.mjs";import{n as c,t as l}from"./resolve-image-styles-YExI329D.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)&&!i(n))return`yes`;let s={width:e.width??0,height:e.height??0},c=a(e.fileKey,s,t.config,t.styles,e);return r(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 a=await e.listImages(i,200),o=a[a.length-1];if(!o)break;i=o.id;let c=0,l=0,u=[];for(let t of a){if(!s(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+a.length,queued:r.queued+p,upToDate:r.upToDate+l,alreadyWaiting:r.alreadyWaiting+m,skipped:r.skipped+c,truncated:h},t(r),h||a.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-B1StTnxg.mjs.map
@@ -1 +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":"4OAoDA,MAAa,EAAsB,IAmDnC,eAAe,EACb,EACA,EACuC,CACvC,IAAM,EAAW,MAAM,EAAK,aAAa,EAAI,OAAO,EACpD,GAAI,IAAa,IAAA,GAAW,MAAO,cAEnC,IADiB,EAAI,aAAe,MAAQ,EAAI,aAAe,IAAA,KAC/C,CAAC,EAAe,CAAQ,EAAG,MAAO,MAClD,IAAM,EAAQ,CAAE,MAAO,EAAI,OAAS,EAAG,OAAQ,EAAI,QAAU,CAAE,EACzD,EAAW,EAAiB,EAAI,QAAS,EAAO,EAAK,OAAQ,EAAK,OAAQ,CAAG,EACnF,OAAO,EAAW,EAAiB,CAAQ,EAAG,EAAS,YAAa,EAAS,MAAM,EAC/E,KACA,KACN,CAGA,eAAsB,EACpB,EACA,MAA0D,CAAC,EAC3D,EAAc,EACqB,CACnC,IAAI,EAAqC,CACvC,MAAO,WACP,QAAS,EACT,OAAQ,EACR,SAAU,EACV,eAAgB,EAChB,QAAS,EACT,UAAW,EACb,EACI,EAAwB,KAC5B,OAAS,CACP,IAAM,EAAO,MAAM,EAAK,WAAW,EAAA,GAAiB,EAC9C,EAAO,EAAK,EAAK,OAAS,GAChC,GAAI,CAAC,EAAM,MACX,EAAS,EAAK,GAEd,IAAI,EAAU,EACV,EAAW,EACT,EAAiC,CAAC,EACxC,IAAK,IAAM,KAAO,EAAM,CACtB,GAAI,CAAC,EAAmB,EAAI,QAAQ,EAAG,CACrC,IACA,QACF,CACA,IAAM,EAAO,MAAM,EAAU,EAAK,CAAI,EAClC,IAAS,cAAe,IACnB,IAAS,KAAM,IACnB,EAAI,KAAK,CAAE,QAAS,EAAI,GAAI,MAAO,EAAK,KAAM,CAAC,CACtD,CACA,IAAM,EAAU,MAAM,EAAK,gBAAgB,EAAI,IAAK,GAAM,EAAE,OAAO,CAAC,EAChE,EAAS,EACT,EAAiB,EACjB,EAAY,GAChB,IAAK,IAAM,KAAW,EAAK,CACzB,GAAI,EAAQ,IAAI,EAAQ,OAAO,EAAG,CAChC,IACA,QACF,CACA,GAAI,EAAS,OAAS,GAAU,EAAK,CACnC,EAAY,GACZ,KACF,CACA,MAAM,EAAK,QAAQ,CAAO,EAC1B,GACF,CAWA,GAVA,EAAW,CACT,GAAG,EACH,QAAS,EAAS,QAAU,EAAK,OACjC,OAAQ,EAAS,OAAS,EAC1B,SAAU,EAAS,SAAW,EAC9B,eAAgB,EAAS,eAAiB,EAC1C,QAAS,EAAS,QAAU,EAC5B,WACF,EACA,EAAW,CAAQ,EACf,GAAa,EAAK,OAAA,IAAoB,KAC5C,CAGA,MAFA,GAAW,CAAE,GAAG,EAAU,MAAO,MAAO,EACxC,EAAW,CAAQ,EACZ,CACT,CAGA,eAAsB,EACpB,EACA,EACA,EACmC,CACnC,GAAM,CAAE,eAAgB,MAAM,OAAO,6BAC/B,CAAE,aAAc,MAAM,OAAO,4BAC7B,CAAE,kBAAmB,MAAM,OAAO,mBAClC,CAAE,uBAAwB,MAAM,OAAO,wBACvC,CAAE,oBAAqB,MAAM,OAAO,+BACpC,CAAE,MAAK,MAAK,KAAI,MAAO,MAAM,OAAO,eAEpC,EAAQ,EAAe,IAAI,OAAO,EACxC,GAAI,CAAC,EAAO,MAAU,MAAM,4DAA4D,EACxF,IAAM,EAAQ,EAAI,UAAU,CAAK,EAC3B,EAAU,EAAoB,EAAiB,EAAG,CAAE,KAAI,CAAC,EACzD,EAAQ,IAAI,EAAY,CAAE,MAAK,OAAQ,EAAI,MAAO,CAAC,EACnD,EAAO,EAAU,WAAW,EAAI,GAAG,SAAS,EAG5C,CAAC,EAAQ,GAAU,MAAM,QAAQ,IAAI,CACzC,EAAqB,EAAK,EAAI,OAAQ,CAAE,OAAQ,EAAK,CAAC,EACtD,EAAmB,EAAK,EAAI,OAAQ,CAAE,OAAQ,EAAK,CAAC,CACtD,CAAC,EAEK,EAAU,MAAM,EACpB,CACE,MAAO,EAAc,EAAQ,SAAS,EACtC,YAAa,EAAS,IACpB,EAAM,SAAS,CACb,MAAO,EACH,EAAI,EAAG,EAAM,UAAW,OAAO,EAAG,EAAG,EAAM,GAAI,CAAO,CAAC,EACvD,EAAG,EAAM,UAAW,OAAO,EAC/B,QAAS,EAAI,EAAM,EAAE,EACrB,OACF,CAAC,EACH,aAAc,KAAO,IAAY,CAC/B,IAAM,EAAO,MAAM,EAAQ,YAAY,CAAO,EAC9C,OAAO,EAAQ,EAAK,UAAY,CAAC,EAAK,IAAA,EACxC,EACA,gBAAiB,KAAO,IAAQ,CAC9B,GAAI,EAAI,SAAW,EAAG,OAAO,IAAI,IAKjC,IAAM,EAAO,MAAM,EAAK,SAAS,CAC/B,MAAO,CACL,KAAM,EAAY,KAClB,OAAQ,CAAE,GAAI,CAAC,UAAW,YAAY,CAAE,EACxC,kBAAmB,CAAE,GAAI,CAAC,GAAG,CAAG,CAAE,CACpC,EACA,MAAO,KAAK,IAAI,EAAI,OAAS,EAAG,GAAI,CACtC,CAAC,EACK,EAAQ,IAAI,IAClB,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAS,EAA8B,UAAU,EAAI,OAAO,EAC9D,EAAO,SAAS,EAAM,IAAI,EAAO,KAAK,OAAO,CACnD,CACA,OAAO,CACT,EACA,QAAS,KAAO,IAAY,CAC1B,MAAM,EAAM,QAAQ,EAAa,CAAO,CAC1C,EACA,SACA,QACF,EACA,EAAQ,UACV,EAOA,OANI,EAAQ,WACV,EAAI,OAAO,KACT,CAAE,IAAK,EAAqB,OAAQ,EAAQ,MAAO,EACnD,oFACF,EAEK,CACT"}
1
+ {"version":3,"file":"backfill-B1StTnxg.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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"}
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { d as MediaUploadOptions, f as MediaUploadResult, i as ImageStyle, l as MediaRecord, m as Media, o as MediaListOptions, s as MediaListResult, t as CropShape } from "./types-6PZQ4Zq6.mjs";
2
2
  import { ToolkitApp } from "@murumets-ee/core";
3
3
  import { AdminClient } from "@murumets-ee/entity/admin";
4
4
  import { StorageClient } from "@murumets-ee/storage";
@@ -55,13 +55,23 @@ declare class MediaClient {
55
55
  /** Clear cached styles so next access re-reads from settings DB. */
56
56
  invalidateImageStylesCache(): void;
57
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.
58
+ * The configured LEGACY image styles this instance would generate variants
59
+ * for. Public wrapper over the private resolver, so a caller doesn't need
60
+ * its own copy of the settings → config → hardcoded-default waterfall.
61
+ *
62
+ * ⚠️ Not the crop editor's tabs any more: those are {@link getCropShapes}
63
+ * (images F031) — a legacy style at its default is folded into a vocabulary
64
+ * shape and is not a name the generator reads a crop by.
63
65
  */
64
66
  getImageStyles(): Promise<Record<string, ImageStyle>>;
67
+ /**
68
+ * The crop editor's tabs: every shape the variant generator plans with a
69
+ * fixed ratio, named as the generator reads a crop (images F031). Resolved
70
+ * from the same settings → config → default waterfall the generator uses
71
+ * (`resolveVariantConfig`), not from the legacy styles — see `cropShapesFor`.
72
+ * Not cached: the media edit page calls it once per render.
73
+ */
74
+ getCropShapes(): Promise<CropShape[]>;
65
75
  /**
66
76
  * Upload a file and create a media entity record in one step.
67
77
  * 1. Uploads original to storage (StorageClient)
@@ -1 +1 @@
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"}
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;;;AClBI;;;WDwBJ,cAAA;EAAA,SACA,MAAA,EAAQ,WAAW;AAAA;;iBAIZ,iBAAA,CAAkB,GAAA,EAAK,UAAA,GAAa,OAAA,CAAQ,cAAA;;;KC1B7D,WAAA,UAAqB,KAAA,CAAM,SAAS;AAAA,UAExB,iBAAA;EACf,KAAA,EAAO,WAAA,CAAY,WAAA;EACnB,OAAA,EAAS,aAAA;EDAI;;;;;ECMb,WAAA,GAAc,MAAA,SAAe,UAAA;AAAA;AAAA,cAGlB,WAAA;EAAA,QACH,KAAA;EAAA,QACA,OAAA;EAAA,QACA,WAAA;cAEI,MAAA,EAAQ,iBAAA;;;;;;UAWN,kBAAA;EDH6B;ECa3C,0BAAA,CAAA;EDbwD;;;AAAsB;;;;AC7B7D;;EAuDX,cAAA,CAAA,GAAkB,OAAA,CAAQ,MAAA,SAAe,UAAA;EApDvB;AAAe;AAEzC;;;;;EA6DQ,aAAA,CAAA,GAAiB,OAAA,CAAQ,SAAA;EArDF;;;;;;;;;;;;;AAAU;AAGzC;;;;;EAqFQ,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;EAqD9C,MAAA,CACJ,EAAA,UACA,IAAA;IAAQ,KAAA;IAAgB,GAAA;IAAc,WAAA;EAAA,IACrC,OAAA,CAAQ,WAAA;EA5DwD;;;EAmE7D,MAAA,CAAO,EAAA,WAAa,OAAA;EA/DkB;;;;EAiGtC,MAAA,CAAO,EAAA,WAAa,OAAA;EAWY;;;;EAAhC,OAAA,CAAQ,GAAA,aAAgB,OAAA,CAAQ,GAAA;EAoEyB;;;;;;;;EAlCzD,aAAA,CAAc,EAAA,UAAY,SAAA,WAAoB,OAAA;EAxTpD;;;;;;;;EA0VM,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-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};
1
+ import{t as e}from"./shapes-BpOEJeLx.mjs";import{t}from"./entity-CsDdjKz6.mjs";import{a as n}from"./variant-plan-86tJKtVi.mjs";import{t as r}from"./async-cache-C_Ycvs7b.mjs";import{t as i}from"./derive-media-type-C38ZA_Cl.mjs";import{t as a}from"./owned-delete-Cn_KFFjS.mjs";import{n as o,s}from"./variant-state-CBtqb9Cb.mjs";import{i as c,r as l,t as u}from"./process-image-wQwiRhyk.mjs";import{n as d}from"./backlog-D-H5VUQA.mjs";import"server-only";var f=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-YExI329D.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 getCropShapes(){let{getApp:t}=await import(`@murumets-ee/core`),{resolveVariantConfig:n}=await import(`./resolve-image-styles-YExI329D.mjs`).then(e=>e.r),r=t();return e(await n(r,r.logger,{strict:!0}))}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}),r=t.width??null,a=t.height??null,o=null,s=null;if(e instanceof Buffer&&c(t.mimeType)){let[n,i]=await Promise.all([l(e).catch(async e=>(await y(e,t.filename,`size`),null)),u(e).catch(async e=>(await y(e,t.filename,`focal point`),null))]);n&&n.width>0&&n.height>0&&(s=n,r=n.width,a=n.height),o=i}let d=i(t.mimeType),f,m;try{m=await this.storage.getUrl(n.key),f=await this.admin.create({title:t.title??p(t.filename),alt:t.alt??null,description:t.description??null,fileKey:n.key,filename:t.filename,mimeType:t.mimeType,size:t.size,width:r,height:a,focalPoint:o,mediaType:d})}catch(e){throw await this.storage.delete(n.key).catch(()=>{}),e}if(s)try{let{getApp:e}=await import(`@murumets-ee/core`),{requestVariants:t}=await import(`./schedule-JlvfUEVA.mjs`);await t(e(),f,{media:this.admin,storage:this.storage})}catch(e){console.warn(`media: variants were not requested for`,f.id,e)}return{media:f,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 a(this.storage,n,r,await b()),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 r=await this.admin.findById(e);if(!r)return null;let i=r.fileKey,a=(await this.resolveImageStyles())[t];if(a){let e=n(i,t,a.format??`webp`);try{return await this.storage.getUrl(e)}catch{}}try{return await this.storage.getUrl(i)}catch{return null}}async getVariantUrls(e,t){if(e.length===0)return new Map;let{schemaRegistry: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())[t],c=new Map;return await Promise.all(o.map(async e=>{if(s){let r=n(e.fileKey,t,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 p(e){return e.replace(/\.[^.]+$/,``).replace(/[-_]/g,` `)}async function m(e){let{createAdminClient:n}=await import(`@murumets-ee/core/clients`);return new f({admin:n(t),storage:e})}const h=r(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 g(){return _()}async function _(){return h()}async function v(){let{createAdminClient:e}=await import(`@murumets-ee/core/clients`),n=await _();return new f({admin:e(t),storage:n})}async function y(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 b(){try{let{getApp:e}=await import(`@murumets-ee/core`);return e().logger}catch{return}}export{f as MediaClient,m as createMediaClient,v as getMediaClient,g as getSharedStorageClient,d as getVariantBacklog};
2
2
  //# sourceMappingURL=client.mjs.map
@@ -1 +1 @@
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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"}
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 { cropShapesFor } from './crop/shapes.js'\nimport { deriveMediaType } from './derive-media-type.js'\nimport { Media } from './entity.js'\nimport { deleteOwnedVariants } from './jobs/owned-delete.js'\nimport { allVariantKeys, readVariantState } from './jobs/variant-state.js'\nimport { detectFocalPoint, getImageDimensions, isProcessableImage } from './process-image.js'\nimport type {\n CropShape,\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 LEGACY image styles this instance would generate variants\n * for. Public wrapper over the private resolver, so a caller doesn't need\n * its own copy of the settings → config → hardcoded-default waterfall.\n *\n * ⚠️ Not the crop editor's tabs any more: those are {@link getCropShapes}\n * (images F031) — a legacy style at its default is folded into a vocabulary\n * shape and is not a name the generator reads a crop by.\n */\n async getImageStyles(): Promise<Record<string, ImageStyle>> {\n return this.resolveImageStyles()\n }\n\n /**\n * The crop editor's tabs: every shape the variant generator plans with a\n * fixed ratio, named as the generator reads a crop (images F031). Resolved\n * from the same settings → config → default waterfall the generator uses\n * (`resolveVariantConfig`), not from the legacy styles — see `cropShapesFor`.\n * Not cached: the media edit page calls it once per render.\n */\n async getCropShapes(): Promise<CropShape[]> {\n const { getApp } = await import('@murumets-ee/core')\n const { resolveVariantConfig } = await import('./resolve-image-styles.js')\n const app = getApp()\n // `strict`, like the generator's own call: a settings read that fails must\n // REJECT rather than fall back to the plugin-config/default tier, whose shape\n // names the stored vocabulary may not share — a crop drawn on such a tab would\n // be keyed by a name the generator never reads. The media edit page catches\n // the rejection, logs it, and offers no tabs (PR #1188 review).\n return cropShapesFor(await resolveVariantConfig(app, app.logger, { strict: true }))\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(ilike(table.filename, pattern), sql`${table.fields} ->> 'title' ILIKE ${pattern}`)!,\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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"}
@@ -1,4 +1,4 @@
1
- import { t as CropShape } from "./types-CgkJF5dc.mjs";
1
+ import { t as CropShape } from "./types-6PZQ4Zq6.mjs";
2
2
  import { n as FocalPoint, r as FractionalRect } from "./focal-point-C_0drV03.mjs";
3
3
 
4
4
  //#region src/crop-editor/types.d.ts
@@ -7,10 +7,11 @@ interface CropEditorProps {
7
7
  readonly previewUrl: string;
8
8
  readonly alt: string;
9
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.
10
+ * The shapes to offer a tab for, in display order — `cropShapesFor`'s
11
+ * output (`MediaClient.getCropShapes()`): every shape the variant generator
12
+ * plans with a fixed ratio, under the name it reads a crop by (images F031).
13
+ * Empty means no such shape; the editor renders
14
+ * {@link CropEditorLabels.noShapes} rather than an empty tab strip.
14
15
  */
15
16
  readonly shapes: readonly CropShape[];
16
17
  /** The stored crops map. `null`/`undefined` means no shape has an override. */
@@ -1 +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"}
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;EAUsB;;;;;;;EAAA,SAFtB,MAAA,WAAiB,SAAA;EARjB;EAAA,SAUA,KAAA,EAAO,MAAA,SAAe,cAAA;EAFL;EAAA,SAIjB,QAAA,GAAW,KAAA,EAAO,MAAA,SAAe,cAAA;EAAA,SACjC,QAAA;EAHsB;EAAA,SAKtB,KAAA;EAHkB;;;;;;EAAA,SAUlB,UAAA,EAAY,UAAA;EAOZ;;AAAU;;;;EAAV,SAAA,UAAA;AAAA;;;iBC2HK,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"}
@@ -1,2 +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};
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-Duj_Pp-O.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
2
  //# sourceMappingURL=crop-editor.mjs.map
@@ -1 +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"}
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 `cropShapesFor` — the variant config's shape names:\n * vocabulary shapes (`SHAPE_NAME_PATTERN`) and edited legacy styles\n * (`STYLE_NAME_RE`), in practice a handful of characters (\"square\",\n * \"portrait\"). `cropShapesFor` offers no tab for a name longer than\n * {@link MAX_SHAPE_NAME_LENGTH}, because a crop saved under it would fail\n * validation here. 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 */\nexport const 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,IAoBnB,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,QAAA,IAAmC,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,QAAA,IAAmC,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"}
@@ -1 +1 @@
1
- {"version":3,"file":"crop-rect-C-SbjoxK.d.mts","names":[],"sources":["../src/crop/crop-rect.ts"],"mappings":";;;AAyEA;;;;;;;AAAA,iBA1BgB,gBAAA,CAAiB,KAAA,YAAiB,KAAA,IAAS,cAAc;;AA0BY;AAyBrF;;;;;;;;AAA6E;iBAzB7D,aAAA,CAAc,KAAA,YAAiB,KAAA,IAAS,MAAM,SAAS,cAAA;;;;;;;;;AAuFtD;;;;;;;iBA9DD,SAAA,CAAU,KAAA,YAAiB,WAAW,SAAS,cAAA;;;;;;;;;;;;;;;;;iBA2D/C,mBAAA,CACd,IAAA,EAAM,cAAA,EACN,MAAA,0BACC,cAAc"}
1
+ {"version":3,"file":"crop-rect-C-SbjoxK.d.mts","names":[],"sources":["../src/crop/crop-rect.ts"],"mappings":";;;;;;AAqG6E;AA2D7E;;;iBA9GgB,gBAAA,CAAiB,KAAA,YAAiB,KAAA,IAAS,cAAc;;;;;;AAiHxD;;;;;;iBAvFD,aAAA,CAAc,KAAA,YAAiB,KAAA,IAAS,MAAM,SAAS,cAAA;;;;;;;;;;;;;;;;iBAyBvD,SAAA,CAAU,KAAA,YAAiB,WAAW,SAAS,cAAA;;;;;;;;;;;;;;;;;iBA2D/C,mBAAA,CACd,IAAA,EAAM,cAAA,EACN,MAAA,0BACC,cAAc"}
@@ -1,2 +1,2 @@
1
- import{t as e}from"./entity-CsDdjKz6.mjs";import{n as t,t as n}from"./resolve-image-styles-pf7Nd3Zf.mjs";async function r(e,r){let[i,a]=await Promise.all([t(e,e.logger,{strict:!0}),n(e,e.logger,{strict:!0})]);return{media:r.media,storage:r.storage,config:i,styles:a,logger:e.logger}}async function i(t){let n=await o(t);return r(t,{media:t.getClient(e),storage:n})}let a=null;function o(e){return a??=(async()=>{let{createStorageClient:t}=await import(`@murumets-ee/storage`),{getStorageConfig:n}=await import(`@murumets-ee/storage/plugin`);return t(n(),{app:e})})(),a.catch(()=>{a=null}),a}export{i as createJobVariantDeps,r as createVariantDeps};
2
- //# sourceMappingURL=deps-CzHEhyJK.mjs.map
1
+ import{t as e}from"./entity-CsDdjKz6.mjs";import{n as t,t as n}from"./resolve-image-styles-YExI329D.mjs";async function r(e,r){let[i,a]=await Promise.all([t(e,e.logger,{strict:!0}),n(e,e.logger,{strict:!0})]);return{media:r.media,storage:r.storage,config:i,styles:a,logger:e.logger}}async function i(t){let n=await o(t);return r(t,{media:t.getClient(e),storage:n})}let a=null;function o(e){return a??=(async()=>{let{createStorageClient:t}=await import(`@murumets-ee/storage`),{getStorageConfig:n}=await import(`@murumets-ee/storage/plugin`);return t(n(),{app:e})})(),a.catch(()=>{a=null}),a}export{i as createJobVariantDeps,r as createVariantDeps};
2
+ //# sourceMappingURL=deps-DaGjHO16.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"deps-CzHEhyJK.mjs","names":[],"sources":["../src/jobs/deps.ts"],"sourcesContent":["/**\n * Building a {@link VariantGenerationDeps} — the configuration a pass needs and\n * the clients it reads and writes through.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { Media } from '../entity.js'\nimport { resolveImageStyles, resolveVariantConfig } from '../resolve-image-styles.js'\nimport type { VariantGenerationDeps } from './generate-variants.js'\n\n/** The media-row slice of the deps: an `AdminClient<Media>` satisfies it. */\nexport type VariantMediaAccess = VariantGenerationDeps['media']\n\n/**\n * Deps for a pass run with the CALLER's clients — the inline fallback inside an\n * upload or regenerate request, which already passed that route's permission\n * gate. The configuration is read fresh (it is two settings reads), strictly.\n */\nexport async function createVariantDeps(\n app: ToolkitApp,\n clients: { media: VariantMediaAccess; storage: VariantGenerationDeps['storage'] },\n): Promise<VariantGenerationDeps> {\n // STRICT: a pass deletes what the configuration no longer names, so a failed\n // settings read must fail the pass (the queue retries), never fall back to\n // the code defaults and delete the operator's shapes.\n const [config, styles] = await Promise.all([\n resolveVariantConfig(app, app.logger, { strict: true }),\n resolveImageStyles(app, app.logger, { strict: true }),\n ])\n return { media: clients.media, storage: clients.storage, config, styles, logger: app.logger }\n}\n\n/**\n * Deps for a pass run by the worker — call INSIDE `runAsCli`, so the\n * `AdminClient` binds to that elevated context.\n */\nexport async function createJobVariantDeps(app: ToolkitApp): Promise<VariantGenerationDeps> {\n const storage = await sharedStorage(app)\n return createVariantDeps(app, { media: app.getClient(Media), storage })\n}\n\n// One per worker process: a `StorageClient` carries no security context (the\n// gate is in whichever client reads the media row), so it is safe to share.\nlet storagePromise: Promise<StorageClient> | null = null\n\nfunction sharedStorage(app: ToolkitApp): Promise<StorageClient> {\n storagePromise ??= (async () => {\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n return createStorageClient(getStorageConfig(), { app })\n })()\n // A failed init is not pinned — the next pass tries again.\n storagePromise.catch(() => {\n storagePromise = null\n })\n return storagePromise\n}\n"],"mappings":"yGAmBA,eAAsB,EACpB,EACA,EACgC,CAIhC,GAAM,CAAC,EAAQ,GAAU,MAAM,QAAQ,IAAI,CACzC,EAAqB,EAAK,EAAI,OAAQ,CAAE,OAAQ,EAAK,CAAC,EACtD,EAAmB,EAAK,EAAI,OAAQ,CAAE,OAAQ,EAAK,CAAC,CACtD,CAAC,EACD,MAAO,CAAE,MAAO,EAAQ,MAAO,QAAS,EAAQ,QAAS,SAAQ,SAAQ,OAAQ,EAAI,MAAO,CAC9F,CAMA,eAAsB,EAAqB,EAAiD,CAC1F,IAAM,EAAU,MAAM,EAAc,CAAG,EACvC,OAAO,EAAkB,EAAK,CAAE,MAAO,EAAI,UAAU,CAAK,EAAG,SAAQ,CAAC,CACxE,CAIA,IAAI,EAAgD,KAEpD,SAAS,EAAc,EAAyC,CAU9D,MATA,MAAoB,SAAY,CAC9B,GAAM,CAAE,uBAAwB,MAAM,OAAO,wBACvC,CAAE,oBAAqB,MAAM,OAAO,+BAC1C,OAAO,EAAoB,EAAiB,EAAG,CAAE,KAAI,CAAC,CACxD,EAAA,CAAG,EAEH,EAAe,UAAY,CACzB,EAAiB,IACnB,CAAC,EACM,CACT"}
1
+ {"version":3,"file":"deps-DaGjHO16.mjs","names":[],"sources":["../src/jobs/deps.ts"],"sourcesContent":["/**\n * Building a {@link VariantGenerationDeps} — the configuration a pass needs and\n * the clients it reads and writes through.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { Media } from '../entity.js'\nimport { resolveImageStyles, resolveVariantConfig } from '../resolve-image-styles.js'\nimport type { VariantGenerationDeps } from './generate-variants.js'\n\n/** The media-row slice of the deps: an `AdminClient<Media>` satisfies it. */\nexport type VariantMediaAccess = VariantGenerationDeps['media']\n\n/**\n * Deps for a pass run with the CALLER's clients — the inline fallback inside an\n * upload or regenerate request, which already passed that route's permission\n * gate. The configuration is read fresh (it is two settings reads), strictly.\n */\nexport async function createVariantDeps(\n app: ToolkitApp,\n clients: { media: VariantMediaAccess; storage: VariantGenerationDeps['storage'] },\n): Promise<VariantGenerationDeps> {\n // STRICT: a pass deletes what the configuration no longer names, so a failed\n // settings read must fail the pass (the queue retries), never fall back to\n // the code defaults and delete the operator's shapes.\n const [config, styles] = await Promise.all([\n resolveVariantConfig(app, app.logger, { strict: true }),\n resolveImageStyles(app, app.logger, { strict: true }),\n ])\n return { media: clients.media, storage: clients.storage, config, styles, logger: app.logger }\n}\n\n/**\n * Deps for a pass run by the worker — call INSIDE `runAsCli`, so the\n * `AdminClient` binds to that elevated context.\n */\nexport async function createJobVariantDeps(app: ToolkitApp): Promise<VariantGenerationDeps> {\n const storage = await sharedStorage(app)\n return createVariantDeps(app, { media: app.getClient(Media), storage })\n}\n\n// One per worker process: a `StorageClient` carries no security context (the\n// gate is in whichever client reads the media row), so it is safe to share.\nlet storagePromise: Promise<StorageClient> | null = null\n\nfunction sharedStorage(app: ToolkitApp): Promise<StorageClient> {\n storagePromise ??= (async () => {\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n return createStorageClient(getStorageConfig(), { app })\n })()\n // A failed init is not pinned — the next pass tries again.\n storagePromise.catch(() => {\n storagePromise = null\n })\n return storagePromise\n}\n"],"mappings":"yGAmBA,eAAsB,EACpB,EACA,EACgC,CAIhC,GAAM,CAAC,EAAQ,GAAU,MAAM,QAAQ,IAAI,CACzC,EAAqB,EAAK,EAAI,OAAQ,CAAE,OAAQ,EAAK,CAAC,EACtD,EAAmB,EAAK,EAAI,OAAQ,CAAE,OAAQ,EAAK,CAAC,CACtD,CAAC,EACD,MAAO,CAAE,MAAO,EAAQ,MAAO,QAAS,EAAQ,QAAS,SAAQ,SAAQ,OAAQ,EAAI,MAAO,CAC9F,CAMA,eAAsB,EAAqB,EAAiD,CAC1F,IAAM,EAAU,MAAM,EAAc,CAAG,EACvC,OAAO,EAAkB,EAAK,CAAE,MAAO,EAAI,UAAU,CAAK,EAAG,SAAQ,CAAC,CACxE,CAIA,IAAI,EAAgD,KAEpD,SAAS,EAAc,EAAyC,CAU9D,MATA,MAAoB,SAAY,CAC9B,GAAM,CAAE,uBAAwB,MAAM,OAAO,wBACvC,CAAE,oBAAqB,MAAM,OAAO,+BAC1C,OAAO,EAAoB,EAAiB,EAAG,CAAE,KAAI,CAAC,CACxD,EAAA,CAAG,EAEH,EAAe,UAAY,CACzB,EAAiB,IACnB,CAAC,EACM,CACT"}
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";var t=e({Media:()=>r,MediaCropEditor:()=>i,PluginNav:()=>n,default:()=>a}),n={media:`Media`},r={imageVocabulary:{description:`Every photograph is generated in each shape below, at each width. A slot whose ratio matches a shape draws that shape’s files; a slot with any other ratio is cropped in the browser instead.`,usingDefaults:`Nothing is saved here yet. Shown are the toolkit defaults; until you save, the site uses them — or the shapes configured in its code, if it has any.`,widthsLabel:`Widths (px)`,widthsHelp:`Ascending, separated by commas. A photograph is never enlarged — a width it cannot supply is skipped.`,widthsInvalid:`Use up to {count} ascending whole numbers between {min} and {max}.`,shapesLabel:`Shapes`,naturalNote:`Plus “natural” — each photograph at its own shape, over the same widths.`,columnName:`Name`,columnRatio:`Ratio (W:H)`,columnWidths:`Own widths`,ownWidthsPlaceholder:`All widths`,ratioInvalid:`Write the ratio as W:H, for example 3:2.`,nameInvalid:`Lowercase letters, digits, - and _, starting with a letter — and not “natural”.`,nameTaken:`A shape named “{name}” already exists.`,tooMany:`At most {count} shapes.`,add:`Add shape`,remove:`Remove {name}`,tooManyFiles:`That makes {count} files for every photograph; the most is {max}. Remove a shape or a width first.`},variantBacklog:{waiting:`Images waiting for their sizes: {count}`,workerRunning:`The queue worker is generating them. Until it finishes, those images are shown at full size.`,workerMissing:`No queue worker is running, so these images are shown at full size — slower pages, especially on phones. Image sizes are made by the queue worker, a separate process that has to run beside the site (pnpm worker). Ask whoever hosts this site to start it.`,waitingSince:`The oldest was queued {since}.`,failed:`Size generation failed {count} times and was given up on — the Queue page shows why.`,catchUp:`A catch-up of the whole library is queued: every image is checked against the current shapes and styles.`,catchUpWorkerMissing:`No queue worker is running, so it has not started. Image sizes are made by the queue worker, a separate process that has to run beside the site (pnpm worker). Ask whoever hosts this site to start it.`,openQueue:`Open the queue`},variantGeneration:{workerRequired:`Image sizes are generated in the background by the queue worker — a separate process that has to run beside the site (pnpm worker). Without it, the site still works, but new and changed images are shown at full size.`,regenerate:`Catch up all images`,regenerating:`Starting…`,queued:`Started. The queue worker catches the library up in the background; the site keeps serving meanwhile. Follow it on the Queue page.`,alreadyRunning:`A catch-up is already running — follow it on the Queue page.`,inlineDone:`Processed {processed} of {total}; skipped {skipped}; errors {errors}.`,failed:`Could not start the catch-up: {reason}`}},i={tabListLabel:`Crop shapes`,description:`Drag to position the photograph, scroll or pinch to zoom. Every slot using this shape will show this crop.`,xLabel:`Left (%)`,yLabel:`Top (%)`,widthLabel:`Width (%)`,heightLabel:`Height (%)`,clearShape:`Clear this crop`,noShapes:`No configured image style has a fixed ratio yet, so there is nothing to crop against.`,hasCropSuffix:`— custom crop saved`},a={PluginNav:n,Media:r,MediaCropEditor:i};export{t as n,a as t};
2
+ //# sourceMappingURL=en-Duj_Pp-O.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"en-Duj_Pp-O.mjs","names":[],"sources":["../src/messages/en.json"],"sourcesContent":[""],"mappings":""}
@@ -0,0 +1,2 @@
1
+ var e={PluginNav:{media:`Meedia`},Media:{imageVocabulary:{description:`Iga foto genereeritakse igas allolevas kujus ja igas laiuses. Pesa, mille kuvasuhe ühtib mõne kujuga, kasutab selle kuju faile; muu kuvasuhtega pesa kärbitakse hoopis brauseris.`,usingDefaults:`Siin pole veel midagi salvestatud. Näidatud on tööriista vaikeväärtused; kuni salvestad, kasutab sait neid — või koodis seadistatud kujusid, kui neid on.`,widthsLabel:`Laiused (px)`,widthsHelp:`Kasvavas järjekorras, komadega eraldatud. Fotot ei suurendata kunagi — laius, mida see ei suuda pakkuda, jäetakse vahele.`,widthsInvalid:`Kasuta kuni {count} kasvavat täisarvu vahemikus {min}–{max}.`,shapesLabel:`Kujud`,naturalNote:`Lisaks „natural“ — iga foto oma kujus, samades laiustes.`,columnName:`Nimi`,columnRatio:`Kuvasuhe (L:K)`,columnWidths:`Oma laiused`,ownWidthsPlaceholder:`Kõik laiused`,ratioInvalid:`Kirjuta kuvasuhe kujul L:K, näiteks 3:2.`,nameInvalid:`Väiketähed, numbrid, - ja _, algab tähega — ja mitte „natural“.`,nameTaken:`Kuju nimega „{name}“ on juba olemas.`,tooMany:`Kõige rohkem {count} kuju.`,add:`Lisa kuju`,remove:`Eemalda {name}`,tooManyFiles:`See teeks iga foto kohta {count} faili; lubatud on kõige rohkem {max}. Eemalda enne kuju või laius.`},variantBacklog:{waiting:`Suuruste loomist ootavaid pilte: {count}`,workerRunning:`Järjekorratöötleja loob neid praegu. Kuni see pole valmis, näidatakse neid pilte täissuuruses.`,workerMissing:`Ükski järjekorratöötleja ei tööta, seega näidatakse neid pilte täissuuruses — lehed on aeglasemad, eriti telefonis. Pildisuurused loob järjekorratöötleja, eraldi protsess, mis peab töötama saidi kõrval (pnpm worker). Palu saidi haldajal see käivitada.`,waitingSince:`Vanim lisati järjekorda {since}.`,failed:`Suuruste loomine ebaõnnestus {count} korda ja jäeti pooleli — põhjuse leiad järjekorra lehelt.`,catchUp:`Kogu meediakogu uuendamine on järjekorras: iga pilti võrreldakse praeguste kujude ja stiilidega.`,catchUpWorkerMissing:`Ükski järjekorratöötleja ei tööta, seega pole see alanud. Pildisuurused loob järjekorratöötleja, eraldi protsess, mis peab töötama saidi kõrval (pnpm worker). Palu saidi haldajal see käivitada.`,openQueue:`Ava järjekord`},variantGeneration:{workerRequired:`Pildisuurused luuakse taustal järjekorratöötleja poolt — see on eraldi protsess, mis peab töötama saidi kõrval (pnpm worker). Ilma selleta sait töötab, kuid uusi ja muudetud pilte näidatakse täissuuruses.`,regenerate:`Uuenda kõik pildid`,regenerating:`Käivitan…`,queued:`Käivitatud. Järjekorratöötleja uuendab meediakogu taustal; sait töötab samal ajal edasi. Jälgi seda järjekorra lehel.`,alreadyRunning:`Uuendamine juba käib — jälgi seda järjekorra lehel.`,inlineDone:`Töödeldud {processed} / {total}; vahele jäetud {skipped}; vigu {errors}.`,failed:`Uuendamist ei õnnestunud käivitada: {reason}`}},MediaCropEditor:{tabListLabel:`Kärpimise kujud`,description:`Lohista foto paigutamiseks, kerimine või näpuliigutus suumib. Iga seda kuju kasutav koht näitab seda kärbet.`,xLabel:`Vasakult (%)`,yLabel:`Ülevalt (%)`,widthLabel:`Laius (%)`,heightLabel:`Kõrgus (%)`,clearShape:`Tühista see kärbe`,noShapes:`Ühelgi seadistatud pildistiilil pole veel kindlat proportsiooni, seega pole midagi kärpida.`,hasCropSuffix:`— kohandatud kärbe salvestatud`}};export{e as default};
2
+ //# sourceMappingURL=et-BFdSZnb5.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"et-BFdSZnb5.mjs","names":[],"sources":["../src/messages/et.json"],"sourcesContent":[""],"mappings":""}
@@ -0,0 +1,2 @@
1
+ import{a as e,i as t,n}from"./variant-plan-86tJKtVi.mjs";import{t as r}from"./owned-delete-Cn_KFFjS.mjs";import{c as i,o as a,s as o,t as s}from"./variant-state-CBtqb9Cb.mjs";import{a as ee,i as te,r as ne,s as re,t as c}from"./process-image-wQwiRhyk.mjs";var l=class extends Error{failed;constructor(e,t){super(`media ${e}: ${t} variant(s) failed to upload; the pass will be retried`),this.name=`VariantUploadError`,this.failed=t}};function u(e,t,n={}){return{mediaId:e,status:t,uploaded:0,reused:0,deleted:0,dropped:0,dimensionsCorrected:!1,focalPointSeeded:!1,...n}}async function ie(e){if(Buffer.isBuffer(e))return e;let t=[],n=e.getReader();for(;;){let{done:e,value:r}=await n.read();if(e)break;r&&t.push(r)}return Buffer.concat(t)}async function d(e,t,n){let r=[...e];await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=r.shift();e!==void 0;e=r.shift())await n(e)}))}function f(e,t){return JSON.stringify(e??null)===JSON.stringify(t??null)}function p(e,n){return t(e,{shape:n.shape,width:n.width,fingerprint:n.fingerprint,format:n.format})}function ae(e,t,n){let{width:r,height:i}=e.metadata??{};return typeof r!=`number`||typeof i!=`number`||r<=0||i<=0?null:{shape:t.shape,rung:t.width,width:r,height:i,format:t.format,key:n}}async function m(t,m){let{media:v,storage:y,config:b,styles:oe,logger:x}=m,S=await v.findById(t);if(!S)return u(t,`missing`);let C=S;if(C.mediaType!==`image`||!te(C.mimeType))return u(t,`skipped`);let w=await y.getMetadata(C.fileKey);if(!w)return x?.warn({mediaId:t},`media variants: original file record is missing; nothing to do`),u(t,`skipped`);let T=await ie((await y.download(C.fileKey)).body),E=!1,D=await ne(T),O={};if(D.width>0&&(C.width!==D.width||C.height!==D.height)&&(O.width=D.width,O.height=D.height),C.focalPoint===null||C.focalPoint===void 0){let e=await c(T).catch(e=>(x?.warn({mediaId:t,err:e},`media variants: focal-point detection failed; left unset`),null));e?O.focalPoint=e:E=!0}if(`focalPoint`in O){let e=await v.findById(t);if(!e)return u(t,`missing`);e.focalPoint!==null&&e.focalPoint!==void 0&&(delete O.focalPoint,C={...C,focalPoint:e.focalPoint,crops:e.crops})}Object.keys(O).length>0&&(await v.updateInternal(t,O),C={...C,...O});let k=n(D,b,{focalPoint:C.focalPoint,crops:C.crops});k.dropped>0&&x?.warn({mediaId:t,dropped:k.dropped},`media variants: the per-image cap cut the largest variants off this photograph`);let A=o(w.metadata),j=new Map(A.shaped.map(e=>[e.key,e])),M=[],N=[];for(let e of k.variants){let t=p(C.fileKey,e),n=j.get(t);if(n){M.push({...n,shape:e.shape,rung:e.width});continue}let r=await y.getMetadata(t),i=r?ae(r,e,t):null;i?M.push(i):N.push({planned:e,key:t,replace:r!==null})}let se=M.length,P=0,F=0,I=[],L=[],ce=await re(T,N.map(e=>e.planned));await d(N.map((e,t)=>({...e,variant:ce[t]})),4,async({planned:e,key:n,replace:r,variant:i})=>{if(i)try{r&&await y.delete(n),await y.upload(i.buffer,{key:n,filename:`${e.shape}_${e.width}w_${C.filename}`,mimeType:i.mimeType,size:i.buffer.byteLength,visibility:w.visibility,metadata:{variantOf:C.fileKey,shape:e.shape,rung:e.width,width:i.width,height:i.height}}),M.push({shape:e.shape,rung:e.width,width:i.width,height:i.height,format:e.format,key:n}),I.push(n),P++}catch(e){F++,x?.warn({mediaId:t,key:n,err:e},`media variants: shaped variant upload failed`)}});let R=new Map,z=new Map,B={},V=new Set;for(let[t,n]of Object.entries(oe)){let r=e(C.fileKey,t,n.format??`webp`),i=a(n);A.legacy.get(t)===r&&A.legacyRecipes.get(t)===i?(R.set(t,r),z.set(t,i)):B[t]=n}Object.keys(B).length>0&&await d([...(await ee(T,B)).variants],4,async([n,r])=>{let i=B[n];if(!i)return;let o=e(C.fileKey,n,r.format),s=!1;try{await y.getMetadata(o)&&(await y.delete(o),s=!0),await y.upload(r.buffer,{key:o,filename:`${n}_${C.filename}`,mimeType:r.mimeType,size:r.buffer.byteLength,visibility:w.visibility,metadata:{variantOf:C.fileKey,style:n}}),R.set(n,o),z.set(n,a(i)),L.push(o),P++}catch(e){F++,s&&V.add(n),x?.warn({mediaId:t,key:o,err:e},`media variants: legacy style upload failed`)}});let H=await v.findById(t);if(!H)return await r(y,C.fileKey,[...I,...L],x),u(t,`missing`,{uploaded:P});let U=!f(H.focalPoint,C.focalPoint)||!f(H.crops,C.crops),W=await y.getMetadata(C.fileKey)??w,G=o(W.metadata),K=g(h(G.legacy,R),V),q=g(h(G.legacyRecipes,z),V),le=[...G.legacy].filter(([e,t])=>K.has(e)&&K.get(e)!==t).map(([,e])=>e),J=U?new Set(n(D,b,{focalPoint:H.focalPoint,crops:H.crops}).variants.map(e=>p(C.fileKey,e))):null,Y=(e,t)=>{let n=new Set(e.map(e=>e.key));return[...new Set([...G.unconfirmed,...t,...le])].filter(e=>!n.has(e))},X;if(U){let e=I.filter(e=>J?.has(e));X={legacy:K,legacyRecipes:q,shaped:G.shaped,unconfirmed:Y(G.shaped,e)}}else if(F>0){let e=new Set(G.shaped.map(e=>`${e.shape}@${e.rung}`)),t=M.filter(t=>!e.has(`${t.shape}@${t.rung}`)),n=_([...G.shaped,...t]);X={legacy:K,legacyRecipes:q,shaped:n,unconfirmed:Y(n,M.map(e=>e.key))}}else X={legacy:R,legacyRecipes:z,shaped:_(M),unconfirmed:[]};let Z=i(W.metadata,X);await y.updateMetadata(C.fileKey,{metadata:E?{...Z,[s]:!0}:Z});let Q=new Set;if(U){let e=new Set(G.shaped.map(e=>e.key));for(let t of I)!J?.has(t)&&!e.has(t)&&Q.add(t)}else if(F===0){let e=new Set([...M.map(e=>e.key),...R.values()]);for(let t of G.shaped)e.has(t.key)||Q.add(t.key);for(let t of G.legacy.values())e.has(t)||Q.add(t);for(let t of G.unconfirmed)e.has(t)||Q.add(t)}let ue=await r(y,C.fileKey,Q,x),$=u(t,U?`stale`:`generated`,{uploaded:P,reused:se,deleted:ue,dropped:k.dropped,dimensionsCorrected:`width`in O,focalPointSeeded:`focalPoint`in O});if(F>0&&!U)throw new l(t,F);return x?.debug($,`media variants: pass complete`),$}function h(e,t){return new Map([...e,...t])}function g(e,t){for(let n of t)e.delete(n);return e}function _(e){return[...e].sort((e,t)=>e.shape===t.shape?e.rung-t.rung||e.width-t.width:e.shape<t.shape?-1:1)}export{m as generateMediaVariants};
2
+ //# sourceMappingURL=generate-variants-Dpy4aT2o.mjs.map