@murumets-ee/media 0.70.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/backfill-D2Ubblh3.mjs +2 -0
- package/dist/backfill-D2Ubblh3.mjs.map +1 -0
- package/dist/backfill-trigger-BFL1L7_j.mjs +2 -0
- package/dist/backfill-trigger-BFL1L7_j.mjs.map +1 -0
- package/dist/backlog-D-H5VUQA.mjs +2 -0
- package/dist/backlog-D-H5VUQA.mjs.map +1 -0
- package/dist/client.d.mts +55 -7
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/crop-editor.d.mts +60 -0
- package/dist/crop-editor.d.mts.map +1 -0
- package/dist/crop-editor.mjs +2 -0
- package/dist/crop-editor.mjs.map +1 -0
- package/dist/crop-rect-B-AJ2pWm.mjs +2 -0
- package/dist/crop-rect-B-AJ2pWm.mjs.map +1 -0
- package/dist/crop-rect-C-SbjoxK.d.mts +59 -0
- package/dist/crop-rect-C-SbjoxK.d.mts.map +1 -0
- package/dist/crop.d.mts +3 -0
- package/dist/crop.mjs +1 -0
- package/dist/definitions-LJpgrdxd.mjs +2 -0
- package/dist/definitions-LJpgrdxd.mjs.map +1 -0
- package/dist/deps-CzHEhyJK.mjs +2 -0
- package/dist/deps-CzHEhyJK.mjs.map +1 -0
- package/dist/derive-media-type-C38ZA_Cl.mjs +2 -0
- package/dist/derive-media-type-C38ZA_Cl.mjs.map +1 -0
- package/dist/en-A8YzaZ51.mjs +2 -0
- package/dist/en-A8YzaZ51.mjs.map +1 -0
- package/dist/entity-CsDdjKz6.mjs +2 -0
- package/dist/entity-CsDdjKz6.mjs.map +1 -0
- package/dist/et-CXvu8U31.mjs +2 -0
- package/dist/et-CXvu8U31.mjs.map +1 -0
- package/dist/focal-point-BWhtJzef.mjs +2 -0
- package/dist/focal-point-BWhtJzef.mjs.map +1 -0
- package/dist/focal-point-C_0drV03.d.mts +125 -0
- package/dist/focal-point-C_0drV03.d.mts.map +1 -0
- package/dist/generate-variants-zyPjaGcy.mjs +2 -0
- package/dist/generate-variants-zyPjaGcy.mjs.map +1 -0
- package/dist/i18n.mjs +1 -1
- package/dist/i18n.mjs.map +1 -1
- package/dist/image-styles-settings.d.mts +8 -1
- package/dist/image-styles-settings.d.mts.map +1 -1
- package/dist/image-styles-settings.mjs +1 -1
- package/dist/image-styles-settings.mjs.map +1 -1
- package/dist/image-styles.d.mts +35 -21
- package/dist/image-styles.d.mts.map +1 -1
- package/dist/image-styles.mjs +1 -2
- package/dist/image-styles.mjs.map +1 -1
- package/dist/index.d.mts +16 -42
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/media-config-DmcTxuDM.mjs +2 -0
- package/dist/media-config-DmcTxuDM.mjs.map +1 -0
- package/dist/owned-delete-Cn_KFFjS.mjs +2 -0
- package/dist/owned-delete-Cn_KFFjS.mjs.map +1 -0
- package/dist/picker.d.mts +108 -99
- package/dist/picker.d.mts.map +1 -1
- package/dist/picker.mjs +1 -2
- package/dist/picker.mjs.map +1 -1
- package/dist/plugin.d.mts +2 -1
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/process-image-Deedtkh4.mjs +2 -0
- package/dist/process-image-Deedtkh4.mjs.map +1 -0
- package/dist/processing.d.mts +201 -7
- package/dist/processing.d.mts.map +1 -1
- package/dist/processing.mjs +1 -1
- package/dist/public-resolver.d.mts +14 -1
- package/dist/public-resolver.d.mts.map +1 -1
- package/dist/public-resolver.mjs +1 -1
- package/dist/public-resolver.mjs.map +1 -1
- package/dist/query-client.d.mts +1 -1
- package/dist/query-client.mjs +1 -1
- package/dist/regenerate-variants-Cp3sNHkT.mjs +2 -0
- package/dist/regenerate-variants-Cp3sNHkT.mjs.map +1 -0
- package/dist/register-BSGkELj0.mjs +2 -0
- package/dist/register-BSGkELj0.mjs.map +1 -0
- package/dist/resolve-image-styles-pf7Nd3Zf.mjs +2 -0
- package/dist/resolve-image-styles-pf7Nd3Zf.mjs.map +1 -0
- package/dist/routes-BJ23Cp0g.mjs +2 -0
- package/dist/routes-BJ23Cp0g.mjs.map +1 -0
- package/dist/ru-Dj-ax8s_.mjs +2 -0
- package/dist/ru-Dj-ax8s_.mjs.map +1 -0
- package/dist/schedule-DREjg2ji.mjs +2 -0
- package/dist/schedule-DREjg2ji.mjs.map +1 -0
- package/dist/shapes-40dHtTb-.d.mts +90 -0
- package/dist/shapes-40dHtTb-.d.mts.map +1 -0
- package/dist/shapes-BlKW-0C1.mjs +2 -0
- package/dist/shapes-BlKW-0C1.mjs.map +1 -0
- package/dist/slot-CY4isqmN.mjs +2 -0
- package/dist/slot-CY4isqmN.mjs.map +1 -0
- package/dist/types-CgkJF5dc.d.mts +261 -0
- package/dist/types-CgkJF5dc.d.mts.map +1 -0
- package/dist/types-DwzblZfW.d.mts +97 -0
- package/dist/types-DwzblZfW.d.mts.map +1 -0
- package/dist/variant-key-Cki4n-42.mjs +2 -0
- package/dist/variant-key-Cki4n-42.mjs.map +1 -0
- package/dist/variant-plan-DV8hsMdz.mjs +2 -0
- package/dist/variant-plan-DV8hsMdz.mjs.map +1 -0
- package/package.json +25 -8
- package/dist/client-YlSoGADl.mjs +0 -2
- package/dist/client-YlSoGADl.mjs.map +0 -1
- package/dist/en-0YnQ_Qz-.mjs +0 -2
- package/dist/en-0YnQ_Qz-.mjs.map +0 -1
- package/dist/entity-Caba_NFw.mjs +0 -2
- package/dist/entity-Caba_NFw.mjs.map +0 -1
- package/dist/entity-fxw-Qywj.mjs +0 -2
- package/dist/entity-fxw-Qywj.mjs.map +0 -1
- package/dist/et-BIxGYWVK.mjs +0 -2
- package/dist/et-BIxGYWVK.mjs.map +0 -1
- package/dist/image-styles-settings-ClIA2MJI.mjs +0 -2
- package/dist/image-styles-settings-ClIA2MJI.mjs.map +0 -1
- package/dist/plugin-BkEED6qj.mjs +0 -2
- package/dist/plugin-BkEED6qj.mjs.map +0 -1
- package/dist/process-image-DYDTMGUJ.mjs +0 -2
- package/dist/process-image-DYDTMGUJ.mjs.map +0 -1
- package/dist/regenerate-variants-BysyeIoA.mjs +0 -2
- package/dist/regenerate-variants-BysyeIoA.mjs.map +0 -1
- package/dist/regenerate-variants-sit6LbUo.mjs +0 -2
- package/dist/regenerate-variants-sit6LbUo.mjs.map +0 -1
- package/dist/resolve-image-styles-BzSRuXUs.mjs +0 -2
- package/dist/resolve-image-styles-BzSRuXUs.mjs.map +0 -1
- package/dist/resolve-image-styles-iN9JbZYf.mjs +0 -2
- package/dist/resolve-image-styles-iN9JbZYf.mjs.map +0 -1
- package/dist/routes-RCy0SYsK.mjs +0 -2
- package/dist/routes-RCy0SYsK.mjs.map +0 -1
- package/dist/ru-UUTBfsMF.mjs +0 -2
- package/dist/ru-UUTBfsMF.mjs.map +0 -1
- package/dist/types-1idCpe1k.d.mts +0 -160
- package/dist/types-1idCpe1k.d.mts.map +0 -1
- package/dist/usage-CVAqkS6h.mjs +0 -2
- package/dist/usage-CVAqkS6h.mjs.map +0 -1
- package/dist/variant-key-CLlac3_1.mjs +0 -2
- package/dist/variant-key-CLlac3_1.mjs.map +0 -1
- package/dist/variant-key-JBTJXPL1.mjs +0 -2
- package/dist/variant-key-JBTJXPL1.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./entity-
|
|
1
|
+
import{t as e}from"./entity-CsDdjKz6.mjs";import{imageStylesSettings as t}from"./image-styles-settings.mjs";import{n,r}from"./media-config-DmcTxuDM.mjs";import{t as i}from"./routes-BJ23Cp0g.mjs";import{getMediaMessages as a}from"./i18n.mjs";import{definePlugin as o}from"@murumets-ee/core";import{ImageStylesManager as s,ImageVocabularyEditor as c,RegenerateVariantsAction as l}from"@murumets-ee/media/image-styles";function u(){return n()}function d(n){let u=r(n);return o({name:`@murumets-ee/media`,requires:[`@murumets-ee/storage`,`@murumets-ee/settings`],shared:{messages:a,settings:[t]},server:{entities:[e],routes:[...i()],config:u,init:async e=>{try{let{registerMediaJobs:t}=await import(`./register-BSGkELj0.mjs`);await t(e)}catch(t){e.logger.warn({err:t},`media: could not register the variant jobs — uploads will generate variants inline`)}e.logger.info({acceptedTypes:u.acceptedTypes,maxUploadSize:u.maxUploadSize,defaultVisibility:u.defaultVisibility},`Media plugin initialized`)}},adminUi:{sidebar:[{id:`media`,group:`Library`,label:`Media`,href:`/admin/media`,iconName:`image`}],defaultRoutes:[{path:`media`,factory:`MediaListPage`},{path:`media/[id]`,factory:`MediaEditPage`}],settingRenderers:{"media.imageStyles":s,"media.imageVocabulary":c},settingsActions:{"media.imageStyles":l}}})}export{u as getMediaConfig,d as media};
|
|
2
2
|
//# sourceMappingURL=plugin.mjs.map
|
package/dist/plugin.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/media-config.ts","../src/admin/routes.ts","../src/plugin.ts"],"sourcesContent":["/**\n * The media plugin's resolved configuration — defaults in one place, read\n * from the framework's `PluginConfigRegistry`.\n *\n * The plugin used to stash its config in a module variable from `init`, and\n * the upload route hardcoded 50 MB with a comment explaining it could not\n * trust that variable to be set yet. So `acceptedTypes` and `maxUploadSize`\n * were declared, documented, logged at boot — and enforced nowhere (F007).\n * `server.config` is placed in the registry at the merge step, BEFORE any\n * `init`, which removes the reason the route could not read it.\n */\n\nimport { getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\nimport type { MediaPluginConfig } from './types.js'\n\ndeclare module '@murumets-ee/core' {\n interface PluginConfigRegistry {\n '@murumets-ee/media': Required<MediaPluginConfig>\n }\n}\n\nexport const MEDIA_PLUGIN_NAME = '@murumets-ee/media'\n\n/**\n * The default `acceptedTypes`, matched against what `detectMimeType` (file-type\n * 21) actually REPORTS — not what a browser claims.\n *\n * Wider than the image/video/audio/PDF the option's docs used to list, on\n * purpose: nothing enforced that list, so modern Office and OpenDocument files\n * were accepted and are in libraries today. Enforcing the narrow list would\n * have turned an F007 memory fix into a silent feature removal.\n *\n * Deliberately NOT in the default, each refused with 415 unless an app lists it:\n * - **Legacy binary Office** (`.doc`, `.xls`, `.ppt`). file-type reports them as\n * `application/x-cfb` — the same container as `.msi` installers and Outlook\n * `.msg` files, so it cannot be allowed for one without the others.\n * - **Macro-enabled Office** (`.docm`, `.xlsm`, `.pptm`), reported with their own\n * `…macroenabled…` types.\n * - **Archives and executables**, and the text types (`application/json`,\n * `text/markdown`, TSV) that `detectMimeType` trusts but the library does not\n * classify as documents.\n *\n * Named office families rather than `application/vnd.*`, which also matches\n * executables and archives.\n */\nexport const DEFAULT_MEDIA_ACCEPTED_TYPES: readonly string[] = [\n 'image/*',\n 'video/*',\n 'audio/*',\n 'application/pdf',\n 'application/vnd.openxmlformats-officedocument.*',\n 'application/vnd.oasis.opendocument.*',\n 'text/plain',\n 'text/csv',\n]\n\n/** Fill every unset option with its default. */\nexport function resolveMediaConfig(config?: MediaPluginConfig): Required<MediaPluginConfig> {\n return {\n acceptedTypes: config?.acceptedTypes ?? [...DEFAULT_MEDIA_ACCEPTED_TYPES],\n // Non-positive means unset, as it does for storage's `maxFileSize`.\n maxUploadSize:\n config?.maxUploadSize !== undefined && config.maxUploadSize > 0\n ? config.maxUploadSize\n : 50 * 1024 * 1024,\n // Resolved for the picker's own use, and deliberately NOT applied by the\n // upload route: an upload that sends no visibility stays private (rent's\n // inspection photos rely on it). See the route's deny-by-default comment.\n defaultVisibility: config?.defaultVisibility ?? 'public',\n imageStyles: config?.imageStyles ?? {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n },\n }\n}\n\n/**\n * The registered config, or the defaults when the plugin is not registered\n * (a unit test, a CLI path that never ran `createApp`). The defaults are what\n * `media()` with no arguments resolves to, so the fallback is never looser\n * than a registered plugin that configured nothing.\n */\nexport function readMediaConfig(): Required<MediaPluginConfig> {\n return hasPluginConfig(MEDIA_PLUGIN_NAME)\n ? getPluginConfig(MEDIA_PLUGIN_NAME)\n : resolveMediaConfig()\n}\n","/**\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 — bulk variant regeneration\n * - DELETE /api/admin/media/<id> — delete + storage cleanup\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 safeAudit,\n} from '@murumets-ee/core'\nimport type { MediaClient } from '../client.js'\nimport { readMediaConfig } from '../media-config.js'\nimport type { MediaPickerItem, MediaPickerListResult } from '../picker/types.js'\n\n// ---------------------------------------------------------------------------\n// Validation\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isValidUuid(value: string | undefined): value is string {\n return value !== undefined && UUID_RE.test(value)\n}\n\n/**\n * Closed enum of allowed `mediaType` filter values, in lockstep with the\n * `mediaType` field on the Media entity. The list MUST match\n * `MediaListOptions.mediaType` (declared in `../types.js`) — TypeScript\n * doesn't link the two automatically, so the small drift risk is\n * accepted in exchange for not pulling a runtime dep on the entity\n * schema package just to read its enum.\n *\n * Used as both the runtime allowlist (`isAllowedMediaType`) and the\n * source of truth for the narrowed type the guard produces — keeping\n * `as` casts off the handler's hot path.\n */\nconst ALLOWED_MEDIA_TYPES = ['image', 'video', 'audio', 'document', 'other'] as const\ntype AllowedMediaType = (typeof ALLOWED_MEDIA_TYPES)[number]\n\nfunction isAllowedMediaType(value: string): value is AllowedMediaType {\n // Cast to readonly string[] so the typed-tuple `includes` check works\n // with an arbitrary string input — `Array.prototype.includes`'s\n // signature on a typed tuple only accepts members of the tuple\n // (TS 4.9+ literal-narrowing), which is exactly what we want to\n // sidestep here.\n return (ALLOWED_MEDIA_TYPES as readonly string[]).includes(value)\n}\n\n/**\n * Hard cap on the `offset` query param. The dispatcher's allowed-pages\n * model is `limit ∈ [1, 100]`; even with the max page size that's\n * 1000 pages before hitting MAX_OFFSET, which is well beyond any\n * realistic admin-UI scroll depth. Forces slow-discard offset attacks\n * (`?offset=999999999`) to revert to a sane upper bound. Cursor-based\n * pagination would let this go higher, but the media list is\n * offset-based by convention with the entity-list shell.\n */\nconst MAX_OFFSET = 100_000\n\n/**\n * Strict non-negative integer parser for query params.\n *\n * `Number(raw)` silently coerces blanks (`'' → 0`), hex (`'0x05' → 5`),\n * exponent notation (`'1e2' → 100`), and signed (`'+5'/'-5'`) values —\n * all of which pass a downstream `Number.isInteger(n) && n >= 0` check.\n * Pre-filtering with `/^\\d+$/` keeps the contract honest: \"if you don't\n * send a pure non-negative integer string, you get the default.\" Same\n * pattern as `packages/taxonomy/src/admin/routes.ts:handleGet` tree\n * branch from PR-C-taxonomy.\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n // The regex above stops hex / exponent / signed / whitespace / blank\n // inputs, but a long digit-only string still slips past — `Number(...)`\n // returns `Infinity` past ~309 digits, and precision is lost past\n // 2^53. Both would propagate through `client.findMany`'s\n // `offset: parsed` into a Postgres query that either crashes the\n // driver (`Infinity` is not a valid bigint) or silently rounds (the\n // 2^53 precision-loss boundary). Reject via `Number.isSafeInteger`\n // so overflowing inputs revert to `fallback` like every other\n // malformed value.\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction jsonError(message: string, status: number): Response {\n return jsonResponse({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory + cached storage client\n// ---------------------------------------------------------------------------\n//\n// MediaClient and its AdminClient MUST be built per-request — AdminClient's\n// context resolver is captured eagerly at construction (see\n// buildContextResolver in @murumets-ee/core/clients). A module-level singleton\n// would bake the first request's user + tenant scope into every subsequent\n// request's writes — a cross-request permission/scope leak.\n//\n// Storage config is process-global and safe to cache once.\n\ntype StorageClient = Awaited<ReturnType<typeof import('@murumets-ee/storage').createStorageClient>>\n\ninterface StorageState {\n clientPromise: Promise<StorageClient> | null\n}\n\n// Module-level state. `null` once eviction fires on a rejection so the next\n// request gets a fresh resolution attempt (see `getStorage`'s `.catch`).\nconst storageState: StorageState = { clientPromise: null }\n\n/**\n * Resolve the process-wide storage client. The pending-promise slot is\n * cleared on rejection so a transient `createStorageClient` failure\n * (DB hiccup, misconfig) doesn't poison every subsequent media request\n * for the lifetime of the Node process. Same pattern as\n * `packages/taxonomy/src/admin/routes.ts:getClient` from PR-C-taxonomy.\n */\nasync function getStorage(): Promise<StorageClient> {\n if (!storageState.clientPromise) {\n const pending = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n })()\n // Clear the slot on rejection so a transient failure doesn't poison\n // every subsequent request. `pending.catch(handler)` attaches a\n // synchronous rejection listener that resets the cached slot — the\n // handler returns `undefined`, NOT a re-throw — so the new\n // `.catch`-returned promise resolves, but we never await or hold\n // that promise. The ORIGINAL `pending` (stored in\n // `storageState.clientPromise`) is what callers `await`, and it's\n // still in its rejected state — so the caller observing this\n // resolution sees the underlying error. The `.catch` also marks\n // the rejection as \"handled\" for Node's unhandled-rejection\n // tracker. `storageState.clientPromise` holds the original `pending`\n // so concurrent in-flight callers share one resolution.\n pending.catch(() => {\n if (storageState.clientPromise === pending) storageState.clientPromise = null\n })\n storageState.clientPromise = pending\n }\n return storageState.clientPromise\n}\n\nasync function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { MediaClient } = await import('../client.js')\n const { Media } = await import('../entity.js')\n const storage = await getStorage()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleGet: AdminRouteHandler = async (req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n const segments = ctx.segments\n\n // GET /media/<id>/usage — DB-only lookup, safe even when storage is\n // unconfigured. Validate the UUID first so a malformed id 400s\n // uniformly with the storage-configured branch below.\n if (segments.length === 2 && segments[1] === 'usage') {\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { findMediaUsages } = await import('../usage.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const usages = await findMediaUsages(id, app)\n return jsonResponse({ usages })\n }\n\n // Reject unknown sub-paths explicitly. The wrapper-gate has already\n // passed (caller has `media:view`); a handler-level 404 here matches\n // the dispatcher's miss-shape so unknown URLs don't fall through to\n // the single-id branch and 400/500 with a misleading reason. The\n // only valid 2-segment URL is `<id>/usage`, which was already\n // claimed by the early-return above — anything else with >=2\n // segments is not a recognized media route.\n if (segments.length >= 2) return jsonError('Not found', 404)\n\n // GET /media/<id> — validate UUID up front so a bad request still\n // 400s when storage happens to be unconfigured (matches DELETE).\n const singleId = segments.length > 0 ? segments[0] : undefined\n if (singleId !== undefined && !isValidUuid(singleId)) {\n return jsonError('Invalid media ID format', 400)\n }\n\n // Everything below needs a working storage client. If env isn't\n // wired, return a structured \"disabled\" response rather than 500 —\n // the media list page renders a banner and new admins can finish\n // onboarding without being blocked on a crash loop.\n if (!isStorageConfigured()) {\n const reason = getStorageConfigReason() ?? 'Storage not configured'\n if (segments.length > 0) {\n // Single item — treat as not-found-ish to avoid leaking\n // existence; include reason so the UI can surface it.\n return jsonResponse({ error: reason, configured: false, reason }, 503)\n }\n // GET /media — empty list + disabled flag for the picker's banner.\n const response: MediaPickerListResult & { configured: false; reason: string } = {\n items: [],\n total: 0,\n configured: false,\n reason,\n }\n return jsonResponse(response)\n }\n\n const client = await getMediaClient()\n\n // GET /media/<id> — single item with URL (full record for EntityForm + picker)\n if (singleId !== undefined) {\n const record = await client.findById(singleId)\n if (!record) return jsonError('Media not found', 404)\n\n const url = await client.getUrl(singleId)\n return jsonResponse({ ...record, url })\n }\n\n // GET /media — list with search/filter + batch URL resolution\n const url = new URL(req.url)\n const search = url.searchParams.get('search') ?? undefined\n // mediaType MUST be validated against the entity's closed enum before\n // it reaches `client.findMany` — the previous shape force-cast the raw\n // query value with `as 'image' | ...` which is a type-system lie. A\n // bogus value like `?mediaType=bogus` would parameterize into\n // `WHERE media_type = 'bogus'` (no SQL injection — Drizzle is\n // parameterized — but the row count is silently zero, which is\n // confusing). Empty string from `?mediaType=` is also passed-through\n // by the prior `?? undefined` shape since empty string is not null.\n // Allowlist + type guard drops bogus / empty values cleanly so the\n // filter is omitted instead of applied with a no-match value.\n const rawMediaType = url.searchParams.get('mediaType')\n const mediaType =\n rawMediaType !== null && isAllowedMediaType(rawMediaType) ? rawMediaType : undefined\n // Strict parsing keeps the contract honest — see `parseUintParam`\n // JSDoc. Cap limit to [1, 100] and offset to [0, MAX_OFFSET] so a\n // request can't ask for an unboundedly large page nor force a\n // slow-discard scan with `?offset=99999999`.\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 24), 1), 100)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n const result = await client.findMany({\n ...(search !== undefined && { search }),\n ...(mediaType !== undefined && { mediaType }),\n limit,\n offset,\n })\n\n // Resolve original URLs for all items, but thumbnail variants ONLY for\n // images — a video/audio/document/other id will never have a `thumbnail`\n // variant, so asking `getVariantUrls` to resolve one is pure waste (a\n // storage lookup that falls through to the original, then gets discarded\n // below anyway). `media-list-page.tsx` filters the same way.\n const ids = result.items.map((item) => item.id)\n const imageIds = result.items.filter((item) => item.mediaType === 'image').map((item) => item.id)\n const [urlMap, thumbMap] = await Promise.all([\n client.getUrls(ids),\n client.getVariantUrls(imageIds, 'thumbnail'),\n ])\n\n const items: (MediaPickerItem & { thumbnailUrl?: string })[] = result.items.map((item) => {\n // F115: `getVariantUrls` falls back to the ORIGINAL asset URL for any id\n // with no `thumbnail` variant — correct for an image that just hasn't\n // been processed yet, wrong for a video/audio/document/other row, which\n // will never have one. Gating on `mediaType` keeps `thumbnailUrl` ABSENT\n // rather than pointing a consumer's `<img src>` at a `.mp4`.\n const thumbnailUrl = item.mediaType === 'image' ? thumbMap.get(item.id) : undefined\n return {\n id: item.id,\n title: item.title ?? null,\n alt: item.alt ?? null,\n filename: item.filename,\n mimeType: item.mimeType,\n size: item.size,\n mediaType: item.mediaType,\n url: urlMap.get(item.id) ?? '',\n ...(thumbnailUrl !== undefined && { thumbnailUrl }),\n width: item.width ?? null,\n height: item.height ?? null,\n }\n })\n\n const response: MediaPickerListResult = { items, total: result.total }\n return jsonResponse(response)\n}\n\nconst handleUpload: AdminRouteHandler = async (req, ctx) => {\n const {\n isStorageConfigured,\n getStorageConfigReason,\n detectMimeType,\n isMimeTypeAllowed,\n readBoundedMultipart,\n tooLarge,\n } = await import('@murumets-ee/storage')\n\n // Upload is registered at `path: ''` (NOT matchAnyPath) — the\n // dispatcher only invokes this handler when segments=[]. The api-\n // handler ALSO rejects empty/whitespace-only first segments with a\n // 400 before any plugin dispatcher runs (see\n // `packages/admin-ui/src/server/api-handler/index.ts` segment-\n // validation block). So this guard is defense-in-depth — per HANDOFF\n // §\"Defense-in-depth `&& segments[i]` guards are fine\" — protecting\n // against a future framework invariant change that lets a non-empty\n // segment reach a `path: ''` static entry.\n if (ctx.segments.length > 0) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // The plugin's `maxUploadSize` / `acceptedTypes`, from the registry the\n // merge step filled before any init — see `media-config.ts` (F007).\n const { maxUploadSize, acceptedTypes } = readMediaConfig()\n\n // F007: bound the body BEFORE `formData()` makes it resident — and before\n // building a MediaClient for a request that is about to be refused.\n const envelope = await readBoundedMultipart(req, maxUploadSize)\n if (!envelope.ok) return jsonError(envelope.error, envelope.status)\n const formData = envelope.value\n\n const file = formData.get('file')\n // FormData `get` returns string | File | null. Reject anything that\n // isn't a File so the typed `file.size`/`file.arrayBuffer()` below\n // is safe without a runtime cast.\n if (!(file instanceof File) || file.size === 0) {\n return jsonError('No file provided', 400)\n }\n\n // The envelope bound covers the whole body; this covers the file, before\n // `arrayBuffer()` copies it.\n if (file.size > maxUploadSize) {\n const refused = tooLarge(maxUploadSize)\n return jsonError(refused.error, refused.status)\n }\n\n const client = await getMediaClient()\n\n const buffer = Buffer.from(await file.arrayBuffer())\n\n // Detect actual MIME type from file content (prevents spoofing).\n const { mimeType, mismatch } = await detectMimeType(\n buffer,\n file.type || 'application/octet-stream',\n )\n if (mismatch) {\n return jsonError(\n `File content doesn't match declared type: claimed ${file.type}, detected ${mimeType}`,\n 400,\n )\n }\n // `acceptedTypes` against the DETECTED type, so a renamed file cannot\n // claim its way past the allowlist.\n if (!isMimeTypeAllowed(mimeType, acceptedTypes)) {\n return jsonError(`File type ${mimeType} is not accepted`, 415)\n }\n\n // Per-upload visibility, DEFAULTING TO PRIVATE — the caller opts in.\n //\n // This route serves two populations with opposite needs. The media picker\n // uploads publishable web assets: a page's hero image MUST be public or the\n // public site resolves it to `null` and renders no images at all, with a 200\n // on every page and one warn log (F030). `packages/rent-ui`'s inspection\n // wizard posts to this SAME route with handover and return photos — vehicle\n // and apartment condition shots, plates, interiors, occasionally documents —\n // which must stay behind time-limited signed urls.\n //\n // Deny-by-default decides the direction, and the asymmetry is the argument:\n // forget the opt-in and an image is invisible, which is annoying, visible and\n // fixable; default to public and forget an opt-out and those photos become\n // permanently world-readable. A missed opt-in cannot leak. So the default is\n // unchanged (`private` via StorageClient's own fallback) and rent's caller is\n // untouched — it sends nothing and behaves exactly as before.\n const visibilityRaw = formData.get('visibility')\n if (visibilityRaw !== null && visibilityRaw !== 'public' && visibilityRaw !== 'private') {\n return jsonError(\"Field 'visibility' must be 'public' or 'private'\", 400)\n }\n const result = await client.upload(buffer, {\n filename: file.name,\n mimeType,\n size: file.size,\n uploadedBy: ctx.user.id,\n ...(visibilityRaw !== null && { visibility: visibilityRaw }),\n })\n\n const item: MediaPickerItem = {\n id: result.media.id,\n title: result.media.title ?? null,\n alt: result.media.alt ?? null,\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n url: result.url,\n width: result.media.width ?? null,\n height: result.media.height ?? null,\n }\n\n safeAudit(ctx, {\n action: 'media.upload',\n entityType: 'media',\n entityId: result.media.id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n changes: {\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n },\n })\n\n return jsonResponse(item, 201)\n}\n\nconst handleRegenerateVariants: AdminRouteHandler = async (_req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n\n // Static POST path='regenerate-variants' — the dispatcher only invokes\n // this handler when `segments[0] === 'regenerate-variants'`. Trailing\n // segments (e.g. POST /media/regenerate-variants/extra) aren't a\n // supported sub-route.\n if (ctx.segments.length !== 1) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const { regenerateAllVariants } = await import('../regenerate-variants.js')\n const { getApp, getContext } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('../resolve-image-styles.js')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n\n const app = getApp()\n const styles = await resolveImageStyles(app, app.logger)\n if (!styles || Object.keys(styles).length === 0) {\n return jsonError('No image styles configured', 400)\n }\n\n const storageConfig = getStorageConfig()\n const storage = createStorageClient(storageConfig, { app })\n\n const result = await regenerateAllVariants({\n app,\n storage,\n logger: app.logger.child({ media: true }),\n styles,\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: { total: result.total, processed: result.processed, errors: result.errors },\n })\n\n return jsonResponse(result)\n}\n\nconst handleDelete: AdminRouteHandler = async (_req, ctx) => {\n const segments = ctx.segments\n\n // DELETE /media — no id supplied. Reject as 400, NOT 404, so a\n // caller confusing \"no id\" with \"id not found\" gets a meaningful\n // error. The wrapper-gate has already validated the caller has\n // media:delete, so the 400 isn't an information-disclosure vector.\n if (segments.length === 0) return jsonError('Media ID required', 400)\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: 'Regenerate every image-style variant for every media row.',\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","/**\n * Media plugin — auto-registers the Media entity, mounts admin routes, and\n * contributes sidebar + default-route scaffolding for the admin shell.\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * storage(),\n * media({ maxUploadSize: 10 * 1024 * 1024 }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin, getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\n// Self-reference the `./image-styles` subpath (not the internal source\n// files) so the plugin bundle externalizes the React components instead\n// of inlining them — the subpath is built separately with a `'use client'`\n// banner, and Next.js needs to see that boundary preserved.\nimport { ImageStylesManager, RegenerateVariantsAction } from '@murumets-ee/media/image-styles'\nimport { mediaRoutes } from './admin/routes.js'\nimport { Media } from './entity.js'\n// Relative: `src/i18n.ts` is a sibling entry of this one in the same tsdown\n// config, so the loader lands in a shared chunk rather than being\n// duplicated, and its `import('./messages/et.json')` resolves against\n// `dist/`, which this package's build script populates.\nimport { getMediaMessages } from './i18n.js'\nimport { imageStylesSettings } from './image-styles-settings.js'\nimport { MEDIA_PLUGIN_NAME, resolveMediaConfig } from './media-config.js'\nimport type { MediaPluginConfig } from './types.js'\n\n/**\n * Get the resolved media plugin configuration.\n * Throws if plugin not registered.\n *\n * Reads the framework's `PluginConfigRegistry`, which the merge step fills\n * from `server.config` below before any plugin `init` runs.\n */\nexport function getMediaConfig(): Required<MediaPluginConfig> {\n if (!hasPluginConfig(MEDIA_PLUGIN_NAME)) {\n throw new Error('@murumets-ee/media plugin not initialized. Add media() to your plugins array.')\n }\n return getPluginConfig(MEDIA_PLUGIN_NAME)\n}\n\n/**\n * Media plugin factory.\n *\n * - Registers the Media entity\n * - Mounts the media admin API routes\n * - Contributes sidebar + default-route metadata\n * - Declares @murumets-ee/storage + @murumets-ee/settings as required deps\n * (framework-validated before any plugin init runs — see `requires` field below)\n * - Declares its resolved configuration (`server.config`) for `getMediaConfig()`\n * and the upload route\n */\nexport function media(config?: MediaPluginConfig) {\n const resolvedConfig = resolveMediaConfig(config)\n\n return definePlugin({\n name: '@murumets-ee/media',\n // Declarative dep validation — replaces the per-plugin\n // `if (!app.plugins.has(...)) throw` loop that used to live in\n // `init`. The framework validates BEFORE any init runs, so a\n // missing dep surfaces immediately with the full punchlist\n // instead of throwing partway through boot.\n requires: ['@murumets-ee/storage', '@murumets-ee/settings'],\n shared: {\n // The interface-message catalogue, declared rather than left for each\n // app to remember (commerce-launch F148/F155). The framework gathers\n // it at the merge step; nothing loads a locale until one is asked for.\n messages: getMediaMessages,\n // Self-contributes the media.imageStyles namespace. The merge engine\n // auto-derives the permission resource (`settings_media.imageStyles`)\n // and the sidebar entry under the \"Settings\" group. The app's\n // admin-api-handler call site aggregates plugin-contributed\n // namespaces into `settingsRoutes(...)` (see apps/admin's\n // `getAllSettings()` for the canonical pattern); apps that follow\n // the scaffold pattern get this for free.\n // Drop the `as PluginSettingsDefinition` cast — it would widen the\n // namespace literal (e.g. `'media.imageStyles'`) to `string`,\n // leaking `settings_${string}:view|update` into the resolved\n // permission union via `<const N>` capture loss.\n // `imageStylesSettings` is structurally a superset of\n // `PluginSettingsDefinition` (full vs. narrow `SettingsDefinition`),\n // which TS accepts directly. The `readonly` outer tuple comes from\n // the parent definePlugin's `<const S>` capture.\n settings: [imageStylesSettings],\n },\n server: {\n entities: [Media],\n routes: [...mediaRoutes()],\n // Declarative config — registered at the merge step, before any init,\n // so the upload route can enforce `acceptedTypes` / `maxUploadSize`\n // (F007) instead of hardcoding a limit it could not trust to be set.\n config: resolvedConfig,\n init: async (app) => {\n // Image style defaults live in the plugin config and are exposed via\n // `getMediaConfig()`. Persistent overrides go to the settings DB\n // through the generic settings API at `/api/admin/settings/media.imageStyles`\n // — see `image-styles-settings.ts`. `resolveImageStyles` reads\n // DB-first, config-fallback.\n //\n // We deliberately do NOT seed the DB here. Init must stay lightweight:\n // it runs in every context (Next.js server, Next.js build, CLI commands).\n // The prior eager-seed path pulled in `@murumets-ee/settings`'s main entry —\n // which carries `import 'server-only'` — and crashed non-RSC Node bootstrap.\n\n app.logger.info(\n {\n acceptedTypes: resolvedConfig.acceptedTypes,\n maxUploadSize: resolvedConfig.maxUploadSize,\n defaultVisibility: resolvedConfig.defaultVisibility,\n },\n 'Media plugin initialized',\n )\n },\n },\n adminUi: {\n sidebar: [\n {\n id: 'media',\n group: 'Library',\n label: 'Media',\n href: '/admin/media',\n iconName: 'image',\n },\n ],\n defaultRoutes: [\n // Routing only — sidebar nav comes from `adminUi.sidebar` above\n // (admin-page-routing SD003).\n {\n path: 'media',\n factory: 'MediaListPage',\n },\n {\n path: 'media/[id]',\n factory: 'MediaEditPage',\n },\n ],\n // Self-contributes the rich image-styles editor and the\n // \"Regenerate All Variants\" action. Apps don't have to wire either\n // — they appear automatically on /admin/settings/media.imageStyles\n // (route + sidebar entry auto-derived from `shared.settings`).\n settingRenderers: {\n 'media.imageStyles': ImageStylesManager,\n },\n settingsActions: {\n 'media.imageStyles': RegenerateVariantsAction,\n },\n },\n })\n}\n"],"mappings":"qZAqBA,MAAa,EAAoB,qBAwBpB,EAAkD,CAC7D,UACA,UACA,UACA,kBACA,kDACA,uCACA,aACA,UACF,EAGA,SAAgB,EAAmB,EAAyD,CAC1F,MAAO,CACL,cAAe,GAAQ,eAAiB,CAAC,GAAG,CAA4B,EAExE,cACE,GAAQ,gBAAkB,IAAA,IAAa,EAAO,cAAgB,EAC1D,EAAO,cACP,GAAK,KAAO,KAIlB,kBAAmB,GAAQ,mBAAqB,SAChD,YAAa,GAAQ,aAAe,CAClC,UAAW,CAAE,MAAO,IAAK,OAAQ,IAAK,IAAK,QAAS,OAAQ,OAAQ,QAAS,EAAG,CAClF,CACF,CACF,CAQA,SAAgB,GAA+C,CAC7D,OAAO,EAAA,oBAAiC,EACpC,EAAgB,CAAiB,EACjC,EAAmB,CACzB,CCVA,MAAM,EAAU,kEAEhB,SAAS,EAAY,EAA4C,CAC/D,OAAO,IAAU,IAAA,IAAa,EAAQ,KAAK,CAAK,CAClD,CAcA,MAAM,EAAsB,CAAC,QAAS,QAAS,QAAS,WAAY,OAAO,EAG3E,SAAS,EAAmB,EAA0C,CAMpE,OAAQ,EAA0C,SAAS,CAAK,CAClE,CAwBA,SAAS,EAAe,EAAoB,EAA0B,CACpE,GAAI,IAAQ,MAAQ,CAAC,QAAQ,KAAK,CAAG,EAAG,OAAO,EAC/C,IAAM,EAAS,OAAO,CAAG,EAWzB,OADK,OAAO,cAAc,CAAM,EACzB,EADmC,CAE5C,CAMA,SAAS,EAAa,EAAe,EAAS,IAAe,CAC3D,OAAO,IAAI,SAAS,KAAK,UAAU,CAAI,EAAG,CACxC,SACA,QAAS,CAAE,eAAgB,kBAAmB,CAChD,CAAC,CACH,CAEA,SAAS,EAAU,EAAiB,EAA0B,CAC5D,OAAO,EAAa,CAAE,MAAO,CAAQ,EAAG,CAAM,CAChD,CAsBA,MAAM,EAA6B,CAAE,cAAe,IAAK,EASzD,eAAe,GAAqC,CAClD,GAAI,CAAC,EAAa,cAAe,CAC/B,IAAM,GAAW,SAAY,CAC3B,GAAM,CAAE,UAAW,MAAM,OAAO,qBAC1B,CAAE,uBAAwB,MAAM,OAAO,wBACvC,CAAE,oBAAqB,MAAM,OAAO,+BACpC,EAAM,EAAO,EACnB,OAAO,EAAoB,EAAiB,EAAG,CAAE,KAAI,CAAC,CACxD,EAAA,CAAG,EAaH,EAAQ,UAAY,CACd,EAAa,gBAAkB,IAAS,EAAa,cAAgB,KAC3E,CAAC,EACD,EAAa,cAAgB,CAC/B,CACA,OAAO,EAAa,aACtB,CAEA,eAAe,GAAuC,CACpD,GAAM,CAAE,qBAAsB,MAAM,OAAO,6BACrC,CAAE,eAAgB,MAAM,OAAO,gBAC/B,CAAE,SAAU,MAAM,OAAO,wBAAe,CAAA,KAAA,GAAA,EAAA,CAAA,EACxC,EAAU,MAAM,EAAW,EAEjC,OAAO,IAAI,EAAY,CAAE,MADX,EAAkB,CACH,EAAG,SAAQ,CAAC,CAC3C,CAMA,MAAM,EAA+B,MAAO,EAAK,IAAQ,CACvD,GAAM,CAAE,sBAAqB,0BAA2B,MAAM,OAAO,wBAC/D,EAAW,EAAI,SAKrB,GAAI,EAAS,SAAW,GAAK,EAAS,KAAO,QAAS,CACpD,IAAM,EAAK,EAAS,GACpB,GAAI,CAAC,EAAY,CAAE,EAAG,OAAO,EAAU,0BAA2B,GAAG,EAErE,GAAM,CAAE,mBAAoB,MAAM,OAAO,eACnC,CAAE,UAAW,MAAM,OAAO,qBAGhC,OAAO,EAAa,CAAE,OAAA,MADD,EAAgB,EADzB,EAC+B,CAAC,CACf,CAAC,CAChC,CASA,GAAI,EAAS,QAAU,EAAG,OAAO,EAAU,YAAa,GAAG,EAI3D,IAAM,EAAW,EAAS,OAAS,EAAI,EAAS,GAAK,IAAA,GACrD,GAAI,IAAa,IAAA,IAAa,CAAC,EAAY,CAAQ,EACjD,OAAO,EAAU,0BAA2B,GAAG,EAOjD,GAAI,CAAC,EAAoB,EAAG,CAC1B,IAAM,EAAS,EAAuB,GAAK,yBAa3C,OAZI,EAAS,OAAS,EAGb,EAAa,CAAE,MAAO,EAAQ,WAAY,GAAO,QAAO,EAAG,GAAG,EAShE,EAAa,CALlB,MAAO,CAAC,EACR,MAAO,EACP,WAAY,GACZ,QAEyB,CAAC,CAC9B,CAEA,IAAM,EAAS,MAAM,EAAe,EAGpC,GAAI,IAAa,IAAA,GAAW,CAC1B,IAAM,EAAS,MAAM,EAAO,SAAS,CAAQ,EAC7C,GAAI,CAAC,EAAQ,OAAO,EAAU,kBAAmB,GAAG,EAEpD,IAAM,EAAM,MAAM,EAAO,OAAO,CAAQ,EACxC,OAAO,EAAa,CAAE,GAAG,EAAQ,KAAI,CAAC,CACxC,CAGA,IAAM,EAAM,IAAI,IAAI,EAAI,GAAG,EACrB,EAAS,EAAI,aAAa,IAAI,QAAQ,GAAK,IAAA,GAW3C,EAAe,EAAI,aAAa,IAAI,WAAW,EAC/C,EACJ,IAAiB,MAAQ,EAAmB,CAAY,EAAI,EAAe,IAAA,GAKvE,EAAQ,KAAK,IAAI,KAAK,IAAI,EAAe,EAAI,aAAa,IAAI,OAAO,EAAG,EAAE,EAAG,CAAC,EAAG,GAAG,EACpF,EAAS,KAAK,IAAI,EAAe,EAAI,aAAa,IAAI,QAAQ,EAAG,CAAC,EAAG,GAAU,EAE/E,EAAS,MAAM,EAAO,SAAS,CACnC,GAAI,IAAW,IAAA,IAAa,CAAE,QAAO,EACrC,GAAI,IAAc,IAAA,IAAa,CAAE,WAAU,EAC3C,QACA,QACF,CAAC,EAOK,EAAM,EAAO,MAAM,IAAK,GAAS,EAAK,EAAE,EACxC,EAAW,EAAO,MAAM,OAAQ,GAAS,EAAK,YAAc,OAAO,CAAC,CAAC,IAAK,GAAS,EAAK,EAAE,EAC1F,CAAC,EAAQ,GAAY,MAAM,QAAQ,IAAI,CAC3C,EAAO,QAAQ,CAAG,EAClB,EAAO,eAAe,EAAU,WAAW,CAC7C,CAAC,EAyBD,OAAO,EAAa,CADsB,MAtBqB,EAAO,MAAM,IAAK,GAAS,CAMxF,IAAM,EAAe,EAAK,YAAc,QAAU,EAAS,IAAI,EAAK,EAAE,EAAI,IAAA,GAC1E,MAAO,CACL,GAAI,EAAK,GACT,MAAO,EAAK,OAAS,KACrB,IAAK,EAAK,KAAO,KACjB,SAAU,EAAK,SACf,SAAU,EAAK,SACf,KAAM,EAAK,KACX,UAAW,EAAK,UAChB,IAAK,EAAO,IAAI,EAAK,EAAE,GAAK,GAC5B,GAAI,IAAiB,IAAA,IAAa,CAAE,cAAa,EACjD,MAAO,EAAK,OAAS,KACrB,OAAQ,EAAK,QAAU,IACzB,CACF,CAE8C,EAAG,MAAO,EAAO,KACpC,CAAC,CAC9B,EAEM,EAAkC,MAAO,EAAK,IAAQ,CAC1D,GAAM,CACJ,sBACA,yBACA,iBACA,oBACA,uBACA,YACE,MAAM,OAAO,wBAWjB,GAAI,EAAI,SAAS,OAAS,EAAG,OAAO,EAAU,YAAa,GAAG,EAE9D,GAAI,CAAC,EAAoB,EACvB,OAAO,EAAU,EAAuB,GAAK,yBAA0B,GAAG,EAK5E,GAAM,CAAE,gBAAe,iBAAkB,EAAgB,EAInD,EAAW,MAAM,EAAqB,EAAK,CAAa,EAC9D,GAAI,CAAC,EAAS,GAAI,OAAO,EAAU,EAAS,MAAO,EAAS,MAAM,EAClE,IAAM,EAAW,EAAS,MAEpB,EAAO,EAAS,IAAI,MAAM,EAIhC,GAAI,EAAE,aAAgB,OAAS,EAAK,OAAS,EAC3C,OAAO,EAAU,mBAAoB,GAAG,EAK1C,GAAI,EAAK,KAAO,EAAe,CAC7B,IAAM,EAAU,EAAS,CAAa,EACtC,OAAO,EAAU,EAAQ,MAAO,EAAQ,MAAM,CAChD,CAEA,IAAM,EAAS,MAAM,EAAe,EAE9B,EAAS,OAAO,KAAK,MAAM,EAAK,YAAY,CAAC,EAG7C,CAAE,WAAU,YAAa,MAAM,EACnC,EACA,EAAK,MAAQ,0BACf,EACA,GAAI,EACF,OAAO,EACL,qDAAqD,EAAK,KAAK,aAAa,IAC5E,GACF,EAIF,GAAI,CAAC,EAAkB,EAAU,CAAa,EAC5C,OAAO,EAAU,aAAa,EAAS,kBAAmB,GAAG,EAmB/D,IAAM,EAAgB,EAAS,IAAI,YAAY,EAC/C,GAAI,IAAkB,MAAQ,IAAkB,UAAY,IAAkB,UAC5E,OAAO,EAAU,mDAAoD,GAAG,EAE1E,IAAM,EAAS,MAAM,EAAO,OAAO,EAAQ,CACzC,SAAU,EAAK,KACf,WACA,KAAM,EAAK,KACX,WAAY,EAAI,KAAK,GACrB,GAAI,IAAkB,MAAQ,CAAE,WAAY,CAAc,CAC5D,CAAC,EAEK,EAAwB,CAC5B,GAAI,EAAO,MAAM,GACjB,MAAO,EAAO,MAAM,OAAS,KAC7B,IAAK,EAAO,MAAM,KAAO,KACzB,SAAU,EAAO,MAAM,SACvB,SAAU,EAAO,MAAM,SACvB,KAAM,EAAO,MAAM,KACnB,UAAW,EAAO,MAAM,UACxB,IAAK,EAAO,IACZ,MAAO,EAAO,MAAM,OAAS,KAC7B,OAAQ,EAAO,MAAM,QAAU,IACjC,EAgBA,OAdA,EAAU,EAAK,CACb,OAAQ,eACR,WAAY,QACZ,SAAU,EAAO,MAAM,GACvB,OAAQ,EAAI,KAAK,GACjB,GAAI,EAAI,KAAK,OAAS,IAAA,IAAa,CAAE,SAAU,EAAI,KAAK,IAAK,EAC7D,QAAS,CACP,SAAU,EAAO,MAAM,SACvB,SAAU,EAAO,MAAM,SACvB,KAAM,EAAO,MAAM,KACnB,UAAW,EAAO,MAAM,SAC1B,CACF,CAAC,EAEM,EAAa,EAAM,GAAG,CAC/B,EAEM,EAA8C,MAAO,EAAM,IAAQ,CACvE,GAAM,CAAE,sBAAqB,0BAA2B,MAAM,OAAO,wBAMrE,GAAI,EAAI,SAAS,SAAW,EAAG,OAAO,EAAU,YAAa,GAAG,EAEhE,GAAI,CAAC,EAAoB,EACvB,OAAO,EAAU,EAAuB,GAAK,yBAA0B,GAAG,EAG5E,GAAM,CAAE,yBAA0B,MAAM,OAAO,sCACzC,CAAE,SAAQ,cAAe,MAAM,OAAO,qBACtC,CAAE,sBAAuB,MAAM,OAAO,uCACtC,CAAE,uBAAwB,MAAM,OAAO,wBACvC,CAAE,oBAAqB,MAAM,OAAO,+BAEpC,EAAM,EAAO,EACb,EAAS,MAAM,EAAmB,EAAK,EAAI,MAAM,EACvD,GAAI,CAAC,GAAU,OAAO,KAAK,CAAM,CAAC,CAAC,SAAW,EAC5C,OAAO,EAAU,6BAA8B,GAAG,EAMpD,IAAM,EAAS,MAAM,EAAsB,CACzC,MACA,QAJc,EADM,EAC0B,EAAG,CAAE,KAAI,CAIjD,EACN,OAAQ,EAAI,OAAO,MAAM,CAAE,MAAO,EAAK,CAAC,EACxC,SACA,oBAAuB,CACrB,IAAM,EAAa,EAAW,EAC1B,MAAC,GAAY,MAAQ,CAAC,GAAY,SACtC,MAAO,CACL,KAAM,EAAW,KACjB,QAAS,EAAW,QACpB,GAAI,EAAW,QAAU,IAAA,IAAa,CAAE,MAAO,EAAW,KAAM,CAClE,CACF,CACF,CAAC,EASD,OAPA,EAAU,EAAK,CACb,OAAQ,4BACR,OAAQ,EAAI,KAAK,GACjB,GAAI,EAAI,KAAK,OAAS,IAAA,IAAa,CAAE,SAAU,EAAI,KAAK,IAAK,EAC7D,SAAU,CAAE,MAAO,EAAO,MAAO,UAAW,EAAO,UAAW,OAAQ,EAAO,MAAO,CACtF,CAAC,EAEM,EAAa,CAAM,CAC5B,EAEM,EAAkC,MAAO,EAAM,IAAQ,CAC3D,IAAM,EAAW,EAAI,SAMrB,GAAI,EAAS,SAAW,EAAG,OAAO,EAAU,oBAAqB,GAAG,EAGpE,GAAI,EAAS,OAAS,EAAG,OAAO,EAAU,YAAa,GAAG,EAE1D,IAAM,EAAK,EAAS,GACpB,GAAI,CAAC,EAAY,CAAE,EAAG,OAAO,EAAU,0BAA2B,GAAG,EAErE,GAAM,CAAE,sBAAqB,0BAA2B,MAAM,OAAO,wBAuBrE,OAtBK,EAAoB,GAYzB,MAAM,MADe,EAAe,EAAA,CACvB,OAAO,CAAE,EAEtB,EAAU,EAAK,CACb,OAAQ,eACR,WAAY,QACZ,SAAU,EACV,OAAQ,EAAI,KAAK,GACjB,GAAI,EAAI,KAAK,OAAS,IAAA,IAAa,CAAE,SAAU,EAAI,KAAK,IAAK,CAC/D,CAAC,EAEM,EAAa,CAAE,QAAS,CAAE,CAAC,GAlBzB,EAAU,EAAuB,GAAK,yBAA0B,GAAG,CAmB9E,EA+BM,EAAqB,CAAC,QAAS,SAAU,QAAS,QAAQ,EAG1D,EAAsB,CAAC,QAAS,QAAQ,EA0B9C,SAAgB,GAA4B,CA0C1C,OAAO,EAAmB,CAxCxB,EAAiB,CACf,OAAQ,QACR,KAAM,GACN,OAAQ,MACR,WAAY,aACZ,aAAc,EACd,aAAc,GACd,YAAa,sEACb,QAAS,CACX,CAAC,EACD,EAAiB,CACf,OAAQ,QACR,KAAM,GACN,OAAQ,OACR,WAAY,eACZ,aAAc,EACd,YAAa,8DACb,QAAS,CACX,CAAC,EACD,EAAiB,CACf,OAAQ,QACR,KAAM,sBACN,OAAQ,OACR,WAAY,eACZ,aAAc,EACd,YAAa,4DACb,QAAS,CACX,CAAC,EACD,EAAiB,CACf,OAAQ,QACR,KAAM,GACN,OAAQ,SACR,WAAY,eACZ,aAAc,EACd,aAAc,GACd,YAAa,8CACb,QAAS,CACX,CAAC,CAG6B,CAAC,CACnC,CCvoBA,SAAgB,GAA8C,CAC5D,GAAI,CAAC,EAAA,oBAAiC,EACpC,MAAU,MAAM,+EAA+E,EAEjG,OAAO,EAAgB,CAAiB,CAC1C,CAaA,SAAgB,EAAM,EAA4B,CAChD,IAAM,EAAiB,EAAmB,CAAM,EAEhD,OAAO,EAAa,CAClB,KAAM,qBAMN,SAAU,CAAC,uBAAwB,uBAAuB,EAC1D,OAAQ,CAIN,SAAU,EAgBV,SAAU,CAAC,CAAmB,CAChC,EACA,OAAQ,CACN,SAAU,CAAC,CAAK,EAChB,OAAQ,CAAC,GAAG,EAAY,CAAC,EAIzB,OAAQ,EACR,KAAM,KAAO,IAAQ,CAYnB,EAAI,OAAO,KACT,CACE,cAAe,EAAe,cAC9B,cAAe,EAAe,cAC9B,kBAAmB,EAAe,iBACpC,EACA,0BACF,CACF,CACF,EACA,QAAS,CACP,QAAS,CACP,CACE,GAAI,QACJ,MAAO,UACP,MAAO,QACP,KAAM,eACN,SAAU,OACZ,CACF,EACA,cAAe,CAGb,CACE,KAAM,QACN,QAAS,eACX,EACA,CACE,KAAM,aACN,QAAS,eACX,CACF,EAKA,iBAAkB,CAChB,oBAAqB,CACvB,EACA,gBAAiB,CACf,oBAAqB,CACvB,CACF,CACF,CAAC,CACH"}
|
|
1
|
+
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Media plugin — auto-registers the Media entity, mounts admin routes, and\n * contributes sidebar + default-route scaffolding for the admin shell.\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * storage(),\n * media({ maxUploadSize: 10 * 1024 * 1024 }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin } from '@murumets-ee/core'\n// Self-reference the `./image-styles` subpath (not the internal source\n// files) so the plugin bundle externalizes the React components instead\n// of inlining them — the subpath is built separately with a `'use client'`\n// banner, and Next.js needs to see that boundary preserved.\nimport {\n ImageStylesManager,\n ImageVocabularyEditor,\n RegenerateVariantsAction,\n} from '@murumets-ee/media/image-styles'\nimport { mediaRoutes } from './admin/routes.js'\nimport { Media } from './entity.js'\n// Relative: `src/i18n.ts` is a sibling entry of this one in the same tsdown\n// config, so the loader lands in a shared chunk rather than being\n// duplicated, and its `import('./messages/et.json')` resolves against\n// `dist/`, which this package's build script populates.\nimport { getMediaMessages } from './i18n.js'\nimport { imageStylesSettings } from './image-styles-settings.js'\nimport { requireMediaConfig, resolveMediaConfig } from './media-config.js'\nimport type { MediaPluginConfig } from './types.js'\n\n/**\n * Get the resolved media plugin configuration.\n * Throws if plugin not registered.\n *\n * Reads the framework's `PluginConfigRegistry`, which the merge step fills\n * from `server.config` below before any plugin `init` runs.\n */\nexport function getMediaConfig(): Required<MediaPluginConfig> {\n return requireMediaConfig()\n}\n\n/**\n * Media plugin factory.\n *\n * - Registers the Media entity\n * - Mounts the media admin API routes\n * - Contributes sidebar + default-route metadata\n * - Declares @murumets-ee/storage + @murumets-ee/settings as required deps\n * (framework-validated before any plugin init runs — see `requires` field below)\n * - Declares its resolved configuration (`server.config`) for `getMediaConfig()`\n * and the upload route\n */\nexport function media(config?: MediaPluginConfig) {\n const resolvedConfig = resolveMediaConfig(config)\n\n return definePlugin({\n name: '@murumets-ee/media',\n // Declarative dep validation — replaces the per-plugin\n // `if (!app.plugins.has(...)) throw` loop that used to live in\n // `init`. The framework validates BEFORE any init runs, so a\n // missing dep surfaces immediately with the full punchlist\n // instead of throwing partway through boot.\n requires: ['@murumets-ee/storage', '@murumets-ee/settings'],\n shared: {\n // The interface-message catalogue, declared rather than left for each\n // app to remember (commerce-launch F148/F155). The framework gathers\n // it at the merge step; nothing loads a locale until one is asked for.\n messages: getMediaMessages,\n // Self-contributes the media.imageStyles namespace. The merge engine\n // auto-derives the permission resource (`settings_media.imageStyles`)\n // and the sidebar entry under the \"Settings\" group. The app's\n // admin-api-handler call site aggregates plugin-contributed\n // namespaces into `settingsRoutes(...)` (see apps/admin's\n // `getAllSettings()` for the canonical pattern); apps that follow\n // the scaffold pattern get this for free.\n // Drop the `as PluginSettingsDefinition` cast — it would widen the\n // namespace literal (e.g. `'media.imageStyles'`) to `string`,\n // leaking `settings_${string}:view|update` into the resolved\n // permission union via `<const N>` capture loss.\n // `imageStylesSettings` is structurally a superset of\n // `PluginSettingsDefinition` (full vs. narrow `SettingsDefinition`),\n // which TS accepts directly. The `readonly` outer tuple comes from\n // the parent definePlugin's `<const S>` capture.\n settings: [imageStylesSettings],\n },\n server: {\n entities: [Media],\n routes: [...mediaRoutes()],\n // Declarative config — registered at the merge step, before any init,\n // so the upload route can enforce `acceptedTypes` / `maxUploadSize`\n // (F007) instead of hardcoding a limit it could not trust to be set.\n config: resolvedConfig,\n init: async (app) => {\n // Image style defaults live in the plugin config and are exposed via\n // `getMediaConfig()`. Persistent overrides go to the settings DB\n // through the generic settings API at `/api/admin/settings/media.imageStyles`\n // — see `image-styles-settings.ts`. `resolveImageStyles` reads\n // DB-first, config-fallback.\n //\n // We deliberately do NOT seed the DB here. Init must stay lightweight:\n // it runs in every context (Next.js server, Next.js build, CLI commands).\n // The prior eager-seed path pulled in `@murumets-ee/settings`'s main entry —\n // which carries `import 'server-only'` — and crashed non-RSC Node bootstrap.\n\n // The variant jobs (images D004). Imperative `registerJob`-from-init —\n // the queue's only registration pattern today, tracked as lumi-cms#497;\n // see `jobs/register.ts`. A no-op without the queue plugin, which\n // leaves uploads generating variants inline. Wrapped so a queue-load\n // failure degrades to exactly that rather than failing media's init.\n try {\n const { registerMediaJobs } = await import('./jobs/register.js')\n await registerMediaJobs(app)\n } catch (err) {\n app.logger.warn(\n { err },\n 'media: could not register the variant jobs — uploads will generate variants inline',\n )\n }\n\n app.logger.info(\n {\n acceptedTypes: resolvedConfig.acceptedTypes,\n maxUploadSize: resolvedConfig.maxUploadSize,\n defaultVisibility: resolvedConfig.defaultVisibility,\n },\n 'Media plugin initialized',\n )\n },\n },\n adminUi: {\n sidebar: [\n {\n id: 'media',\n group: 'Library',\n label: 'Media',\n href: '/admin/media',\n iconName: 'image',\n },\n ],\n defaultRoutes: [\n // Routing only — sidebar nav comes from `adminUi.sidebar` above\n // (admin-page-routing SD003).\n {\n path: 'media',\n factory: 'MediaListPage',\n },\n {\n path: 'media/[id]',\n factory: 'MediaEditPage',\n },\n ],\n // Self-contributes the rich image-styles editor and the\n // \"Regenerate All Variants\" action. Apps don't have to wire either\n // — they appear automatically on /admin/settings/media.imageStyles\n // (route + sidebar entry auto-derived from `shared.settings`).\n settingRenderers: {\n 'media.imageStyles': ImageStylesManager,\n // The shape vocabulary + width ladder, the `vocabulary` key of the same\n // namespace (images D003).\n 'media.imageVocabulary': ImageVocabularyEditor,\n },\n settingsActions: {\n 'media.imageStyles': RegenerateVariantsAction,\n },\n },\n })\n}\n"],"mappings":"gaA6CA,SAAgB,GAA8C,CAC5D,OAAO,EAAmB,CAC5B,CAaA,SAAgB,EAAM,EAA4B,CAChD,IAAM,EAAiB,EAAmB,CAAM,EAEhD,OAAO,EAAa,CAClB,KAAM,qBAMN,SAAU,CAAC,uBAAwB,uBAAuB,EAC1D,OAAQ,CAIN,SAAU,EAgBV,SAAU,CAAC,CAAmB,CAChC,EACA,OAAQ,CACN,SAAU,CAAC,CAAK,EAChB,OAAQ,CAAC,GAAG,EAAY,CAAC,EAIzB,OAAQ,EACR,KAAM,KAAO,IAAQ,CAiBnB,GAAI,CACF,GAAM,CAAE,qBAAsB,MAAM,OAAO,2BAC3C,MAAM,EAAkB,CAAG,CAC7B,OAAS,EAAK,CACZ,EAAI,OAAO,KACT,CAAE,KAAI,EACN,oFACF,CACF,CAEA,EAAI,OAAO,KACT,CACE,cAAe,EAAe,cAC9B,cAAe,EAAe,cAC9B,kBAAmB,EAAe,iBACpC,EACA,0BACF,CACF,CACF,EACA,QAAS,CACP,QAAS,CACP,CACE,GAAI,QACJ,MAAO,UACP,MAAO,QACP,KAAM,eACN,SAAU,OACZ,CACF,EACA,cAAe,CAGb,CACE,KAAM,QACN,QAAS,eACX,EACA,CACE,KAAM,aACN,QAAS,eACX,CACF,EAKA,iBAAkB,CAChB,oBAAqB,EAGrB,wBAAyB,CAC3B,EACA,gBAAiB,CACf,oBAAqB,CACvB,CACF,CACF,CAAC,CACH"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{n as e,t}from"./variant-key-Cki4n-42.mjs";import{n}from"./variant-plan-DV8hsMdz.mjs";import r from"sharp";const i=`focalPointSeedTried`;function a(e){return o(e)&&e.focalPointSeedTried===!0}function o(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function s(e){let t=new Map;if(!o(e))return t;for(let[n,r]of Object.entries(e))typeof r==`string`&&t.set(n,r);return t}function c(e){return typeof e==`number`&&Number.isInteger(e)&&e>0}function l(e){if(!o(e))return null;let{shape:t,rung:n,width:r,height:i,format:a,key:s}=e;return typeof t!=`string`||typeof a!=`string`||typeof s!=`string`||!c(n)||!c(r)||!c(i)?null:{shape:t,rung:n,width:r,height:i,format:a,key:s}}function u(e){let t=o(e)?e:{},n=Array.isArray(t.shaped)?t.shaped.map(l).filter(e=>e!==null):[],r=Array.isArray(t.unconfirmedVariants)?t.unconfirmedVariants.filter(e=>typeof e==`string`):[];return{legacy:s(t.variants),legacyRecipes:s(t.legacyRecipes),shaped:n,unconfirmed:r}}function d(e,t){let{unconfirmedVariants:n,...r}=o(e)?e:{};return{...r,variants:Object.fromEntries(t.legacy),legacyRecipes:Object.fromEntries(t.legacyRecipes),shaped:t.shaped.map(e=>({...e})),...t.unconfirmed.length>0&&{unconfirmedVariants:[...t.unconfirmed]}}}function f(e){return[...new Set([...e.legacy.values(),...e.shaped.map(e=>e.key),...e.unconfirmed])]}function p(e){return[`${e.width??``}x${e.height??``}`,e.fit??`cover`,e.format??`webp`,`q${e.quality??80}`].join(`|`)}function m(e,t,n){if(e.unconfirmed.length>0||e.shaped.length!==t.size)return!1;for(let n of e.shaped)if(!t.has(n.key))return!1;if(e.legacy.size!==n.size)return!1;for(let[t,r]of n)if(e.legacy.get(t)!==r.key||e.legacyRecipes.get(t)!==r.recipe)return!1;return!0}function h(r,i,a,o,s){return{plannedKeys:new Set(n(i,a,s).variants.map(e=>t(r,{shape:e.shape,width:e.width,fingerprint:e.fingerprint,format:e.format}))),legacy:new Map(Object.entries(o).map(([t,n])=>[t,{key:e(r,t,n.format??`webp`),recipe:p(n)}]))}}const g=new Set([`image/svg+xml`,`image/gif`]);function _(e){return e.startsWith(`image/`)&&!g.has(e)}async function v(e,t){let{width:n,height:i}=w(await r(e).metadata()),a=new Map,o=Object.entries(t);return await Promise.all(o.map(async([t,n])=>{let i=n.format??`webp`,o=n.quality??80,s=n.fit??`cover`,{data:c,info:l}=await r(e).rotate().resize({width:n.width,height:n.height,fit:s,withoutEnlargement:!0})[i]({quality:o}).toBuffer({resolveWithObject:!0});a.set(t,{buffer:c,format:i,mimeType:`image/${i}`,width:l.width,height:l.height})})),{width:n,height:i,variants:a}}async function y(e,t,i){let{width:a,height:o}=w(await r(e).metadata()),s=n({width:a,height:o},t,i);return{width:a,height:o,variants:await b(e,s.variants),dropped:s.dropped}}async function b(e,t){let n=new Map;return S(t,2,async t=>{let r=JSON.stringify([t.extract,t.width,t.height,t.quality,t.format]),i=n.get(r);return i||(i=x(e,t),n.set(r,i)),{...await i,shape:t.shape,plannedWidth:t.width}})}async function x(e,t){let{data:n,info:i}=await r(e).rotate().extract(t.extract).resize({width:t.width,height:t.height,fit:`cover`,withoutEnlargement:!0})[t.format]({quality:t.quality}).toBuffer({resolveWithObject:!0});return{buffer:n,format:t.format,mimeType:`image/${t.format}`,width:i.width,height:i.height,shape:t.shape,plannedWidth:t.width,fingerprint:t.fingerprint}}async function S(e,t,n){let r=Array(e.length),i=e.map((e,t)=>({item:e,index:t})),a=!1;return await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=i.shift();e!==void 0&&!a;e=i.shift())try{r[e.index]=await n(e.item)}catch(e){throw a=!0,e}})),r}async function C(e){return w(await r(e).metadata())}function w(e){return{width:e.autoOrient?.width??e.width??0,height:e.autoOrient?.height??e.height??0}}async function T(e){let{data:t,info:n}=await r(e).rotate().resize(512,512,{fit:`inside`,withoutEnlargement:!0}).raw().toBuffer({resolveWithObject:!0}),{width:i,height:a,channels:o}=n;if(i<8||a<8)return null;let s=await r(t,{raw:{width:i,height:a,channels:o}}).flop().flip().raw().toBuffer(),[c,l]=await Promise.all([E(t,i,a,o),E(s,i,a,o)]);if(c===null||l===null)return null;let u=D(c.x,1-l.x),d=D(c.y,1-l.y);return u===null&&d===null?null:{x:O(u??.5),y:O(d??.5)}}async function E(e,t,n,i){let a=t>=n,{info:o}=await r(e,{raw:{width:t,height:n,channels:i}}).resize(a?t-1:t,a?n:n-1,{fit:`cover`,position:r.strategy.attention}).toBuffer({resolveWithObject:!0}),{attentionX:s,attentionY:c}=o;return s===void 0||c===void 0?null:{x:s/t,y:c/n}}function D(e,t){return Math.abs(e-t)<=.25?(e+t)/2:null}function O(e){return Number.isFinite(e)?Math.min(Math.max(Math.round(e*1e4)/1e4,0),1):.5}export{v as a,i as c,a as d,m as f,d as h,_ as i,f as l,u as m,y as n,x as o,p,C as r,b as s,T as t,h as u};
|
|
2
|
+
//# sourceMappingURL=process-image-Deedtkh4.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-image-Deedtkh4.mjs","names":[],"sources":["../src/jobs/variant-state.ts","../src/process-image.ts"],"sourcesContent":["/**\n * What a photograph's ORIGINAL file records about the variants that exist for\n * it — the single source a reader resolves variants from (images F016/F017).\n *\n * Stored in the original's `toolkit_files.metadata`, beside the `variants` map\n * the legacy path has always written there:\n *\n * ```jsonc\n * {\n * \"variants\": { \"thumbnail\": \"<dir>/thumbnail_photo.webp\" }, // legacy, unchanged\n * \"legacyRecipes\": { \"thumbnail\": \"200x200|cover|webp|q80\" }, // new\n * \"shaped\": [ // new\n * { \"shape\": \"square\", \"rung\": 320, \"width\": 320, \"height\": 320,\n * \"format\": \"webp\", \"key\": \"<dir>/variants/photo_square_320w_1k2x9a0.webp\" }\n * ],\n * \"unconfirmedVariants\": [\"<dir>/variants/…\"] // new, usually absent\n * }\n * ```\n *\n * 🔴 A shaped entry records `width`/`height` as sharp RETURNED them, never the\n * rung that was asked for (F003). The rung names the key; the recorded size is\n * what a `w` descriptor may claim. A reader must never re-derive keys from the\n * media row: the row's dimensions can differ from the file's (F017).\n *\n * `unconfirmedVariants` are files a FAILED pass uploaded before giving up. They\n * are not in `shaped` — so a reader never sees two crops for one (shape, rung)\n * — but they are recorded, so deleting the photograph deletes them too, and\n * the retry either adopts them or deletes them.\n *\n * Pure — no sharp, no storage, no database.\n */\n\nimport type { VariantConfig } from '../shapes.js'\nimport type { ImageStyle } from '../types.js'\nimport { deriveShapedVariantKey, deriveVariantKey } from '../variant-key.js'\nimport { planVariants, type VariantPlanInput } from '../variant-plan.js'\n\n/**\n * Set on the original's metadata when the pass looked for a focal point and\n * found nothing to guess about (`detectFocalPoint` → `null`: a strip, a flat\n * image). The row keeps `focalPoint: null` — the centre — and the backfill\n * stops treating that as \"never seeded\", which would queue it on every walk.\n */\nexport const FOCAL_SEED_TRIED = 'focalPointSeedTried'\n\n/** Did a pass already look for this photograph's focal point and find none? */\nexport function focalSeedTried(metadata: unknown): boolean {\n return isRecord(metadata) && metadata[FOCAL_SEED_TRIED] === true\n}\n\n/** One generated (shape × rung) variant, as recorded. */\nexport interface StoredShapedVariant {\n readonly shape: string\n /** The rung the variant was PLANNED at — part of its key. */\n readonly rung: number\n /** What the file actually is (F003). */\n readonly width: number\n readonly height: number\n readonly format: string\n readonly key: string\n}\n\n/** Everything the original's metadata says about its variants. */\nexport interface VariantState {\n /** Legacy style name → key (`metadata.variants`). */\n readonly legacy: ReadonlyMap<string, string>\n /** Legacy style name → the {@link legacyRecipe} its file was made with. */\n readonly legacyRecipes: ReadonlyMap<string, string>\n readonly shaped: readonly StoredShapedVariant[]\n /** Keys a failed pass uploaded that no reader should resolve yet — see the module doc. */\n readonly unconfirmed: readonly string[]\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction stringEntries(value: unknown): Map<string, string> {\n const out = new Map<string, string>()\n if (!isRecord(value)) return out\n for (const [k, v] of Object.entries(value)) if (typeof v === 'string') out.set(k, v)\n return out\n}\n\nfunction isPositiveInt(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value > 0\n}\n\nfunction readShapedEntry(value: unknown): StoredShapedVariant | null {\n if (!isRecord(value)) return null\n const { shape, rung, width, height, format, key } = value\n if (typeof shape !== 'string' || typeof format !== 'string' || typeof key !== 'string') {\n return null\n }\n if (!isPositiveInt(rung) || !isPositiveInt(width) || !isPositiveInt(height)) return null\n return { shape, rung, width, height, format, key }\n}\n\n/**\n * Read the variant state off an original's metadata. Lenient: the column is\n * caller-shaped JSON, so a malformed entry is dropped rather than trusted, and\n * a file that predates this module reads as its legacy map alone.\n */\nexport function readVariantState(metadata: unknown): VariantState {\n const meta = isRecord(metadata) ? metadata : {}\n const shaped = Array.isArray(meta.shaped)\n ? meta.shaped.map(readShapedEntry).filter((v): v is StoredShapedVariant => v !== null)\n : []\n const unconfirmed = Array.isArray(meta.unconfirmedVariants)\n ? meta.unconfirmedVariants.filter((k): k is string => typeof k === 'string')\n : []\n return {\n legacy: stringEntries(meta.variants),\n legacyRecipes: stringEntries(meta.legacyRecipes),\n shaped,\n unconfirmed,\n }\n}\n\n/**\n * `metadata` with `state` written into it — every other key the original's\n * metadata carries is kept as it was.\n */\nexport function writeVariantState(metadata: unknown, state: VariantState): Record<string, unknown> {\n const { unconfirmedVariants: _dropped, ...base } = isRecord(metadata) ? metadata : {}\n return {\n ...base,\n variants: Object.fromEntries(state.legacy),\n legacyRecipes: Object.fromEntries(state.legacyRecipes),\n shaped: state.shaped.map((v) => ({ ...v })),\n ...(state.unconfirmed.length > 0 && { unconfirmedVariants: [...state.unconfirmed] }),\n }\n}\n\n/**\n * Every variant file the original's metadata accounts for — legacy, shaped and\n * unconfirmed. What deleting the photograph must delete: a public photograph an\n * editor deleted must not stay reachable at its variant URLs.\n */\nexport function allVariantKeys(state: VariantState): string[] {\n return [\n ...new Set([...state.legacy.values(), ...state.shaped.map((v) => v.key), ...state.unconfirmed]),\n ]\n}\n\n/**\n * Everything that determines a LEGACY style file's bytes, as one string.\n *\n * A legacy key names the style and nothing else (`thumbnail_photo.webp`), so\n * without this a regeneration cannot tell \"the operator changed thumbnail to\n * 320×320\" from \"nothing changed\" — and re-rendering every legacy file on every\n * pass is exactly the all-or-nothing cost this module exists to remove.\n * Defaults are applied, so `{width: 200}` and `{width: 200, fit: 'cover'}` are\n * the same recipe, as they produce the same file.\n */\nexport function legacyRecipe(style: ImageStyle): string {\n return [\n `${style.width ?? ''}x${style.height ?? ''}`,\n style.fit ?? 'cover',\n style.format ?? 'webp',\n `q${style.quality ?? 80}`,\n ].join('|')\n}\n\n/**\n * Does the recorded state already say exactly what a pass would produce now?\n *\n * The backfill asks this per photograph from its metadata alone — no download,\n * no decode — and queues a pass only when the answer is no. That is what makes\n * a walk incremental (WordPress's `--only-missing`, R014): a library that is\n * already caught up costs one metadata read per photograph, and a walk that\n * stopped at its cap reaches further the next time.\n *\n * \"Exactly\" in both directions: a planned key that is not recorded needs\n * generating, and a recorded key that is not planned needs deleting (a removed\n * shape, a re-crop). Same for the legacy styles and their recipes.\n */\nexport function isUpToDate(\n state: VariantState,\n plannedKeys: ReadonlySet<string>,\n legacy: ReadonlyMap<string, { readonly key: string; readonly recipe: string }>,\n): boolean {\n // Leftovers of a failed pass are work: the next pass adopts or deletes them.\n if (state.unconfirmed.length > 0) return false\n if (state.shaped.length !== plannedKeys.size) return false\n for (const v of state.shaped) if (!plannedKeys.has(v.key)) return false\n if (state.legacy.size !== legacy.size) return false\n for (const [name, want] of legacy) {\n if (state.legacy.get(name) !== want.key || state.legacyRecipes.get(name) !== want.recipe) {\n return false\n }\n }\n return true\n}\n\n/**\n * The keys a pass would leave recorded for this photograph under this\n * configuration — the other half of {@link isUpToDate}. Planned from `drawn`,\n * which the pass takes from the FILE and the backfill from the row (a row\n * whose size disagrees with its file plans different keys, so it is queued,\n * and its pass corrects the row — F014).\n */\nexport function expectedVariants(\n fileKey: string,\n drawn: { readonly width: number; readonly height: number },\n config: VariantConfig,\n styles: Readonly<Record<string, ImageStyle>>,\n input: VariantPlanInput,\n): {\n plannedKeys: Set<string>\n legacy: Map<string, { key: string; recipe: string }>\n} {\n const plannedKeys = new Set(\n planVariants(drawn, config, input).variants.map((p) =>\n deriveShapedVariantKey(fileKey, {\n shape: p.shape,\n width: p.width,\n fingerprint: p.fingerprint,\n format: p.format,\n }),\n ),\n )\n const legacy = new Map(\n Object.entries(styles).map(([name, style]) => [\n name,\n { key: deriveVariantKey(fileKey, name, style.format ?? 'webp'), recipe: legacyRecipe(style) },\n ]),\n )\n return { plannedKeys, legacy }\n}\n","/**\n * Server-side image processing via Sharp.\n *\n * Pure processing module — no DB or storage dependencies.\n * Takes a buffer and image styles, returns metadata + variant buffers.\n */\n\nimport sharp from 'sharp'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport type { VariantConfig } from './shapes.js'\nimport type { ImageStyle } from './types.js'\nimport { type PlannedVariant, planVariants, type VariantPlanInput } from './variant-plan.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n // `.rotate()` first: without it an EXIF-rotated phone photo's variant is\n // written SIDEWAYS — the output format drops the orientation tag — while\n // the row's width/height (`displayDimensions`) and its focal point are in\n // the orientation a browser draws. Keys are unchanged; only the pixels\n // of a rotated original change, and they change to what was intended.\n const resized = sharp(buffer).rotate().resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/** One generated (shape × rung) variant. */\nexport interface ShapedVariant extends ProcessedVariant {\n readonly shape: string\n /** The width the plan ASKED for. It names the key; it is not a claim about the file. */\n readonly plannedWidth: number\n /** Goes into the key — see `deriveShapedVariantKey`. */\n readonly fingerprint: string\n}\n\n/** A photograph's shaped variants. */\nexport interface ShapedImage {\n /** The image as a browser draws it (post-EXIF orientation). */\n width: number\n height: number\n variants: ShapedVariant[]\n /** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`) — for the caller to log. */\n dropped: number\n}\n\n/**\n * How many sharp pipelines one image runs at once. Each decodes the original,\n * so a 24-megapixel JPEG costs ~70 MB of pixels per pipeline in flight; the\n * seeded vocabulary plans 30 of them. libvips already parallelises inside each\n * pipeline, so a small number here loses little throughput.\n */\nconst SHAPED_CONCURRENCY = 2\n\n/**\n * Generate every (shape × rung) variant `config` calls for (images D003).\n *\n * The region comes from {@link planVariants} — `crops[shape]`, else the focal\n * point's window for that ratio, else the whole image — and sharp runs\n * `rotate() → extract() → resize()` in that order:\n *\n * - `rotate()` first, so `extract()` works in the coordinates a browser DRAWS\n * (measured, R013). The crop and the focal point are stored in that space.\n * - `extract()` BEFORE `resize()` — what sharp documents for pre-resize\n * extraction, and the only way to express a rect at all: `resize({position})`\n * takes nine gravities and two strategies and throws on coordinates (C5).\n *\n * 🔴 The variant records `info.width`/`info.height` — what sharp RETURNED —\n * never the rung it asked for (F003). `withoutEnlargement` silently returns a\n * smaller file than requested, and a `640w` descriptor on a 400px file makes a\n * browser's `srcset` choice wrong in the direction it never corrects (R007).\n * The plan never asks for more than the region supplies, so today the two\n * agree; recording the real one is what keeps them agreeing when they don't.\n */\nexport async function generateShapedVariants(\n buffer: Buffer,\n config: VariantConfig,\n input: VariantPlanInput,\n): Promise<ShapedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n const plan = planVariants({ width, height }, config, input)\n const variants = await renderPlannedVariants(buffer, plan.variants)\n return { width, height, variants, dropped: plan.dropped }\n}\n\n/**\n * Render `planned` — a whole plan, or the part of one a caller does not already\n * have (the variant job renders only keys that are missing) — at most\n * {@link SHAPED_CONCURRENCY} sharp pipelines at a time, results in input order.\n */\nexport async function renderPlannedVariants(\n buffer: Buffer,\n planned: readonly PlannedVariant[],\n): Promise<ShapedVariant[]> {\n // Two shapes can plan the SAME recipe — `wide` on a 16:9 photograph is the\n // whole image, exactly as `natural` is — and encoding it twice buys nothing.\n // Keyed by the full recipe, never by the fingerprint: a 32-bit hash that\n // collided here would hand one shape another shape's picture.\n const rendered = new Map<string, Promise<ShapedVariant>>()\n return mapBounded(planned, SHAPED_CONCURRENCY, async (planned) => {\n const recipe = JSON.stringify([\n planned.extract,\n planned.width,\n planned.height,\n planned.quality,\n planned.format,\n ])\n let pending = rendered.get(recipe)\n if (!pending) {\n pending = renderPlannedVariant(buffer, planned)\n rendered.set(recipe, pending)\n }\n const first = await pending\n return { ...first, shape: planned.shape, plannedWidth: planned.width }\n })\n}\n\nexport async function renderPlannedVariant(\n buffer: Buffer,\n planned: PlannedVariant,\n): Promise<ShapedVariant> {\n const pipeline = sharp(buffer).rotate().extract(planned.extract).resize({\n width: planned.width,\n height: planned.height,\n fit: 'cover',\n withoutEnlargement: true,\n })\n const { data, info } = await pipeline[planned.format]({ quality: planned.quality }).toBuffer({\n resolveWithObject: true,\n })\n return {\n buffer: data,\n format: planned.format,\n mimeType: `image/${planned.format}`,\n width: info.width,\n height: info.height,\n shape: planned.shape,\n plannedWidth: planned.width,\n fingerprint: planned.fingerprint,\n }\n}\n\n/** `Promise.all(items.map(fn))` with at most `limit` in flight, results in input order. */\nasync function mapBounded<T, R>(\n items: readonly T[],\n limit: number,\n fn: (item: T) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length)\n const queue = items.map((item, index) => ({ item, index }))\n // Once one job has failed the whole call rejects, so the other workers stop\n // taking jobs rather than running sharp for a result nobody will read.\n let failed = false\n const worker = async (): Promise<void> => {\n for (let job = queue.shift(); job !== undefined && !failed; job = queue.shift()) {\n try {\n results[job.index] = await fn(job.item)\n } catch (err) {\n failed = true\n throw err\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))\n return results\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n return displayDimensions(await sharp(buffer).metadata())\n}\n\n/**\n * The size a browser DRAWS the image at — after EXIF orientation — not the\n * sensor's.\n *\n * `metadata().width/height` are the stored pixel grid, so a phone portrait\n * tagged orientation 6 reports 400×200 while every browser draws it 200×400\n * (`image-orientation: from-image` is the default). Those numbers become the\n * media row's `width`/`height`, which are the `<img>`'s CLS reservation AND the\n * image ratio the focal-point crop is computed against (images PR02) — a\n * swapped ratio positions the crop on the wrong axis. `autoOrient` is sharp's\n * own post-orientation size (0.33+).\n */\nfunction displayDimensions(meta: sharp.Metadata): { width: number; height: number } {\n return {\n width: meta.autoOrient?.width ?? meta.width ?? 0,\n height: meta.autoOrient?.height ?? meta.height ?? 0,\n }\n}\n\n/**\n * The longest edge the attention pass looks at. libvips scores saliency on a\n * ~32px grid, so a larger buffer buys no precision — it only costs decode time.\n */\nconst ATTENTION_EDGE = 512\n\n/** Below this on either side there is nothing to guess about — see {@link detectFocalPoint}. */\nconst MIN_ATTENTION_EDGE = 8\n\n/**\n * How far the two attention passes may disagree on an axis and still be the\n * same subject. libvips places its hotspot on a ~32-cell grid (1/32 ≈ 0.03), so\n * a real subject agrees to within a cell or two (measured ≤ 0.07); a FLAT axis\n * disagrees by the whole image (0 in one pass, 1 in the mirrored one).\n */\nconst AXIS_AGREEMENT = 0.25\n\n/**\n * Guess where the subject is, with sharp's `attention` strategy — the seed for\n * a new photograph's focal point, so an editor who opens nothing still gets\n * something other than a blind centre crop (images D002).\n *\n * ## Two passes, one of them mirrored\n *\n * When the saliency map is flat along an axis — a white product background, a\n * sky, a horizontal band, a plain texture — libvips reports the FIRST cell of\n * it: 0. Stored, that pins every shaped slot to the left or top edge, which is\n * worse than no guess. But a subject genuinely near the edge also reports 0 (a\n * disc at 5% from the corner does, measured), so \"0 means flat\" is not a rule.\n *\n * So the same buffer is scored twice: as-is, and flipped on both axes. A real\n * subject moves with the flip and the two answers agree once the second is\n * mirrored back; a flat axis reports 0 both times, i.e. 0 and 1 once mirrored.\n * Per axis: agree → their mean; disagree → that axis says nothing → 0.5. Both\n * axes flat → `null` (the caller stores it; it renders as the centre, D010).\n * The second pass runs on the already-decoded ≤512px raw buffer, so it costs a\n * saliency pass over ~0.25 MP, not a second decode.\n *\n * Also `null`: an image under {@link MIN_ATTENTION_EDGE} on either side — a\n * strip or an icon has no subject worth cropping around.\n *\n * ## Why not read `info.attentionX` off an ordinary resize\n *\n * Because it is WRONG whenever the same call resizes. sharp converts the\n * libvips hotspot with `attention_x * jpegShrinkOnLoad / scale`\n * (`src/pipeline.cc:505`, 0.34.5 and 0.35.3 alike), and those terms do not\n * describe the image smartcrop ran on: a disc drawn at x = 0.80 reported 0.50\n * from a JPEG and 0.25 from a PNG (images R012). The crop OFFSET beside it is\n * right; only the reported point is not.\n *\n * So each pass runs at SCALE 1: decode once to a bounded raw buffer, then crop\n * that buffer to its own size minus one pixel on the long axis. No\n * shrink-on-load and no scale, so the reported point is in that buffer's own\n * pixels and dividing by its size is the answer. A square image has no long\n * axis, but trimming either one still runs the strategy, and libvips takes the\n * hotspot over the whole saliency map whichever axis it crops.\n *\n * `.rotate()` first, so the point is in the orientation a browser DRAWS — an\n * EXIF-rotated phone photo would otherwise be seeded in sensor coordinates.\n *\n * ⚠️ Attention scores luminance frequency, saturation and skin tones. It is a\n * guess, not detection, and on a product shot the product can win (R012).\n */\nexport async function detectFocalPoint(buffer: Buffer): Promise<FocalPoint | null> {\n const { data, info } = await sharp(buffer)\n .rotate()\n .resize(ATTENTION_EDGE, ATTENTION_EDGE, { fit: 'inside', withoutEnlargement: true })\n .raw()\n .toBuffer({ resolveWithObject: true })\n const { width, height, channels } = info\n if (width < MIN_ATTENTION_EDGE || height < MIN_ATTENTION_EDGE) return null\n const raw = { raw: { width, height, channels } }\n const mirrored = await sharp(data, raw).flop().flip().raw().toBuffer()\n const [straight, flipped] = await Promise.all([\n attentionHotspot(data, width, height, channels),\n attentionHotspot(mirrored, width, height, channels),\n ])\n if (straight === null || flipped === null) return null\n const x = agreeingAxis(straight.x, 1 - flipped.x)\n const y = agreeingAxis(straight.y, 1 - flipped.y)\n if (x === null && y === null) return null\n return { x: clampFraction(x ?? 0.5), y: clampFraction(y ?? 0.5) }\n}\n\n/** One attention pass at scale 1, as fractions of the buffer. */\nasync function attentionHotspot(\n data: Buffer,\n width: number,\n height: number,\n channels: 1 | 2 | 3 | 4,\n): Promise<FocalPoint | null> {\n const landscape = width >= height\n const { info } = await sharp(data, { raw: { width, height, channels } })\n .resize(landscape ? width - 1 : width, landscape ? height : height - 1, {\n fit: 'cover',\n position: sharp.strategy.attention,\n })\n .toBuffer({ resolveWithObject: true })\n const { attentionX, attentionY } = info\n if (attentionX === undefined || attentionY === undefined) return null\n return { x: attentionX / width, y: attentionY / height }\n}\n\n/** The two passes' answer for one axis, or `null` when they disagree (a flat axis). */\nfunction agreeingAxis(straight: number, mirrored: number): number | null {\n return Math.abs(straight - mirrored) <= AXIS_AGREEMENT ? (straight + mirrored) / 2 : null\n}\n\n/** Round to four decimals and keep inside `[0, 1]`, so the stored value passes `isFocalPoint`. */\nfunction clampFraction(value: number): number {\n if (!Number.isFinite(value)) return 0.5\n return Math.min(Math.max(Math.round(value * 10000) / 10000, 0), 1)\n}\n"],"mappings":"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"}
|
package/dist/processing.d.mts
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as ImageVocabulary, i as ImageStyle, n as ImageFormat, r as ImageShape } from "./types-CgkJF5dc.mjs";
|
|
2
|
+
import { a as ResolvedShape, c as VariantConfig, i as NATURAL_SHAPE, l as buildVariantConfig, n as DEFAULT_IMAGE_SHAPES, o as ShapeAlias, r as DEFAULT_IMAGE_VOCABULARY, s as UnmappedStyle, t as DEFAULT_IMAGE_LADDER, u as resolveStyleName } from "./shapes-40dHtTb-.mjs";
|
|
2
3
|
|
|
4
|
+
//#region src/variant-plan.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The hard ceiling on variants per image, whatever the configuration says.
|
|
7
|
+
*
|
|
8
|
+
* The vocabulary is bounded (`MAX_SHAPES` × `MAX_LADDER_RUNGS`), but legacy
|
|
9
|
+
* styles predate those bounds and each becomes a shape, so the product is not.
|
|
10
|
+
* Every variant is a decode-extract-resize-encode on the generating process, so
|
|
11
|
+
* this is the cap that keeps one upload from being an amplification vector. The
|
|
12
|
+
* seeded vocabulary plans at most 30 (four shapes and `natural`, six rungs).
|
|
13
|
+
* What it drops is COUNTED in {@link VariantPlan.dropped}, never silent.
|
|
14
|
+
*/
|
|
15
|
+
declare const MAX_VARIANTS_PER_IMAGE = 64;
|
|
16
|
+
/** The region `extract()` takes, in integral pixels of the image as drawn. */
|
|
17
|
+
interface PixelRect {
|
|
18
|
+
readonly left: number;
|
|
19
|
+
readonly top: number;
|
|
20
|
+
readonly width: number;
|
|
21
|
+
readonly height: number;
|
|
22
|
+
}
|
|
23
|
+
/** One variant to generate. */
|
|
24
|
+
interface PlannedVariant {
|
|
25
|
+
readonly shape: string;
|
|
26
|
+
/** The width to ask sharp for. Never above what {@link extract} supplies. */
|
|
27
|
+
readonly width: number;
|
|
28
|
+
/** The height to ask sharp for — derived from the shape's ratio. */
|
|
29
|
+
readonly height: number;
|
|
30
|
+
readonly extract: PixelRect;
|
|
31
|
+
readonly format: ImageFormat;
|
|
32
|
+
readonly quality: number;
|
|
33
|
+
/** {@link variantFingerprint} of this variant's recipe — goes into its key. */
|
|
34
|
+
readonly fingerprint: string;
|
|
35
|
+
}
|
|
36
|
+
interface VariantPlan {
|
|
37
|
+
readonly variants: readonly PlannedVariant[];
|
|
38
|
+
/** How many variants {@link MAX_VARIANTS_PER_IMAGE} cut off. */
|
|
39
|
+
readonly dropped: number;
|
|
40
|
+
}
|
|
41
|
+
/** What the plan needs from the media row. Untrusted — both are read leniently. */
|
|
42
|
+
interface VariantPlanInput {
|
|
43
|
+
readonly focalPoint?: unknown;
|
|
44
|
+
readonly crops?: unknown;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Plan every variant a photograph of `source` dimensions gets under `config`.
|
|
48
|
+
*
|
|
49
|
+
* `source` must be the DRAWN size (post-EXIF orientation) of the actual file —
|
|
50
|
+
* `getImageDimensions`, not the row's client-measured `width`/`height` — since
|
|
51
|
+
* the regions are cut from the file.
|
|
52
|
+
*/
|
|
53
|
+
declare function planVariants(source: {
|
|
54
|
+
readonly width: number;
|
|
55
|
+
readonly height: number;
|
|
56
|
+
}, config: VariantConfig, input: VariantPlanInput): VariantPlan;
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/jobs/variant-state.d.ts
|
|
59
|
+
/** One generated (shape × rung) variant, as recorded. */
|
|
60
|
+
interface StoredShapedVariant {
|
|
61
|
+
readonly shape: string;
|
|
62
|
+
/** The rung the variant was PLANNED at — part of its key. */
|
|
63
|
+
readonly rung: number;
|
|
64
|
+
/** What the file actually is (F003). */
|
|
65
|
+
readonly width: number;
|
|
66
|
+
readonly height: number;
|
|
67
|
+
readonly format: string;
|
|
68
|
+
readonly key: string;
|
|
69
|
+
}
|
|
70
|
+
/** Everything the original's metadata says about its variants. */
|
|
71
|
+
interface VariantState {
|
|
72
|
+
/** Legacy style name → key (`metadata.variants`). */
|
|
73
|
+
readonly legacy: ReadonlyMap<string, string>;
|
|
74
|
+
/** Legacy style name → the {@link legacyRecipe} its file was made with. */
|
|
75
|
+
readonly legacyRecipes: ReadonlyMap<string, string>;
|
|
76
|
+
readonly shaped: readonly StoredShapedVariant[];
|
|
77
|
+
/** Keys a failed pass uploaded that no reader should resolve yet — see the module doc. */
|
|
78
|
+
readonly unconfirmed: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read the variant state off an original's metadata. Lenient: the column is
|
|
82
|
+
* caller-shaped JSON, so a malformed entry is dropped rather than trusted, and
|
|
83
|
+
* a file that predates this module reads as its legacy map alone.
|
|
84
|
+
*/
|
|
85
|
+
declare function readVariantState(metadata: unknown): VariantState;
|
|
86
|
+
//#endregion
|
|
3
87
|
//#region src/process-image.d.ts
|
|
4
88
|
/** Result of processing an image through Sharp */
|
|
5
89
|
interface ProcessedImage {
|
|
@@ -31,6 +115,45 @@ declare function isProcessableImage(mimeType: string): boolean;
|
|
|
31
115
|
* @returns Metadata (width/height) and variant buffers
|
|
32
116
|
*/
|
|
33
117
|
declare function processImage(buffer: Buffer, styles: Record<string, ImageStyle>): Promise<ProcessedImage>;
|
|
118
|
+
/** One generated (shape × rung) variant. */
|
|
119
|
+
interface ShapedVariant extends ProcessedVariant {
|
|
120
|
+
readonly shape: string;
|
|
121
|
+
/** The width the plan ASKED for. It names the key; it is not a claim about the file. */
|
|
122
|
+
readonly plannedWidth: number;
|
|
123
|
+
/** Goes into the key — see `deriveShapedVariantKey`. */
|
|
124
|
+
readonly fingerprint: string;
|
|
125
|
+
}
|
|
126
|
+
/** A photograph's shaped variants. */
|
|
127
|
+
interface ShapedImage {
|
|
128
|
+
/** The image as a browser draws it (post-EXIF orientation). */
|
|
129
|
+
width: number;
|
|
130
|
+
height: number;
|
|
131
|
+
variants: ShapedVariant[];
|
|
132
|
+
/** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`) — for the caller to log. */
|
|
133
|
+
dropped: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Generate every (shape × rung) variant `config` calls for (images D003).
|
|
137
|
+
*
|
|
138
|
+
* The region comes from {@link planVariants} — `crops[shape]`, else the focal
|
|
139
|
+
* point's window for that ratio, else the whole image — and sharp runs
|
|
140
|
+
* `rotate() → extract() → resize()` in that order:
|
|
141
|
+
*
|
|
142
|
+
* - `rotate()` first, so `extract()` works in the coordinates a browser DRAWS
|
|
143
|
+
* (measured, R013). The crop and the focal point are stored in that space.
|
|
144
|
+
* - `extract()` BEFORE `resize()` — what sharp documents for pre-resize
|
|
145
|
+
* extraction, and the only way to express a rect at all: `resize({position})`
|
|
146
|
+
* takes nine gravities and two strategies and throws on coordinates (C5).
|
|
147
|
+
*
|
|
148
|
+
* 🔴 The variant records `info.width`/`info.height` — what sharp RETURNED —
|
|
149
|
+
* never the rung it asked for (F003). `withoutEnlargement` silently returns a
|
|
150
|
+
* smaller file than requested, and a `640w` descriptor on a 400px file makes a
|
|
151
|
+
* browser's `srcset` choice wrong in the direction it never corrects (R007).
|
|
152
|
+
* The plan never asks for more than the region supplies, so today the two
|
|
153
|
+
* agree; recording the real one is what keeps them agreeing when they don't.
|
|
154
|
+
*/
|
|
155
|
+
declare function generateShapedVariants(buffer: Buffer, config: VariantConfig, input: VariantPlanInput): Promise<ShapedImage>;
|
|
156
|
+
declare function renderPlannedVariant(buffer: Buffer, planned: PlannedVariant): Promise<ShapedVariant>;
|
|
34
157
|
/**
|
|
35
158
|
* Extract only image dimensions (no variant generation).
|
|
36
159
|
* Useful for getting width/height when styles are empty.
|
|
@@ -42,17 +165,46 @@ declare function getImageDimensions(buffer: Buffer): Promise<{
|
|
|
42
165
|
//#endregion
|
|
43
166
|
//#region src/variant-key.d.ts
|
|
44
167
|
/**
|
|
45
|
-
* Variant key derivation
|
|
168
|
+
* Variant key derivation — where a generated variant lives in storage.
|
|
46
169
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
170
|
+
* Two key shapes coexist (images D005):
|
|
171
|
+
*
|
|
172
|
+
* **Legacy** — one file per image STYLE, keyed by the style name alone:
|
|
49
173
|
*
|
|
50
|
-
* Convention:
|
|
51
174
|
* Original: uploads/2026/02/{uuid}/photo.jpg
|
|
52
175
|
* Variant: uploads/2026/02/{uuid}/thumbnail_photo.webp
|
|
176
|
+
*
|
|
177
|
+
* Sound only while nothing about a variant can change. It stays exactly as it
|
|
178
|
+
* is: every row that has not been regenerated under the shape model still has
|
|
179
|
+
* its files there, and ticketing derives its thumbnails with it.
|
|
180
|
+
*
|
|
181
|
+
* **Shaped** — one file per (shape × rung), carrying a FINGERPRINT of the
|
|
182
|
+
* recipe that produced it:
|
|
183
|
+
*
|
|
184
|
+
* Variant: uploads/2026/02/{uuid}/variants/photo_portrait_640w_1k2x9a0.webp
|
|
185
|
+
*
|
|
186
|
+
* The moment an editor can re-crop, a name-only key serves different bytes at
|
|
187
|
+
* the same URL, and every cache in front of it — the CDN, the browser, the
|
|
188
|
+
* content-api response that inlined the URL — keeps the old picture. So the
|
|
189
|
+
* fingerprint is of the EFFECTIVE recipe: the integral pixel rect actually
|
|
190
|
+
* extracted, the output box and the quality (R013). Same recipe → same key → a
|
|
191
|
+
* backfill that re-derives it uploads nothing. Different recipe → different key
|
|
192
|
+
* → every cache misses correctly, because it is a different file. No
|
|
193
|
+
* timestamps, no salt: determinism is what makes the backfill idempotent.
|
|
194
|
+
*
|
|
195
|
+
* ⚠️ A query-string cache-buster is not an alternative. Storage keys are what
|
|
196
|
+
* `getPublicUrls` resolves, so a query string is not part of the identity and
|
|
197
|
+
* the stale file is still the file (D005 (d); Craft's issue #3685 is that
|
|
198
|
+
* failure in a shipping product).
|
|
199
|
+
*
|
|
200
|
+
* The shaped keys live in a `variants/` directory beside the original, which is
|
|
201
|
+
* what makes the two shapes impossible to confuse: a legacy key is always a
|
|
202
|
+
* direct child of the original's directory (a style name cannot contain `/`),
|
|
203
|
+
* and both stay under the original's own prefix — which ticketing's
|
|
204
|
+
* prefix-ownership check relies on.
|
|
53
205
|
*/
|
|
54
206
|
/**
|
|
55
|
-
* Derive a variant storage key from the original key + style name.
|
|
207
|
+
* Derive a LEGACY variant storage key from the original key + style name.
|
|
56
208
|
*
|
|
57
209
|
* @param originalKey - The original file's storage key
|
|
58
210
|
* @param styleName - The image style name (e.g., 'thumbnail', 'medium')
|
|
@@ -60,6 +212,48 @@ declare function getImageDimensions(buffer: Buffer): Promise<{
|
|
|
60
212
|
* @returns The derived variant key
|
|
61
213
|
*/
|
|
62
214
|
declare function deriveVariantKey(originalKey: string, styleName: string, format?: string): string;
|
|
215
|
+
/** Everything that determines a shaped variant's bytes, apart from the source file itself. */
|
|
216
|
+
interface VariantRecipe {
|
|
217
|
+
/** The region `extract()` takes, in integral pixels of the image as DRAWN (post-EXIF). */
|
|
218
|
+
readonly extract: {
|
|
219
|
+
readonly left: number;
|
|
220
|
+
readonly top: number;
|
|
221
|
+
readonly width: number;
|
|
222
|
+
readonly height: number;
|
|
223
|
+
};
|
|
224
|
+
/** The box the region is resized to. */
|
|
225
|
+
readonly width: number;
|
|
226
|
+
readonly height: number;
|
|
227
|
+
readonly quality: number;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* A short deterministic fingerprint of a {@link VariantRecipe}: 32-bit FNV-1a
|
|
231
|
+
* over a canonical string, as 7 base-36 characters.
|
|
232
|
+
*
|
|
233
|
+
* Not cryptographic and not meant to be — it only has to differ between two
|
|
234
|
+
* recipes for the SAME file, shape and width, and nothing an attacker controls
|
|
235
|
+
* chooses between colliding recipes. Pure JS rather than `node:crypto` so the
|
|
236
|
+
* key can be derived in any realm that needs to name a variant.
|
|
237
|
+
*/
|
|
238
|
+
declare function variantFingerprint(recipe: VariantRecipe): string;
|
|
239
|
+
/** The parts a shaped variant key is made of. */
|
|
240
|
+
interface ShapedVariantKeyParts {
|
|
241
|
+
/** The shape name (`square`, `natural`, …). */
|
|
242
|
+
readonly shape: string;
|
|
243
|
+
/** The rung the variant was PLANNED at — part of the name, not a claim about the file. */
|
|
244
|
+
readonly width: number;
|
|
245
|
+
/** {@link variantFingerprint} of the recipe. */
|
|
246
|
+
readonly fingerprint: string;
|
|
247
|
+
/** Output format — the extension. */
|
|
248
|
+
readonly format: string;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Derive a SHAPED variant storage key (images D005).
|
|
252
|
+
*
|
|
253
|
+
* `photo.jpg` + `{portrait, 640, 1k2x9a0, webp}` →
|
|
254
|
+
* `<dir>/variants/photo_portrait_640w_1k2x9a0.webp`.
|
|
255
|
+
*/
|
|
256
|
+
declare function deriveShapedVariantKey(originalKey: string, parts: ShapedVariantKeyParts): string;
|
|
63
257
|
//#endregion
|
|
64
|
-
export { type ImageStyle, type ProcessedImage, type ProcessedVariant, deriveVariantKey, getImageDimensions, isProcessableImage, processImage };
|
|
258
|
+
export { DEFAULT_IMAGE_LADDER, DEFAULT_IMAGE_SHAPES, DEFAULT_IMAGE_VOCABULARY, type ImageFormat, type ImageShape, type ImageStyle, type ImageVocabulary, MAX_VARIANTS_PER_IMAGE, NATURAL_SHAPE, type PixelRect, type PlannedVariant, type ProcessedImage, type ProcessedVariant, type ResolvedShape, type ShapeAlias, type ShapedImage, type ShapedVariant, type ShapedVariantKeyParts, type StoredShapedVariant, type UnmappedStyle, type VariantConfig, type VariantPlan, type VariantPlanInput, type VariantRecipe, type VariantState, buildVariantConfig, deriveShapedVariantKey, deriveVariantKey, generateShapedVariants, getImageDimensions, isProcessableImage, planVariants, processImage, readVariantState, renderPlannedVariant, resolveStyleName, variantFingerprint };
|
|
65
259
|
//# sourceMappingURL=processing.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"processing.d.mts","names":[],"sources":["../src/process-image.ts","../src/variant-key.ts"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"processing.d.mts","names":[],"sources":["../src/variant-plan.ts","../src/jobs/variant-state.ts","../src/process-image.ts","../src/variant-key.ts"],"mappings":"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"}
|
package/dist/processing.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{a as e,n as t,r as n,s as r,t as i,u as a}from"./shapes-BlKW-0C1.mjs";import{n as o,r as s,t as c}from"./variant-key-Cki4n-42.mjs";import{n as l,t as u}from"./variant-plan-DV8hsMdz.mjs";import{a as d,i as f,m as p,n as m,o as h,r as g}from"./process-image-Deedtkh4.mjs";export{i as DEFAULT_IMAGE_LADDER,t as DEFAULT_IMAGE_SHAPES,n as DEFAULT_IMAGE_VOCABULARY,u as MAX_VARIANTS_PER_IMAGE,e as NATURAL_SHAPE,r as buildVariantConfig,c as deriveShapedVariantKey,o as deriveVariantKey,m as generateShapedVariants,g as getImageDimensions,f as isProcessableImage,l as planVariants,d as processImage,p as readVariantState,h as renderPlannedVariant,a as resolveStyleName,s as variantFingerprint};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as ImageStyle } from "./types-CgkJF5dc.mjs";
|
|
2
|
+
import { n as FocalPoint } from "./focal-point-C_0drV03.mjs";
|
|
2
3
|
|
|
3
4
|
//#region src/async-cache.d.ts
|
|
4
5
|
/**
|
|
@@ -72,6 +73,16 @@ interface PublicMediaProjection {
|
|
|
72
73
|
* in. A style whose variant file is missing or not public is simply absent.
|
|
73
74
|
*/
|
|
74
75
|
variants: Record<string, string>;
|
|
76
|
+
/**
|
|
77
|
+
* Where the subject is, as `{ x, y }` fractions of the image (images D002).
|
|
78
|
+
*
|
|
79
|
+
* ALWAYS present: a row with no focal point, or one whose stored value is
|
|
80
|
+
* unusable, projects as the centre, so no consumer branches on absence (D010).
|
|
81
|
+
* It rides the PROJECTION and never the stored block ref — the resolver
|
|
82
|
+
* substitutes fresh data on every read, so a point edited today reaches a page
|
|
83
|
+
* saved last year with nothing rewritten (D007).
|
|
84
|
+
*/
|
|
85
|
+
focalPoint: FocalPoint;
|
|
75
86
|
}
|
|
76
87
|
/** The Media rows this resolver needs. Structural, so any read path can supply them. */
|
|
77
88
|
interface PublicMediaRow {
|
|
@@ -82,6 +93,8 @@ interface PublicMediaRow {
|
|
|
82
93
|
height?: number | null;
|
|
83
94
|
alt?: string | null;
|
|
84
95
|
title?: string | null;
|
|
96
|
+
/** The stored `media.focalPoint` — untrusted here, read through `readFocalPoint`. */
|
|
97
|
+
focalPoint?: unknown;
|
|
85
98
|
}
|
|
86
99
|
interface PublicMediaResolverDeps {
|
|
87
100
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-resolver.d.mts","names":[],"sources":["../src/async-cache.ts","../src/public-resolver.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"public-resolver.d.mts","names":[],"sources":["../src/async-cache.ts","../src/public-resolver.ts"],"mappings":";;;;;;;;AA2BA;;;;;;;;;;;;;;;;AAAmF;;;;AC6BnF;;iBD7BgB,uBAAA,GAAA,CAA2B,IAAA,QAAY,OAAA,CAAQ,CAAA,UAAW,OAAA,CAAQ,CAAA;;;;;;;ACiE1D;AAIxB;;;;;;;;;;UAxCiB,qBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EAuDwD;;;;;;;;;;;;;;;;;;EApCxD,QAAA,EAAU,MAAA;EA4CiB;;;;;;;AAEoC;AAkBjE;EAtDE,UAAA,EAAY,UAAU;AAAA;;UAIP,cAAA;EACf,EAAA;EACA,OAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EA8CQ;EA5CR,UAAA;AAAA;AAAA,UAGe,uBAAA;EA0CM;;AAAqB;AA4F5C;;;;;;EA5HE,SAAA,GAAY,GAAA,qBAAwB,MAAA,cAAoB,OAAA,CAAQ,cAAA;EAgIlD;;;;;EA1Hd,aAAA,GAAgB,IAAA,wBAA4B,OAAA,CAAQ,GAAA;EAwHpD;EAtHA,WAAA,QAAmB,OAAA,CAAQ,MAAA,SAAe,UAAA;EAwHnC;EAtHP,UAAA,GAAa,OAAA,UAAiB,SAAA,UAAmB,MAAA;AAAA;AAsHjC;AAelB;;;;;;;;;;;;;;AAfkB,iBApGI,kBAAA,CACpB,GAAA,qBACA,IAAA,EAAM,uBAAA,EACN,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;;;;AA6LN;AAgCjB;;;;;;;;;;;;;;;AAGkC;AAUlC;;;;;iBA9IgB,cAAA,GAAA,CACd,IAAA,QAAY,OAAA,CAAQ,CAAA,GACpB,KAAA,UACA,GAAA,wBACO,OAAA,CAAQ,CAAA;AAAA,iBAeD,yBAAA,CAAA,IACd,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;UAuER,WAAA;EAAA,SACN,GAAA;EAAA,SACA,GAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCW,mBAAA,CACpB,GAAA,qBACA,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,WAAA;;;;;;iBAUP,cAAA,CACd,KAAA,EAAO,WAAA,SAAoB,qBAAA,IAC1B,GAAA,SAAY,WAAA"}
|
package/dist/public-resolver.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{s as e}from"./focal-point-BWhtJzef.mjs";import{t}from"./async-cache-C_Ycvs7b.mjs";async function n(t,n,r={}){let i=new Map,a=[...new Set(t)];if(a.length===0)return i;let o=await n.readMedia(a,r.locale);if(o.length===0)return i;let s=await n.imageStyles(),c=Object.entries(s),l=[],u=new Map;for(let e of o){if(typeof e.fileKey!=`string`||e.fileKey===``)continue;let t=c.map(([t,r])=>[t,n.variantKey(e.fileKey,t,r.format??`webp`)]);u.set(e.id,{fileKey:e.fileKey,variantKeys:t}),l.push(e.fileKey,...t.map(([,e])=>e))}let d=await n.getPublicUrls(l);for(let t of o){let n=u.get(t.id);if(!n)continue;let r=d.get(n.fileKey);if(r===void 0)continue;let a={};for(let[e,t]of n.variantKeys){let n=d.get(t);n!==void 0&&(a[e]=n)}i.set(t.id,{id:t.id,url:r,mimeType:t.mimeType??null,width:t.width??null,height:t.height??null,alt:t.alt??null,title:t.title??null,variants:a,focalPoint:e(t.focalPoint)})}return i}function r(e,t,n=Date.now){let r=null;return async()=>{let i=n();if(r&&i-r.at<t)return r.value;let a=e();return r={value:a,at:i},a.catch(()=>{r?.value===a&&(r=null)}),a}}function i(){let e=r(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{resolveImageStyles:t}=await import(`./resolve-image-styles-pf7Nd3Zf.mjs`).then(e=>e.r);return t(e())},3e4);return async(t,r={})=>{let[{createMediaQueryClient:i},{getSharedStorageClient:a},{deriveVariantKey:o}]=await Promise.all([import(`./query-client.mjs`),import(`./client.mjs`),import(`./variant-key-Cki4n-42.mjs`).then(e=>e.i)]),[s,c]=await Promise.all([i(),a()]);return n(t,{readMedia:async(e,t)=>{let{schemaRegistry:n}=await import(`@murumets-ee/db`),{getTableColumns:r,inArray:i}=await import(`drizzle-orm`),a=n.get(`media`);if(!a)return[];let o=r(a).id;return o?await s.findMany({where:i(o,[...e]),limit:e.length,...t!==void 0&&{locale:t}}):[]},getPublicUrls:e=>c.getPublicUrls(e),imageStyles:e,variantKey:o},r)}}let a;async function o(e,t){return a??=i(),s(await a(e,t))}function s(e){let t=new Map;for(let[n,r]of e)r.mimeType?.startsWith(`image/`)===!0&&t.set(n,{url:r.url,alt:r.alt,width:r.width,height:r.height});return t}export{t as cacheOnceUnlessRejected,i as createPublicMediaResolver,r as memoiseWithTtl,o as resolveCanvasImages,n as resolvePublicMedia,s as toCanvasImages};
|
|
2
2
|
//# sourceMappingURL=public-resolver.mjs.map
|