@murumets-ee/media 0.74.1 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/backfill-BpcWvL5P.mjs +2 -0
  4. package/dist/backfill-BpcWvL5P.mjs.map +1 -0
  5. package/dist/blur-data-url-EtSndBso.mjs +2 -0
  6. package/dist/blur-data-url-EtSndBso.mjs.map +1 -0
  7. package/dist/client.d.mts +3 -2
  8. package/dist/client.d.mts.map +1 -1
  9. package/dist/client.mjs +1 -1
  10. package/dist/client.mjs.map +1 -1
  11. package/dist/crop-editor.d.mts +1 -1
  12. package/dist/crop.d.mts +53 -1
  13. package/dist/crop.d.mts.map +1 -0
  14. package/dist/crop.mjs +1 -1
  15. package/dist/{deps-DaGjHO16.mjs → deps-BMOCedBq.mjs} +2 -2
  16. package/dist/{deps-DaGjHO16.mjs.map → deps-BMOCedBq.mjs.map} +1 -1
  17. package/dist/entity-CSrPWVJu.mjs +2 -0
  18. package/dist/entity-CSrPWVJu.mjs.map +1 -0
  19. package/dist/generate-variants-CJ1oDbdp.mjs +2 -0
  20. package/dist/generate-variants-CJ1oDbdp.mjs.map +1 -0
  21. package/dist/image-styles-settings.d.mts +1 -1
  22. package/dist/image-styles.d.mts +1 -1
  23. package/dist/index.d.mts +3 -3
  24. package/dist/index.mjs +1 -1
  25. package/dist/picker.d.mts +1 -1
  26. package/dist/plugin.d.mts +1 -1
  27. package/dist/plugin.mjs +1 -1
  28. package/dist/process-image-Cwa8LlGb.mjs +2 -0
  29. package/dist/process-image-Cwa8LlGb.mjs.map +1 -0
  30. package/dist/processing.d.mts +2 -2
  31. package/dist/processing.d.mts.map +1 -1
  32. package/dist/processing.mjs +1 -1
  33. package/dist/public-resolver.d.mts +15 -0
  34. package/dist/public-resolver.d.mts.map +1 -1
  35. package/dist/public-resolver.mjs +1 -1
  36. package/dist/public-resolver.mjs.map +1 -1
  37. package/dist/query-client.d.mts +1 -1
  38. package/dist/query-client.mjs +1 -1
  39. package/dist/{regenerate-variants-RwV_AQSo.mjs → regenerate-variants-BpCAq5U5.mjs} +2 -2
  40. package/dist/{regenerate-variants-RwV_AQSo.mjs.map → regenerate-variants-BpCAq5U5.mjs.map} +1 -1
  41. package/dist/{register-CJKjNl2w.mjs → register-CJAkO7a2.mjs} +2 -2
  42. package/dist/{register-CJKjNl2w.mjs.map → register-CJAkO7a2.mjs.map} +1 -1
  43. package/dist/routes-Dk_oVBmE.mjs +2 -0
  44. package/dist/routes-Dk_oVBmE.mjs.map +1 -0
  45. package/dist/{schedule-JlvfUEVA.mjs → schedule-BIcDoT1d.mjs} +2 -2
  46. package/dist/{schedule-JlvfUEVA.mjs.map → schedule-BIcDoT1d.mjs.map} +1 -1
  47. package/dist/{shapes-C51rXSDJ.d.mts → shapes-BQH9_3h8.d.mts} +2 -2
  48. package/dist/{shapes-C51rXSDJ.d.mts.map → shapes-BQH9_3h8.d.mts.map} +1 -1
  49. package/dist/{types-Lj3SkZQ_.d.mts → types-Ba76KK9j.d.mts} +7 -1
  50. package/dist/types-Ba76KK9j.d.mts.map +1 -0
  51. package/dist/{types-6PZQ4Zq6.d.mts → types-lobQhTow.d.mts} +16 -2
  52. package/dist/types-lobQhTow.d.mts.map +1 -0
  53. package/package.json +9 -9
  54. package/dist/backfill-B1StTnxg.mjs +0 -2
  55. package/dist/backfill-B1StTnxg.mjs.map +0 -1
  56. package/dist/entity-CsDdjKz6.mjs +0 -2
  57. package/dist/entity-CsDdjKz6.mjs.map +0 -1
  58. package/dist/generate-variants-Dpy4aT2o.mjs +0 -2
  59. package/dist/generate-variants-Dpy4aT2o.mjs.map +0 -1
  60. package/dist/process-image-wQwiRhyk.mjs +0 -2
  61. package/dist/process-image-wQwiRhyk.mjs.map +0 -1
  62. package/dist/routes-DaE0M8h8.mjs +0 -2
  63. package/dist/routes-DaE0M8h8.mjs.map +0 -1
  64. package/dist/types-6PZQ4Zq6.d.mts.map +0 -1
  65. package/dist/types-Lj3SkZQ_.d.mts.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes-Dk_oVBmE.mjs","names":[],"sources":["../src/admin/routes.ts"],"sourcesContent":["/**\n * Media admin routes — one static `defineAdminRoute` per HTTP method.\n *\n * **URL surface served by this module:**\n *\n * - GET /api/admin/media — list with search/filter + URLs\n * - GET /api/admin/media/<id> — single record with URL\n * - GET /api/admin/media/<id>/usage — entities referencing this media\n * - POST /api/admin/media — upload (multipart FormData)\n * - POST /api/admin/media/regenerate-variants — queue the library variant backfill\n * (synchronous only in a queueless app)\n * - DELETE /api/admin/media/<id> — delete + storage cleanup\n * - DELETE /api/admin/media — bulk delete (body.ids[], ≤100)\n *\n * Standard entity CRUD (PATCH with translations) falls through to the\n * generic entity handler — add `Media` to `entities` in your handler config.\n *\n * **Why per-method `defineAdminRoute`** (vs. the previous top-level\n * `resource: 'media'` + auto-mapped permission):\n *\n * - The wrapper (`guardedHandler` from `@murumets-ee/core`) owns the\n * permission gate, `permission.denied` audit, and 403 body shape\n * uniformly with every other migrated plugin. The previous shape\n * relied on `METHOD_TO_ACTION` auto-mapping at the api-handler\n * dispatch level which couldn't emit per-route audit metadata.\n * - Inline `if (!checkPermission('media', 'create')) ...` defenses\n * at the regenerate-variants / upload / delete branches are now\n * redundant — the wrapper enforces the permission BEFORE the\n * handler runs.\n *\n * **`matchAnyPath` rationale:** media's URL surface predates the\n * static-second-segment convention every other plugin follows\n * (`/taxonomy/<vocab>/<id>`, `/settings/<namespace>/<rest>`). The\n * second segment under `/media` is the media id itself — a runtime\n * UUID — which the dispatcher's literal `segments[0]` match would\n * miss for every request. The catch-all flag declares \"this entry\n * claims every (prefix, method) sub-path no other entry matched\" so\n * the existing URL contract works under the per-method dispatch\n * model. Static POST sub-paths (`regenerate-variants`) take\n * precedence over the catch-all entry on POST. See\n * `DefineAdminRouteSpec.matchAnyPath` in `@murumets-ee/core` for the\n * full contract.\n *\n * **Default roles:** broad for view (`['admin', 'editor', 'agent',\n * 'viewer']`), narrower for writes (`['admin', 'editor']`).\n * Conservative starting point — apps can grant more via the\n * Permissions UI.\n *\n * @example\n * ```typescript\n * import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'\n * import { mediaRoutes } from '@murumets-ee/media/admin'\n * import { Media } from '@murumets-ee/media'\n *\n * const handler = createAdminApiHandler({\n * authenticate: async (req) => { ... },\n * entities: [Article, Media],\n * routes: [...mediaRoutes()],\n * })\n * ```\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n runBounded,\n safeAudit,\n safeAuditMany,\n} from '@murumets-ee/core'\nimport type { MediaClient } from '../client.js'\nimport { readBlurDataUrl } from '../crop/blur-data-url.js'\nimport { readFocalPoint } from '../crop/focal-point.js'\nimport { readMediaConfig } from '../media-config.js'\nimport type { MediaPickerItem, MediaPickerListResult } from '../picker/types.js'\n\n// ---------------------------------------------------------------------------\n// Validation\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isValidUuid(value: string | undefined): value is string {\n return value !== undefined && UUID_RE.test(value)\n}\n\n/**\n * Closed enum of allowed `mediaType` filter values, in lockstep with the\n * `mediaType` field on the Media entity. The list MUST match\n * `MediaListOptions.mediaType` (declared in `../types.js`) — TypeScript\n * doesn't link the two automatically, so the small drift risk is\n * accepted in exchange for not pulling a runtime dep on the entity\n * schema package just to read its enum.\n *\n * Used as both the runtime allowlist (`isAllowedMediaType`) and the\n * source of truth for the narrowed type the guard produces — keeping\n * `as` casts off the handler's hot path.\n */\nconst ALLOWED_MEDIA_TYPES = ['image', 'video', 'audio', 'document', 'other'] as const\ntype AllowedMediaType = (typeof ALLOWED_MEDIA_TYPES)[number]\n\nfunction isAllowedMediaType(value: string): value is AllowedMediaType {\n // Cast to readonly string[] so the typed-tuple `includes` check works\n // with an arbitrary string input — `Array.prototype.includes`'s\n // signature on a typed tuple only accepts members of the tuple\n // (TS 4.9+ literal-narrowing), which is exactly what we want to\n // sidestep here.\n return (ALLOWED_MEDIA_TYPES as readonly string[]).includes(value)\n}\n\n/**\n * Hard cap on the `offset` query param. The dispatcher's allowed-pages\n * model is `limit ∈ [1, 100]`; even with the max page size that's\n * 1000 pages before hitting MAX_OFFSET, which is well beyond any\n * realistic admin-UI scroll depth. Forces slow-discard offset attacks\n * (`?offset=999999999`) to revert to a sane upper bound. Cursor-based\n * pagination would let this go higher, but the media list is\n * offset-based by convention with the entity-list shell.\n */\nconst MAX_OFFSET = 100_000\n\n/**\n * Strict non-negative integer parser for query params.\n *\n * `Number(raw)` silently coerces blanks (`'' → 0`), hex (`'0x05' → 5`),\n * exponent notation (`'1e2' → 100`), and signed (`'+5'/'-5'`) values —\n * all of which pass a downstream `Number.isInteger(n) && n >= 0` check.\n * Pre-filtering with `/^\\d+$/` keeps the contract honest: \"if you don't\n * send a pure non-negative integer string, you get the default.\" Same\n * pattern as `packages/taxonomy/src/admin/routes.ts:handleGet` tree\n * branch from PR-C-taxonomy.\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n // The regex above stops hex / exponent / signed / whitespace / blank\n // inputs, but a long digit-only string still slips past — `Number(...)`\n // returns `Infinity` past ~309 digits, and precision is lost past\n // 2^53. Both would propagate through `client.findMany`'s\n // `offset: parsed` into a Postgres query that either crashes the\n // driver (`Infinity` is not a valid bigint) or silently rounds (the\n // 2^53 precision-loss boundary). Reject via `Number.isSafeInteger`\n // so overflowing inputs revert to `fallback` like every other\n // malformed value.\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction jsonError(message: string, status: number): Response {\n return jsonResponse({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory + cached storage client\n// ---------------------------------------------------------------------------\n//\n// MediaClient and its AdminClient MUST be built per-request — AdminClient's\n// context resolver is captured eagerly at construction (see\n// buildContextResolver in @murumets-ee/core/clients). A module-level singleton\n// would bake the first request's user + tenant scope into every subsequent\n// request's writes — a cross-request permission/scope leak.\n//\n// Storage config is process-global and safe to cache once.\n\ntype StorageClient = Awaited<ReturnType<typeof import('@murumets-ee/storage').createStorageClient>>\n\ninterface StorageState {\n clientPromise: Promise<StorageClient> | null\n}\n\n// Module-level state. `null` once eviction fires on a rejection so the next\n// request gets a fresh resolution attempt (see `getStorage`'s `.catch`).\nconst storageState: StorageState = { clientPromise: null }\n\n/**\n * Resolve the process-wide storage client. The pending-promise slot is\n * cleared on rejection so a transient `createStorageClient` failure\n * (DB hiccup, misconfig) doesn't poison every subsequent media request\n * for the lifetime of the Node process. Same pattern as\n * `packages/taxonomy/src/admin/routes.ts:getClient` from PR-C-taxonomy.\n */\nasync function getStorage(): Promise<StorageClient> {\n if (!storageState.clientPromise) {\n const pending = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n })()\n // Clear the slot on rejection so a transient failure doesn't poison\n // every subsequent request. `pending.catch(handler)` attaches a\n // synchronous rejection listener that resets the cached slot — the\n // handler returns `undefined`, NOT a re-throw — so the new\n // `.catch`-returned promise resolves, but we never await or hold\n // that promise. The ORIGINAL `pending` (stored in\n // `storageState.clientPromise`) is what callers `await`, and it's\n // still in its rejected state — so the caller observing this\n // resolution sees the underlying error. The `.catch` also marks\n // the rejection as \"handled\" for Node's unhandled-rejection\n // tracker. `storageState.clientPromise` holds the original `pending`\n // so concurrent in-flight callers share one resolution.\n pending.catch(() => {\n if (storageState.clientPromise === pending) storageState.clientPromise = null\n })\n storageState.clientPromise = pending\n }\n return storageState.clientPromise\n}\n\nasync function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { MediaClient } = await import('../client.js')\n const { Media } = await import('../entity.js')\n const storage = await getStorage()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleGet: AdminRouteHandler = async (req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n const segments = ctx.segments\n\n // GET /media/<id>/usage — DB-only lookup, safe even when storage is\n // unconfigured. Validate the UUID first so a malformed id 400s\n // uniformly with the storage-configured branch below.\n if (segments.length === 2 && segments[1] === 'usage') {\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { findMediaUsages } = await import('../usage.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const usages = await findMediaUsages(id, app)\n return jsonResponse({ usages })\n }\n\n // Reject unknown sub-paths explicitly. The wrapper-gate has already\n // passed (caller has `media:view`); a handler-level 404 here matches\n // the dispatcher's miss-shape so unknown URLs don't fall through to\n // the single-id branch and 400/500 with a misleading reason. The\n // only valid 2-segment URL is `<id>/usage`, which was already\n // claimed by the early-return above — anything else with >=2\n // segments is not a recognized media route.\n if (segments.length >= 2) return jsonError('Not found', 404)\n\n // GET /media/<id> — validate UUID up front so a bad request still\n // 400s when storage happens to be unconfigured (matches DELETE).\n const singleId = segments.length > 0 ? segments[0] : undefined\n if (singleId !== undefined && !isValidUuid(singleId)) {\n return jsonError('Invalid media ID format', 400)\n }\n\n // Everything below needs a working storage client. If env isn't\n // wired, return a structured \"disabled\" response rather than 500 —\n // the media list page renders a banner and new admins can finish\n // onboarding without being blocked on a crash loop.\n if (!isStorageConfigured()) {\n const reason = getStorageConfigReason() ?? 'Storage not configured'\n if (segments.length > 0) {\n // Single item — treat as not-found-ish to avoid leaking\n // existence; include reason so the UI can surface it.\n return jsonResponse({ error: reason, configured: false, reason }, 503)\n }\n // GET /media — empty list + disabled flag for the picker's banner.\n const response: MediaPickerListResult & { configured: false; reason: string } = {\n items: [],\n total: 0,\n configured: false,\n reason,\n }\n return jsonResponse(response)\n }\n\n const client = await getMediaClient()\n\n // GET /media/<id> — single item with URL (full record for EntityForm + picker)\n if (singleId !== undefined) {\n const record = await client.findById(singleId)\n if (!record) return jsonError('Media not found', 404)\n\n const url = await client.getUrl(singleId)\n return jsonResponse({ ...record, url })\n }\n\n // GET /media — list with search/filter + batch URL resolution\n const url = new URL(req.url)\n const search = url.searchParams.get('search') ?? undefined\n // mediaType MUST be validated against the entity's closed enum before\n // it reaches `client.findMany` — the previous shape force-cast the raw\n // query value with `as 'image' | ...` which is a type-system lie. A\n // bogus value like `?mediaType=bogus` would parameterize into\n // `WHERE media_type = 'bogus'` (no SQL injection — Drizzle is\n // parameterized — but the row count is silently zero, which is\n // confusing). Empty string from `?mediaType=` is also passed-through\n // by the prior `?? undefined` shape since empty string is not null.\n // Allowlist + type guard drops bogus / empty values cleanly so the\n // filter is omitted instead of applied with a no-match value.\n const rawMediaType = url.searchParams.get('mediaType')\n const mediaType =\n rawMediaType !== null && isAllowedMediaType(rawMediaType) ? rawMediaType : undefined\n // Strict parsing keeps the contract honest — see `parseUintParam`\n // JSDoc. Cap limit to [1, 100] and offset to [0, MAX_OFFSET] so a\n // request can't ask for an unboundedly large page nor force a\n // slow-discard scan with `?offset=99999999`.\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 24), 1), 100)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n const result = await client.findMany({\n ...(search !== undefined && { search }),\n ...(mediaType !== undefined && { mediaType }),\n limit,\n offset,\n })\n\n // Resolve original URLs for all items, but thumbnail variants ONLY for\n // images — a video/audio/document/other id will never have a `thumbnail`\n // variant, so asking `getVariantUrls` to resolve one is pure waste (a\n // storage lookup that falls through to the original, then gets discarded\n // below anyway). `media-list-page.tsx` filters the same way.\n const ids = result.items.map((item) => item.id)\n const imageIds = result.items.filter((item) => item.mediaType === 'image').map((item) => item.id)\n const [urlMap, thumbMap] = await Promise.all([\n client.getUrls(ids),\n client.getVariantUrls(imageIds, 'thumbnail'),\n ])\n\n const items: (MediaPickerItem & { thumbnailUrl?: string })[] = result.items.map((item) => {\n // F115: `getVariantUrls` falls back to the ORIGINAL asset URL for any id\n // with no `thumbnail` variant — correct for an image that just hasn't\n // been processed yet, wrong for a video/audio/document/other row, which\n // will never have one. Gating on `mediaType` keeps `thumbnailUrl` ABSENT\n // rather than pointing a consumer's `<img src>` at a `.mp4`.\n const thumbnailUrl = item.mediaType === 'image' ? thumbMap.get(item.id) : undefined\n return {\n id: item.id,\n title: item.title ?? null,\n alt: item.alt ?? null,\n filename: item.filename,\n mimeType: item.mimeType,\n size: item.size,\n mediaType: item.mediaType,\n url: urlMap.get(item.id) ?? '',\n ...(thumbnailUrl !== undefined && { thumbnailUrl }),\n width: item.width ?? null,\n height: item.height ?? null,\n // Images only, like the thumbnail: a video has no subject to crop around.\n ...(item.mediaType === 'image' && {\n focalPoint: readFocalPoint(item.focalPoint),\n // The preview the canvas shows while a freshly picked photograph\n // loads (images PR11) — validated here, and again where it is drawn.\n blurDataUrl: readBlurDataUrl(item.blurDataUrl),\n }),\n }\n })\n\n const response: MediaPickerListResult = { items, total: result.total }\n return jsonResponse(response)\n}\n\nconst handleUpload: AdminRouteHandler = async (req, ctx) => {\n const {\n isStorageConfigured,\n getStorageConfigReason,\n detectMimeType,\n isMimeTypeAllowed,\n readBoundedMultipart,\n tooLarge,\n } = await import('@murumets-ee/storage')\n\n // Upload is registered at `path: ''` (NOT matchAnyPath) — the\n // dispatcher only invokes this handler when segments=[]. The api-\n // handler ALSO rejects empty/whitespace-only first segments with a\n // 400 before any plugin dispatcher runs (see\n // `packages/admin-ui/src/server/api-handler/index.ts` segment-\n // validation block). So this guard is defense-in-depth — per HANDOFF\n // §\"Defense-in-depth `&& segments[i]` guards are fine\" — protecting\n // against a future framework invariant change that lets a non-empty\n // segment reach a `path: ''` static entry.\n if (ctx.segments.length > 0) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // The plugin's `maxUploadSize` / `acceptedTypes`, from the registry the\n // merge step filled before any init — see `media-config.ts` (F007).\n const { maxUploadSize, acceptedTypes } = readMediaConfig()\n\n // F007: bound the body BEFORE `formData()` makes it resident — and before\n // building a MediaClient for a request that is about to be refused.\n const envelope = await readBoundedMultipart(req, maxUploadSize)\n if (!envelope.ok) return jsonError(envelope.error, envelope.status)\n const formData = envelope.value\n\n const file = formData.get('file')\n // FormData `get` returns string | File | null. Reject anything that\n // isn't a File so the typed `file.size`/`file.arrayBuffer()` below\n // is safe without a runtime cast.\n if (!(file instanceof File) || file.size === 0) {\n return jsonError('No file provided', 400)\n }\n\n // The envelope bound covers the whole body; this covers the file, before\n // `arrayBuffer()` copies it.\n if (file.size > maxUploadSize) {\n const refused = tooLarge(maxUploadSize)\n return jsonError(refused.error, refused.status)\n }\n\n const client = await getMediaClient()\n\n const buffer = Buffer.from(await file.arrayBuffer())\n\n // Detect actual MIME type from file content (prevents spoofing).\n const { mimeType, mismatch } = await detectMimeType(\n buffer,\n file.type || 'application/octet-stream',\n )\n if (mismatch) {\n return jsonError(\n `File content doesn't match declared type: claimed ${file.type}, detected ${mimeType}`,\n 400,\n )\n }\n // `acceptedTypes` against the DETECTED type, so a renamed file cannot\n // claim its way past the allowlist.\n if (!isMimeTypeAllowed(mimeType, acceptedTypes)) {\n return jsonError(`File type ${mimeType} is not accepted`, 415)\n }\n\n // Per-upload visibility, DEFAULTING TO PRIVATE — the caller opts in.\n //\n // This route serves two populations with opposite needs. The media picker\n // uploads publishable web assets: a page's hero image MUST be public or the\n // public site resolves it to `null` and renders no images at all, with a 200\n // on every page and one warn log (F030). `packages/rent-ui`'s inspection\n // wizard posts to this SAME route with handover and return photos — vehicle\n // and apartment condition shots, plates, interiors, occasionally documents —\n // which must stay behind time-limited signed urls.\n //\n // Deny-by-default decides the direction, and the asymmetry is the argument:\n // forget the opt-in and an image is invisible, which is annoying, visible and\n // fixable; default to public and forget an opt-out and those photos become\n // permanently world-readable. A missed opt-in cannot leak. So the default is\n // unchanged (`private` via StorageClient's own fallback) and rent's caller is\n // untouched — it sends nothing and behaves exactly as before.\n const visibilityRaw = formData.get('visibility')\n if (visibilityRaw !== null && visibilityRaw !== 'public' && visibilityRaw !== 'private') {\n return jsonError(\"Field 'visibility' must be 'public' or 'private'\", 400)\n }\n const result = await client.upload(buffer, {\n filename: file.name,\n mimeType,\n size: file.size,\n uploadedBy: ctx.user.id,\n ...(visibilityRaw !== null && { visibility: visibilityRaw }),\n })\n\n const item: MediaPickerItem = {\n id: result.media.id,\n title: result.media.title ?? null,\n alt: result.media.alt ?? null,\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n url: result.url,\n width: result.media.width ?? null,\n height: result.media.height ?? null,\n // The same two the list sends, so a photograph uploaded from the picker\n // draws on the canvas with its crop point and its preview at once.\n ...(result.media.mediaType === 'image' && {\n focalPoint: readFocalPoint(result.media.focalPoint),\n blurDataUrl: readBlurDataUrl(result.media.blurDataUrl),\n }),\n }\n\n safeAudit(ctx, {\n action: 'media.upload',\n entityType: 'media',\n entityId: result.media.id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n changes: {\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n },\n })\n\n return jsonResponse(item, 201)\n}\n\nconst handleRegenerateVariants: AdminRouteHandler = async (_req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n\n // Static POST path='regenerate-variants' — the dispatcher only invokes\n // this handler when `segments[0] === 'regenerate-variants'`. Trailing\n // segments (e.g. POST /media/regenerate-variants/extra) aren't a\n // supported sub-route.\n if (ctx.segments.length !== 1) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const { getApp, getContext } = await import('@murumets-ee/core')\n const { getMediaJobs } = await import('../jobs/slot.js')\n const app = getApp()\n\n // With a queue: queue the library walk and return at once (images D004, S6).\n // The walk queues one pass per photograph; the Queue page and the media\n // library count them down while the site keeps serving.\n const jobs = getMediaJobs()\n if (jobs) {\n const { enqueueVariantsBackfill } = await import('../jobs/backfill-trigger.js')\n const { jobId, alreadyRunning } = await enqueueVariantsBackfill(app, jobs.variantsBackfill)\n safeAudit(ctx, {\n action: 'media.regenerate_variants',\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { mode: 'queued', jobId, alreadyRunning },\n })\n const { getVariantBacklog } = await import('../jobs/backlog.js')\n const backlog = await getVariantBacklog(app).catch(() => null)\n return jsonResponse({ mode: 'queued', jobId, alreadyRunning, backlog }, 202)\n }\n\n // No queue in this app: the synchronous loop it always had, now additive.\n const { regenerateAllVariants } = await import('../regenerate-variants.js')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const storage = createStorageClient(getStorageConfig(), { app })\n\n const result = await regenerateAllVariants({\n app,\n storage,\n logger: app.logger.child({ media: true }),\n contextResolver: () => {\n const requestCtx = getContext()\n if (!requestCtx?.user || !requestCtx?.checker) return undefined\n return {\n user: requestCtx.user,\n checker: requestCtx.checker,\n ...(requestCtx.scope !== undefined && { scope: requestCtx.scope }),\n }\n },\n })\n\n safeAudit(ctx, {\n action: 'media.regenerate_variants',\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: {\n mode: 'inline',\n total: result.total,\n processed: result.processed,\n errors: result.errors,\n },\n })\n\n return jsonResponse({ mode: 'inline', ...result })\n}\n\n/**\n * Cap on a bulk delete — the same number the generic entity CRUD route's own\n * bulk delete uses (`entity-crud.ts`'s `MAX_BULK_DELETE`), so a caller sees\n * one consistent limit regardless of which admin list it is deleting from.\n */\nconst MAX_BULK_DELETE = 100\n\n/**\n * Bound on the bulk-delete request body. A body here is `{ ids: string[] }`\n * of short UUID strings — generous headroom for {@link MAX_BULK_DELETE} of\n * them (each ~38 bytes quoted-and-comma'd).\n */\nconst MAX_BULK_DELETE_BODY_BYTES = 16 * 1024\n\n/** A validated bulk-delete request, or the error response to send instead. */\ntype BulkDeleteBody = { ids: string[] } | { error: string; status: number }\n\n/**\n * Read and parse the bulk-delete body, refusing anything past\n * {@link MAX_BULK_DELETE_BODY_BYTES}.\n *\n * `content-length` alone is NOT a bound — it is client-supplied, so a\n * chunked request (no header at all) or a spoofed small one sails through a\n * header-only check straight into an unbounded `req.json()` buffering\n * whatever the caller sends (review finding, entity-table PR06). Streamed\n * and byte-counted instead, same shape `entity-crud.ts`'s `readJsonBody`\n * uses for arbitrary write bodies — this one is sized for a much smaller,\n * closed shape, not copied wholesale, because `@murumets-ee/media` must not\n * depend on `@murumets-ee/admin-ui` (wrong dependency direction).\n */\nasync function readBulkDeleteBody(req: Request): Promise<BulkDeleteBody> {\n const declared = Number(req.headers.get('content-length'))\n if (Number.isFinite(declared) && declared > MAX_BULK_DELETE_BODY_BYTES) {\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n\n const body = req.body\n let text: string\n if (body === null) {\n text = await req.text()\n if (Buffer.byteLength(text, 'utf8') > MAX_BULK_DELETE_BODY_BYTES) {\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n } else {\n const reader = body.getReader()\n const chunks: Uint8Array[] = []\n let total = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (value === undefined) continue\n total += value.byteLength\n if (total > MAX_BULK_DELETE_BODY_BYTES) {\n // Stop pulling — without the cancel the peer keeps sending into a\n // reader nobody drains, which is the connection this refusal\n // exists to end.\n await reader.cancel().catch(() => undefined)\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n chunks.push(value)\n }\n } finally {\n reader.releaseLock()\n }\n text = Buffer.concat(chunks).toString('utf8')\n }\n\n let raw: unknown\n try {\n raw = JSON.parse(text)\n } catch {\n return { error: 'Invalid JSON body', status: 400 }\n }\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {\n return { error: 'Body must be a JSON object', status: 400 }\n }\n\n const ids = (raw as Record<string, unknown>).ids\n if (!Array.isArray(ids) || ids.length === 0) {\n return { error: 'Body must contain \"ids\" array', status: 400 }\n }\n if (ids.length > MAX_BULK_DELETE) {\n return { error: `Bulk delete limited to ${MAX_BULK_DELETE} items per request`, status: 400 }\n }\n\n // Reject anything that isn't a well-formed media id before any mutation —\n // `client.delete()` would otherwise throw mid-batch, leaving a partially\n // deleted set for a request that was malformed from the start.\n //\n // `Array.isArray` narrows to `any[]` (its lib.es5.d.ts signature is\n // `arg is any[]`), so without the cast below every read of the loop\n // variable counts as an `any` against `verify:types` — `as unknown[]`\n // resets it to a type `typeof`/`isValidUuid` still narrow correctly, one\n // step later than the guard that actually proves it.\n const validated: string[] = []\n for (const id of ids as unknown[]) {\n if (typeof id !== 'string' || !isValidUuid(id)) {\n return { error: 'Body \"ids\" must contain valid media IDs', status: 400 }\n }\n validated.push(id)\n }\n return { ids: validated }\n}\n\nconst handleDelete: AdminRouteHandler = async (req, ctx) => {\n const segments = ctx.segments\n\n // DELETE /media — no id supplied. Bulk delete (body: { ids: string[] }),\n // matching the generic entity CRUD route's own bulk shape — `EntityList`'s\n // \"Delete selected\" always sends this, and Media's `matchAnyPath` DELETE\n // entry claims the whole `/media` prefix, so without this branch that\n // request lands here and gets refused with \"Media ID required\" (found via\n // browser verification, entity-table PR06 — S8's demonstration on a real\n // Media list is what surfaced it: nothing before this PR had exercised a\n // bulk delete against the live Media admin page).\n if (segments.length === 0) {\n const parsed = await readBulkDeleteBody(req)\n if ('error' in parsed) return jsonError(parsed.error, parsed.status)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const client = await getMediaClient()\n // MAX_BULK_DELETE bounds the TOTAL; `runBounded` bounds the CONCURRENCY —\n // each `delete()` does entity DB work AND storage object/metadata work,\n // so 100 ids fired via a bare `Promise.all` would open up to 100 of both\n // at once, which is exactly the shared-pool-contention shape CLAUDE.md's\n // \"No Unbounded Fan-Out\" rule exists to catch (CodeRabbit CLI finding,\n // entity-table PR06). The worker catches internally and returns a\n // settled-shaped result instead of letting `runBounded` see a rejection —\n // that keeps its own fail-fast behavior from firing, so this stays\n // allSettled-equivalent: every id gets attempted regardless of a sibling's\n // failure, and the audit trail for the ones that DID succeed survives one\n // that didn't (`safeAuditMany` below would otherwise be skipped entirely\n // on any rejection, auditing NONE of them). The first failure is still\n // rethrown after the audit write, so the response shape is unchanged —\n // no new 207-partial-success contract.\n const results = await runBounded(parsed.ids, async (id) => {\n try {\n await client.delete(id)\n return { status: 'fulfilled' as const, id }\n } catch (reason) {\n return { status: 'rejected' as const, id, reason }\n }\n })\n const deletedIds = results.filter((r) => r.status === 'fulfilled').map((r) => r.id)\n\n // ONE batched write, awaited — not N unawaited `safeAudit` calls racing\n // the connection pool after the response is already decided (review\n // finding, entity-table PR06; `safeAuditMany`'s own docblock names this\n // exact loop shape as the reason it exists).\n await safeAuditMany(\n ctx,\n deletedIds.map((id) => ({\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { bulk: true },\n })),\n )\n\n const failure = results.find((r) => r.status === 'rejected')\n if (failure && failure.status === 'rejected') throw failure.reason\n\n return jsonResponse({ deleted: deletedIds.length })\n }\n\n // Reject sub-paths beyond /<id> — DELETE /media/<id>/usage etc.\n if (segments.length > 1) return jsonError('Not found', 404)\n\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n // Can't safely delete — MediaClient needs storage to remove the\n // actual object, and partial delete (DB row gone, object orphaned)\n // would leak storage. Surface the reason so the UI can display it.\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // AdminClient.delete() checks entity_refs and throws\n // ReferencedEntityError if this media is still referenced. The\n // error bubbles to the caller's error handler which returns 409\n // with usage details.\n const client = await getMediaClient()\n await client.delete(id)\n\n safeAudit(ctx, {\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n })\n\n return jsonResponse({ deleted: 1 })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Default roles for view: broad reach across the admin shell.\n *\n * `BUILT_IN_ROLES` in `packages/auth/src/permissions.ts` is\n * `['admin', 'public', 'authenticated', 'agent', 'customer']` — `admin` is\n * hardcoded as an unconditional yes in `buildPermissionChecker`,\n * `agent` is the only non-admin built-in that flows through\n * `buildInitialRoleDefinitions()` + `upsertBuiltInRoles` with default\n * media access (`customer` seeds with zero permissions — see that\n * function's JSDoc). The `agent` entry in this list is what gives ops\n * agents read-only media access on first boot of a fresh install.\n *\n * `editor` and `viewer` are CONVENTIONAL app-defined roles —\n * apps that follow the scaffold pattern create them as part of\n * their own role catalog. Including them in `defaultRoles` doesn't\n * seed them (the seeder only iterates the built-ins above), but\n * the values DO flow into the process-local permission catalog\n * (see `registerPermission` in `@murumets-ee/core`), which the\n * forthcoming Permission Matrix UI (PR-D) reads to show \"this\n * route's recommended default grants\" against the app's actual\n * role set. The catalog is also the mechanism via which a future\n * upgrade of `upsertBuiltInRoles` could extend the seed surface\n * to app-defined roles without each plugin having to re-declare\n * its defaults.\n */\nconst VIEW_DEFAULT_ROLES = ['admin', 'editor', 'agent', 'viewer'] as const\n\n/** Default roles for create/delete — narrower than view. */\nconst WRITE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Build admin API routes for media management.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry per\n * `(prefix, method)` after grouping. Callers spread the result into\n * their top-level routes list: `routes: [...mediaRoutes()]`.\n *\n * Four `defineAdminRoute` entries:\n *\n * - GET matchAnyPath with `media:view` defaultRoles: VIEW_DEFAULT_ROLES\n * - POST path='' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - POST path='regenerate-variants' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - DELETE matchAnyPath with `media:delete` defaultRoles: WRITE_DEFAULT_ROLES\n *\n * Permissions are auto-registered by the entity catalog\n * (`buildResourceCatalog` in `@murumets-ee/auth`) AND by\n * `registerPermission` inside `defineAdminRoute`. Idempotent —\n * `registerPermission` unions `defaultRoles` on repeat registration,\n * so the framework's two pathways agree on the final grant set.\n *\n * Standard entity CRUD (PATCH with translations) is NOT registered\n * here — it falls through to the generic entity handler. Add `Media`\n * to the `entities` array in your handler config to enable it.\n */\nexport function mediaRoutes(): AdminRoute[] {\n const entries = [\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'GET',\n permission: 'media:view',\n defaultRoles: VIEW_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Read media — list, single item, or referencing-entity usage report.',\n handler: handleGet,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description: 'Upload a media file (multipart FormData with `file` field).',\n handler: handleUpload,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: 'regenerate-variants',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description:\n 'Catch every image up with the current shapes, widths and styles — queued as a background ' +\n 'backfill (synchronous only when the app has no queue).',\n handler: handleRegenerateVariants,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'DELETE',\n permission: 'media:delete',\n defaultRoles: WRITE_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Delete a media record + its storage object.',\n handler: handleDelete,\n }),\n ]\n\n return combineAdminRoutes(entries)\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{r as e}from"./definitions-LJpgrdxd.mjs";import{t}from"./slot-CY4isqmN.mjs";import{createVariantDeps as n}from"./deps-DaGjHO16.mjs";let r=!1;async function i(i,a,o){try{let s=t();if(s){let{QueueClient:t}=await import(`@murumets-ee/queue/client`);return{mode:`queued`,jobId:await new t({app:i,logger:i.logger}).enqueue(s.generateVariants,{mediaId:a.id,epoch:e})}}r||(r=!0,i.logger.warn(`media: no queue plugin in this app — image variants are generated inline, so every image upload waits for them. Add queue() and run a worker to make uploads return at once.`));let{generateMediaVariants:c}=await import(`./generate-variants-Dpy4aT2o.mjs`);return await c(a.id,await n(i,o)),{mode:`inline`}}catch(e){return i.logger.error({err:e,mediaId:a.id},`media: variants could not be requested; the original is served until a backfill runs`),{mode:`failed`}}}export{i as requestVariants};
2
- //# sourceMappingURL=schedule-JlvfUEVA.mjs.map
1
+ import{r as e}from"./definitions-LJpgrdxd.mjs";import{t}from"./slot-CY4isqmN.mjs";import{createVariantDeps as n}from"./deps-BMOCedBq.mjs";let r=!1;async function i(i,a,o){try{let s=t();if(s){let{QueueClient:t}=await import(`@murumets-ee/queue/client`);return{mode:`queued`,jobId:await new t({app:i,logger:i.logger}).enqueue(s.generateVariants,{mediaId:a.id,epoch:e})}}r||(r=!0,i.logger.warn(`media: no queue plugin in this app — image variants are generated inline, so every image upload waits for them. Add queue() and run a worker to make uploads return at once.`));let{generateMediaVariants:c}=await import(`./generate-variants-CJ1oDbdp.mjs`);return await c(a.id,await n(i,o)),{mode:`inline`}}catch(e){return i.logger.error({err:e,mediaId:a.id},`media: variants could not be requested; the original is served until a backfill runs`),{mode:`failed`}}}export{i as requestVariants};
2
+ //# sourceMappingURL=schedule-BIcDoT1d.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"schedule-JlvfUEVA.mjs","names":[],"sources":["../src/jobs/schedule.ts"],"sourcesContent":["/**\n * Asking for a photograph's variants — queued when the app has a queue, inline\n * when it does not.\n *\n * The queued path is the design (images D004): the upload returns as soon as\n * the row exists, and the worker generates. The inline path is what an app\n * without the queue plugin (`apps/web-astro`) always had, kept rather than\n * removed so that such an app does not silently lose variants — it pays the\n * old upload stall instead, and says so once in its log.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { UPLOAD_EPOCH } from './definitions.js'\nimport { createVariantDeps, type VariantMediaAccess } from './deps.js'\nimport type { VariantGenerationDeps, VariantSourceRow } from './generate-variants.js'\nimport { getMediaJobs } from './slot.js'\n\nexport type VariantRequestOutcome =\n /** A `media:generate-variants` job is waiting for the worker. */\n | { readonly mode: 'queued'; readonly jobId: string }\n /** No queue in this app — generated before returning. */\n | { readonly mode: 'inline' }\n /** Neither happened; logged. The original is served until a backfill runs. */\n | { readonly mode: 'failed' }\n\nlet warnedInline = false\n\n/**\n * Queue (or, with no queue, run) a variant pass for `row`. Never throws: the\n * row already exists and serves its original, which is the correct fallback\n * for anything that goes wrong here.\n */\nexport async function requestVariants(\n app: ToolkitApp,\n row: Pick<VariantSourceRow, 'id'>,\n clients: { media: VariantMediaAccess; storage: VariantGenerationDeps['storage'] },\n): Promise<VariantRequestOutcome> {\n try {\n const jobs = getMediaJobs()\n if (jobs) {\n const { QueueClient } = await import('@murumets-ee/queue/client')\n const queue = new QueueClient({ app, logger: app.logger })\n const jobId = await queue.enqueue(jobs.generateVariants, {\n mediaId: row.id,\n epoch: UPLOAD_EPOCH,\n })\n return { mode: 'queued', jobId }\n }\n if (!warnedInline) {\n warnedInline = true\n app.logger.warn(\n 'media: no queue plugin in this app — image variants are generated inline, so every ' +\n 'image upload waits for them. Add queue() and run a worker to make uploads return at once.',\n )\n }\n const { generateMediaVariants } = await import('./generate-variants.js')\n await generateMediaVariants(row.id, await createVariantDeps(app, clients))\n return { mode: 'inline' }\n } catch (err) {\n app.logger.error(\n { err, mediaId: row.id },\n 'media: variants could not be requested; the original is served until a backfill runs',\n )\n return { mode: 'failed' }\n }\n}\n"],"mappings":"0IAyBA,IAAI,EAAe,GAOnB,eAAsB,EACpB,EACA,EACA,EACgC,CAChC,GAAI,CACF,IAAM,EAAO,EAAa,EAC1B,GAAI,EAAM,CACR,GAAM,CAAE,eAAgB,MAAM,OAAO,6BAMrC,MAAO,CAAE,KAAM,SAAU,MAAA,MAJL,IADF,EAAY,CAAE,MAAK,OAAQ,EAAI,MAAO,CAChC,CAAC,CAAC,QAAQ,EAAK,iBAAkB,CACvD,QAAS,EAAI,GACb,MAAO,CACT,CAAC,CAC8B,CACjC,CACK,IACH,EAAe,GACf,EAAI,OAAO,KACT,8KAEF,GAEF,GAAM,CAAE,yBAA0B,MAAM,OAAO,oCAE/C,OADA,MAAM,EAAsB,EAAI,GAAI,MAAM,EAAkB,EAAK,CAAO,CAAC,EAClE,CAAE,KAAM,QAAS,CAC1B,OAAS,EAAK,CAKZ,OAJA,EAAI,OAAO,MACT,CAAE,MAAK,QAAS,EAAI,EAAG,EACvB,sFACF,EACO,CAAE,KAAM,QAAS,CAC1B,CACF"}
1
+ {"version":3,"file":"schedule-BIcDoT1d.mjs","names":[],"sources":["../src/jobs/schedule.ts"],"sourcesContent":["/**\n * Asking for a photograph's variants — queued when the app has a queue, inline\n * when it does not.\n *\n * The queued path is the design (images D004): the upload returns as soon as\n * the row exists, and the worker generates. The inline path is what an app\n * without the queue plugin (`apps/web-astro`) always had, kept rather than\n * removed so that such an app does not silently lose variants — it pays the\n * old upload stall instead, and says so once in its log.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { UPLOAD_EPOCH } from './definitions.js'\nimport { createVariantDeps, type VariantMediaAccess } from './deps.js'\nimport type { VariantGenerationDeps, VariantSourceRow } from './generate-variants.js'\nimport { getMediaJobs } from './slot.js'\n\nexport type VariantRequestOutcome =\n /** A `media:generate-variants` job is waiting for the worker. */\n | { readonly mode: 'queued'; readonly jobId: string }\n /** No queue in this app — generated before returning. */\n | { readonly mode: 'inline' }\n /** Neither happened; logged. The original is served until a backfill runs. */\n | { readonly mode: 'failed' }\n\nlet warnedInline = false\n\n/**\n * Queue (or, with no queue, run) a variant pass for `row`. Never throws: the\n * row already exists and serves its original, which is the correct fallback\n * for anything that goes wrong here.\n */\nexport async function requestVariants(\n app: ToolkitApp,\n row: Pick<VariantSourceRow, 'id'>,\n clients: { media: VariantMediaAccess; storage: VariantGenerationDeps['storage'] },\n): Promise<VariantRequestOutcome> {\n try {\n const jobs = getMediaJobs()\n if (jobs) {\n const { QueueClient } = await import('@murumets-ee/queue/client')\n const queue = new QueueClient({ app, logger: app.logger })\n const jobId = await queue.enqueue(jobs.generateVariants, {\n mediaId: row.id,\n epoch: UPLOAD_EPOCH,\n })\n return { mode: 'queued', jobId }\n }\n if (!warnedInline) {\n warnedInline = true\n app.logger.warn(\n 'media: no queue plugin in this app — image variants are generated inline, so every ' +\n 'image upload waits for them. Add queue() and run a worker to make uploads return at once.',\n )\n }\n const { generateMediaVariants } = await import('./generate-variants.js')\n await generateMediaVariants(row.id, await createVariantDeps(app, clients))\n return { mode: 'inline' }\n } catch (err) {\n app.logger.error(\n { err, mediaId: row.id },\n 'media: variants could not be requested; the original is served until a backfill runs',\n )\n return { mode: 'failed' }\n }\n}\n"],"mappings":"0IAyBA,IAAI,EAAe,GAOnB,eAAsB,EACpB,EACA,EACA,EACgC,CAChC,GAAI,CACF,IAAM,EAAO,EAAa,EAC1B,GAAI,EAAM,CACR,GAAM,CAAE,eAAgB,MAAM,OAAO,6BAMrC,MAAO,CAAE,KAAM,SAAU,MAAA,MAJL,IADF,EAAY,CAAE,MAAK,OAAQ,EAAI,MAAO,CAChC,CAAC,CAAC,QAAQ,EAAK,iBAAkB,CACvD,QAAS,EAAI,GACb,MAAO,CACT,CAAC,CAC8B,CACjC,CACK,IACH,EAAe,GACf,EAAI,OAAO,KACT,8KAEF,GAEF,GAAM,CAAE,yBAA0B,MAAM,OAAO,oCAE/C,OADA,MAAM,EAAsB,EAAI,GAAI,MAAM,EAAkB,EAAK,CAAO,CAAC,EAClE,CAAE,KAAM,QAAS,CAC1B,OAAS,EAAK,CAKZ,OAJA,EAAI,OAAO,MACT,CAAE,MAAK,QAAS,EAAI,EAAG,EACvB,sFACF,EACO,CAAE,KAAM,QAAS,CAC1B,CACF"}
@@ -1,4 +1,4 @@
1
- import { i as ImageStyle, n as ImageFormat, p as ReadonlyImageVocabulary, r as ImageShape } from "./types-6PZQ4Zq6.mjs";
1
+ import { i as ImageStyle, n as ImageFormat, p as ReadonlyImageVocabulary, r as ImageShape } from "./types-lobQhTow.mjs";
2
2
 
3
3
  //#region src/shapes.d.ts
4
4
  /**
@@ -87,4 +87,4 @@ declare function buildVariantConfig(input: {
87
87
  declare function resolveStyleName(config: VariantConfig, name: string): ShapeAlias | undefined;
88
88
  //#endregion
89
89
  export { ResolvedShape as a, VariantConfig as c, NATURAL_SHAPE as i, buildVariantConfig as l, DEFAULT_IMAGE_SHAPES as n, ShapeAlias as o, DEFAULT_IMAGE_VOCABULARY as r, UnmappedStyle as s, DEFAULT_IMAGE_LADDER as t, resolveStyleName as u };
90
- //# sourceMappingURL=shapes-C51rXSDJ.d.mts.map
90
+ //# sourceMappingURL=shapes-BQH9_3h8.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shapes-C51rXSDJ.d.mts","names":[],"sources":["../src/shapes.ts"],"mappings":";;;;AA2MgB;AAIhB;;;cAvJa,aAAA;AAyJI;AAIjB;;;;AAJiB,cAlJJ,oBAAA;;;;;;;;cAWA,oBAAA,EAAsB,QAAA,CAAS,MAAA,SAAe,QAAA,CAAS,UAAA;;;;;AAiJzB;cArI9B,wBAAA,EAA0B,uBAGrC;;UA2Fe,aAAA;EAAA,SACN,IAAA;;WAEA,KAAA;;WAEA,MAAA;EAAA,SACA,MAAA,EAAQ,WAAW;EAAA,SACnB,OAAA;;WAEA,MAAA;AAAA;;;;;;;;;;UAYM,UAAA;EAAA,SACN,KAAA;EAAA,SACA,KAAK;AAAA;;UAIC,aAAA;EAAA,SACN,IAAA;EAAA,SACA,MAAM;AAAA;;UAIA,aAAA;;WAEN,MAAA,WAAiB,aAAA;;WAEjB,OAAA,EAAS,WAAA,SAAoB,UAAA;;WAE7B,QAAA,WAAmB,aAAA;AAAA;;;;;;;;;;iBAwGd,kBAAA,CAAmB,KAAA;EAAA,SACxB,UAAA,EAAY,uBAAA;EAAA,SACZ,MAAA,EAAQ,QAAA,CAAS,MAAA,SAAe,UAAA;AAAA,IACvC,aAAA;;;;;;iBAgIY,gBAAA,CAAiB,MAAA,EAAQ,aAAA,EAAe,IAAA,WAAe,UAAU"}
1
+ {"version":3,"file":"shapes-BQH9_3h8.d.mts","names":[],"sources":["../src/shapes.ts"],"mappings":";;;;AA2MgB;AAIhB;;;cAvJa,aAAA;AAyJI;AAIjB;;;;AAJiB,cAlJJ,oBAAA;;;;;;;;cAWA,oBAAA,EAAsB,QAAA,CAAS,MAAA,SAAe,QAAA,CAAS,UAAA;;;;;AAiJzB;cArI9B,wBAAA,EAA0B,uBAGrC;;UA2Fe,aAAA;EAAA,SACN,IAAA;;WAEA,KAAA;;WAEA,MAAA;EAAA,SACA,MAAA,EAAQ,WAAW;EAAA,SACnB,OAAA;;WAEA,MAAA;AAAA;;;;;;;;;;UAYM,UAAA;EAAA,SACN,KAAA;EAAA,SACA,KAAK;AAAA;;UAIC,aAAA;EAAA,SACN,IAAA;EAAA,SACA,MAAM;AAAA;;UAIA,aAAA;;WAEN,MAAA,WAAiB,aAAA;;WAEjB,OAAA,EAAS,WAAA,SAAoB,UAAA;;WAE7B,QAAA,WAAmB,aAAA;AAAA;;;;;;;;;;iBAwGd,kBAAA,CAAmB,KAAA;EAAA,SACxB,UAAA,EAAY,uBAAA;EAAA,SACZ,MAAA,EAAQ,QAAA,CAAS,MAAA,SAAe,UAAA;AAAA,IACvC,aAAA;;;;;;iBAgIY,gBAAA,CAAiB,MAAA,EAAQ,aAAA,EAAe,IAAA,WAAe,UAAU"}
@@ -23,6 +23,12 @@ interface MediaPickerItem {
23
23
  x: number;
24
24
  y: number;
25
25
  } | undefined;
26
+ /**
27
+ * The row's blurred preview (images PR11) — `null` or `''` when it has none.
28
+ * Handed to the canvas the same way as `focalPoint`, and validated where the
29
+ * canvas draws it.
30
+ */
31
+ blurDataUrl?: string | null | undefined;
26
32
  }
27
33
  /** Paginated result from fetchMedia callback */
28
34
  interface MediaPickerListResult {
@@ -104,4 +110,4 @@ interface MediaPickerProps {
104
110
  }
105
111
  //#endregion
106
112
  export { MediaPickerMode as a, MediaPickerListResult as i, MediaPickerClassNames as n, MediaPickerProps as o, MediaPickerItem as r, MediaPickerCallbacks as t };
107
- //# sourceMappingURL=types-Lj3SkZQ_.d.mts.map
113
+ //# sourceMappingURL=types-Ba76KK9j.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-Ba76KK9j.d.mts","names":[],"sources":["../src/picker/types.ts"],"mappings":";;;;UAGiB,eAAA;EACf,EAAA;EACA,KAAA;EACA,GAAA;EACA,QAAA;EACA,QAAA;EACA,IAAA;EACA,KAAA;EACA,MAAA;EACA,SAAA;EACA,GAAA;EAHA;;;;;;EAUA,UAAA;IAAe,CAAA;IAAW,CAAA;EAAA;EAUX;;;;;EAJf,WAAA;AAAA;;UAIe,qBAAA;EACf,KAAA,EAAO,eAAe;EACtB,KAAA;EAYe;;;;;EANf,UAAA;EAgBqC;EAdrC,MAAA;AAAA;;UAIe,oBAAA;EAGb;EADF,UAAA,GAAa,OAAA;IACX,MAAA;IACA,SAAA;IACA,KAAA;IACA,MAAA;EAAA,MACI,OAAA,CAAQ,qBAAA;EAGd;EAAA,WAAA,GAAc,IAAA,EAAM,IAAA,KAAS,OAAA,CAAQ,eAAA;AAAA;;KAI3B,eAAA;;UAGK,qBAAA;EACf,OAAA;EACA,OAAA;EACA,MAAA;EACA,KAAA;EACA,OAAA;EACA,WAAA;EACA,UAAA;EACA,IAAA;EACA,IAAA;EACA,YAAA;EACA,SAAA;EACA,SAAA;EACA,UAAA;EACA,gBAAA;EACA,MAAA;EACA,aAAA;EACA,YAAA;EACA,OAAA;EACA,KAAA;AAAA;;UAIe,gBAAA;EATf;EAWA,IAAA;EATA;EAWA,YAAA,GAAe,IAAA;EATf;EAWA,QAAA,GAAW,KAAA,EAAO,eAAA;EAVb;EAYL,IAAA,GAAO,eAAA;EARQ;EAUf,MAAA;;EAEA,SAAA;EAJO;EAMP,SAAA;EAYW;EAVX,WAAA;EAUoB;EARpB,KAAA;EAdA;EAgBA,WAAA;EAdA;EAgBA,UAAA,GAAa,qBAAA;EAhBF;EAkBX,SAAA;EAhBO;EAkBP,QAAA,GAAW,SAAA;AAAA"}
@@ -84,6 +84,20 @@ declare const Media: import("@murumets-ee/entity").Entity<{
84
84
  * by `cropsValidation()` below.
85
85
  */
86
86
  crops: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>;
87
+ /**
88
+ * The blurred preview the photograph shows while it loads — a data URL of a
89
+ * tiny image of it, generated once by the server (images PR11). A render
90
+ * draws it as the `<img>`'s own background.
91
+ *
92
+ * `null` means "never generated" (a row from before PR11, or one whose
93
+ * file sharp could not read): the catch-up generates it. `''` means "looked,
94
+ * and there is none" — a source with transparency, where a blur would stay
95
+ * visible through the transparent pixels. Both render nothing.
96
+ *
97
+ * Checked by `blurDataUrlValidation()` below, and again at the projection
98
+ * and the render: the value ends up in an inline `style` on public pages.
99
+ */
100
+ blurDataUrl: import("@murumets-ee/entity").TextField & Partial<import("@murumets-ee/entity").TextField>;
87
101
  mediaType: import("@murumets-ee/entity").SelectField & {
88
102
  options: readonly ["image", "video", "audio", "document", "other"];
89
103
  } & {
@@ -91,7 +105,7 @@ declare const Media: import("@murumets-ee/entity").Entity<{
91
105
  readonly required: true;
92
106
  readonly indexed: true;
93
107
  };
94
- }, "media", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<Record<never, never>, "focalPointValidation">, import("@murumets-ee/entity").Behavior<Record<never, never>, "cropsValidation">, import("@murumets-ee/entity").Behavior<Record<never, never>, "variantRegeneration">, import("@murumets-ee/entity").SearchableBehavior]>;
108
+ }, "media", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<Record<never, never>, "focalPointValidation">, import("@murumets-ee/entity").Behavior<Record<never, never>, "cropsValidation">, import("@murumets-ee/entity").Behavior<Record<never, never>, "blurDataUrlValidation">, import("@murumets-ee/entity").Behavior<Record<never, never>, "variantRegeneration">, import("@murumets-ee/entity").SearchableBehavior]>;
95
109
  //#endregion
96
110
  //#region src/types.d.ts
97
111
  /** Media type derived from MIME type */
@@ -255,4 +269,4 @@ interface MediaPluginConfig {
255
269
  }
256
270
  //#endregion
257
271
  export { ImageVocabulary as a, MediaPluginConfig as c, MediaUploadOptions as d, MediaUploadResult as f, ImageStyle as i, MediaRecord as l, Media as m, ImageFormat as n, MediaListOptions as o, ReadonlyImageVocabulary as p, ImageShape as r, MediaListResult as s, CropShape as t, MediaType as u };
258
- //# sourceMappingURL=types-6PZQ4Zq6.d.mts.map
272
+ //# sourceMappingURL=types-lobQhTow.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-lobQhTow.d.mts","names":[],"sources":["../src/entity.ts","../src/types.ts"],"mappings":";;;;;;;;AAuBA;;;;;;;;;;;;cAAa,KAAA,gCAAK,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AClBlB;;;;;;;;;;;;;;;;KAAY,SAAA;;UAGK,kBAAA;;EAEf,QAAA;;EAEA,QAAA;;EAEA,IAAA;;EAEA,UAAA,GAAa,cAAc;;EAE3B,KAAA;EDKgB;ECHhB,GAAA;;EAEA,WAAA;;EAEA,KAAA;;EAEA,MAAA;;EAEA,UAAA;AAAA;;UAIe,iBAAA;;EAEf,KAAA,EAAO,WAAW;;EAElB,GAAA;AAAA;;;;;;;;KAUU,WAAA,GAAc,cAAc,QAAQ,KAAA,CAAM,SAAA;;UAGrC,gBAAA;;EAEf,SAAA,GAAY,SAAS;;EAErB,cAAA;;EAEA,MAAA;;EAEA,KAAA;;EAEA,MAAA;;EAEA,OAAA;;EAEA,cAAA;AAAA;;UAIe,eAAA;EACf,KAAA,EAAO,WAAW;EAClB,KAAA;EACA,KAAA;EACA,MAAA;AAAA;;KAIU,WAAA;;;;;;;;;;;;KAaA,UAAA;iEAEV,KAAA;;;;;EAKA,MAAA;EAEA,MAAA,GAAS,WAAW;EAEpB,OAAA;AAAA;;;;AA9FmB;KAqGT,eAAA;EAlGuB,qFAoGjC,MAAA,EAAQ,MAAM,SAAS,UAAA,GA5FI;EA8F3B,MAAA;AAAA;;KAIU,uBAAA;EAAA,SACD,MAAA,EAAQ,QAAA,CAAS,MAAA,SAAe,UAAA;EAAA,SAChC,MAAA;AAAA;;;;;;AAxFC;AAIZ;;;;;;;;AAIK;AAUL;;;UA4FiB,SAAA;EAAA,SACN,IAAA;EA7FqC;EAAA,SA+FrC,KAAK;AAAA;AA/F+C;AAAA,UAmG9C,UAAA;EAhGgB;EAkG/B,KAAA;EAhGqB;EAkGrB,MAAA;EAlGY;EAoGZ,GAAA;EAhGA;EAkGA,MAAA,GAAS,WAAW;EA9FpB;EAgGA,OAAA;AAAA;;UAIe,iBAAA;EA5FA;;;;;;;EAoGf,aAAA;EAhGA;EAkGA,aAAA;EAlGM;EAoGN,iBAAA,GAAoB,cAAA;EAhGC;EAkGrB,WAAA,GAAc,MAAA,SAAe,UAAA;EAlGR;AAAA;AAavB;;;EA2FE,UAAA,GAAa,uBAAA;AAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/media",
3
- "version": "0.74.1",
3
+ "version": "0.75.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -74,14 +74,14 @@
74
74
  "sharp": "^0.34.5",
75
75
  "tailwind-merge": "^2.6.0",
76
76
  "zod": "^3.24.1",
77
- "@murumets-ee/core": "0.74.1",
78
- "@murumets-ee/db": "0.74.1",
79
- "@murumets-ee/entity": "0.74.1",
80
- "@murumets-ee/logging": "0.74.1",
81
- "@murumets-ee/queue": "0.74.1",
82
- "@murumets-ee/settings": "0.74.1",
83
- "@murumets-ee/storage": "0.74.1",
84
- "@murumets-ee/ui": "0.74.1"
77
+ "@murumets-ee/core": "0.75.0",
78
+ "@murumets-ee/logging": "0.75.0",
79
+ "@murumets-ee/db": "0.75.0",
80
+ "@murumets-ee/queue": "0.75.0",
81
+ "@murumets-ee/settings": "0.75.0",
82
+ "@murumets-ee/entity": "0.75.0",
83
+ "@murumets-ee/storage": "0.75.0",
84
+ "@murumets-ee/ui": "0.75.0"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "lucide-react": ">=0.400.0 <1",
@@ -1,2 +0,0 @@
1
- import{t as e}from"./entity-CsDdjKz6.mjs";import{a as t,c as n}from"./definitions-LJpgrdxd.mjs";import{a as r,i,r as a,s as o}from"./variant-state-CBtqb9Cb.mjs";import{i as s}from"./process-image-wQwiRhyk.mjs";import{n as c,t as l}from"./resolve-image-styles-YExI329D.mjs";const u=1e5;async function d(e,t){let n=await t.readMetadata(e.fileKey);if(n===void 0)return`no-original`;if((e.focalPoint===null||e.focalPoint===void 0)&&!i(n))return`yes`;let s={width:e.width??0,height:e.height??0},c=a(e.fileKey,s,t.config,t.styles,e);return r(o(n),c.plannedKeys,c.legacy)?`no`:`yes`}async function f(e,t=()=>{},n=u){let r={phase:`scanning`,scanned:0,queued:0,upToDate:0,alreadyWaiting:0,skipped:0,truncated:!1},i=null;for(;;){let a=await e.listImages(i,200),o=a[a.length-1];if(!o)break;i=o.id;let c=0,l=0,u=[];for(let t of a){if(!s(t.mimeType)){c++;continue}let n=await d(t,e);n===`no-original`?c++:n===`no`?l++:u.push({mediaId:t.id,epoch:e.epoch})}let f=await e.waitingMediaIds(u.map(e=>e.mediaId)),p=0,m=0,h=!1;for(let t of u){if(f.has(t.mediaId)){m++;continue}if(r.queued+p>=n){h=!0;break}await e.enqueue(t),p++}if(r={...r,scanned:r.scanned+a.length,queued:r.queued+p,upToDate:r.upToDate+l,alreadyWaiting:r.alreadyWaiting+m,skipped:r.skipped+c,truncated:h},t(r),h||a.length<200)break}return r={...r,phase:`done`},t(r),r}async function p(r,i,a){let{QueueClient:o}=await import(`@murumets-ee/queue/client`),{jobsTable:s}=await import(`@murumets-ee/queue/admin`),{schemaRegistry:d}=await import(`@murumets-ee/db`),{createStorageClient:p}=await import(`@murumets-ee/storage`),{getStorageConfig:m}=await import(`@murumets-ee/storage/plugin`),{and:h,asc:g,eq:_,gt:v}=await import(`drizzle-orm`),y=d.get(`media`);if(!y)throw Error(`Media schema not registered. Is the media() plugin loaded?`);let b=r.getClient(e),x=p(m(),{app:r}),S=new o({app:r,logger:r.logger}),C=s.makeClient(r.db.readWrite),[w,T]=await Promise.all([c(r,r.logger,{strict:!0}),l(r,r.logger,{strict:!0})]),E=await f({epoch:t(a.walkJobId),listImages:(e,t)=>b.findMany({where:e?h(_(y.mediaType,`image`),v(y.id,e)):_(y.mediaType,`image`),orderBy:g(y.id),limit:t}),readMetadata:async e=>{let t=await x.getMetadata(e);return t?t.metadata??{}:void 0},waitingMediaIds:async e=>{if(e.length===0)return new Set;let t=await C.findMany({where:{type:i.name,status:{in:[`pending`,`processing`]},"payload.mediaId":{in:[...e]}},limit:Math.min(e.length*4,1e3)}),r=new Set;for(let e of t){let t=n.safeParse(e.payload);t.success&&r.add(t.data.mediaId)}return r},enqueue:async e=>{await S.enqueue(i,e)},config:w,styles:T},a.onProgress);return E.truncated&&r.logger.warn({cap:u,queued:E.queued},`media variants: backfill stopped at its cap — run it again once these have drained`),E}export{p as runVariantsBackfill};
2
- //# sourceMappingURL=backfill-B1StTnxg.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"backfill-B1StTnxg.mjs","names":[],"sources":["../src/jobs/backfill.ts"],"sourcesContent":["/**\n * The library catch-up (images G5, S6) — the body of `media:variants-backfill`,\n * which replaces `regenerateAllVariants`'s synchronous loop inside one HTTP\n * request.\n *\n * It generates NOTHING itself. It pages through the image library and queues\n * one `media:generate-variants` pass per photograph, so:\n *\n * - **the site keeps serving** — nothing is deleted up front, and every pass\n * is additive (see `generate-variants.ts`);\n * - **progress is counted by the queue** — the operator watches passes drain\n * on the Queue page and the waiting count on the media library;\n * - **it is incremental** — a photograph whose recorded variants already\n * match the configuration is not queued (read from its metadata; nothing is\n * downloaded), so a walk over a caught-up library is cheap and a capped walk\n * reaches further the next time;\n * - **it never piles up** — a photograph that already has a pass waiting (from\n * an upload, an edit, or an earlier walk) is not queued again, because a\n * waiting pass reads the configuration when it RUNS. So repeated clicks do\n * not multiply work, and a walk retried after a crash re-queues nothing;\n * - **one bad photograph blocks nothing** — it fails its own pass, not the walk\n * (Craft moved off one batch job for exactly this, R014).\n *\n * Bounded (CLAUDE.md, no unbounded fan-out): keyset pages of {@link PAGE_SIZE}\n * rows, never the whole table in memory, one enqueue at a time, and a hard\n * ceiling of {@link MAX_BACKFILL_QUEUED} passes per walk — reported as\n * `truncated`, never silent. Runs only in the worker, off every request path.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { JobDefinition } from '@murumets-ee/queue/client'\nimport { Media } from '../entity.js'\nimport { isProcessableImage } from '../process-image.js'\nimport { resolveImageStyles, resolveVariantConfig } from '../resolve-image-styles.js'\nimport type { VariantConfig } from '../shapes.js'\nimport type { ImageStyle } from '../types.js'\nimport {\n backfillEpoch,\n type GenerateVariantsPayload,\n generateVariantsPayloadSchema,\n} from './definitions.js'\nimport { expectedVariants, focalSeedTried, isUpToDate, readVariantState } from './variant-state.js'\n\n/** Rows read per page. */\nexport const PAGE_SIZE = 200\n\n/**\n * Passes one walk will queue. A library needing more is caught up by running\n * the backfill again once these have drained: a photograph whose pass has\n * completed is up to date and is not queued again, so each walk reaches\n * further.\n */\nexport const MAX_BACKFILL_QUEUED = 100_000\n\nexport interface VariantsBackfillProgress {\n readonly phase: 'scanning' | 'done'\n /** Image rows read. */\n readonly scanned: number\n /** Passes queued by this walk. */\n readonly queued: number\n /** Photographs whose recorded variants already match the configuration. */\n readonly upToDate: number\n /** Photographs that already had a pass waiting — not queued twice. */\n readonly alreadyWaiting: number\n /** Image rows this pipeline does not process (SVG, GIF), or with no original file. */\n readonly skipped: number\n /** The walk stopped at {@link MAX_BACKFILL_QUEUED}. */\n readonly truncated: boolean\n}\n\n/** One image row, as much of it as the walk reads. */\nexport interface BackfillRow {\n readonly id: string\n readonly fileKey: string\n readonly mimeType: string\n readonly width?: number | null\n readonly height?: number | null\n readonly focalPoint?: unknown\n readonly crops?: unknown\n}\n\n/** What the walk needs, injected so it can be tested without a database. */\nexport interface BackfillDeps {\n /** This walk's epoch — `backfillEpoch(its job id)`. */\n readonly epoch: string\n /** Up to `limit` image rows with `id > afterId`, ascending by id. */\n listImages(afterId: string | null, limit: number): Promise<readonly BackfillRow[]>\n /** The original's metadata, or `undefined` when there is no original file. */\n readMetadata(fileKey: string): Promise<unknown>\n /** Which of these media ids already have a `media:generate-variants` pass waiting or running. */\n waitingMediaIds(ids: readonly string[]): Promise<ReadonlySet<string>>\n enqueue(payload: GenerateVariantsPayload): Promise<void>\n readonly config: VariantConfig\n readonly styles: Readonly<Record<string, ImageStyle>>\n}\n\n/**\n * Does this photograph need a pass? No original → nothing a pass could do. A\n * focal point never set → yes, so the pass seeds it (F014) — unless a pass\n * already looked and found nothing to guess about (`focalSeedTried`).\n * Otherwise: does its\n * recorded state already match what a pass would produce (`isUpToDate`)?\n */\nasync function needsPass(\n row: BackfillRow,\n deps: BackfillDeps,\n): Promise<'yes' | 'no' | 'no-original'> {\n const metadata = await deps.readMetadata(row.fileKey)\n if (metadata === undefined) return 'no-original'\n const unseeded = row.focalPoint === null || row.focalPoint === undefined\n if (unseeded && !focalSeedTried(metadata)) return 'yes'\n const drawn = { width: row.width ?? 0, height: row.height ?? 0 }\n const expected = expectedVariants(row.fileKey, drawn, deps.config, deps.styles, row)\n return isUpToDate(readVariantState(metadata), expected.plannedKeys, expected.legacy)\n ? 'no'\n : 'yes'\n}\n\n/** The walk itself. */\nexport async function walkVariantsBackfill(\n deps: BackfillDeps,\n onProgress: (p: VariantsBackfillProgress) => void = () => {},\n cap: number = MAX_BACKFILL_QUEUED,\n): Promise<VariantsBackfillProgress> {\n let progress: VariantsBackfillProgress = {\n phase: 'scanning',\n scanned: 0,\n queued: 0,\n upToDate: 0,\n alreadyWaiting: 0,\n skipped: 0,\n truncated: false,\n }\n let cursor: string | null = null\n for (;;) {\n const page = await deps.listImages(cursor, PAGE_SIZE)\n const last = page[page.length - 1]\n if (!last) break\n cursor = last.id\n\n let skipped = 0\n let upToDate = 0\n const due: GenerateVariantsPayload[] = []\n for (const row of page) {\n if (!isProcessableImage(row.mimeType)) {\n skipped++\n continue\n }\n const need = await needsPass(row, deps)\n if (need === 'no-original') skipped++\n else if (need === 'no') upToDate++\n else due.push({ mediaId: row.id, epoch: deps.epoch })\n }\n const waiting = await deps.waitingMediaIds(due.map((p) => p.mediaId))\n let queued = 0\n let alreadyWaiting = 0\n let truncated = false\n for (const payload of due) {\n if (waiting.has(payload.mediaId)) {\n alreadyWaiting++\n continue\n }\n if (progress.queued + queued >= cap) {\n truncated = true\n break\n }\n await deps.enqueue(payload)\n queued++\n }\n progress = {\n ...progress,\n scanned: progress.scanned + page.length,\n queued: progress.queued + queued,\n upToDate: progress.upToDate + upToDate,\n alreadyWaiting: progress.alreadyWaiting + alreadyWaiting,\n skipped: progress.skipped + skipped,\n truncated,\n }\n onProgress(progress)\n if (truncated || page.length < PAGE_SIZE) break\n }\n progress = { ...progress, phase: 'done' }\n onProgress(progress)\n return progress\n}\n\n/** The walk wired to the app's database and queue. Call inside `runAsCli`. */\nexport async function runVariantsBackfill(\n app: ToolkitApp,\n generateJob: JobDefinition<GenerateVariantsPayload>,\n options: { walkJobId: string; onProgress?: (p: VariantsBackfillProgress) => void },\n): Promise<VariantsBackfillProgress> {\n const { QueueClient } = await import('@murumets-ee/queue/client')\n const { jobsTable } = await import('@murumets-ee/queue/admin')\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const { and, asc, eq, gt } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n const admin = app.getClient(Media)\n const storage = createStorageClient(getStorageConfig(), { app })\n const queue = new QueueClient({ app, logger: app.logger })\n const jobs = jobsTable.makeClient(app.db.readWrite)\n // Strict, like the pass: a walk planned from the code defaults during a\n // settings outage would queue a pass for every photograph.\n const [config, styles] = await Promise.all([\n resolveVariantConfig(app, app.logger, { strict: true }),\n resolveImageStyles(app, app.logger, { strict: true }),\n ])\n\n const outcome = await walkVariantsBackfill(\n {\n epoch: backfillEpoch(options.walkJobId),\n listImages: (afterId, limit) =>\n admin.findMany({\n where: afterId\n ? and(eq(table.mediaType, 'image'), gt(table.id, afterId))\n : eq(table.mediaType, 'image'),\n orderBy: asc(table.id),\n limit,\n }),\n readMetadata: async (fileKey) => {\n const file = await storage.getMetadata(fileKey)\n return file ? (file.metadata ?? {}) : undefined\n },\n waitingMediaIds: async (ids) => {\n if (ids.length === 0) return new Set()\n // One query per page, on the payload's `mediaId` (a jsonb path the\n // where-builder validates and parameterizes). Bounded: a page is at\n // most PAGE_SIZE ids, and a photograph rarely has more than a pass or\n // two waiting — if the cap cuts, the cost is one redundant pass.\n const rows = await jobs.findMany({\n where: {\n type: generateJob.name,\n status: { in: ['pending', 'processing'] },\n 'payload.mediaId': { in: [...ids] },\n },\n limit: Math.min(ids.length * 4, 1000),\n })\n const found = new Set<string>()\n for (const row of rows) {\n const parsed = generateVariantsPayloadSchema.safeParse(row.payload)\n if (parsed.success) found.add(parsed.data.mediaId)\n }\n return found\n },\n enqueue: async (payload) => {\n await queue.enqueue(generateJob, payload)\n },\n config,\n styles,\n },\n options.onProgress,\n )\n if (outcome.truncated) {\n app.logger.warn(\n { cap: MAX_BACKFILL_QUEUED, queued: outcome.queued },\n 'media variants: backfill stopped at its cap — run it again once these have drained',\n )\n }\n return outcome\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{n as t}from"./crop-rect-B-AJ2pWm.mjs";import{a as n}from"./focal-point-BWhtJzef.mjs";import{o as r}from"./definitions-LJpgrdxd.mjs";import{t as i}from"./slot-CY4isqmN.mjs";import{behavior as a,defineEntity as o,field as s,searchable as c}from"@murumets-ee/entity";import{z as l}from"zod";function u(e){if(!(`crops`in e))return e;let n=e.crops;if(n==null||t(n))return e;throw new l.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 d(e){if(e.keys.includes(`crops`))throw new l.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 f(){return{name:`cropsValidation`,hooks:{beforeCreate:async e=>u(e),beforeUpdate:async(e,t)=>u(t)},assertBulkUpdate:d}}function p(e){if(!(`focalPoint`in e))return e;let t=e.focalPoint;if(t==null||n(t))return e;throw new l.ZodError([{code:`custom`,path:[`focalPoint`],message:`focalPoint must be { x, y } with both between 0 and 1, or null.`}])}function m(e){if(e.keys.includes(`focalPoint`))throw new l.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 h(){return{name:`focalPointValidation`,hooks:{beforeCreate:async e=>p(e),beforeUpdate:async(e,t)=>p(t)},assertBulkUpdate:m}}function g(e,t){return JSON.stringify(e??null)===JSON.stringify(t??null)}function _(){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=i();if(!n)return;let a=await t.loadCurrent();if(!a||g(a.focalPoint,e.focalPoint)&&g(a.crops,e.crops))return;let o=r(e.updatedAt);o&&t.enqueueOnCommit(n.generateVariants,{mediaId:e.id,epoch:o})}}}}var v=e({Media:()=>y});const y=o({name:`media`,fields:{title:s.text({translatable:!0}),alt:s.text({translatable:!0}),description:s.text({translatable:!0}),fileKey:s.text({required:!0,indexed:!0}),filename:s.text({required:!0}),mimeType:s.text({required:!0,indexed:!0,admin:{display:{type:`mimeType`}}}),size:s.number({required:!0,integer:!0,admin:{display:{type:`filesize`}}}),width:s.number({integer:!0}),height:s.number({integer:!0}),focalPoint:s.json(),crops:s.json(),mediaType:s.select({options:[`image`,`video`,`audio`,`document`,`other`],required:!0,indexed:!0})},behaviors:[a.auditable(),h(),f(),_(),c({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{v as n,y as t};
2
- //# sourceMappingURL=entity-CsDdjKz6.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"entity-CsDdjKz6.mjs","names":[],"sources":["../src/crop/crops-validation.ts","../src/crop/focal-point-validation.ts","../src/jobs/regenerate-on-edit.ts","../src/entity.ts"],"sourcesContent":["/**\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 { 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 // --- 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 // 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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"}
@@ -1,2 +0,0 @@
1
- import{a as e,i as t,n}from"./variant-plan-86tJKtVi.mjs";import{t as r}from"./owned-delete-Cn_KFFjS.mjs";import{c as i,o as a,s as o,t as s}from"./variant-state-CBtqb9Cb.mjs";import{a as ee,i as te,r as ne,s as re,t as c}from"./process-image-wQwiRhyk.mjs";var l=class extends Error{failed;constructor(e,t){super(`media ${e}: ${t} variant(s) failed to upload; the pass will be retried`),this.name=`VariantUploadError`,this.failed=t}};function u(e,t,n={}){return{mediaId:e,status:t,uploaded:0,reused:0,deleted:0,dropped:0,dimensionsCorrected:!1,focalPointSeeded:!1,...n}}async function ie(e){if(Buffer.isBuffer(e))return e;let t=[],n=e.getReader();for(;;){let{done:e,value:r}=await n.read();if(e)break;r&&t.push(r)}return Buffer.concat(t)}async function d(e,t,n){let r=[...e];await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=r.shift();e!==void 0;e=r.shift())await n(e)}))}function f(e,t){return JSON.stringify(e??null)===JSON.stringify(t??null)}function p(e,n){return t(e,{shape:n.shape,width:n.width,fingerprint:n.fingerprint,format:n.format})}function ae(e,t,n){let{width:r,height:i}=e.metadata??{};return typeof r!=`number`||typeof i!=`number`||r<=0||i<=0?null:{shape:t.shape,rung:t.width,width:r,height:i,format:t.format,key:n}}async function m(t,m){let{media:v,storage:y,config:b,styles:oe,logger:x}=m,S=await v.findById(t);if(!S)return u(t,`missing`);let C=S;if(C.mediaType!==`image`||!te(C.mimeType))return u(t,`skipped`);let w=await y.getMetadata(C.fileKey);if(!w)return x?.warn({mediaId:t},`media variants: original file record is missing; nothing to do`),u(t,`skipped`);let T=await ie((await y.download(C.fileKey)).body),E=!1,D=await ne(T),O={};if(D.width>0&&(C.width!==D.width||C.height!==D.height)&&(O.width=D.width,O.height=D.height),C.focalPoint===null||C.focalPoint===void 0){let e=await c(T).catch(e=>(x?.warn({mediaId:t,err:e},`media variants: focal-point detection failed; left unset`),null));e?O.focalPoint=e:E=!0}if(`focalPoint`in O){let e=await v.findById(t);if(!e)return u(t,`missing`);e.focalPoint!==null&&e.focalPoint!==void 0&&(delete O.focalPoint,C={...C,focalPoint:e.focalPoint,crops:e.crops})}Object.keys(O).length>0&&(await v.updateInternal(t,O),C={...C,...O});let k=n(D,b,{focalPoint:C.focalPoint,crops:C.crops});k.dropped>0&&x?.warn({mediaId:t,dropped:k.dropped},`media variants: the per-image cap cut the largest variants off this photograph`);let A=o(w.metadata),j=new Map(A.shaped.map(e=>[e.key,e])),M=[],N=[];for(let e of k.variants){let t=p(C.fileKey,e),n=j.get(t);if(n){M.push({...n,shape:e.shape,rung:e.width});continue}let r=await y.getMetadata(t),i=r?ae(r,e,t):null;i?M.push(i):N.push({planned:e,key:t,replace:r!==null})}let se=M.length,P=0,F=0,I=[],L=[],ce=await re(T,N.map(e=>e.planned));await d(N.map((e,t)=>({...e,variant:ce[t]})),4,async({planned:e,key:n,replace:r,variant:i})=>{if(i)try{r&&await y.delete(n),await y.upload(i.buffer,{key:n,filename:`${e.shape}_${e.width}w_${C.filename}`,mimeType:i.mimeType,size:i.buffer.byteLength,visibility:w.visibility,metadata:{variantOf:C.fileKey,shape:e.shape,rung:e.width,width:i.width,height:i.height}}),M.push({shape:e.shape,rung:e.width,width:i.width,height:i.height,format:e.format,key:n}),I.push(n),P++}catch(e){F++,x?.warn({mediaId:t,key:n,err:e},`media variants: shaped variant upload failed`)}});let R=new Map,z=new Map,B={},V=new Set;for(let[t,n]of Object.entries(oe)){let r=e(C.fileKey,t,n.format??`webp`),i=a(n);A.legacy.get(t)===r&&A.legacyRecipes.get(t)===i?(R.set(t,r),z.set(t,i)):B[t]=n}Object.keys(B).length>0&&await d([...(await ee(T,B)).variants],4,async([n,r])=>{let i=B[n];if(!i)return;let o=e(C.fileKey,n,r.format),s=!1;try{await y.getMetadata(o)&&(await y.delete(o),s=!0),await y.upload(r.buffer,{key:o,filename:`${n}_${C.filename}`,mimeType:r.mimeType,size:r.buffer.byteLength,visibility:w.visibility,metadata:{variantOf:C.fileKey,style:n}}),R.set(n,o),z.set(n,a(i)),L.push(o),P++}catch(e){F++,s&&V.add(n),x?.warn({mediaId:t,key:o,err:e},`media variants: legacy style upload failed`)}});let H=await v.findById(t);if(!H)return await r(y,C.fileKey,[...I,...L],x),u(t,`missing`,{uploaded:P});let U=!f(H.focalPoint,C.focalPoint)||!f(H.crops,C.crops),W=await y.getMetadata(C.fileKey)??w,G=o(W.metadata),K=g(h(G.legacy,R),V),q=g(h(G.legacyRecipes,z),V),le=[...G.legacy].filter(([e,t])=>K.has(e)&&K.get(e)!==t).map(([,e])=>e),J=U?new Set(n(D,b,{focalPoint:H.focalPoint,crops:H.crops}).variants.map(e=>p(C.fileKey,e))):null,Y=(e,t)=>{let n=new Set(e.map(e=>e.key));return[...new Set([...G.unconfirmed,...t,...le])].filter(e=>!n.has(e))},X;if(U){let e=I.filter(e=>J?.has(e));X={legacy:K,legacyRecipes:q,shaped:G.shaped,unconfirmed:Y(G.shaped,e)}}else if(F>0){let e=new Set(G.shaped.map(e=>`${e.shape}@${e.rung}`)),t=M.filter(t=>!e.has(`${t.shape}@${t.rung}`)),n=_([...G.shaped,...t]);X={legacy:K,legacyRecipes:q,shaped:n,unconfirmed:Y(n,M.map(e=>e.key))}}else X={legacy:R,legacyRecipes:z,shaped:_(M),unconfirmed:[]};let Z=i(W.metadata,X);await y.updateMetadata(C.fileKey,{metadata:E?{...Z,[s]:!0}:Z});let Q=new Set;if(U){let e=new Set(G.shaped.map(e=>e.key));for(let t of I)!J?.has(t)&&!e.has(t)&&Q.add(t)}else if(F===0){let e=new Set([...M.map(e=>e.key),...R.values()]);for(let t of G.shaped)e.has(t.key)||Q.add(t.key);for(let t of G.legacy.values())e.has(t)||Q.add(t);for(let t of G.unconfirmed)e.has(t)||Q.add(t)}let ue=await r(y,C.fileKey,Q,x),$=u(t,U?`stale`:`generated`,{uploaded:P,reused:se,deleted:ue,dropped:k.dropped,dimensionsCorrected:`width`in O,focalPointSeeded:`focalPoint`in O});if(F>0&&!U)throw new l(t,F);return x?.debug($,`media variants: pass complete`),$}function h(e,t){return new Map([...e,...t])}function g(e,t){for(let n of t)e.delete(n);return e}function _(e){return[...e].sort((e,t)=>e.shape===t.shape?e.rung-t.rung||e.width-t.width:e.shape<t.shape?-1:1)}export{m as generateMediaVariants};
2
- //# sourceMappingURL=generate-variants-Dpy4aT2o.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"generate-variants-Dpy4aT2o.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. The pass already decodes the original, so it writes the drawn\n * size back 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 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}\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}\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 ...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 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 })\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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"}
@@ -1,2 +0,0 @@
1
- import{n as e}from"./variant-plan-86tJKtVi.mjs";import t from"sharp";const n=new Set([`image/svg+xml`,`image/gif`]);function r(e){return e.startsWith(`image/`)&&!n.has(e)}async function i(e,n){let{width:r,height:i}=u(await t(e).metadata()),a=new Map,o=Object.entries(n);return await Promise.all(o.map(async([n,r])=>{let i=r.format??`webp`,o=r.quality??80,s=r.fit??`cover`,{data:c,info:l}=await t(e).rotate().resize({width:r.width,height:r.height,fit:s,withoutEnlargement:!0})[i]({quality:o}).toBuffer({resolveWithObject:!0});a.set(n,{buffer:c,format:i,mimeType:`image/${i}`,width:l.width,height:l.height})})),{width:r,height:i,variants:a}}async function a(n,r,i){let{width:a,height:s}=u(await t(n).metadata()),c=e({width:a,height:s},r,i);return{width:a,height:s,variants:await o(n,c.variants),dropped:c.dropped}}async function o(e,t){let n=new Map;return c(t,2,async t=>{let r=JSON.stringify([t.extract,t.width,t.height,t.quality,t.format]),i=n.get(r);return i||(i=s(e,t),n.set(r,i)),{...await i,shape:t.shape,plannedWidth:t.width}})}async function s(e,n){let{data:r,info:i}=await t(e).rotate().extract(n.extract).resize({width:n.width,height:n.height,fit:`cover`,withoutEnlargement:!0})[n.format]({quality:n.quality}).toBuffer({resolveWithObject:!0});return{buffer:r,format:n.format,mimeType:`image/${n.format}`,width:i.width,height:i.height,shape:n.shape,plannedWidth:n.width,fingerprint:n.fingerprint}}async function c(e,t,n){let r=Array(e.length),i=e.map((e,t)=>({item:e,index:t})),a=!1;return await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=i.shift();e!==void 0&&!a;e=i.shift())try{r[e.index]=await n(e.item)}catch(e){throw a=!0,e}})),r}async function l(e){return u(await t(e).metadata())}function u(e){return{width:e.autoOrient?.width??e.width??0,height:e.autoOrient?.height??e.height??0}}async function d(e){let{data:n,info:r}=await t(e).rotate().resize(512,512,{fit:`inside`,withoutEnlargement:!0}).raw().toBuffer({resolveWithObject:!0}),{width:i,height:a,channels:o}=r;if(i<8||a<8)return null;let s=await t(n,{raw:{width:i,height:a,channels:o}}).flop().flip().raw().toBuffer(),[c,l]=await Promise.all([f(n,i,a,o),f(s,i,a,o)]);if(c===null||l===null)return null;let u=p(c.x,1-l.x),d=p(c.y,1-l.y);return u===null&&d===null?null:{x:m(u??.5),y:m(d??.5)}}async function f(e,n,r,i){let a=n>=r,{info:o}=await t(e,{raw:{width:n,height:r,channels:i}}).resize(a?n-1:n,a?r:r-1,{fit:`cover`,position:t.strategy.attention}).toBuffer({resolveWithObject:!0}),{attentionX:s,attentionY:c}=o;return s===void 0||c===void 0?null:{x:s/n,y:c/r}}function p(e,t){return Math.abs(e-t)<=.25?(e+t)/2:null}function m(e){return Number.isFinite(e)?Math.min(Math.max(Math.round(e*1e4)/1e4,0),1):.5}export{i as a,r as i,a as n,s as o,l as r,o as s,d as t};
2
- //# sourceMappingURL=process-image-wQwiRhyk.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"process-image-wQwiRhyk.mjs","names":[],"sources":["../src/process-image.ts"],"sourcesContent":["/**\n * Server-side image processing via Sharp.\n *\n * Pure processing module — no DB or storage dependencies.\n * Takes a buffer and image styles, returns metadata + variant buffers.\n */\n\nimport sharp from 'sharp'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport type { VariantConfig } from './shapes.js'\nimport type { ImageStyle } from './types.js'\nimport { type PlannedVariant, planVariants, type VariantPlanInput } from './variant-plan.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n // `.rotate()` first: without it an EXIF-rotated phone photo's variant is\n // written SIDEWAYS — the output format drops the orientation tag — while\n // the row's width/height (`displayDimensions`) and its focal point are in\n // the orientation a browser draws. Keys are unchanged; only the pixels\n // of a rotated original change, and they change to what was intended.\n const resized = sharp(buffer).rotate().resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/** One generated (shape × rung) variant. */\nexport interface ShapedVariant extends ProcessedVariant {\n readonly shape: string\n /** The width the plan ASKED for. It names the key; it is not a claim about the file. */\n readonly plannedWidth: number\n /** Goes into the key — see `deriveShapedVariantKey`. */\n readonly fingerprint: string\n}\n\n/** A photograph's shaped variants. */\nexport interface ShapedImage {\n /** The image as a browser draws it (post-EXIF orientation). */\n width: number\n height: number\n variants: ShapedVariant[]\n /** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`) — for the caller to log. */\n dropped: number\n}\n\n/**\n * How many sharp pipelines one image runs at once. Each decodes the original,\n * so a 24-megapixel JPEG costs ~70 MB of pixels per pipeline in flight; the\n * seeded vocabulary plans 30 of them. libvips already parallelises inside each\n * pipeline, so a small number here loses little throughput.\n */\nconst SHAPED_CONCURRENCY = 2\n\n/**\n * Generate every (shape × rung) variant `config` calls for (images D003).\n *\n * The region comes from {@link planVariants} — `crops[shape]`, else the focal\n * point's window for that ratio, else the whole image — and sharp runs\n * `rotate() → extract() → resize()` in that order:\n *\n * - `rotate()` first, so `extract()` works in the coordinates a browser DRAWS\n * (measured, R013). The crop and the focal point are stored in that space.\n * - `extract()` BEFORE `resize()` — what sharp documents for pre-resize\n * extraction, and the only way to express a rect at all: `resize({position})`\n * takes nine gravities and two strategies and throws on coordinates (C5).\n *\n * 🔴 The variant records `info.width`/`info.height` — what sharp RETURNED —\n * never the rung it asked for (F003). `withoutEnlargement` silently returns a\n * smaller file than requested, and a `640w` descriptor on a 400px file makes a\n * browser's `srcset` choice wrong in the direction it never corrects (R007).\n * The plan never asks for more than the region supplies, so today the two\n * agree; recording the real one is what keeps them agreeing when they don't.\n */\nexport async function generateShapedVariants(\n buffer: Buffer,\n config: VariantConfig,\n input: VariantPlanInput,\n): Promise<ShapedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n const plan = planVariants({ width, height }, config, input)\n const variants = await renderPlannedVariants(buffer, plan.variants)\n return { width, height, variants, dropped: plan.dropped }\n}\n\n/**\n * Render `planned` — a whole plan, or the part of one a caller does not already\n * have (the variant job renders only keys that are missing) — at most\n * {@link SHAPED_CONCURRENCY} sharp pipelines at a time, results in input order.\n */\nexport async function renderPlannedVariants(\n buffer: Buffer,\n planned: readonly PlannedVariant[],\n): Promise<ShapedVariant[]> {\n // Two shapes can plan the SAME recipe — `wide` on a 16:9 photograph is the\n // whole image, exactly as `natural` is — and encoding it twice buys nothing.\n // Keyed by the full recipe, never by the fingerprint: a 32-bit hash that\n // collided here would hand one shape another shape's picture.\n const rendered = new Map<string, Promise<ShapedVariant>>()\n return mapBounded(planned, SHAPED_CONCURRENCY, async (planned) => {\n const recipe = JSON.stringify([\n planned.extract,\n planned.width,\n planned.height,\n planned.quality,\n planned.format,\n ])\n let pending = rendered.get(recipe)\n if (!pending) {\n pending = renderPlannedVariant(buffer, planned)\n rendered.set(recipe, pending)\n }\n const first = await pending\n return { ...first, shape: planned.shape, plannedWidth: planned.width }\n })\n}\n\nexport async function renderPlannedVariant(\n buffer: Buffer,\n planned: PlannedVariant,\n): Promise<ShapedVariant> {\n const pipeline = sharp(buffer).rotate().extract(planned.extract).resize({\n width: planned.width,\n height: planned.height,\n fit: 'cover',\n withoutEnlargement: true,\n })\n const { data, info } = await pipeline[planned.format]({ quality: planned.quality }).toBuffer({\n resolveWithObject: true,\n })\n return {\n buffer: data,\n format: planned.format,\n mimeType: `image/${planned.format}`,\n width: info.width,\n height: info.height,\n shape: planned.shape,\n plannedWidth: planned.width,\n fingerprint: planned.fingerprint,\n }\n}\n\n/** `Promise.all(items.map(fn))` with at most `limit` in flight, results in input order. */\nasync function mapBounded<T, R>(\n items: readonly T[],\n limit: number,\n fn: (item: T) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length)\n const queue = items.map((item, index) => ({ item, index }))\n // Once one job has failed the whole call rejects, so the other workers stop\n // taking jobs rather than running sharp for a result nobody will read.\n let failed = false\n const worker = async (): Promise<void> => {\n for (let job = queue.shift(); job !== undefined && !failed; job = queue.shift()) {\n try {\n results[job.index] = await fn(job.item)\n } catch (err) {\n failed = true\n throw err\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))\n return results\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n return displayDimensions(await sharp(buffer).metadata())\n}\n\n/**\n * The size a browser DRAWS the image at — after EXIF orientation — not the\n * sensor's.\n *\n * `metadata().width/height` are the stored pixel grid, so a phone portrait\n * tagged orientation 6 reports 400×200 while every browser draws it 200×400\n * (`image-orientation: from-image` is the default). Those numbers become the\n * media row's `width`/`height`, which are the `<img>`'s CLS reservation AND the\n * image ratio the focal-point crop is computed against (images PR02) — a\n * swapped ratio positions the crop on the wrong axis. `autoOrient` is sharp's\n * own post-orientation size (0.33+).\n */\nfunction displayDimensions(meta: sharp.Metadata): { width: number; height: number } {\n return {\n width: meta.autoOrient?.width ?? meta.width ?? 0,\n height: meta.autoOrient?.height ?? meta.height ?? 0,\n }\n}\n\n/**\n * The longest edge the attention pass looks at. libvips scores saliency on a\n * ~32px grid, so a larger buffer buys no precision — it only costs decode time.\n */\nconst ATTENTION_EDGE = 512\n\n/** Below this on either side there is nothing to guess about — see {@link detectFocalPoint}. */\nconst MIN_ATTENTION_EDGE = 8\n\n/**\n * How far the two attention passes may disagree on an axis and still be the\n * same subject. libvips places its hotspot on a ~32-cell grid (1/32 ≈ 0.03), so\n * a real subject agrees to within a cell or two (measured ≤ 0.07); a FLAT axis\n * disagrees by the whole image (0 in one pass, 1 in the mirrored one).\n */\nconst AXIS_AGREEMENT = 0.25\n\n/**\n * Guess where the subject is, with sharp's `attention` strategy — the seed for\n * a new photograph's focal point, so an editor who opens nothing still gets\n * something other than a blind centre crop (images D002).\n *\n * ## Two passes, one of them mirrored\n *\n * When the saliency map is flat along an axis — a white product background, a\n * sky, a horizontal band, a plain texture — libvips reports the FIRST cell of\n * it: 0. Stored, that pins every shaped slot to the left or top edge, which is\n * worse than no guess. But a subject genuinely near the edge also reports 0 (a\n * disc at 5% from the corner does, measured), so \"0 means flat\" is not a rule.\n *\n * So the same buffer is scored twice: as-is, and flipped on both axes. A real\n * subject moves with the flip and the two answers agree once the second is\n * mirrored back; a flat axis reports 0 both times, i.e. 0 and 1 once mirrored.\n * Per axis: agree → their mean; disagree → that axis says nothing → 0.5. Both\n * axes flat → `null` (the caller stores it; it renders as the centre, D010).\n * The second pass runs on the already-decoded ≤512px raw buffer, so it costs a\n * saliency pass over ~0.25 MP, not a second decode.\n *\n * Also `null`: an image under {@link MIN_ATTENTION_EDGE} on either side — a\n * strip or an icon has no subject worth cropping around.\n *\n * ## Why not read `info.attentionX` off an ordinary resize\n *\n * Because it is WRONG whenever the same call resizes. sharp converts the\n * libvips hotspot with `attention_x * jpegShrinkOnLoad / scale`\n * (`src/pipeline.cc:505`, 0.34.5 and 0.35.3 alike), and those terms do not\n * describe the image smartcrop ran on: a disc drawn at x = 0.80 reported 0.50\n * from a JPEG and 0.25 from a PNG (images R012). The crop OFFSET beside it is\n * right; only the reported point is not.\n *\n * So each pass runs at SCALE 1: decode once to a bounded raw buffer, then crop\n * that buffer to its own size minus one pixel on the long axis. No\n * shrink-on-load and no scale, so the reported point is in that buffer's own\n * pixels and dividing by its size is the answer. A square image has no long\n * axis, but trimming either one still runs the strategy, and libvips takes the\n * hotspot over the whole saliency map whichever axis it crops.\n *\n * `.rotate()` first, so the point is in the orientation a browser DRAWS — an\n * EXIF-rotated phone photo would otherwise be seeded in sensor coordinates.\n *\n * ⚠️ Attention scores luminance frequency, saturation and skin tones. It is a\n * guess, not detection, and on a product shot the product can win (R012).\n */\nexport async function detectFocalPoint(buffer: Buffer): Promise<FocalPoint | null> {\n const { data, info } = await sharp(buffer)\n .rotate()\n .resize(ATTENTION_EDGE, ATTENTION_EDGE, { fit: 'inside', withoutEnlargement: true })\n .raw()\n .toBuffer({ resolveWithObject: true })\n const { width, height, channels } = info\n if (width < MIN_ATTENTION_EDGE || height < MIN_ATTENTION_EDGE) return null\n const raw = { raw: { width, height, channels } }\n const mirrored = await sharp(data, raw).flop().flip().raw().toBuffer()\n const [straight, flipped] = await Promise.all([\n attentionHotspot(data, width, height, channels),\n attentionHotspot(mirrored, width, height, channels),\n ])\n if (straight === null || flipped === null) return null\n const x = agreeingAxis(straight.x, 1 - flipped.x)\n const y = agreeingAxis(straight.y, 1 - flipped.y)\n if (x === null && y === null) return null\n return { x: clampFraction(x ?? 0.5), y: clampFraction(y ?? 0.5) }\n}\n\n/** One attention pass at scale 1, as fractions of the buffer. */\nasync function attentionHotspot(\n data: Buffer,\n width: number,\n height: number,\n channels: 1 | 2 | 3 | 4,\n): Promise<FocalPoint | null> {\n const landscape = width >= height\n const { info } = await sharp(data, { raw: { width, height, channels } })\n .resize(landscape ? width - 1 : width, landscape ? height : height - 1, {\n fit: 'cover',\n position: sharp.strategy.attention,\n })\n .toBuffer({ resolveWithObject: true })\n const { attentionX, attentionY } = info\n if (attentionX === undefined || attentionY === undefined) return null\n return { x: attentionX / width, y: attentionY / height }\n}\n\n/** The two passes' answer for one axis, or `null` when they disagree (a flat axis). */\nfunction agreeingAxis(straight: number, mirrored: number): number | null {\n return Math.abs(straight - mirrored) <= AXIS_AGREEMENT ? (straight + mirrored) / 2 : null\n}\n\n/** Round to four decimals and keep inside `[0, 1]`, so the stored value passes `isFocalPoint`. */\nfunction clampFraction(value: number): number {\n if (!Number.isFinite(value)) return 0.5\n return Math.min(Math.max(Math.round(value * 10000) / 10000, 0), 1)\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- import{s as e}from"./focal-point-BWhtJzef.mjs";import{t}from"./media-config-DkbnXIjC.mjs";import{combineAdminRoutes as n,defineAdminRoute as r,runBounded as i,safeAudit as a,safeAuditMany as o}from"@murumets-ee/core";const s=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function c(e){return e!==void 0&&s.test(e)}const l=[`image`,`video`,`audio`,`document`,`other`];function u(e){return l.includes(e)}function d(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function f(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function p(e,t){return f({error:e},t)}const m={clientPromise:null};async function h(){if(!m.clientPromise){let e=(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{createStorageClient:t}=await import(`@murumets-ee/storage`),{getStorageConfig:n}=await import(`@murumets-ee/storage/plugin`),r=e();return t(n(),{app:r})})();e.catch(()=>{m.clientPromise===e&&(m.clientPromise=null)}),m.clientPromise=e}return m.clientPromise}async function g(){let{createAdminClient:e}=await import(`@murumets-ee/core/clients`),{MediaClient:t}=await import(`./client.mjs`),{Media:n}=await import(`./entity-CsDdjKz6.mjs`).then(e=>e.n),r=await h();return new t({admin:e(n),storage:r})}const _=async(t,n)=>{let{isStorageConfigured:r,getStorageConfigReason:i}=await import(`@murumets-ee/storage`),a=n.segments;if(a.length===2&&a[1]===`usage`){let e=a[0];if(!c(e))return p(`Invalid media ID format`,400);let{findMediaUsages:t}=await import(`./usage.mjs`),{getApp:n}=await import(`@murumets-ee/core`);return f({usages:await t(e,n())})}if(a.length>=2)return p(`Not found`,404);let o=a.length>0?a[0]:void 0;if(o!==void 0&&!c(o))return p(`Invalid media ID format`,400);if(!r()){let e=i()??`Storage not configured`;return a.length>0?f({error:e,configured:!1,reason:e},503):f({items:[],total:0,configured:!1,reason:e})}let s=await g();if(o!==void 0){let e=await s.findById(o);if(!e)return p(`Media not found`,404);let t=await s.getUrl(o);return f({...e,url:t})}let l=new URL(t.url),m=l.searchParams.get(`search`)??void 0,h=l.searchParams.get(`mediaType`),_=h!==null&&u(h)?h:void 0,v=Math.min(Math.max(d(l.searchParams.get(`limit`),24),1),100),y=Math.min(d(l.searchParams.get(`offset`),0),1e5),b=await s.findMany({...m!==void 0&&{search:m},..._!==void 0&&{mediaType:_},limit:v,offset:y}),x=b.items.map(e=>e.id),S=b.items.filter(e=>e.mediaType===`image`).map(e=>e.id),[C,w]=await Promise.all([s.getUrls(x),s.getVariantUrls(S,`thumbnail`)]);return f({items:b.items.map(t=>{let n=t.mediaType===`image`?w.get(t.id):void 0;return{id:t.id,title:t.title??null,alt:t.alt??null,filename:t.filename,mimeType:t.mimeType,size:t.size,mediaType:t.mediaType,url:C.get(t.id)??``,...n!==void 0&&{thumbnailUrl:n},width:t.width??null,height:t.height??null,...t.mediaType===`image`&&{focalPoint:e(t.focalPoint)}}}),total:b.total})},v=async(e,n)=>{let{isStorageConfigured:r,getStorageConfigReason:i,detectMimeType:o,isMimeTypeAllowed:s,readBoundedMultipart:c,tooLarge:l}=await import(`@murumets-ee/storage`);if(n.segments.length>0)return p(`Not found`,404);if(!r())return p(i()??`Storage not configured`,503);let{maxUploadSize:u,acceptedTypes:d}=t(),m=await c(e,u);if(!m.ok)return p(m.error,m.status);let h=m.value,_=h.get(`file`);if(!(_ instanceof File)||_.size===0)return p(`No file provided`,400);if(_.size>u){let e=l(u);return p(e.error,e.status)}let v=await g(),y=Buffer.from(await _.arrayBuffer()),{mimeType:b,mismatch:x}=await o(y,_.type||`application/octet-stream`);if(x)return p(`File content doesn't match declared type: claimed ${_.type}, detected ${b}`,400);if(!s(b,d))return p(`File type ${b} is not accepted`,415);let S=h.get(`visibility`);if(S!==null&&S!==`public`&&S!==`private`)return p(`Field 'visibility' must be 'public' or 'private'`,400);let C=await v.upload(y,{filename:_.name,mimeType:b,size:_.size,uploadedBy:n.user.id,...S!==null&&{visibility:S}}),w={id:C.media.id,title:C.media.title??null,alt:C.media.alt??null,filename:C.media.filename,mimeType:C.media.mimeType,size:C.media.size,mediaType:C.media.mediaType,url:C.url,width:C.media.width??null,height:C.media.height??null};return a(n,{action:`media.upload`,entityType:`media`,entityId:C.media.id,userId:n.user.id,...n.user.name!==void 0&&{userName:n.user.name},changes:{filename:C.media.filename,mimeType:C.media.mimeType,size:C.media.size,mediaType:C.media.mediaType}}),f(w,201)},y=async(e,t)=>{let{isStorageConfigured:n,getStorageConfigReason:r}=await import(`@murumets-ee/storage`);if(t.segments.length!==1)return p(`Not found`,404);if(!n())return p(r()??`Storage not configured`,503);let{getApp:i,getContext:o}=await import(`@murumets-ee/core`),{getMediaJobs:s}=await import(`./slot-CY4isqmN.mjs`).then(e=>e.r),c=i(),l=s();if(l){let{enqueueVariantsBackfill:e}=await import(`./backfill-trigger-BFL1L7_j.mjs`),{jobId:n,alreadyRunning:r}=await e(c,l.variantsBackfill);a(t,{action:`media.regenerate_variants`,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},metadata:{mode:`queued`,jobId:n,alreadyRunning:r}});let{getVariantBacklog:i}=await import(`./backlog-D-H5VUQA.mjs`).then(e=>e.t);return f({mode:`queued`,jobId:n,alreadyRunning:r,backlog:await i(c).catch(()=>null)},202)}let{regenerateAllVariants:u}=await import(`./regenerate-variants-RwV_AQSo.mjs`),{createStorageClient:d}=await import(`@murumets-ee/storage`),{getStorageConfig:m}=await import(`@murumets-ee/storage/plugin`),h=await u({app:c,storage:d(m(),{app:c}),logger:c.logger.child({media:!0}),contextResolver:()=>{let e=o();if(!(!e?.user||!e?.checker))return{user:e.user,checker:e.checker,...e.scope!==void 0&&{scope:e.scope}}}});return a(t,{action:`media.regenerate_variants`,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},metadata:{mode:`inline`,total:h.total,processed:h.processed,errors:h.errors}}),f({mode:`inline`,...h})},b=16*1024;async function x(e){let t=Number(e.headers.get(`content-length`));if(Number.isFinite(t)&&t>b)return{error:`Request body exceeds the maximum size`,status:413};let n=e.body,r;if(n===null){if(r=await e.text(),Buffer.byteLength(r,`utf8`)>b)return{error:`Request body exceeds the maximum size`,status:413}}else{let e=n.getReader(),t=[],i=0;try{for(;;){let{done:n,value:r}=await e.read();if(n)break;if(r!==void 0){if(i+=r.byteLength,i>b)return await e.cancel().catch(()=>void 0),{error:`Request body exceeds the maximum size`,status:413};t.push(r)}}}finally{e.releaseLock()}r=Buffer.concat(t).toString(`utf8`)}let i;try{i=JSON.parse(r)}catch{return{error:`Invalid JSON body`,status:400}}if(typeof i!=`object`||!i||Array.isArray(i))return{error:`Body must be a JSON object`,status:400};let a=i.ids;if(!Array.isArray(a)||a.length===0)return{error:`Body must contain "ids" array`,status:400};if(a.length>100)return{error:`Bulk delete limited to 100 items per request`,status:400};let o=[];for(let e of a){if(typeof e!=`string`||!c(e))return{error:`Body "ids" must contain valid media IDs`,status:400};o.push(e)}return{ids:o}}const S=async(e,t)=>{let n=t.segments;if(n.length===0){let n=await x(e);if(`error`in n)return p(n.error,n.status);let{isStorageConfigured:r,getStorageConfigReason:a}=await import(`@murumets-ee/storage`);if(!r())return p(a()??`Storage not configured`,503);let s=await g(),c=await i(n.ids,async e=>{try{return await s.delete(e),{status:`fulfilled`,id:e}}catch(t){return{status:`rejected`,id:e,reason:t}}}),l=c.filter(e=>e.status===`fulfilled`).map(e=>e.id);await o(t,l.map(e=>({action:`media.delete`,entityType:`media`,entityId:e,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},metadata:{bulk:!0}})));let u=c.find(e=>e.status===`rejected`);if(u&&u.status===`rejected`)throw u.reason;return f({deleted:l.length})}if(n.length>1)return p(`Not found`,404);let r=n[0];if(!c(r))return p(`Invalid media ID format`,400);let{isStorageConfigured:s,getStorageConfigReason:l}=await import(`@murumets-ee/storage`);return s()?(await(await g()).delete(r),a(t,{action:`media.delete`,entityType:`media`,entityId:r,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name}}),f({deleted:1})):p(l()??`Storage not configured`,503)},C=[`admin`,`editor`,`agent`,`viewer`],w=[`admin`,`editor`];function T(){return n([r({prefix:`media`,path:``,method:`GET`,permission:`media:view`,defaultRoles:C,matchAnyPath:!0,description:`Read media — list, single item, or referencing-entity usage report.`,handler:_}),r({prefix:`media`,path:``,method:`POST`,permission:`media:create`,defaultRoles:w,description:"Upload a media file (multipart FormData with `file` field).",handler:v}),r({prefix:`media`,path:`regenerate-variants`,method:`POST`,permission:`media:create`,defaultRoles:w,description:`Catch every image up with the current shapes, widths and styles — queued as a background backfill (synchronous only when the app has no queue).`,handler:y}),r({prefix:`media`,path:``,method:`DELETE`,permission:`media:delete`,defaultRoles:w,matchAnyPath:!0,description:`Delete a media record + its storage object.`,handler:S})])}export{T as t};
2
- //# sourceMappingURL=routes-DaE0M8h8.mjs.map