@murumets-ee/media 0.74.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/backfill-BpcWvL5P.mjs +2 -0
- package/dist/backfill-BpcWvL5P.mjs.map +1 -0
- package/dist/blur-data-url-EtSndBso.mjs +2 -0
- package/dist/blur-data-url-EtSndBso.mjs.map +1 -0
- package/dist/client.d.mts +3 -2
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/crop-editor.d.mts +1 -1
- package/dist/crop.d.mts +53 -1
- package/dist/crop.d.mts.map +1 -0
- package/dist/crop.mjs +1 -1
- package/dist/{deps-DaGjHO16.mjs → deps-BMOCedBq.mjs} +2 -2
- package/dist/{deps-DaGjHO16.mjs.map → deps-BMOCedBq.mjs.map} +1 -1
- package/dist/entity-CSrPWVJu.mjs +2 -0
- package/dist/entity-CSrPWVJu.mjs.map +1 -0
- package/dist/generate-variants-CJ1oDbdp.mjs +2 -0
- package/dist/generate-variants-CJ1oDbdp.mjs.map +1 -0
- package/dist/image-styles-settings.d.mts +1 -1
- package/dist/image-styles.d.mts +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +1 -1
- package/dist/picker.d.mts +1 -1
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/process-image-Cwa8LlGb.mjs +2 -0
- package/dist/process-image-Cwa8LlGb.mjs.map +1 -0
- package/dist/processing.d.mts +2 -2
- package/dist/processing.d.mts.map +1 -1
- package/dist/processing.mjs +1 -1
- package/dist/public-resolver.d.mts +15 -0
- package/dist/public-resolver.d.mts.map +1 -1
- package/dist/public-resolver.mjs +1 -1
- package/dist/public-resolver.mjs.map +1 -1
- package/dist/query-client.d.mts +1 -1
- package/dist/query-client.mjs +1 -1
- package/dist/{regenerate-variants-RwV_AQSo.mjs → regenerate-variants-BpCAq5U5.mjs} +2 -2
- package/dist/{regenerate-variants-RwV_AQSo.mjs.map → regenerate-variants-BpCAq5U5.mjs.map} +1 -1
- package/dist/{register-CJKjNl2w.mjs → register-CJAkO7a2.mjs} +2 -2
- package/dist/{register-CJKjNl2w.mjs.map → register-CJAkO7a2.mjs.map} +1 -1
- package/dist/routes-Dk_oVBmE.mjs +2 -0
- package/dist/routes-Dk_oVBmE.mjs.map +1 -0
- package/dist/{schedule-JlvfUEVA.mjs → schedule-BIcDoT1d.mjs} +2 -2
- package/dist/{schedule-JlvfUEVA.mjs.map → schedule-BIcDoT1d.mjs.map} +1 -1
- package/dist/{shapes-C51rXSDJ.d.mts → shapes-BQH9_3h8.d.mts} +2 -2
- package/dist/{shapes-C51rXSDJ.d.mts.map → shapes-BQH9_3h8.d.mts.map} +1 -1
- package/dist/{types-Lj3SkZQ_.d.mts → types-Ba76KK9j.d.mts} +7 -1
- package/dist/types-Ba76KK9j.d.mts.map +1 -0
- package/dist/{types-6PZQ4Zq6.d.mts → types-lobQhTow.d.mts} +16 -2
- package/dist/types-lobQhTow.d.mts.map +1 -0
- package/package.json +9 -9
- package/dist/backfill-B1StTnxg.mjs +0 -2
- package/dist/backfill-B1StTnxg.mjs.map +0 -1
- package/dist/entity-CsDdjKz6.mjs +0 -2
- package/dist/entity-CsDdjKz6.mjs.map +0 -1
- package/dist/generate-variants-Dpy4aT2o.mjs +0 -2
- package/dist/generate-variants-Dpy4aT2o.mjs.map +0 -1
- package/dist/process-image-wQwiRhyk.mjs +0 -2
- package/dist/process-image-wQwiRhyk.mjs.map +0 -1
- package/dist/routes-DaE0M8h8.mjs +0 -2
- package/dist/routes-DaE0M8h8.mjs.map +0 -1
- package/dist/types-6PZQ4Zq6.d.mts.map +0 -1
- package/dist/types-Lj3SkZQ_.d.mts.map +0 -1
package/dist/admin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/routes.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/routes.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA40BgB,WAAA,CAAA,GAAe,UAAU"}
|
package/dist/admin.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./routes-
|
|
1
|
+
import{t as e}from"./routes-Dk_oVBmE.mjs";export{e as mediaRoutes};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./entity-CSrPWVJu.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{a as s}from"./process-image-Cwa8LlGb.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)||e.blurDataUrl===null||e.blurDataUrl===void 0)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-BpcWvL5P.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backfill-BpcWvL5P.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 /** `null`/absent = no blurred preview generated yet (images PR11). */\n readonly blurDataUrl?: string | null\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`). No\n * blurred preview generated yet → yes (images PR11). 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 // No blurred preview yet (images PR11) → yes. A pass writes the column\n // (`''` when the source is transparent), so this queues a photograph once —\n // unless its preview THREW, which leaves it `null` to be tried again.\n if (row.blurDataUrl === null || row.blurDataUrl === undefined) 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":"iRAoDA,MAAa,EAAsB,IAqDnC,eAAe,EACb,EACA,EACuC,CACvC,IAAM,EAAW,MAAM,EAAK,aAAa,EAAI,OAAO,EACpD,GAAI,IAAa,IAAA,GAAW,MAAO,cAMnC,IALiB,EAAI,aAAe,MAAQ,EAAI,aAAe,IAAA,KAC/C,CAAC,EAAe,CAAQ,GAIpC,EAAI,cAAgB,MAAQ,EAAI,cAAgB,IAAA,GAAW,MAAO,MACtE,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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=1024,t=/^data:image\/(?:webp|png);base64,(?=[A-Za-z0-9+/])(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;function n(e){return typeof e!=`string`||e.length===0||e.length>1024?!1:t.test(e)}function r(e){return n(e)?e:null}export{n,r,e as t};
|
|
2
|
+
//# sourceMappingURL=blur-data-url-EtSndBso.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blur-data-url-EtSndBso.mjs","names":[],"sources":["../src/crop/blur-data-url.ts"],"sourcesContent":["/**\n * The blurred preview a photograph shows while it loads — its validation, in\n * ONE pure module (images PR11).\n *\n * The preview is a tiny image of the photograph, encoded as a data URL. The\n * server generates it once (`generateBlurDataUrl` in `process-image.ts`) and\n * stores it on the media row. A render draws it as the `<img>`'s own\n * `background-image`, which the photograph paints over when it arrives.\n *\n * 🔴 **It goes into an inline `style`, so it is a CSS injection vector**\n * (CLAUDE.md `# Images`, fact 3). React's server renderer does not escape `;`\n * or `)` inside a style value, so a stored `data:image/png;base64,x); background:\n * url(//evil` would close the `url()` and add a declaration to every page that\n * draws the photograph. The row is admin-writable JSON-adjacent text, and the\n * projection hands it to anonymous pages. So it is checked at every boundary it\n * crosses — the write (`blurDataUrlValidation`), the projection\n * (`resolvePublicMedia`) and the render (`@murumets-ee/blocks`) — and all three\n * ask THIS function, so they cannot disagree.\n *\n * What passes is deliberately narrow: two raster types, the base64 alphabet\n * and nothing else, under a length cap. No quote, parenthesis, semicolon,\n * backslash or whitespace can appear in a value that passes, which is what\n * makes it safe inside `url(…)` with or without quotes. No `svg+xml`: an SVG is\n * a document, and the whole point of the check is that the value cannot carry\n * one.\n *\n * No DOM, no sharp, no database — imported by `@murumets-ee/blocks`' render\n * spine in the visitor's browser, through `@murumets-ee/media/crop`.\n */\n\n/**\n * The longest data URL any boundary accepts, in characters.\n *\n * The generator's output is 32px on the long edge, blurred: median 267, max 331\n * characters on the Kulli Farm set, and 299 for a synthetic worst case of\n * random colour blocks. The cap is three times that, so no real photograph is\n * refused, and small enough that a row carrying a crafted value cannot bloat\n * every page that draws it.\n */\nexport const MAX_BLUR_DATA_URL_LENGTH = 1024\n\n/**\n * `data:image/webp;base64,` or `data:image/png;base64,`, then canonical,\n * non-empty base64: groups of four, with padding only at the end. Anchored at both ends, and\n * linear — there is no nested quantifier to backtrack through.\n */\nconst BLUR_DATA_URL =\n /^data:image\\/(?:webp|png);base64,(?=[A-Za-z0-9+/])(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/\n\n/** Is `value` a blurred preview a render may put into `background-image`? */\nexport function isBlurDataUrl(value: unknown): value is string {\n if (typeof value !== 'string') return false\n // Length FIRST, so the pattern never runs over a crafted megabyte.\n if (value.length === 0 || value.length > MAX_BLUR_DATA_URL_LENGTH) return false\n return BLUR_DATA_URL.test(value)\n}\n\n/**\n * The stored value as a render may use it, or `null` — for absent, empty (the\n * \"looked, and there is none\" marker) and anything that fails\n * {@link isBlurDataUrl}. Lenient in the way `readFocalPoint` is: a bad value is\n * treated as no preview, never as an error on the page.\n */\nexport function readBlurDataUrl(value: unknown): string | null {\n return isBlurDataUrl(value) ? value : null\n}\n"],"mappings":"AAuCA,MAAa,EAA2B,KAOlC,EACJ,oHAGF,SAAgB,EAAc,EAAiC,CAI7D,OAHI,OAAO,GAAU,UAEjB,EAAM,SAAW,GAAK,EAAM,OAAA,KAA0C,GACnE,EAAc,KAAK,CAAK,CACjC,CAQA,SAAgB,EAAgB,EAA+B,CAC7D,OAAO,EAAc,CAAK,EAAI,EAAQ,IACxC"}
|
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, t as CropShape } from "./types-
|
|
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-lobQhTow.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";
|
|
@@ -75,7 +75,8 @@ declare class MediaClient {
|
|
|
75
75
|
/**
|
|
76
76
|
* Upload a file and create a media entity record in one step.
|
|
77
77
|
* 1. Uploads original to storage (StorageClient)
|
|
78
|
-
* 2. If image: reads its drawn size
|
|
78
|
+
* 2. If image: reads its drawn size, seeds a focal point and makes the blurred
|
|
79
|
+
* preview (images PR11) — INLINE, cheap
|
|
79
80
|
* 3. Creates media entity record (AdminClient)
|
|
80
81
|
* 4. If image: requests its variants — QUEUED (images D004), inline only
|
|
81
82
|
* when the app has no queue plugin
|
package/dist/client.d.mts.map
CHANGED
|
@@ -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;;;
|
|
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;;;ACbI;;;WDmBJ,cAAA;EAAA,SACA,MAAA,EAAQ,WAAW;AAAA;;iBAIZ,iBAAA,CAAkB,GAAA,EAAK,UAAA,GAAa,OAAA,CAAQ,cAAA;;;KCrB7D,WAAA,UAAqB,KAAA,CAAM,SAAS;AAAA,UAExB,iBAAA;EACf,KAAA,EAAO,WAAA,CAAY,WAAA;EACnB,OAAA,EAAS,aAAA;EDLI;;;;;ECWb,WAAA,GAAc,MAAA,SAAe,UAAA;AAAA;AAAA,cAGlB,WAAA;EAAA,QACH,KAAA;EAAA,QACA,OAAA;EAAA,QACA,WAAA;cAEI,MAAA,EAAQ,iBAAA;;;;;;UAWN,kBAAA;EDR6B;ECkB3C,0BAAA,CAAA;EDlBwD;;;AAAsB;;;;ACxB7D;;EAuDX,cAAA,CAAA,GAAkB,OAAA,CAAQ,MAAA,SAAe,UAAA;EApDvB;AAAe;AAEzC;;;;;EA6DQ,aAAA,CAAA,GAAiB,OAAA,CAAQ,SAAA;EArDF;;;;;;;;;;;;;AAAU;AAGzC;;;;;;EAsFQ,MAAA,CACJ,IAAA,EAAM,MAAA,GAAS,cAAA,CAAe,UAAA,GAC9B,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,iBAAA;EAiHL,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;EA5DgD;;;EAmErD,MAAA,CAAO,EAAA,WAAa,OAAA;EAPf;;;;EAyCL,MAAA,CAAO,EAAA,WAAa,OAAA;EAWI;;;;EAAxB,OAAA,CAAQ,GAAA,aAAgB,OAAA,CAAQ,GAAA;EAoEyB;;;;;;;;EAlCzD,aAAA,CAAc,EAAA,UAAY,SAAA,WAAoB,OAAA;EArT9C;;;;;;;;EAuVA,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"./shapes-BpOEJeLx.mjs";import{t}from"./entity-
|
|
1
|
+
import{t as e}from"./shapes-BpOEJeLx.mjs";import{t}from"./entity-CSrPWVJu.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{a as c,i as l,n as u,t as d}from"./process-image-Cwa8LlGb.mjs";import{n as f}from"./backlog-D-H5VUQA.mjs";import"server-only";var p=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,f=null;if(e instanceof Buffer&&c(t.mimeType)){let[n,i,c]=await Promise.all([l(e).catch(async e=>(await b(e,t.filename,`size`),null)),d(e).catch(async e=>(await b(e,t.filename,`focal point`),null)),u(e).catch(async e=>(await b(e,t.filename,`blurred preview`),null))]);n&&n.width>0&&n.height>0&&(f=n,r=n.width,a=n.height),o=i,s=c}let p=i(t.mimeType),h,g;try{g=await this.storage.getUrl(n.key),h=await this.admin.create({title:t.title??m(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,blurDataUrl:s,mediaType:p})}catch(e){throw await this.storage.delete(n.key).catch(()=>{}),e}if(f)try{let{getApp:e}=await import(`@murumets-ee/core`),{requestVariants:t}=await import(`./schedule-BIcDoT1d.mjs`);await t(e(),h,{media:this.admin,storage:this.storage})}catch(e){console.warn(`media: variants were not requested for`,h.id,e)}return{media:h,url:g}}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 x()),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 m(e){return e.replace(/\.[^.]+$/,``).replace(/[-_]/g,` `)}async function h(e){let{createAdminClient:n}=await import(`@murumets-ee/core/clients`);return new p({admin:n(t),storage:e})}const g=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 _(){return v()}async function v(){return g()}async function y(){let{createAdminClient:e}=await import(`@murumets-ee/core/clients`),n=await v();return new p({admin:e(t),storage:n})}async function b(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 x(){try{let{getApp:e}=await import(`@murumets-ee/core`);return e().logger}catch{return}}export{p as MediaClient,h as createMediaClient,y as getMediaClient,_ as getSharedStorageClient,f as getVariantBacklog};
|
|
2
2
|
//# sourceMappingURL=client.mjs.map
|
package/dist/client.mjs.map
CHANGED
|
@@ -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 { 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
|
+
{"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 {\n detectFocalPoint,\n generateBlurDataUrl,\n getImageDimensions,\n isProcessableImage,\n} 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, seeds a focal point and makes the blurred\n * preview (images PR11) — 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 // `null` = \"never looked\" (the catch-up tries again); `''` = \"looked, none\"\n // (a transparent source); otherwise the preview's data URL (images PR11).\n let blurDataUrl: string | null = null\n let drawn: { width: number; height: number } | null = null\n\n if (data instanceof Buffer && isProcessableImage(options.mimeType)) {\n // Three independent sharp reads of the same buffer. No failure fails the\n // upload: a file sharp cannot read keeps the client-measured size, the\n // centre and no preview, and gets no variants.\n const [dimensions, seeded, preview] = 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 generateBlurDataUrl(data).catch(async (err: unknown) => {\n await warnImageReadFailed(err, options.filename, 'blurred preview')\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 blurDataUrl = preview\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 blurDataUrl,\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' | 'blurred preview',\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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"}
|
package/dist/crop-editor.d.mts
CHANGED
package/dist/crop.d.mts
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
1
|
import { a as formatObjectPosition, c as objectPositionFor, i as focalCropRect, l as readFocalPoint, n as FocalPoint, o as isCentreFocalPoint, r as FractionalRect, s as isFocalPoint, t as CENTRE_FOCAL_POINT, u as visibleWindow } from "./focal-point-C_0drV03.mjs";
|
|
2
2
|
import { i as readCrops, n as isCropsRecord, r as isFractionalRect, t as clampFractionalRect } from "./crop-rect-C-SbjoxK.mjs";
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
//#region src/crop/blur-data-url.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The blurred preview a photograph shows while it loads — its validation, in
|
|
7
|
+
* ONE pure module (images PR11).
|
|
8
|
+
*
|
|
9
|
+
* The preview is a tiny image of the photograph, encoded as a data URL. The
|
|
10
|
+
* server generates it once (`generateBlurDataUrl` in `process-image.ts`) and
|
|
11
|
+
* stores it on the media row. A render draws it as the `<img>`'s own
|
|
12
|
+
* `background-image`, which the photograph paints over when it arrives.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 **It goes into an inline `style`, so it is a CSS injection vector**
|
|
15
|
+
* (CLAUDE.md `# Images`, fact 3). React's server renderer does not escape `;`
|
|
16
|
+
* or `)` inside a style value, so a stored `data:image/png;base64,x); background:
|
|
17
|
+
* url(//evil` would close the `url()` and add a declaration to every page that
|
|
18
|
+
* draws the photograph. The row is admin-writable JSON-adjacent text, and the
|
|
19
|
+
* projection hands it to anonymous pages. So it is checked at every boundary it
|
|
20
|
+
* crosses — the write (`blurDataUrlValidation`), the projection
|
|
21
|
+
* (`resolvePublicMedia`) and the render (`@murumets-ee/blocks`) — and all three
|
|
22
|
+
* ask THIS function, so they cannot disagree.
|
|
23
|
+
*
|
|
24
|
+
* What passes is deliberately narrow: two raster types, the base64 alphabet
|
|
25
|
+
* and nothing else, under a length cap. No quote, parenthesis, semicolon,
|
|
26
|
+
* backslash or whitespace can appear in a value that passes, which is what
|
|
27
|
+
* makes it safe inside `url(…)` with or without quotes. No `svg+xml`: an SVG is
|
|
28
|
+
* a document, and the whole point of the check is that the value cannot carry
|
|
29
|
+
* one.
|
|
30
|
+
*
|
|
31
|
+
* No DOM, no sharp, no database — imported by `@murumets-ee/blocks`' render
|
|
32
|
+
* spine in the visitor's browser, through `@murumets-ee/media/crop`.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* The longest data URL any boundary accepts, in characters.
|
|
36
|
+
*
|
|
37
|
+
* The generator's output is 32px on the long edge, blurred: median 267, max 331
|
|
38
|
+
* characters on the Kulli Farm set, and 299 for a synthetic worst case of
|
|
39
|
+
* random colour blocks. The cap is three times that, so no real photograph is
|
|
40
|
+
* refused, and small enough that a row carrying a crafted value cannot bloat
|
|
41
|
+
* every page that draws it.
|
|
42
|
+
*/
|
|
43
|
+
declare const MAX_BLUR_DATA_URL_LENGTH = 1024;
|
|
44
|
+
/** Is `value` a blurred preview a render may put into `background-image`? */
|
|
45
|
+
declare function isBlurDataUrl(value: unknown): value is string;
|
|
46
|
+
/**
|
|
47
|
+
* The stored value as a render may use it, or `null` — for absent, empty (the
|
|
48
|
+
* "looked, and there is none" marker) and anything that fails
|
|
49
|
+
* {@link isBlurDataUrl}. Lenient in the way `readFocalPoint` is: a bad value is
|
|
50
|
+
* treated as no preview, never as an error on the page.
|
|
51
|
+
*/
|
|
52
|
+
declare function readBlurDataUrl(value: unknown): string | null;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { CENTRE_FOCAL_POINT, type FocalPoint, type FractionalRect, MAX_BLUR_DATA_URL_LENGTH, clampFractionalRect, focalCropRect, formatObjectPosition, isBlurDataUrl, isCentreFocalPoint, isCropsRecord, isFocalPoint, isFractionalRect, objectPositionFor, readBlurDataUrl, readCrops, readFocalPoint, visibleWindow };
|
|
55
|
+
//# sourceMappingURL=crop.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crop.d.mts","names":[],"sources":["../src/crop/blur-data-url.ts"],"mappings":";;;;;;;;AAuCA;;;;AAAqC;AAWrC;;;;AAAoD;AAapD;;;;AAA8C;;;;;;;;;;;;;;;;;;;;cAxBjC,wBAAA;;iBAWG,aAAA,CAAc,KAAA,YAAiB,KAAK;;;;;;;iBAapC,eAAA,CAAgB,KAAc"}
|
package/dist/crop.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{i as e,n as t,r as n,t as r}from"./crop-rect-B-AJ2pWm.mjs";import{
|
|
1
|
+
import{i as e,n as t,r as n,t as r}from"./crop-rect-B-AJ2pWm.mjs";import{n as i,r as a,t as o}from"./blur-data-url-EtSndBso.mjs";import{a as s,c,i as l,n as u,o as d,r as f,s as p,t as m}from"./focal-point-BWhtJzef.mjs";export{m as CENTRE_FOCAL_POINT,o as MAX_BLUR_DATA_URL_LENGTH,r as clampFractionalRect,u as focalCropRect,f as formatObjectPosition,i as isBlurDataUrl,l as isCentreFocalPoint,t as isCropsRecord,s as isFocalPoint,n as isFractionalRect,d as objectPositionFor,a as readBlurDataUrl,e as readCrops,p as readFocalPoint,c as visibleWindow};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./entity-
|
|
2
|
-
//# sourceMappingURL=deps-
|
|
1
|
+
import{t as e}from"./entity-CSrPWVJu.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-BMOCedBq.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deps-
|
|
1
|
+
{"version":3,"file":"deps-BMOCedBq.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";import{n as t}from"./crop-rect-B-AJ2pWm.mjs";import{n,t as r}from"./blur-data-url-EtSndBso.mjs";import{a as i}from"./focal-point-BWhtJzef.mjs";import{o as a}from"./definitions-LJpgrdxd.mjs";import{t as o}from"./slot-CY4isqmN.mjs";import{behavior as s,defineEntity as c,field as l,searchable as u}from"@murumets-ee/entity";import{z as d}from"zod";function f(e){if(!(`blurDataUrl`in e))return e;let t=e.blurDataUrl;if(t==null||t===``||n(t))return e;throw new d.ZodError([{code:`custom`,path:[`blurDataUrl`],message:`blurDataUrl must be a data:image/webp or data:image/png base64 URL of at most ${r} characters, an empty string, or null.`}])}function p(e){if(e.keys.includes(`blurDataUrl`))throw new d.ZodError([{code:`custom`,path:[`blurDataUrl`],message:`bulk update on '${e.entityName}' cannot write 'blurDataUrl': updateMany runs no per-row hooks, so the value would bypass its shape check. Update each row individually.`}])}function m(){return{name:`blurDataUrlValidation`,hooks:{beforeCreate:async e=>f(e),beforeUpdate:async(e,t)=>f(t)},assertBulkUpdate:p}}function h(e){if(!(`crops`in e))return e;let n=e.crops;if(n==null||t(n))return e;throw new d.ZodError([{code:`custom`,path:[`crops`],message:`crops must be a map of shape name to { x, y, w, h } fractions in [0, 1], or null.`}])}function g(e){if(e.keys.includes(`crops`))throw new d.ZodError([{code:`custom`,path:[`crops`],message:`bulk update on '${e.entityName}' cannot write 'crops': updateMany runs no per-row hooks, so the value would bypass its shape check. Update each row individually.`}])}function _(){return{name:`cropsValidation`,hooks:{beforeCreate:async e=>h(e),beforeUpdate:async(e,t)=>h(t)},assertBulkUpdate:g}}function v(e){if(!(`focalPoint`in e))return e;let t=e.focalPoint;if(t==null||i(t))return e;throw new d.ZodError([{code:`custom`,path:[`focalPoint`],message:`focalPoint must be { x, y } with both between 0 and 1, or null.`}])}function y(e){if(e.keys.includes(`focalPoint`))throw new d.ZodError([{code:`custom`,path:[`focalPoint`],message:`bulk update on '${e.entityName}' cannot write 'focalPoint': updateMany runs no per-row hooks, so the value would bypass its shape check. Update each row individually.`}])}function b(){return{name:`focalPointValidation`,hooks:{beforeCreate:async e=>v(e),beforeUpdate:async(e,t)=>v(t)},assertBulkUpdate:y}}function x(e,t){return JSON.stringify(e??null)===JSON.stringify(t??null)}function S(){return{name:`variantRegeneration`,hooks:{afterUpdate:async(e,t)=>{if(!t||t.viaInternal||!t.enqueueOnCommit||!t.loadCurrent||e.mediaType!==`image`||typeof e.id!=`string`)return;let n=o();if(!n)return;let r=await t.loadCurrent();if(!r||x(r.focalPoint,e.focalPoint)&&x(r.crops,e.crops))return;let i=a(e.updatedAt);i&&t.enqueueOnCommit(n.generateVariants,{mediaId:e.id,epoch:i})}}}}var C=e({Media:()=>w});const w=c({name:`media`,fields:{title:l.text({translatable:!0}),alt:l.text({translatable:!0}),description:l.text({translatable:!0}),fileKey:l.text({required:!0,indexed:!0}),filename:l.text({required:!0}),mimeType:l.text({required:!0,indexed:!0,admin:{display:{type:`mimeType`}}}),size:l.number({required:!0,integer:!0,admin:{display:{type:`filesize`}}}),width:l.number({integer:!0}),height:l.number({integer:!0}),focalPoint:l.json(),crops:l.json(),blurDataUrl:l.text(),mediaType:l.select({options:[`image`,`video`,`audio`,`document`,`other`],required:!0,indexed:!0})},behaviors:[s.auditable(),b(),_(),m(),S(),u({fields:[`filename`,`fileKey`,`mimeType`,`title`,`alt`,`description`],fts:{fields:[`filename`,`title`,`alt`,`description`],language:`simple`},projection:e=>({id:e.id,label:e.title??e.filename,description:e.mimeType})})],admin:{list:{fields:[`title`,`mimeType`,`size`,`createdAt`],layouts:[`table`,`grid`]}},scope:`global`,access:{view:`public`,create:`group.editor`,update:`group.editor`,delete:`group.admin`}});export{C as n,w as t};
|
|
2
|
+
//# sourceMappingURL=entity-CSrPWVJu.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-CSrPWVJu.mjs","names":[],"sources":["../src/crop/blur-data-url-validation.ts","../src/crop/crops-validation.ts","../src/crop/focal-point-validation.ts","../src/jobs/regenerate-on-edit.ts","../src/entity.ts"],"sourcesContent":["/**\n * `blurDataUrlValidation()` — the write guard on `media.blurDataUrl`.\n *\n * The column is generated by the server (`generateBlurDataUrl`), but it is an\n * ordinary text field, so an editor's PATCH can write it too — and the\n * projection hands its value to every public page drawing the photograph, into\n * an inline `style` (CLAUDE.md `# Images`, fact 3). The render and the\n * projection check it again; this is the first of the three checks, so a bad\n * value never reaches the database at all.\n *\n * Errors are `ZodError`s, so the admin API answers 400 pathed at\n * `blurDataUrl`, the contract `focalPointValidation()` already relies on.\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { isBlurDataUrl, MAX_BLUR_DATA_URL_LENGTH } from './blur-data-url.js'\n\n/**\n * `null` means \"never looked\" — the catch-up generates one. `''` means \"looked,\n * and there is none\" (a transparent source; images PR11) — the catch-up leaves\n * it. Anything else must pass {@link isBlurDataUrl}.\n */\nfunction validateBlurDataUrl(data: Record<string, unknown>): Record<string, unknown> {\n // A payload without the key is a partial update that does not touch the column.\n if (!('blurDataUrl' in data)) return data\n const value = data.blurDataUrl\n if (value === null || value === undefined || value === '' || isBlurDataUrl(value)) return data\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['blurDataUrl'],\n message:\n 'blurDataUrl must be a data:image/webp or data:image/png base64 URL of at most ' +\n `${MAX_BLUR_DATA_URL_LENGTH} characters, an empty string, or null.`,\n },\n ])\n}\n\n/** `updateMany` runs no per-row hooks — refused, for `focalPointValidation`'s reason. */\nfunction assertNoBulkBlurDataUrlWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('blurDataUrl')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['blurDataUrl'],\n message:\n `bulk update on '${info.entityName}' cannot write 'blurDataUrl': updateMany runs no ` +\n 'per-row hooks, so the value would bypass its shape check. Update each row individually.',\n },\n ])\n}\n\n/** Contributes NO fields — see `focalPointValidation()` for why the map is `Record<never, never>`. */\nexport function blurDataUrlValidation(): Behavior<Record<never, never>, 'blurDataUrlValidation'> {\n return {\n name: 'blurDataUrlValidation',\n hooks: {\n beforeCreate: async (data) => validateBlurDataUrl(data),\n beforeUpdate: async (_id, data) => validateBlurDataUrl(data),\n },\n assertBulkUpdate: assertNoBulkBlurDataUrlWrite,\n }\n}\n","/**\n * `cropsValidation()` — the write guard on `media.crops`.\n *\n * Same reasoning as `focalPointValidation()`, one column over: `field.json()`\n * accepts any JSON, so without this hook an editor's PATCH could park\n * anything in `crops` — a bad shape name's worth of megabyte string, a\n * malformed rect — and every shaped slot resolving that shape would carry it\n * (images D002).\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { isCropsRecord } from './crop-rect.js'\n\n/**\n * `null` is a legitimate value — no shape has an override, so every shape\n * falls back to the focal point (D002's \"a missing crop is a fallback, never\n * a hole\"). Anything else must be a map of valid rects.\n */\nfunction validateCrops(data: Record<string, unknown>): Record<string, unknown> {\n // A payload without the key is a partial update that does not touch the\n // column — see `validateFocalPoint` for why defaulting it here would be wrong.\n if (!('crops' in data)) return data\n const value = data.crops\n if (value === null || value === undefined || isCropsRecord(value)) return data\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['crops'],\n message: 'crops must be a map of shape name to { x, y, w, h } fractions in [0, 1], or null.',\n },\n ])\n}\n\n/** Same reasoning as `assertNoBulkFocalPointWrite` — `updateMany` runs no per-row hooks. */\nfunction assertNoBulkCropsWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('crops')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['crops'],\n message:\n `bulk update on '${info.entityName}' cannot write 'crops': updateMany runs no per-row ` +\n 'hooks, so the value would bypass its shape check. Update each row individually.',\n },\n ])\n}\n\n/** Contributes NO fields — see `focalPointValidation`'s note on `Record<never, never>`. */\nexport function cropsValidation(): Behavior<Record<never, never>, 'cropsValidation'> {\n return {\n name: 'cropsValidation',\n hooks: {\n beforeCreate: async (data) => validateCrops(data),\n beforeUpdate: async (_id, data) => validateCrops(data),\n },\n assertBulkUpdate: assertNoBulkCropsWrite,\n }\n}\n","/**\n * `focalPointValidation()` — the write guard on `media.focalPoint`.\n *\n * `field.json()` validates as the permissive JSON union on purpose, so without\n * this hook an editor's PATCH could park any shape in the column — a megabyte\n * string, a nested object — and every public page drawing the photograph would\n * carry it, because the projection hands the value to the render. CLAUDE.md's\n * \"validate at the boundary\", on the one column this package adds.\n *\n * Errors are `ZodError`s, so the admin API answers 400 with an issue pathed at\n * `focalPoint` rather than a 500 — the contract `menuEntityTypesValidation()`\n * already relies on.\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { isFocalPoint } from './focal-point.js'\n\n/**\n * `null` is a legitimate value — it means \"no focal point\", which renders as the\n * centre (D010). Anything else must be exactly `{x, y}` in `[0, 1]`.\n */\nfunction validateFocalPoint(data: Record<string, unknown>): Record<string, unknown> {\n // A payload without the key is a partial update that does not touch the\n // column. Defaulting it here would let a title-only PATCH reset the point.\n if (!('focalPoint' in data)) return data\n const value = data.focalPoint\n if (value === null || value === undefined || isFocalPoint(value)) return data\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['focalPoint'],\n message: 'focalPoint must be { x, y } with both between 0 and 1, or null.',\n },\n ])\n}\n\n/**\n * `updateMany` runs no per-row hooks, so `beforeUpdate` never sees a bulk write.\n * Refused rather than validated: `BulkUpdateInfo` is deliberately shape-only (it\n * names the columns, never the values), and \"every photograph has the same\n * subject position\" is not an intent anyone has. A migration is the tool for a\n * one-off backfill.\n */\nfunction assertNoBulkFocalPointWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('focalPoint')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['focalPoint'],\n message:\n `bulk update on '${info.entityName}' cannot write 'focalPoint': updateMany runs no ` +\n 'per-row hooks, so the value would bypass its shape check. Update each row individually.',\n },\n ])\n}\n\n/**\n * Contributes NO fields — the empty map `Record<never, never>`, not\n * `Record<string, never>`, which would merge into the entity's field map and\n * collapse every field to `never`.\n */\nexport function focalPointValidation(): Behavior<Record<never, never>, 'focalPointValidation'> {\n return {\n name: 'focalPointValidation',\n hooks: {\n beforeCreate: async (data) => validateFocalPoint(data),\n beforeUpdate: async (_id, data) => validateFocalPoint(data),\n },\n assertBulkUpdate: assertNoBulkFocalPointWrite,\n }\n}\n","/**\n * `variantRegeneration()` — when an editor moves a photograph's focal point (or,\n * with images PR03's crop editor, redraws a crop), queue a pass for it.\n *\n * The shaped variants are cut around the point and the crop (`planVariants`),\n * and their keys carry a fingerprint of the cut (D005). So an edit changes WHICH\n * files the photograph should have; without a pass, the recorded set would keep\n * describing the old cut while the page's `object-position` follows the new one.\n *\n * Skipped when:\n * - the write came through `updateInternal` (`ctx.viaInternal`) — that is the\n * pass itself writing the drawn size or a seeded point back, and it has\n * already planned with them, so a hook here would only queue a duplicate;\n * - neither `focalPoint` nor `crops` changed — a title or alt edit changes no\n * pixels;\n * - the app has no queue (`ctx.enqueueOnCommit` absent, or no registered job):\n * an inline pass on every PATCH is the stall this plan removes. The\n * regenerate button catches such an app up.\n *\n * Enqueued through `ctx.enqueueOnCommit`, so the job row commits (or rolls\n * back) with the update. Its epoch is the updated row's `updatedAt`, so a\n * return to an earlier point is a new request, not a skipped repeat (F018).\n *\n * Imports nothing heavier than the slot: this module is reachable from\n * `entity.ts`, which client code imports for `toEntityMeta(Media)`.\n */\n\nimport type { Behavior } from '@murumets-ee/entity'\nimport { editEpoch } from './definitions.js'\nimport { getMediaJobs } from './slot.js'\n\nfunction sameJson(a: unknown, b: unknown): boolean {\n return JSON.stringify(a ?? null) === JSON.stringify(b ?? null)\n}\n\nexport function variantRegeneration(): Behavior<Record<never, never>, 'variantRegeneration'> {\n return {\n name: 'variantRegeneration',\n hooks: {\n afterUpdate: async (entity, ctx) => {\n if (!ctx || ctx.viaInternal || !ctx.enqueueOnCommit || !ctx.loadCurrent) return\n if (entity.mediaType !== 'image' || typeof entity.id !== 'string') return\n const jobs = getMediaJobs()\n if (!jobs) return\n const before = await ctx.loadCurrent()\n if (!before) return\n if (\n sameJson(before.focalPoint, entity.focalPoint) &&\n sameJson(before.crops, entity.crops)\n ) {\n return\n }\n const epoch = editEpoch(entity.updatedAt)\n if (!epoch) return\n ctx.enqueueOnCommit(jobs.generateVariants, { mediaId: entity.id, epoch })\n },\n },\n }\n}\n","/**\n * Pre-defined Media entity.\n *\n * Auto-registered by the media() plugin — users do NOT add this to their entities array.\n * Connected to toolkit_files via the fileKey field (stores the StorageClient file key).\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineConfig({\n * plugins: [storage(), media()],\n * // Media entity is auto-added — no need to list it here\n * })\n * ```\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport { blurDataUrlValidation } from './crop/blur-data-url-validation.js'\nimport { cropsValidation } from './crop/crops-validation.js'\nimport { focalPointValidation } from './crop/focal-point-validation.js'\nimport { variantRegeneration } from './jobs/regenerate-on-edit.js'\n\nexport const Media = defineEntity({\n name: 'media',\n fields: {\n // --- Display metadata (editable, translatable) ---\n title: field.text({ translatable: true }),\n alt: field.text({ translatable: true }),\n description: field.text({ translatable: true }),\n\n // --- File linkage (set on upload, immutable in practice) ---\n /** Key into toolkit_files table (e.g., 'uploads/2026/02/uuid/photo.jpg') */\n fileKey: field.text({ required: true, indexed: true }),\n\n // --- File metadata (set on upload) ---\n filename: field.text({ required: true }),\n // Drawn as an icon in the admin list (a film for `video/mp4`), not the raw\n // string — declared on the field, so any list of media reads the same\n // (entity-table D008).\n mimeType: field.text({\n required: true,\n indexed: true,\n admin: { display: { type: 'mimeType' } },\n }),\n // Bytes, drawn as \"1.5 MB\" in the operator's locale in the admin list\n // (entity-table D008). Hidden by default — an unindexed number is — and one\n // click away in the View menu.\n size: field.number({\n required: true,\n integer: true,\n admin: { display: { type: 'filesize' } },\n }),\n\n // --- Image-specific metadata (client-measured on upload) ---\n width: field.number({ integer: true }),\n height: field.number({ integer: true }),\n /**\n * Where the subject is, as `{ x, y }` fractions of the image — never pixels,\n * so it survives the file being replaced at another resolution (images\n * D002). A shaped slot crops around it (`@murumets-ee/media/crop`).\n *\n * Nullable, and `null` means the centre: an absent point and `{0.5, 0.5}`\n * render identically, so no reader branches on a legacy row (D010). The\n * shape is enforced by `focalPointValidation()` below — `field.json()` on\n * its own accepts any JSON.\n */\n focalPoint: field.json(),\n /**\n * Per-shape crop overrides, keyed by shape name — `{x, y, w, h}` fractions\n * of the image, `[0,1]` (images D002). Optional: a shape absent from this\n * map falls back to the rect `focalPoint` implies for that shape's ratio,\n * which is why a missing crop is a fallback and never a hole. Written by\n * the crop editor (`@murumets-ee/media/crop-editor`, images PR03).\n *\n * `null` and `{}` both mean \"no shape has an override\" — the same reading\n * `readFocalPoint` gives an absent focal point (D010). Shape is enforced\n * by `cropsValidation()` below.\n */\n crops: field.json(),\n /**\n * The blurred preview the photograph shows while it loads — a data URL of a\n * tiny image of it, generated once by the server (images PR11). A render\n * draws it as the `<img>`'s own background.\n *\n * `null` means \"never generated\" (a row from before PR11, or one whose\n * file sharp could not read): the catch-up generates it. `''` means \"looked,\n * and there is none\" — a source with transparency, where a blur would stay\n * visible through the transparent pixels. Both render nothing.\n *\n * Checked by `blurDataUrlValidation()` below, and again at the projection\n * and the render: the value ends up in an inline `style` on public pages.\n */\n blurDataUrl: field.text(),\n\n // --- Classification (derived from mimeType on upload) ---\n mediaType: field.select({\n options: ['image', 'video', 'audio', 'document', 'other'] as const,\n required: true,\n indexed: true,\n }),\n },\n behaviors: [\n behavior.auditable(),\n focalPointValidation(),\n cropsValidation(),\n blurDataUrlValidation(),\n // A moved focal point (or a redrawn crop) changes which variant files the\n // photograph should have — queue a pass for it (images PR05).\n variantRegeneration(),\n // filename / fileKey / mimeType live on the main table.\n // title/alt/description are translatable — their tsvector goes on\n // media_translations so non-default-locale callers still get FTS.\n searchable({\n fields: ['filename', 'fileKey', 'mimeType', 'title', 'alt', 'description'],\n fts: {\n fields: ['filename', 'title', 'alt', 'description'],\n language: 'simple',\n },\n projection: (row) => ({\n id: row.id as string,\n label: (row.title as string | undefined) ?? (row.filename as string),\n description: row.mimeType as string,\n }),\n }),\n ],\n admin: {\n list: {\n // Title first (its own row-link column), then the type — the icon\n // display already draws it, so the raw `mimeType` string is not a\n // second column — size and when it was added. `fileKey` (the raw\n // storage path) and `mediaType` (redundant with the icon) stay off by\n // default, one View-menu click away (entity-table PR06 §6).\n fields: ['title', 'mimeType', 'size', 'createdAt'],\n layouts: ['table', 'grid'],\n },\n },\n scope: 'global',\n access: {\n view: 'public',\n create: 'group.editor',\n update: 'group.editor',\n delete: 'group.admin',\n },\n})\n"],"mappings":"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"}
|
|
@@ -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,c as te,i as c,n as ne,o as re,t as l}from"./process-image-Cwa8LlGb.mjs";var ie=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,blurDataUrlWritten:!1,...n}}async function d(e){if(Buffer.isBuffer(e))return e;let t=[],n=e.getReader();for(;;){let{done:e,value:r}=await n.read();if(e)break;r&&t.push(r)}return Buffer.concat(t)}async function f(e,t,n){let r=[...e];await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=r.shift();e!==void 0;e=r.shift())await n(e)}))}function p(e,t){return JSON.stringify(e??null)===JSON.stringify(t??null)}function m(e,n){return t(e,{shape:n.shape,width:n.width,fingerprint:n.fingerprint,format:n.format})}function 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 h(t,h){let{media:y,storage:b,config:x,styles:oe,logger:S}=h,C=await y.findById(t);if(!C)return u(t,`missing`);let w=C;if(w.mediaType!==`image`||!ee(w.mimeType))return u(t,`skipped`);let T=await b.getMetadata(w.fileKey);if(!T)return S?.warn({mediaId:t},`media variants: original file record is missing; nothing to do`),u(t,`skipped`);let E=await d((await b.download(w.fileKey)).body),D=!1,O=await c(E),k={};if(O.width>0&&(w.width!==O.width||w.height!==O.height)&&(k.width=O.width,k.height=O.height),w.focalPoint===null||w.focalPoint===void 0){let e=await l(E).catch(e=>(S?.warn({mediaId:t,err:e},`media variants: focal-point detection failed; left unset`),null));e?k.focalPoint=e:D=!0}if(w.blurDataUrl===null||w.blurDataUrl===void 0){let e=await ne(E).catch(e=>(S?.warn({mediaId:t,err:e},`media variants: blurred preview failed; will retry`),null));e!==null&&(k.blurDataUrl=e)}if(`focalPoint`in k){let e=await y.findById(t);if(!e)return u(t,`missing`);e.focalPoint!==null&&e.focalPoint!==void 0&&(delete k.focalPoint,w={...w,focalPoint:e.focalPoint,crops:e.crops})}Object.keys(k).length>0&&(await y.updateInternal(t,k),w={...w,...k});let A=n(O,x,{focalPoint:w.focalPoint,crops:w.crops});A.dropped>0&&S?.warn({mediaId:t,dropped:A.dropped},`media variants: the per-image cap cut the largest variants off this photograph`);let j=o(T.metadata),se=new Map(j.shaped.map(e=>[e.key,e])),M=[],N=[];for(let e of A.variants){let t=m(w.fileKey,e),n=se.get(t);if(n){M.push({...n,shape:e.shape,rung:e.width});continue}let r=await b.getMetadata(t),i=r?ae(r,e,t):null;i?M.push(i):N.push({planned:e,key:t,replace:r!==null})}let ce=M.length,P=0,F=0,I=[],L=[],le=await te(E,N.map(e=>e.planned));await f(N.map((e,t)=>({...e,variant:le[t]})),4,async({planned:e,key:n,replace:r,variant:i})=>{if(i)try{r&&await b.delete(n),await b.upload(i.buffer,{key:n,filename:`${e.shape}_${e.width}w_${w.filename}`,mimeType:i.mimeType,size:i.buffer.byteLength,visibility:T.visibility,metadata:{variantOf:w.fileKey,shape:e.shape,rung:e.width,width:i.width,height:i.height}}),M.push({shape:e.shape,rung:e.width,width:i.width,height:i.height,format:e.format,key:n}),I.push(n),P++}catch(e){F++,S?.warn({mediaId:t,key:n,err:e},`media variants: shaped variant upload failed`)}});let R=new Map,z=new Map,B={},V=new Set;for(let[t,n]of Object.entries(oe)){let r=e(w.fileKey,t,n.format??`webp`),i=a(n);j.legacy.get(t)===r&&j.legacyRecipes.get(t)===i?(R.set(t,r),z.set(t,i)):B[t]=n}Object.keys(B).length>0&&await f([...(await re(E,B)).variants],4,async([n,r])=>{let i=B[n];if(!i)return;let o=e(w.fileKey,n,r.format),s=!1;try{await b.getMetadata(o)&&(await b.delete(o),s=!0),await b.upload(r.buffer,{key:o,filename:`${n}_${w.filename}`,mimeType:r.mimeType,size:r.buffer.byteLength,visibility:T.visibility,metadata:{variantOf:w.fileKey,style:n}}),R.set(n,o),z.set(n,a(i)),L.push(o),P++}catch(e){F++,s&&V.add(n),S?.warn({mediaId:t,key:o,err:e},`media variants: legacy style upload failed`)}});let H=await y.findById(t);if(!H)return await r(b,w.fileKey,[...I,...L],S),u(t,`missing`,{uploaded:P});let U=!p(H.focalPoint,w.focalPoint)||!p(H.crops,w.crops),W=await b.getMetadata(w.fileKey)??T,G=o(W.metadata),K=_(g(G.legacy,R),V),q=_(g(G.legacyRecipes,z),V),ue=[...G.legacy].filter(([e,t])=>K.has(e)&&K.get(e)!==t).map(([,e])=>e),J=U?new Set(n(O,x,{focalPoint:H.focalPoint,crops:H.crops}).variants.map(e=>m(w.fileKey,e))):null,Y=(e,t)=>{let n=new Set(e.map(e=>e.key));return[...new Set([...G.unconfirmed,...t,...ue])].filter(e=>!n.has(e))},X;if(U){let e=I.filter(e=>J?.has(e));X={legacy:K,legacyRecipes:q,shaped:G.shaped,unconfirmed:Y(G.shaped,e)}}else if(F>0){let e=new Set(G.shaped.map(e=>`${e.shape}@${e.rung}`)),t=M.filter(t=>!e.has(`${t.shape}@${t.rung}`)),n=v([...G.shaped,...t]);X={legacy:K,legacyRecipes:q,shaped:n,unconfirmed:Y(n,M.map(e=>e.key))}}else X={legacy:R,legacyRecipes:z,shaped:v(M),unconfirmed:[]};let Z=i(W.metadata,X);await b.updateMetadata(w.fileKey,{metadata:D?{...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 de=await r(b,w.fileKey,Q,S),$=u(t,U?`stale`:`generated`,{uploaded:P,reused:ce,deleted:de,dropped:A.dropped,dimensionsCorrected:`width`in k,focalPointSeeded:`focalPoint`in k,blurDataUrlWritten:`blurDataUrl`in k});if(F>0&&!U)throw new ie(t,F);return S?.debug($,`media variants: pass complete`),$}function g(e,t){return new Map([...e,...t])}function _(e,t){for(let n of t)e.delete(n);return e}function v(e){return[...e].sort((e,t)=>e.shape===t.shape?e.rung-t.rung||e.width-t.width:e.shape<t.shape?-1:1)}export{h as generateMediaVariants};
|
|
2
|
+
//# sourceMappingURL=generate-variants-CJ1oDbdp.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generate-variants-CJ1oDbdp.mjs","names":[],"sources":["../src/jobs/generate-variants.ts"],"sourcesContent":["/**\n * One generation pass for one photograph — the body of the\n * `media:generate-variants` job (images D004), and of the inline fallback an\n * app without the queue plugin runs at upload.\n *\n * ## The order, which is the point (F002)\n *\n * 1. Read the row and the ORIGINAL; correct the row's drawn size and seed a\n * missing focal point (F014, below).\n * 2. Plan the shaped set from the FILE's drawn size (F016) and render only the\n * variants whose key does not exist yet. A shaped key is recipe-addressed\n * (D005), so an existing key already holds the right bytes.\n * 3. Upload the new files.\n * 4. Record the new set on the original's metadata — the pointer readers use.\n * 5. Only then delete what the new set superseded.\n *\n * Today's `regenerateAllVariants` deleted first, so removing a style destroyed\n * its files before anything replaced them and every variant URL 404'd for the\n * length of the run. Here a reader holding a stale URL keeps getting the old\n * picture until the pointer has moved.\n *\n * Legacy `{style}_{base}` keys cannot be additive — the key has no recipe in it\n * and `StorageClient.upload` refuses to overwrite a key (R014). So a legacy file\n * is re-rendered only when its style's recipe CHANGED (`legacyRecipes`), and\n * then replaced one key at a time with the new bytes already in memory: a\n * window of one upload, not of the whole library.\n *\n * ## The row, while the original is open anyway (F014)\n *\n * Rows uploaded before images PR02 hold sensor-oriented `width`/`height` — an\n * EXIF-rotated phone portrait stored 4000×3000 is drawn 3000×4000, and its focal\n * crop is positioned on the wrong axis. Rows uploaded before PR02 also have no\n * focal point, and rows uploaded before images PR11 have no blurred preview.\n * The pass already decodes the original, so it writes the drawn size back,\n * makes the missing preview, and seeds a `null` focal point with `detectFocalPoint` — never\n * overwriting a point someone set (the picker's Reset writes the centre, not\n * `null`, so `null` means \"never set\"). Both go through `updateInternal`, which\n * the regenerate-on-edit behavior ignores, so this write does not enqueue\n * another pass of itself.\n *\n * ## Failure\n *\n * A variant that fails to upload is logged and the pass THROWS after recording\n * and deleting nothing a reader resolves — so the queue retries it, and the\n * retry only renders what is still missing. The shaped set readers see is left\n * exactly as it was; what did land is recorded as `unconfirmed` (adopted by the\n * retry, and deleted with the photograph). The one exception is a legacy file\n * replaced in place whose new upload failed after the old was deleted: its\n * mapping is dropped, so readers fall back to the original rather than a 404.\n *\n * ## Concurrency, and what is accepted\n *\n * Two passes for one photograph converge: a key already written by the other\n * fails the unique-key upload and is adopted on retry. A pass that sees the\n * row's point or crop change between planning and recording returns `stale`\n * and records nothing shaped. What is NOT closed: a newer pass recording\n * between this pass's freshness check and its own record — a window of two\n * round trips. The loser's set is then recorded for the winner's point until\n * the next backfill finds the mismatch (`isUpToDate`) and queues a repair.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { FileRecord, StorageClient } from '@murumets-ee/storage'\nimport {\n detectFocalPoint,\n generateBlurDataUrl,\n getImageDimensions,\n isProcessableImage,\n processImage,\n renderPlannedVariants,\n} from '../process-image.js'\nimport type { VariantConfig } from '../shapes.js'\nimport type { ImageStyle } from '../types.js'\nimport { deriveShapedVariantKey, deriveVariantKey } from '../variant-key.js'\nimport { type PlannedVariant, planVariants } from '../variant-plan.js'\nimport { deleteOwnedVariants } from './owned-delete.js'\nimport {\n FOCAL_SEED_TRIED,\n legacyRecipe,\n readVariantState,\n type StoredShapedVariant,\n type VariantState,\n writeVariantState,\n} from './variant-state.js'\n\n/** The slice of a media row a pass reads. `crops` arrives with images PR03; read leniently. */\nexport interface VariantSourceRow {\n readonly id: string\n readonly fileKey: string\n readonly filename: string\n readonly mimeType: string\n readonly mediaType: string\n readonly width?: number | null\n readonly height?: number | null\n readonly focalPoint?: unknown\n readonly crops?: unknown\n /** `null`/absent = never generated (images PR11); `''` = looked, none. */\n readonly blurDataUrl?: string | null\n}\n\nexport interface VariantGenerationDeps {\n /** Reads the media row, and writes the corrected size / seeded focal point. */\n readonly media: {\n findById(id: string): Promise<VariantSourceRow | null>\n updateInternal(id: string, data: Record<string, unknown>): Promise<unknown>\n }\n readonly storage: Pick<\n StorageClient,\n 'download' | 'getMetadata' | 'upload' | 'updateMetadata' | 'delete'\n >\n /** The shape model — `resolveVariantConfig`. */\n readonly config: VariantConfig\n /** The legacy fixed styles — `resolveImageStyles`. */\n readonly styles: Readonly<Record<string, ImageStyle>>\n readonly logger?: Pick<Logger, 'info' | 'warn' | 'debug'> | undefined\n}\n\nexport type VariantGenerationStatus =\n /** The pass ran — possibly finding nothing to do. */\n | 'generated'\n /** Not a raster image this pipeline processes (SVG, GIF, a PDF), or its original is gone. */\n | 'skipped'\n /** The row no longer exists. */\n | 'missing'\n /**\n * The row's focal point or crops changed while the pass ran, so what it\n * rendered is already out of date. Nothing shaped was recorded; the edit\n * enqueued its own pass, which will.\n */\n | 'stale'\n\nexport interface VariantGenerationResult {\n readonly mediaId: string\n readonly status: VariantGenerationStatus\n /** Files written this pass (shaped + legacy). */\n readonly uploaded: number\n /** Planned shaped variants that already existed and were kept as they were. */\n readonly reused: number\n /** Superseded files removed after the new set was recorded. */\n readonly deleted: number\n /** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`). */\n readonly dropped: number\n readonly dimensionsCorrected: boolean\n readonly focalPointSeeded: boolean\n /** The row had no blurred preview and this pass wrote one (or `''`, none). */\n readonly blurDataUrlWritten: boolean\n}\n\n/** Uploads run a few at a time — each is a DB insert plus an object write. */\nconst UPLOAD_CONCURRENCY = 4\n\n/** Thrown when some variants failed to upload — the queue retries the pass. */\nexport class VariantUploadError extends Error {\n readonly failed: number\n constructor(mediaId: string, failed: number) {\n super(`media ${mediaId}: ${failed} variant(s) failed to upload; the pass will be retried`)\n this.name = 'VariantUploadError'\n this.failed = failed\n }\n}\n\nfunction result(\n mediaId: string,\n status: VariantGenerationStatus,\n partial: Partial<Omit<VariantGenerationResult, 'mediaId' | 'status'>> = {},\n): VariantGenerationResult {\n return {\n mediaId,\n status,\n uploaded: 0,\n reused: 0,\n deleted: 0,\n dropped: 0,\n dimensionsCorrected: false,\n focalPointSeeded: false,\n blurDataUrlWritten: false,\n ...partial,\n }\n}\n\n/** A downloaded body as one Buffer. */\nasync function toBuffer(body: Buffer | ReadableStream<Uint8Array>): Promise<Buffer> {\n if (Buffer.isBuffer(body)) return body\n const chunks: Uint8Array[] = []\n const reader = body.getReader()\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n if (value) chunks.push(value)\n }\n return Buffer.concat(chunks)\n}\n\n/** `Promise.all(items.map(fn))` with at most `limit` in flight. */\nasync function eachBounded<T>(\n items: readonly T[],\n limit: number,\n fn: (item: T) => Promise<void>,\n): Promise<void> {\n const queue = [...items]\n const worker = async (): Promise<void> => {\n for (let item = queue.shift(); item !== undefined; item = queue.shift()) await fn(item)\n }\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))\n}\n\nfunction sameJson(a: unknown, b: unknown): boolean {\n return JSON.stringify(a ?? null) === JSON.stringify(b ?? null)\n}\n\nfunction shapedKey(fileKey: string, planned: PlannedVariant): string {\n return deriveShapedVariantKey(fileKey, {\n shape: planned.shape,\n width: planned.width,\n fingerprint: planned.fingerprint,\n format: planned.format,\n })\n}\n\n/**\n * A shaped file that exists but was not recorded — a previous pass uploaded it\n * and then failed before recording, or a concurrent pass did. The job writes the\n * real size onto every file it uploads, so it can be recorded without decoding.\n */\nfunction recordedFromFile(\n file: FileRecord,\n planned: PlannedVariant,\n key: string,\n): StoredShapedVariant | null {\n const meta = file.metadata ?? {}\n const { width, height } = meta\n if (typeof width !== 'number' || typeof height !== 'number' || width <= 0 || height <= 0) {\n return null\n }\n return { shape: planned.shape, rung: planned.width, width, height, format: planned.format, key }\n}\n\n/** Generate — and record, and clean up after — every variant one photograph should have. */\nexport async function generateMediaVariants(\n mediaId: string,\n deps: VariantGenerationDeps,\n): Promise<VariantGenerationResult> {\n const { media, storage, config, styles, logger } = deps\n\n const found = await media.findById(mediaId)\n if (!found) return result(mediaId, 'missing')\n let row = found\n if (row.mediaType !== 'image' || !isProcessableImage(row.mimeType)) {\n return result(mediaId, 'skipped')\n }\n const original = await storage.getMetadata(row.fileKey)\n if (!original) {\n logger?.warn({ mediaId }, 'media variants: original file record is missing; nothing to do')\n return result(mediaId, 'skipped')\n }\n const buffer = await toBuffer((await storage.download(row.fileKey)).body)\n\n // ── 1. The row: drawn size, and a seed for a point nobody has set (F014) ──\n let seedTried = false\n const drawn = await getImageDimensions(buffer)\n const patch: Record<string, unknown> = {}\n if (drawn.width > 0 && (row.width !== drawn.width || row.height !== drawn.height)) {\n patch.width = drawn.width\n patch.height = drawn.height\n }\n if (row.focalPoint === null || row.focalPoint === undefined) {\n const seeded = await detectFocalPoint(buffer).catch((err: unknown) => {\n logger?.warn({ mediaId, err }, 'media variants: focal-point detection failed; left unset')\n return null\n })\n if (seeded) patch.focalPoint = seeded\n // Nothing to guess about (a strip, a flat image): say so on the original,\n // or every walk would queue this photograph again to guess once more.\n else seedTried = true\n }\n // The blurred preview (images PR11), for a row from before it existed. `''`\n // is what the generator returns for a transparent source (\"none\"), and it is\n // written so the next walk does not queue this photograph again to look once\n // more. A THROW writes nothing: the column stays `null`, so a transient\n // failure is retried by the next walk rather than recorded as \"none\" for\n // good. A file whose header reads but whose body does not throws here every\n // time and is queued again by every walk the operator starts — waste, not a\n // loop, and the same as such a file already gets from `processImage`. No\n // re-read before writing: nothing but the server sets it, so there is no\n // editor's value to protect.\n if (row.blurDataUrl === null || row.blurDataUrl === undefined) {\n const preview = await generateBlurDataUrl(buffer).catch((err: unknown) => {\n logger?.warn({ mediaId, err }, 'media variants: blurred preview failed; will retry')\n return null\n })\n if (preview !== null) patch.blurDataUrl = preview\n }\n if ('focalPoint' in patch) {\n // The seed took a download and a saliency pass; an editor may have set a\n // point meanwhile. Re-read right before writing, and never overwrite one\n // that is now set (a window of one round trip remains — accepted).\n const current = await media.findById(mediaId)\n if (!current) return result(mediaId, 'missing')\n if (current.focalPoint !== null && current.focalPoint !== undefined) {\n delete patch.focalPoint\n row = { ...row, focalPoint: current.focalPoint, crops: current.crops }\n }\n }\n if (Object.keys(patch).length > 0) {\n await media.updateInternal(mediaId, patch)\n row = { ...row, ...patch }\n }\n\n // ── 2. The shaped plan, from the FILE's drawn size ──\n const plan = planVariants(drawn, config, { focalPoint: row.focalPoint, crops: row.crops })\n if (plan.dropped > 0) {\n logger?.warn(\n { mediaId, dropped: plan.dropped },\n 'media variants: the per-image cap cut the largest variants off this photograph',\n )\n }\n\n const before = readVariantState(original.metadata)\n const recorded = new Map(before.shaped.map((v) => [v.key, v]))\n const present: StoredShapedVariant[] = []\n const toRender: Array<{ planned: PlannedVariant; key: string; replace: boolean }> = []\n for (const planned of plan.variants) {\n const key = shapedKey(row.fileKey, planned)\n const known = recorded.get(key)\n if (known) {\n present.push({ ...known, shape: planned.shape, rung: planned.width })\n continue\n }\n const existing = await storage.getMetadata(key)\n const fromFile = existing ? recordedFromFile(existing, planned, key) : null\n if (fromFile) present.push(fromFile)\n else toRender.push({ planned, key, replace: existing !== null })\n }\n const reused = present.length\n\n // ── 3. Render what is missing, and upload it ──\n let uploaded = 0\n let failed = 0\n const uploadedKeys: string[] = []\n /** Legacy files written this pass — only for the photograph-deleted-mid-pass cleanup. */\n const uploadedLegacyKeys: string[] = []\n const rendered = await renderPlannedVariants(\n buffer,\n toRender.map((t) => t.planned),\n )\n const work = toRender.map((t, i) => ({ ...t, variant: rendered[i] }))\n await eachBounded(work, UPLOAD_CONCURRENCY, async ({ planned, key, replace, variant }) => {\n if (!variant) return\n try {\n // An unrecorded file with no size on it predates this module's metadata\n // and cannot be trusted as the recipe's bytes — replaced, not kept.\n if (replace) await storage.delete(key)\n await storage.upload(variant.buffer, {\n key,\n filename: `${planned.shape}_${planned.width}w_${row.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility: original.visibility,\n metadata: {\n variantOf: row.fileKey,\n shape: planned.shape,\n rung: planned.width,\n width: variant.width,\n height: variant.height,\n },\n })\n present.push({\n shape: planned.shape,\n rung: planned.width,\n width: variant.width,\n height: variant.height,\n format: planned.format,\n key,\n })\n uploadedKeys.push(key)\n uploaded++\n } catch (err) {\n failed++\n logger?.warn({ mediaId, key, err }, 'media variants: shaped variant upload failed')\n }\n })\n\n // ── Legacy fixed styles: re-rendered only when the recipe changed ──\n const legacy = new Map<string, string>()\n const legacyRecipes = new Map<string, string>()\n const legacyDue: Record<string, ImageStyle> = {}\n /** Styles whose old file was deleted for an in-place replacement whose upload then failed. */\n const legacyGone = new Set<string>()\n for (const [name, style] of Object.entries(styles)) {\n const key = deriveVariantKey(row.fileKey, name, style.format ?? 'webp')\n const recipe = legacyRecipe(style)\n if (before.legacy.get(name) === key && before.legacyRecipes.get(name) === recipe) {\n legacy.set(name, key)\n legacyRecipes.set(name, recipe)\n } else {\n legacyDue[name] = style\n }\n }\n if (Object.keys(legacyDue).length > 0) {\n const processed = await processImage(buffer, legacyDue)\n await eachBounded([...processed.variants], UPLOAD_CONCURRENCY, async ([name, variant]) => {\n const style = legacyDue[name]\n if (!style) return\n const key = deriveVariantKey(row.fileKey, name, variant.format)\n let oldDeleted = false\n try {\n // In place: a legacy key names no recipe, so new bytes need the old row\n // gone first. The bytes are already rendered — the gap is one upload.\n if (await storage.getMetadata(key)) {\n await storage.delete(key)\n oldDeleted = true\n }\n await storage.upload(variant.buffer, {\n key,\n filename: `${name}_${row.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility: original.visibility,\n metadata: { variantOf: row.fileKey, style: name },\n })\n legacy.set(name, key)\n legacyRecipes.set(name, legacyRecipe(style))\n uploadedLegacyKeys.push(key)\n uploaded++\n } catch (err) {\n failed++\n // The old file is gone and its replacement is not there: stop pointing\n // at it, so readers fall back to the original until the retry lands.\n if (oldDeleted) legacyGone.add(name)\n logger?.warn({ mediaId, key, err }, 'media variants: legacy style upload failed')\n }\n })\n }\n\n // ── Was the plan still the right one? ──\n const fresh = await media.findById(mediaId)\n if (!fresh) {\n // Deleted while the pass ran. `MediaClient.delete` removed what was\n // recorded; what this pass wrote was never recorded, so it goes here.\n await deleteOwnedVariants(\n storage,\n row.fileKey,\n [...uploadedKeys, ...uploadedLegacyKeys],\n logger,\n )\n return result(mediaId, 'missing', { uploaded })\n }\n const stale = !sameJson(fresh.focalPoint, row.focalPoint) || !sameJson(fresh.crops, row.crops)\n\n // ── 4. Record — merged over a fresh read, so a concurrent writer's other keys survive ──\n const latest = (await storage.getMetadata(row.fileKey)) ?? original\n const latestState = readVariantState(latest.metadata)\n const mergedLegacy = without(merged(latestState.legacy, legacy), legacyGone)\n const mergedRecipes = without(merged(latestState.legacyRecipes, legacyRecipes), legacyGone)\n // A legacy style whose key CHANGED (its format did) now points at the new\n // file; the old one is recorded nowhere else. Outside a successful pass it\n // must stay findable, so it goes to `unconfirmed` (deleted by the next\n // successful pass, and with the photograph).\n const replacedLegacy = [...latestState.legacy]\n .filter(([name, key]) => mergedLegacy.has(name) && mergedLegacy.get(name) !== key)\n .map(([, key]) => key)\n const freshKeys = stale\n ? new Set(\n planVariants(drawn, config, {\n focalPoint: fresh.focalPoint,\n crops: fresh.crops,\n }).variants.map((p) => shapedKey(row.fileKey, p)),\n )\n : null\n /** Every file this pass holds that the recorded `shaped` set will not name. */\n const leftovers = (shaped: readonly StoredShapedVariant[], extra: readonly string[]) => {\n const named = new Set(shaped.map((v) => v.key))\n return [...new Set([...latestState.unconfirmed, ...extra, ...replacedLegacy])].filter(\n (k) => !named.has(k),\n )\n }\n let next: VariantState\n if (stale) {\n // The shaped set belongs to the edit's own pass. Record the legacy files\n // (independent of focal point and crops). What this pass rendered that the\n // NEW point also plans is kept as unconfirmed for the edit's pass to adopt;\n // the rest is deleted below.\n const keepForEdit = uploadedKeys.filter((k) => freshKeys?.has(k))\n next = {\n legacy: mergedLegacy,\n legacyRecipes: mergedRecipes,\n shaped: latestState.shaped,\n unconfirmed: leftovers(latestState.shaped, keepForEdit),\n }\n } else if (failed > 0) {\n // Never two crops for one (shape, rung): a variant that landed is promoted\n // only where readers have nothing for its (shape, rung) yet — so a photo\n // whose one format always fails still gets every other size. The rest is\n // UNCONFIRMED: the retry adopts it, and deleting the photograph finds it.\n const taken = new Set(latestState.shaped.map((v) => `${v.shape}@${v.rung}`))\n const promoted = present.filter((v) => !taken.has(`${v.shape}@${v.rung}`))\n const shaped = sortShaped([...latestState.shaped, ...promoted])\n next = {\n legacy: mergedLegacy,\n legacyRecipes: mergedRecipes,\n shaped,\n unconfirmed: leftovers(\n shaped,\n present.map((v) => v.key),\n ),\n }\n } else {\n next = { legacy, legacyRecipes, shaped: sortShaped(present), unconfirmed: [] }\n }\n const written = writeVariantState(latest.metadata, next)\n await storage.updateMetadata(row.fileKey, {\n metadata: seedTried ? { ...written, [FOCAL_SEED_TRIED]: true } : written,\n })\n\n // ── 5. Only now delete what the new set superseded — and only files that are\n // variants OF this original (`deleteOwnedVariants`): the lists come from\n // caller-writable metadata. ──\n const toDelete = new Set<string>()\n if (stale) {\n const stillRecorded = new Set(latestState.shaped.map((v) => v.key))\n for (const key of uploadedKeys) {\n if (!freshKeys?.has(key) && !stillRecorded.has(key)) toDelete.add(key)\n }\n } else if (failed === 0) {\n const keep = new Set([...present.map((v) => v.key), ...legacy.values()])\n for (const v of latestState.shaped) if (!keep.has(v.key)) toDelete.add(v.key)\n for (const key of latestState.legacy.values()) if (!keep.has(key)) toDelete.add(key)\n for (const key of latestState.unconfirmed) if (!keep.has(key)) toDelete.add(key)\n }\n const deleted = await deleteOwnedVariants(storage, row.fileKey, toDelete, logger)\n\n const outcome = result(mediaId, stale ? 'stale' : 'generated', {\n uploaded,\n reused,\n deleted,\n dropped: plan.dropped,\n dimensionsCorrected: 'width' in patch,\n focalPointSeeded: 'focalPoint' in patch,\n blurDataUrlWritten: 'blurDataUrl' in patch,\n })\n if (failed > 0 && !stale) throw new VariantUploadError(mediaId, failed)\n logger?.debug(outcome, 'media variants: pass complete')\n return outcome\n}\n\nfunction merged<V>(base: ReadonlyMap<string, V>, over: ReadonlyMap<string, V>): Map<string, V> {\n return new Map([...base, ...over])\n}\n\nfunction without<V>(map: Map<string, V>, names: ReadonlySet<string>): Map<string, V> {\n for (const name of names) map.delete(name)\n return map\n}\n\n/** Shape, then width — a stable order, so an unchanged set writes unchanged JSON. */\nfunction sortShaped(list: readonly StoredShapedVariant[]): StoredShapedVariant[] {\n return [...list].sort((x, y) =>\n x.shape === y.shape ? x.rung - y.rung || x.width - y.width : x.shape < y.shape ? -1 : 1,\n )\n}\n"],"mappings":"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"}
|
package/dist/image-styles.d.mts
CHANGED
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { a as ImageVocabulary, c as MediaPluginConfig, d as MediaUploadOptions, f as MediaUploadResult, i as ImageStyle, l as MediaRecord, m as Media, n as ImageFormat, o as MediaListOptions, r as ImageShape, s as MediaListResult, t as CropShape, u as MediaType } from "./types-
|
|
1
|
+
import { a as ImageVocabulary, c as MediaPluginConfig, d as MediaUploadOptions, f as MediaUploadResult, i as ImageStyle, l as MediaRecord, m as Media, n as ImageFormat, o as MediaListOptions, r as ImageShape, s as MediaListResult, t as CropShape, u as MediaType } from "./types-lobQhTow.mjs";
|
|
2
2
|
import { defaultImageStyles, imageStylesSettings } from "./image-styles-settings.mjs";
|
|
3
|
-
import { a as ResolvedShape, n as DEFAULT_IMAGE_SHAPES, r as DEFAULT_IMAGE_VOCABULARY, t as DEFAULT_IMAGE_LADDER } from "./shapes-
|
|
4
|
-
import { i as MediaPickerListResult, r as MediaPickerItem, t as MediaPickerCallbacks } from "./types-
|
|
3
|
+
import { a as ResolvedShape, n as DEFAULT_IMAGE_SHAPES, r as DEFAULT_IMAGE_VOCABULARY, t as DEFAULT_IMAGE_LADDER } from "./shapes-BQH9_3h8.mjs";
|
|
4
|
+
import { i as MediaPickerListResult, r as MediaPickerItem, t as MediaPickerCallbacks } from "./types-Ba76KK9j.mjs";
|
|
5
5
|
//#region src/crop/shapes.d.ts
|
|
6
6
|
/**
|
|
7
7
|
* The crop tabs for a resolved variant config — every shape the generator
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{n as e,t}from"./shapes-BpOEJeLx.mjs";import{t as n}from"./entity-
|
|
1
|
+
import{n as e,t}from"./shapes-BpOEJeLx.mjs";import{t as n}from"./entity-CSrPWVJu.mjs";import{n as r,r as i,t as a}from"./shapes-CqppzFzb.mjs";import{defaultImageStyles as o,imageStylesSettings as s}from"./image-styles-settings.mjs";import"server-only";async function c(e,t,n=`thumbnail`){let r=Object.entries(e.allFields).filter(([,e])=>e.type===`media`).map(([e])=>e);if(r.length===0)return;let i=new Set;for(let e of t)for(let t of r){let n=e[t];typeof n==`string`&&n.length>0&&i.add(n)}if(i.size===0)return;let{getMediaClient:a}=await import(`./client.mjs`),o=await(await a()).getVariantUrls([...i],n);for(let e of t)for(let t of r){let n=e[t];typeof n==`string`&&o.has(n)&&(e[`${t}Url`]=o.get(n))}}export{a as DEFAULT_IMAGE_LADDER,r as DEFAULT_IMAGE_SHAPES,i as DEFAULT_IMAGE_VOCABULARY,n as Media,t as cropShapesFor,o as defaultImageStyles,e as deriveCropShapes,c as enrichWithMediaUrls,s as imageStylesSettings};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|