@aglyn/aglyn 1.0.0-beta.200 → 1.0.0-beta.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/artifact-list-keys.js +10 -0
  3. package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
  4. package/src/lib/app-utils/crm.d.ts +19 -0
  5. package/src/lib/app-utils/crm.js +27 -1
  6. package/src/lib/app-utils/crm.js.map +1 -1
  7. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  8. package/src/lib/app-utils/docs-help.generated.js +10 -0
  9. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  10. package/src/lib/app-utils/docs-index.generated.js +78 -64
  11. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  12. package/src/lib/app-utils/email-state.d.ts +7 -3
  13. package/src/lib/app-utils/email-state.js +22 -6
  14. package/src/lib/app-utils/email-state.js.map +1 -1
  15. package/src/lib/app-utils/forms.d.ts +101 -9
  16. package/src/lib/app-utils/forms.js +137 -7
  17. package/src/lib/app-utils/forms.js.map +1 -1
  18. package/src/lib/app-utils/health-report.d.ts +3 -3
  19. package/src/lib/app-utils/health-report.js +9 -9
  20. package/src/lib/app-utils/health-report.js.map +1 -1
  21. package/src/lib/app-utils/host-naming.d.ts +12 -7
  22. package/src/lib/app-utils/host-naming.js +18 -3
  23. package/src/lib/app-utils/host-naming.js.map +1 -1
  24. package/src/lib/app-utils/lockdown.d.ts +19 -0
  25. package/src/lib/app-utils/lockdown.js +21 -0
  26. package/src/lib/app-utils/lockdown.js.map +1 -1
  27. package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
  28. package/src/lib/app-utils/media-embedded-fields.js +77 -0
  29. package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
  30. package/src/lib/app-utils/media-metadata.d.ts +17 -1
  31. package/src/lib/app-utils/media-metadata.js +22 -2
  32. package/src/lib/app-utils/media-metadata.js.map +1 -1
  33. package/src/lib/app-utils/payment-risk.d.ts +75 -0
  34. package/src/lib/app-utils/payment-risk.js +139 -0
  35. package/src/lib/app-utils/payment-risk.js.map +1 -0
  36. package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
  37. package/src/lib/app-utils/plugin-api-request-host.js +90 -0
  38. package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
  39. package/src/lib/app-utils/plugin-bundle-checks.d.ts +3 -2
  40. package/src/lib/app-utils/plugin-bundle-checks.js +76 -5
  41. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  42. package/src/lib/app-utils/webhook-delivery.js +4 -1
  43. package/src/lib/app-utils/webhook-delivery.js.map +1 -1
  44. package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +9 -0
  45. package/src/lib/plugin-manager/billing-webhook-hooks.js +5 -1
  46. package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
  47. package/src/lib/plugin-manager/plugin-activity-actions.d.ts +9 -1
  48. package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
  49. package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
  50. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  51. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  52. package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
  53. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  54. package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
  55. package/src/lib/plugin-manager/plugin-jobs.js +8 -4
  56. package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-embedded-fields.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The metadata a file carries INSIDE its own bytes (AGL-3331): EXIF, IPTC\n * and XMP on a photo, the document info of a PDF, the core and custom\n * properties of an Office file, the tags of a video container.\n *\n * ## Why this module is client-safe and the parsers are not\n *\n * The parsers and writers live in `media-embedded-metadata/` and are exposed\n * only through `@aglyn/aglyn/server` — they need `node:zlib` and\n * `node:crypto`, and nothing in a browser ever holds the bytes they read.\n * What the console needs is the SHAPE of what they produced (it is stored on\n * the media document) and the rules for presenting and editing it, which is\n * everything here. Keeping the two apart keeps a PDF parser out of the\n * console bundle.\n *\n * ## Canonical keys\n *\n * A photo's caption can be written in three places at once — XMP\n * `dc:description`, IPTC 2:120 and EXIF `ImageDescription` — and a PDF's in\n * two. The reader folds every spelling of one idea into one canonical key\n * ({@link MEDIA_EMBEDDED_CATALOG}), so the drawer shows ONE \"Description\"\n * rather than three rows that may or may not agree, and the writer fans an\n * edit back out to every place that already holds it (the Metadata Working\n * Group's rule: never leave a stale copy for the next reader to prefer).\n *\n * Anything a file carries that is not in the catalog — an XMP property from\n * a DAM we have never heard of, a PDF custom property, a PNG text chunk —\n * still surfaces, under a namespaced \"other\" key ({@link otherEmbeddedKey})\n * that round-trips to the writer unchanged.\n */\n\n/** Where a value was read from. Several sources can back one field. */\nexport type MediaEmbeddedSource =\n | 'xmp'\n | 'iptc'\n | 'exif'\n | 'icc'\n | 'png'\n | 'comment'\n | 'pdf'\n | 'ooxml'\n | 'mp4'\n | 'webm'\n | 'svg'\n\n/** How the drawer groups fields, in display order. */\nexport type MediaEmbeddedGroup =\n | 'description'\n | 'rights'\n | 'location'\n | 'capture'\n | 'document'\n | 'technical'\n | 'other'\n\nexport const MEDIA_EMBEDDED_GROUP_ORDER: readonly MediaEmbeddedGroup[] = [\n 'description',\n 'rights',\n 'location',\n 'capture',\n 'document',\n 'technical',\n 'other',\n]\n\nexport const MEDIA_EMBEDDED_GROUP_LABELS: Record<MediaEmbeddedGroup, string> = {\n description: 'Description',\n rights: 'Rights',\n location: 'Location',\n capture: 'Camera',\n document: 'Document',\n technical: 'Technical',\n other: 'Other embedded fields',\n}\n\n/**\n * The container a file was read as. `null`-free: a file whose format has no\n * reader is simply not given an `embeddedMetadata` record at all.\n */\nexport type MediaEmbeddedFormat =\n | 'jpeg'\n | 'png'\n | 'webp'\n | 'gif'\n | 'tiff'\n | 'heif'\n | 'svg'\n | 'pdf'\n | 'ooxml'\n | 'mp4'\n | 'webm'\n\n/**\n * The formats whose bytes can take an edit without being re-encoded.\n *\n * Every writer here rewrites METADATA containers only — JPEG APP segments,\n * PNG ancillary chunks, WebP RIFF chunks, a TIFF's IFD, a PDF incremental\n * update, an Office package's property parts — and copies the image data,\n * page content and every other part through byte for byte. A format that\n * cannot promise that (a video's `moov` would move its sample offsets; an\n * SVG is sanitized on every ingress and is its own markup) is read-only.\n */\nexport const MEDIA_EMBEDDED_WRITABLE_FORMATS: ReadonlySet<MediaEmbeddedFormat> =\n new Set(['jpeg', 'png', 'webp', 'tiff', 'pdf', 'ooxml'])\n\n/** How a value is typed, which decides the editor and the validation. */\nexport type MediaEmbeddedValueKind =\n | 'text'\n | 'longText'\n | 'list'\n | 'date'\n | 'rating'\n | 'gps'\n\nexport interface MediaEmbeddedCatalogEntry {\n label: string\n group: MediaEmbeddedGroup\n kind: MediaEmbeddedValueKind\n /**\n * The formats an edit of this key can be written into. Absent means the\n * key is read-only everywhere — a measured fact such as an exposure time\n * or a page count, which describes the bytes rather than annotating them.\n */\n writable?: readonly MediaEmbeddedFormat[]\n /**\n * `gps` only: the one edit offered is removal. Moving a photo's recorded\n * location is not a thing anybody needs from a DAM; taking it OFF a photo\n * before it goes on a public page is.\n */\n removeOnly?: boolean\n /** Format-specific label, where a format names the idea differently. */\n labels?: Partial<Record<MediaEmbeddedFormat, string>>\n /** Format-specific group — a PDF's creation date is not a camera fact. */\n groups?: Partial<Record<MediaEmbeddedFormat, MediaEmbeddedGroup>>\n}\n\nconst IMAGE_WRITABLE: readonly MediaEmbeddedFormat[] = [\n 'jpeg',\n 'png',\n 'webp',\n 'tiff',\n]\nconst IMAGE_AND_PDF: readonly MediaEmbeddedFormat[] = [...IMAGE_WRITABLE, 'pdf']\nconst IMAGE_PDF_OOXML: readonly MediaEmbeddedFormat[] = [\n ...IMAGE_AND_PDF,\n 'ooxml',\n]\n\n/**\n * Every canonical key, in display order within its group.\n *\n * ## Value formats every reader and writer agrees on\n *\n * - `date`: ISO 8601 — `2021-05-03T10:11:12`, with `Z` or `±HH:MM` only when\n * the source recorded a zone (EXIF 2.3's `OffsetTimeOriginal`, a PDF\n * date's `+05'00'`). Never invented: a local camera time stays local.\n * - `list`: an array of strings (creators, keywords).\n * - `gps`: `\"<lat>,<lon>\"` in signed decimal degrees, six places.\n * - `rating`: `\"0\"`–`\"5\"`, XMP's scale; `\"-1\"` is XMP's \"rejected\".\n * - technical values are display strings already (`1/250`, `f/2.8`,\n * `35 mm`) because nothing edits them.\n */\nexport const MEDIA_EMBEDDED_CATALOG = {\n title: {\n label: 'Title',\n group: 'description',\n kind: 'text',\n writable: IMAGE_PDF_OOXML,\n },\n headline: {\n label: 'Headline',\n group: 'description',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n description: {\n label: 'Description',\n group: 'description',\n kind: 'longText',\n writable: IMAGE_PDF_OOXML,\n labels: { pdf: 'Subject', ooxml: 'Comments' },\n },\n subject: {\n label: 'Subject',\n group: 'description',\n kind: 'text',\n writable: ['ooxml'],\n },\n keywords: {\n label: 'Keywords',\n group: 'description',\n kind: 'list',\n writable: IMAGE_PDF_OOXML,\n },\n category: {\n label: 'Category',\n group: 'description',\n kind: 'text',\n writable: ['ooxml'],\n },\n comment: {\n label: 'Comment',\n group: 'description',\n kind: 'longText',\n writable: ['png'],\n },\n instructions: {\n label: 'Instructions',\n group: 'description',\n kind: 'longText',\n writable: IMAGE_WRITABLE,\n },\n label: {\n label: 'Label',\n group: 'description',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n rating: {\n label: 'Rating',\n group: 'description',\n kind: 'rating',\n writable: IMAGE_WRITABLE,\n },\n creator: {\n label: 'Creator',\n group: 'rights',\n kind: 'list',\n writable: IMAGE_PDF_OOXML,\n labels: { pdf: 'Author', ooxml: 'Author' },\n },\n copyright: {\n label: 'Copyright',\n group: 'rights',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n credit: {\n label: 'Credit line',\n group: 'rights',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n source: {\n label: 'Source',\n group: 'rights',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n usageTerms: {\n label: 'Usage terms',\n group: 'rights',\n kind: 'longText',\n writable: IMAGE_WRITABLE,\n },\n webStatement: {\n label: 'Copyright info URL',\n group: 'rights',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n city: {\n label: 'City',\n group: 'location',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n state: {\n label: 'State / province',\n group: 'location',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n country: {\n label: 'Country',\n group: 'location',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n gps: {\n label: 'GPS position',\n group: 'location',\n kind: 'gps',\n writable: IMAGE_WRITABLE,\n removeOnly: true,\n },\n gpsAltitude: { label: 'Altitude', group: 'location', kind: 'text' },\n createdAt: {\n label: 'Date taken',\n group: 'capture',\n kind: 'date',\n writable: IMAGE_AND_PDF,\n labels: {\n pdf: 'Created',\n ooxml: 'Created',\n svg: 'Created',\n mp4: 'Recorded',\n webm: 'Recorded',\n },\n groups: { pdf: 'document', ooxml: 'document', svg: 'document' },\n },\n make: {\n label: 'Camera make',\n group: 'capture',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n model: {\n label: 'Camera model',\n group: 'capture',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n lens: { label: 'Lens', group: 'capture', kind: 'text', writable: IMAGE_WRITABLE },\n exposure: { label: 'Exposure', group: 'capture', kind: 'text' },\n aperture: { label: 'Aperture', group: 'capture', kind: 'text' },\n iso: { label: 'ISO', group: 'capture', kind: 'text' },\n focalLength: { label: 'Focal length', group: 'capture', kind: 'text' },\n flash: { label: 'Flash', group: 'capture', kind: 'text' },\n modifiedAt: {\n label: 'Modified',\n group: 'document',\n kind: 'date',\n },\n software: {\n label: 'Software',\n group: 'document',\n kind: 'text',\n writable: IMAGE_AND_PDF,\n labels: { pdf: 'Created with', ooxml: 'Application' },\n },\n producer: {\n label: 'PDF producer',\n group: 'document',\n kind: 'text',\n writable: ['pdf'],\n },\n lastModifiedBy: { label: 'Last modified by', group: 'document', kind: 'text' },\n company: { label: 'Company', group: 'document', kind: 'text' },\n pageCount: {\n label: 'Pages',\n group: 'document',\n kind: 'text',\n labels: { ooxml: 'Pages / slides' },\n },\n pdfVersion: { label: 'PDF version', group: 'document', kind: 'text' },\n orientation: { label: 'Orientation', group: 'technical', kind: 'text' },\n colorProfile: { label: 'Color profile', group: 'technical', kind: 'text' },\n encoder: { label: 'Encoder', group: 'technical', kind: 'text' },\n} as const satisfies Record<string, MediaEmbeddedCatalogEntry>\n\nexport type MediaEmbeddedCanonicalKey = keyof typeof MEDIA_EMBEDDED_CATALOG\n\n/**\n * The namespaces an \"other\" key can come from. The key round-trips from\n * reader to writer verbatim, so its only job is to be unambiguous:\n *\n * - `xmp|<namespace URI>|<local name>` — a simple XMP property outside the\n * catalog. Keyed by URI, not prefix, because two files may bind the same\n * namespace to different prefixes and must still produce one key.\n * - `png|<keyword>` — a PNG `tEXt`/`zTXt`/`iTXt` chunk.\n * - `pdf|<Name>` — a PDF document-info entry (Acrobat's \"Custom\" tab).\n * - `ooxml|<name>` — an Office custom document property.\n * - `mp4|<atom or freeform name>`, `webm|<TAG>` — video tags, read-only.\n */\nexport type MediaEmbeddedOtherNamespace =\n | 'xmp'\n | 'png'\n | 'pdf'\n | 'ooxml'\n | 'mp4'\n | 'webm'\n\nexport function otherEmbeddedKey(\n namespace: MediaEmbeddedOtherNamespace,\n ...parts: string[]\n): string {\n return [namespace, ...parts].join('|')\n}\n\nexport function parseOtherEmbeddedKey(\n key: string,\n): { namespace: MediaEmbeddedOtherNamespace; parts: string[] } | null {\n const [namespace = '', ...parts] = key.split('|')\n if (!parts.length) return null\n if (!['xmp', 'png', 'pdf', 'ooxml', 'mp4', 'webm'].includes(namespace))\n return null\n return { namespace: namespace as MediaEmbeddedOtherNamespace, parts }\n}\n\n/** The \"other\" namespaces a writer accepts edits for, by format. */\nconst OTHER_WRITABLE: Partial<\n Record<MediaEmbeddedOtherNamespace, readonly MediaEmbeddedFormat[]>\n> = {\n xmp: IMAGE_WRITABLE,\n png: ['png'],\n pdf: ['pdf'],\n ooxml: ['ooxml'],\n}\n\n/** One field as stored on the media document and shown in the drawer. */\nexport interface MediaEmbeddedField {\n /** A {@link MediaEmbeddedCanonicalKey} or an {@link otherEmbeddedKey}. */\n key: string\n label: string\n group: MediaEmbeddedGroup\n /** Single-valued fields. Absent when `values` is set. */\n value?: string\n /** List-valued fields (creators, keywords). */\n values?: string[]\n /** Every block the value was found in, strongest first. */\n sources: MediaEmbeddedSource[]\n /** Whether the drawer may offer an edit for it on this file. */\n editable: boolean\n}\n\n/**\n * `embeddedMetadata` on a media document (AGL-3331). Server-written only —\n * the Firestore rules lock it like every other fact read from the bytes.\n */\nexport interface MediaEmbeddedMetadata {\n /** Bumped when the reader's output changes shape, so a sweep can re-read. */\n version: number\n format: MediaEmbeddedFormat\n fields: MediaEmbeddedField[]\n /** Whether an edit can be written back into this file at all. */\n writable: boolean\n /** Why not, in a sentence for the drawer, when `writable` is false. */\n readOnlyReason?: string\n /** True when fields were dropped to stay inside the stored caps. */\n truncated?: boolean\n /**\n * The `contentSha256` of the bytes this was read from. A record whose\n * digest no longer matches the document's is stale — the file was\n * replaced by a path that did not re-read it — and is re-read on open.\n */\n contentSha256?: string\n}\n\n/** The reader's output version. See {@link MediaEmbeddedMetadata.version}. */\nexport const MEDIA_EMBEDDED_METADATA_VERSION = 1\n\n/** Stored caps: one media document must never approach Firestore's 1 MiB. */\nexport const MEDIA_EMBEDDED_MAX_FIELDS = 150\nexport const MEDIA_EMBEDDED_MAX_VALUE_LENGTH = 2000\nexport const MEDIA_EMBEDDED_MAX_LIST_ITEMS = 100\n/** A single keyword or creator; IPTC's own limit for keywords is 64 bytes. */\nexport const MEDIA_EMBEDDED_MAX_LIST_ITEM_LENGTH = 256\n\n/**\n * An edit to a file's embedded metadata: key → new value, or `null` to\n * remove it from the file. A list-valued key takes an array.\n */\nexport type MediaEmbeddedPatch = Record<string, string | string[] | null>\n\nexport function isCanonicalEmbeddedKey(\n key: string,\n): key is MediaEmbeddedCanonicalKey {\n return Object.prototype.hasOwnProperty.call(MEDIA_EMBEDDED_CATALOG, key)\n}\n\n/** The label a key carries on a given format. */\nexport function embeddedFieldLabel(\n key: string,\n format: MediaEmbeddedFormat,\n fallback?: string,\n): string {\n if (isCanonicalEmbeddedKey(key)) {\n const entry: MediaEmbeddedCatalogEntry = MEDIA_EMBEDDED_CATALOG[key]\n return entry.labels?.[format] ?? entry.label\n }\n return fallback ?? parseOtherEmbeddedKey(key)?.parts.at(-1) ?? key\n}\n\n/** The group a key is shown under on a given format. */\nexport function embeddedFieldGroup(\n key: string,\n format: MediaEmbeddedFormat,\n): MediaEmbeddedGroup {\n if (!isCanonicalEmbeddedKey(key)) return 'other'\n const entry: MediaEmbeddedCatalogEntry = MEDIA_EMBEDDED_CATALOG[key]\n return entry.groups?.[format] ?? entry.group\n}\n\n/** How a key's value is edited. Other keys are free text. */\nexport function embeddedFieldKind(key: string): MediaEmbeddedValueKind {\n return isCanonicalEmbeddedKey(key)\n ? (MEDIA_EMBEDDED_CATALOG[key] as MediaEmbeddedCatalogEntry).kind\n : 'text'\n}\n\n/**\n * Whether an edit of `key` can be written into a file of `format`.\n *\n * The one place the answer lives: the reader stamps `editable` with it, the\n * drawer offers the \"add a field\" choices from it, and the write route\n * refuses anything it says no to — so the three cannot disagree about which\n * fields a JPEG can take.\n */\nexport function embeddedKeyWritable(\n key: string,\n format: MediaEmbeddedFormat,\n): boolean {\n if (!MEDIA_EMBEDDED_WRITABLE_FORMATS.has(format)) return false\n if (isCanonicalEmbeddedKey(key)) {\n const entry: MediaEmbeddedCatalogEntry = MEDIA_EMBEDDED_CATALOG[key]\n return Boolean(entry.writable?.includes(format))\n }\n const other = parseOtherEmbeddedKey(key)\n return Boolean(other && OTHER_WRITABLE[other.namespace]?.includes(format))\n}\n\n/**\n * The canonical keys a file of this format can take, whether or not it\n * holds them yet — what the drawer offers under \"Add a field\".\n */\nexport function embeddedWritableKeys(\n format: MediaEmbeddedFormat,\n): MediaEmbeddedCanonicalKey[] {\n return (Object.keys(MEDIA_EMBEDDED_CATALOG) as MediaEmbeddedCanonicalKey[])\n .filter((key) => embeddedKeyWritable(key, format))\n}\n\n/** A strict ISO 8601 date or date-time, the only date a writer accepts. */\nconst ISO_DATE =\n /^\\d{4}-\\d{2}-\\d{2}(?:T\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d{1,3})?)?(?:Z|[+-]\\d{2}:\\d{2})?)?$/\n\n/**\n * Validate an edit before it reaches a writer, or say why it cannot.\n *\n * Runs in the drawer (to explain) and in the write route (to refuse), so\n * the messages are written for the person who typed the value.\n */\nexport function sanitizeEmbeddedPatch(\n format: MediaEmbeddedFormat,\n patch: unknown,\n): { patch: MediaEmbeddedPatch } | { error: string } {\n if (!MEDIA_EMBEDDED_WRITABLE_FORMATS.has(format)) {\n return { error: 'This kind of file cannot take metadata edits.' }\n }\n if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {\n return { error: 'Nothing to change.' }\n }\n const out: MediaEmbeddedPatch = {}\n for (const [rawKey, raw] of Object.entries(patch as Record<string, unknown>)) {\n const key = rawKey.trim()\n if (!embeddedKeyWritable(key, format)) {\n return {\n error: `${embeddedFieldLabel(key, format)} cannot be written into this file.`,\n }\n }\n const label = embeddedFieldLabel(key, format)\n if (raw === null) {\n out[key] = null\n continue\n }\n const entry: MediaEmbeddedCatalogEntry | undefined = isCanonicalEmbeddedKey(\n key,\n )\n ? MEDIA_EMBEDDED_CATALOG[key]\n : undefined\n if (entry?.removeOnly) {\n return { error: `${label} can only be removed.` }\n }\n const kind = embeddedFieldKind(key)\n if (kind === 'list') {\n const items = (Array.isArray(raw) ? raw : String(raw).split(/[,;\\n]/))\n .map((item) => String(item).trim())\n .filter(Boolean)\n if (items.length > MEDIA_EMBEDDED_MAX_LIST_ITEMS) {\n return { error: `${label} holds at most ${MEDIA_EMBEDDED_MAX_LIST_ITEMS} entries.` }\n }\n if (items.some((item) => item.length > MEDIA_EMBEDDED_MAX_LIST_ITEM_LENGTH)) {\n return { error: `An entry in ${label} is too long.` }\n }\n out[key] = items.length ? [...new Set(items)] : null\n continue\n }\n if (typeof raw !== 'string') {\n return { error: `${label} must be text.` }\n }\n const value = raw.trim()\n if (!value) {\n out[key] = null\n continue\n }\n if (value.length > MEDIA_EMBEDDED_MAX_VALUE_LENGTH) {\n return { error: `${label} is too long.` }\n }\n if (kind === 'date' && !ISO_DATE.test(value)) {\n return { error: `${label} must be a date.` }\n }\n if (kind === 'rating' && !/^(?:-1|[0-5])$/.test(value)) {\n return { error: `${label} must be between 0 and 5.` }\n }\n out[key] = value\n }\n if (!Object.keys(out).length) return { error: 'Nothing to change.' }\n return { patch: out }\n}\n\n/** The value of a field as one line of text. */\nexport function embeddedFieldText(field: MediaEmbeddedField): string {\n return field.values ? field.values.join(', ') : (field.value ?? '')\n}\n\n/**\n * The stored content types a reader exists for — the drawer asks the\n * server to read a file only when this says there is something to find, so\n * a CSV never costs a round trip to learn it has no caption.\n */\nconst READABLE_TYPES = new Set([\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'image/tiff',\n 'image/heic',\n 'image/heif',\n 'image/avif',\n 'image/svg+xml',\n 'application/pdf',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n 'video/mp4',\n 'video/quicktime',\n 'video/webm',\n])\n\nexport function embeddedMetadataReadable(contentType: unknown): boolean {\n return READABLE_TYPES.has(\n String(contentType ?? '').toLowerCase().split(';')[0]?.trim() ?? '',\n )\n}\n\n/**\n * A field's value as the drawer shows it — the stored value is the\n * machine form every writer agrees on, and this is the only place it is\n * dressed for a person.\n */\nexport function embeddedFieldDisplay(\n field: Pick<MediaEmbeddedField, 'key' | 'value' | 'values'>,\n locale?: string,\n): string {\n if (field.values) return field.values.join(', ')\n const value = field.value ?? ''\n switch (embeddedFieldKind(field.key)) {\n case 'date': {\n const dateOnly = /^\\d{4}-\\d{2}-\\d{2}$/.test(value)\n // A date-only value parsed by `Date` is UTC midnight, which renders as\n // the previous day west of Greenwich; noon keeps it on its own date.\n const parsed = new Date(dateOnly ? `${value}T12:00:00` : value)\n if (Number.isNaN(parsed.getTime())) return value\n return parsed.toLocaleString(locale, {\n dateStyle: 'medium',\n ...(dateOnly ? {} : { timeStyle: 'short' }),\n })\n }\n case 'gps': {\n const [lat, lon] = value.split(',').map(Number)\n if (!Number.isFinite(lat) || !Number.isFinite(lon)) return value\n const part = (n: number, positive: string, negative: string) =>\n `${Math.abs(n).toFixed(5)}° ${n < 0 ? negative : positive}`\n return `${part(lat as number, 'N', 'S')}, ${part(lon as number, 'E', 'W')}`\n }\n case 'rating': {\n const stars = Number(value)\n if (stars === -1) return 'Rejected'\n if (!Number.isInteger(stars) || stars < 0 || stars > 5) return value\n return '★'.repeat(stars) + '☆'.repeat(5 - stars)\n }\n default:\n return value\n }\n}\n"],"names":["MEDIA_EMBEDDED_GROUP_ORDER","MEDIA_EMBEDDED_GROUP_LABELS","description","rights","location","capture","document","technical","other","MEDIA_EMBEDDED_WRITABLE_FORMATS","Set","IMAGE_WRITABLE","IMAGE_AND_PDF","IMAGE_PDF_OOXML","MEDIA_EMBEDDED_CATALOG","title","label","group","kind","writable","headline","labels","pdf","ooxml","subject","keywords","category","comment","instructions","rating","creator","copyright","credit","source","usageTerms","webStatement","city","state","country","gps","removeOnly","gpsAltitude","createdAt","svg","mp4","webm","groups","make","model","lens","exposure","aperture","iso","focalLength","flash","modifiedAt","software","producer","lastModifiedBy","company","pageCount","pdfVersion","orientation","colorProfile","encoder","otherEmbeddedKey","namespace","parts","join","parseOtherEmbeddedKey","key","split","length","includes","OTHER_WRITABLE","xmp","png","MEDIA_EMBEDDED_METADATA_VERSION","MEDIA_EMBEDDED_MAX_FIELDS","MEDIA_EMBEDDED_MAX_VALUE_LENGTH","MEDIA_EMBEDDED_MAX_LIST_ITEMS","MEDIA_EMBEDDED_MAX_LIST_ITEM_LENGTH","isCanonicalEmbeddedKey","Object","prototype","hasOwnProperty","call","embeddedFieldLabel","format","fallback","entry","at","embeddedFieldGroup","embeddedFieldKind","embeddedKeyWritable","has","Boolean","embeddedWritableKeys","keys","filter","ISO_DATE","sanitizeEmbeddedPatch","patch","error","Array","isArray","out","rawKey","raw","entries","trim","undefined","items","String","map","item","some","value","test","embeddedFieldText","field","values","READABLE_TYPES","embeddedMetadataReadable","contentType","toLowerCase","embeddedFieldDisplay","locale","dateOnly","parsed","Date","Number","isNaN","getTime","toLocaleString","dateStyle","timeStyle","lat","lon","isFinite","part","n","positive","negative","Math","abs","toFixed","stars","isInteger","repeat"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-embedded-fields.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The metadata a file carries INSIDE its own bytes (AGL-3331): EXIF, IPTC\n * and XMP on a photo, the document info of a PDF, the core and custom\n * properties of an Office file, the tags of a video container.\n *\n * ## Why this module is client-safe and the parsers are not\n *\n * The parsers and writers live in `media-embedded-metadata/` and are exposed\n * only through `@aglyn/aglyn/server` — they need `node:zlib` and\n * `node:crypto`, and nothing in a browser ever holds the bytes they read.\n * What the console needs is the SHAPE of what they produced (it is stored on\n * the media document) and the rules for presenting and editing it, which is\n * everything here. Keeping the two apart keeps a PDF parser out of the\n * console bundle.\n *\n * ## Canonical keys\n *\n * A photo's caption can be written in three places at once — XMP\n * `dc:description`, IPTC 2:120 and EXIF `ImageDescription` — and a PDF's in\n * two. The reader folds every spelling of one idea into one canonical key\n * ({@link MEDIA_EMBEDDED_CATALOG}), so the drawer shows ONE \"Description\"\n * rather than three rows that may or may not agree, and the writer fans an\n * edit back out to every place that already holds it (the Metadata Working\n * Group's rule: never leave a stale copy for the next reader to prefer).\n *\n * Anything a file carries that is not in the catalog — an XMP property from\n * a DAM we have never heard of, a PDF custom property, a PNG text chunk —\n * still surfaces, under a namespaced \"other\" key ({@link otherEmbeddedKey})\n * that round-trips to the writer unchanged.\n */\n\n/** Where a value was read from. Several sources can back one field. */\nexport type MediaEmbeddedSource =\n | 'xmp'\n | 'iptc'\n | 'exif'\n | 'icc'\n | 'png'\n | 'comment'\n | 'pdf'\n | 'ooxml'\n | 'mp4'\n | 'webm'\n | 'svg'\n\n/** How the drawer groups fields, in display order. */\nexport type MediaEmbeddedGroup =\n | 'description'\n | 'rights'\n | 'location'\n | 'capture'\n | 'document'\n | 'technical'\n | 'other'\n\nexport const MEDIA_EMBEDDED_GROUP_ORDER: readonly MediaEmbeddedGroup[] = [\n 'description',\n 'rights',\n 'location',\n 'capture',\n 'document',\n 'technical',\n 'other',\n]\n\nexport const MEDIA_EMBEDDED_GROUP_LABELS: Record<MediaEmbeddedGroup, string> = {\n description: 'Description',\n rights: 'Rights',\n location: 'Location',\n capture: 'Camera',\n document: 'Document',\n technical: 'Technical',\n other: 'Other embedded fields',\n}\n\n/**\n * The container a file was read as. `null`-free: a file whose format has no\n * reader is simply not given an `embeddedMetadata` record at all.\n */\nexport type MediaEmbeddedFormat =\n | 'jpeg'\n | 'png'\n | 'webp'\n | 'gif'\n | 'tiff'\n | 'heif'\n | 'svg'\n | 'pdf'\n | 'ooxml'\n | 'mp4'\n | 'webm'\n\n/**\n * The formats whose bytes can take an edit without being re-encoded.\n *\n * Every writer here rewrites METADATA containers only — JPEG APP segments,\n * PNG ancillary chunks, WebP RIFF chunks, a TIFF's IFD, a PDF incremental\n * update, an Office package's property parts — and copies the image data,\n * page content and every other part through byte for byte. A format that\n * cannot promise that (a video's `moov` would move its sample offsets; an\n * SVG is sanitized on every ingress and is its own markup) is read-only.\n */\nexport const MEDIA_EMBEDDED_WRITABLE_FORMATS: ReadonlySet<MediaEmbeddedFormat> =\n new Set(['jpeg', 'png', 'webp', 'tiff', 'pdf', 'ooxml'])\n\n/** How a value is typed, which decides the editor and the validation. */\nexport type MediaEmbeddedValueKind =\n | 'text'\n | 'longText'\n | 'list'\n | 'date'\n | 'rating'\n | 'gps'\n\nexport interface MediaEmbeddedCatalogEntry {\n label: string\n group: MediaEmbeddedGroup\n kind: MediaEmbeddedValueKind\n /**\n * The formats an edit of this key can be written into. Absent means the\n * key is read-only everywhere — a measured fact such as an exposure time\n * or a page count, which describes the bytes rather than annotating them.\n */\n writable?: readonly MediaEmbeddedFormat[]\n /**\n * `gps` only: the one edit offered is removal. Moving a photo's recorded\n * location is not a thing anybody needs from a DAM; taking it OFF a photo\n * before it goes on a public page is.\n */\n removeOnly?: boolean\n /** Format-specific label, where a format names the idea differently. */\n labels?: Partial<Record<MediaEmbeddedFormat, string>>\n /** Format-specific group — a PDF's creation date is not a camera fact. */\n groups?: Partial<Record<MediaEmbeddedFormat, MediaEmbeddedGroup>>\n}\n\nconst IMAGE_WRITABLE: readonly MediaEmbeddedFormat[] = [\n 'jpeg',\n 'png',\n 'webp',\n 'tiff',\n]\nconst IMAGE_AND_PDF: readonly MediaEmbeddedFormat[] = [...IMAGE_WRITABLE, 'pdf']\nconst IMAGE_PDF_OOXML: readonly MediaEmbeddedFormat[] = [\n ...IMAGE_AND_PDF,\n 'ooxml',\n]\n\n/**\n * Every canonical key, in display order within its group.\n *\n * ## Value formats every reader and writer agrees on\n *\n * - `date`: ISO 8601 — `2021-05-03T10:11:12`, with `Z` or `±HH:MM` only when\n * the source recorded a zone (EXIF 2.3's `OffsetTimeOriginal`, a PDF\n * date's `+05'00'`). Never invented: a local camera time stays local.\n * - `list`: an array of strings (creators, keywords).\n * - `gps`: `\"<lat>,<lon>\"` in signed decimal degrees, six places.\n * - `rating`: `\"0\"`–`\"5\"`, XMP's scale; `\"-1\"` is XMP's \"rejected\".\n * - technical values are display strings already (`1/250`, `f/2.8`,\n * `35 mm`) because nothing edits them.\n */\nexport const MEDIA_EMBEDDED_CATALOG = {\n title: {\n label: 'Title',\n group: 'description',\n kind: 'text',\n writable: IMAGE_PDF_OOXML,\n },\n headline: {\n label: 'Headline',\n group: 'description',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n description: {\n label: 'Description',\n group: 'description',\n kind: 'longText',\n writable: IMAGE_PDF_OOXML,\n labels: { pdf: 'Subject', ooxml: 'Comments' },\n },\n subject: {\n label: 'Subject',\n group: 'description',\n kind: 'text',\n writable: ['ooxml'],\n },\n keywords: {\n label: 'Keywords',\n group: 'description',\n kind: 'list',\n writable: IMAGE_PDF_OOXML,\n },\n category: {\n label: 'Category',\n group: 'description',\n kind: 'text',\n writable: ['ooxml'],\n },\n comment: {\n label: 'Comment',\n group: 'description',\n kind: 'longText',\n writable: ['png'],\n },\n instructions: {\n label: 'Instructions',\n group: 'description',\n kind: 'longText',\n writable: IMAGE_WRITABLE,\n },\n label: {\n label: 'Label',\n group: 'description',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n rating: {\n label: 'Rating',\n group: 'description',\n kind: 'rating',\n writable: IMAGE_WRITABLE,\n },\n creator: {\n label: 'Creator',\n group: 'rights',\n kind: 'list',\n writable: IMAGE_PDF_OOXML,\n labels: { pdf: 'Author', ooxml: 'Author' },\n },\n copyright: {\n label: 'Copyright',\n group: 'rights',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n credit: {\n label: 'Credit line',\n group: 'rights',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n source: {\n label: 'Source',\n group: 'rights',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n usageTerms: {\n label: 'Usage terms',\n group: 'rights',\n kind: 'longText',\n writable: IMAGE_WRITABLE,\n },\n webStatement: {\n label: 'Copyright info URL',\n group: 'rights',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n city: {\n label: 'City',\n group: 'location',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n state: {\n label: 'State / province',\n group: 'location',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n country: {\n label: 'Country',\n group: 'location',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n gps: {\n label: 'GPS position',\n group: 'location',\n kind: 'gps',\n writable: IMAGE_WRITABLE,\n removeOnly: true,\n },\n gpsAltitude: { label: 'Altitude', group: 'location', kind: 'text' },\n createdAt: {\n label: 'Date taken',\n group: 'capture',\n kind: 'date',\n writable: IMAGE_AND_PDF,\n labels: {\n pdf: 'Created',\n ooxml: 'Created',\n svg: 'Created',\n mp4: 'Recorded',\n webm: 'Recorded',\n },\n groups: { pdf: 'document', ooxml: 'document', svg: 'document' },\n },\n make: {\n label: 'Camera make',\n group: 'capture',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n model: {\n label: 'Camera model',\n group: 'capture',\n kind: 'text',\n writable: IMAGE_WRITABLE,\n },\n lens: { label: 'Lens', group: 'capture', kind: 'text', writable: IMAGE_WRITABLE },\n exposure: { label: 'Exposure', group: 'capture', kind: 'text' },\n aperture: { label: 'Aperture', group: 'capture', kind: 'text' },\n iso: { label: 'ISO', group: 'capture', kind: 'text' },\n focalLength: { label: 'Focal length', group: 'capture', kind: 'text' },\n flash: { label: 'Flash', group: 'capture', kind: 'text' },\n modifiedAt: {\n label: 'Modified',\n group: 'document',\n kind: 'date',\n },\n software: {\n label: 'Software',\n group: 'document',\n kind: 'text',\n writable: IMAGE_AND_PDF,\n labels: { pdf: 'Created with', ooxml: 'Application' },\n },\n producer: {\n label: 'PDF producer',\n group: 'document',\n kind: 'text',\n writable: ['pdf'],\n },\n lastModifiedBy: { label: 'Last modified by', group: 'document', kind: 'text' },\n company: { label: 'Company', group: 'document', kind: 'text' },\n pageCount: {\n label: 'Pages',\n group: 'document',\n kind: 'text',\n labels: { ooxml: 'Pages / slides' },\n },\n pdfVersion: { label: 'PDF version', group: 'document', kind: 'text' },\n orientation: { label: 'Orientation', group: 'technical', kind: 'text' },\n colorProfile: { label: 'Color profile', group: 'technical', kind: 'text' },\n encoder: { label: 'Encoder', group: 'technical', kind: 'text' },\n} as const satisfies Record<string, MediaEmbeddedCatalogEntry>\n\nexport type MediaEmbeddedCanonicalKey = keyof typeof MEDIA_EMBEDDED_CATALOG\n\n/**\n * The namespaces an \"other\" key can come from. The key round-trips from\n * reader to writer verbatim, so its only job is to be unambiguous:\n *\n * - `xmp|<namespace URI>|<local name>` — a simple XMP property outside the\n * catalog. Keyed by URI, not prefix, because two files may bind the same\n * namespace to different prefixes and must still produce one key.\n * - `png|<keyword>` — a PNG `tEXt`/`zTXt`/`iTXt` chunk.\n * - `pdf|<Name>` — a PDF document-info entry (Acrobat's \"Custom\" tab).\n * - `ooxml|<name>` — an Office custom document property.\n * - `mp4|<atom or freeform name>`, `webm|<TAG>` — video tags, read-only.\n */\nexport type MediaEmbeddedOtherNamespace =\n | 'xmp'\n | 'png'\n | 'pdf'\n | 'ooxml'\n | 'mp4'\n | 'webm'\n\nexport function otherEmbeddedKey(\n namespace: MediaEmbeddedOtherNamespace,\n ...parts: string[]\n): string {\n return [namespace, ...parts].join('|')\n}\n\nexport function parseOtherEmbeddedKey(\n key: string,\n): { namespace: MediaEmbeddedOtherNamespace; parts: string[] } | null {\n const [namespace = '', ...parts] = key.split('|')\n if (!parts.length) return null\n if (!['xmp', 'png', 'pdf', 'ooxml', 'mp4', 'webm'].includes(namespace))\n return null\n return { namespace: namespace as MediaEmbeddedOtherNamespace, parts }\n}\n\n/** The \"other\" namespaces a writer accepts edits for, by format. */\nconst OTHER_WRITABLE: Partial<\n Record<MediaEmbeddedOtherNamespace, readonly MediaEmbeddedFormat[]>\n> = {\n xmp: IMAGE_WRITABLE,\n png: ['png'],\n pdf: ['pdf'],\n ooxml: ['ooxml'],\n}\n\n/** One field as stored on the media document and shown in the drawer. */\nexport interface MediaEmbeddedField {\n /** A {@link MediaEmbeddedCanonicalKey} or an {@link otherEmbeddedKey}. */\n key: string\n label: string\n group: MediaEmbeddedGroup\n /** Single-valued fields. Absent when `values` is set. */\n value?: string\n /** List-valued fields (creators, keywords). */\n values?: string[]\n /** Every block the value was found in, strongest first. */\n sources: MediaEmbeddedSource[]\n /** Whether the drawer may offer an edit for it on this file. */\n editable: boolean\n}\n\n/**\n * `embeddedMetadata` on a media document (AGL-3331). Server-written only —\n * the Firestore rules lock it like every other fact read from the bytes.\n */\nexport interface MediaEmbeddedMetadata {\n /** Bumped when the reader's output changes shape, so a sweep can re-read. */\n version: number\n format: MediaEmbeddedFormat\n fields: MediaEmbeddedField[]\n /** Whether an edit can be written back into this file at all. */\n writable: boolean\n /** Why not, in a sentence for the drawer, when `writable` is false. */\n readOnlyReason?: string\n /** True when fields were dropped to stay inside the stored caps. */\n truncated?: boolean\n /**\n * The `contentSha256` of the bytes this was read from. A record whose\n * digest no longer matches the document's is stale — the file was\n * replaced by a path that did not re-read it — and is re-read on open.\n */\n contentSha256?: string\n}\n\n/** The reader's output version. See {@link MediaEmbeddedMetadata.version}. */\nexport const MEDIA_EMBEDDED_METADATA_VERSION = 1\n\n/** Stored caps: one media document must never approach Firestore's 1 MiB. */\nexport const MEDIA_EMBEDDED_MAX_FIELDS = 150\nexport const MEDIA_EMBEDDED_MAX_VALUE_LENGTH = 2000\nexport const MEDIA_EMBEDDED_MAX_LIST_ITEMS = 100\n/** A single keyword or creator; IPTC's own limit for keywords is 64 bytes. */\nexport const MEDIA_EMBEDDED_MAX_LIST_ITEM_LENGTH = 256\n\n/**\n * An edit to a file's embedded metadata: key → new value, or `null` to\n * remove it from the file. A list-valued key takes an array.\n */\nexport type MediaEmbeddedPatch = Record<string, string | string[] | null>\n\nexport function isCanonicalEmbeddedKey(\n key: string,\n): key is MediaEmbeddedCanonicalKey {\n return Object.prototype.hasOwnProperty.call(MEDIA_EMBEDDED_CATALOG, key)\n}\n\n/** The label a key carries on a given format. */\nexport function embeddedFieldLabel(\n key: string,\n format: MediaEmbeddedFormat,\n fallback?: string,\n): string {\n if (isCanonicalEmbeddedKey(key)) {\n const entry: MediaEmbeddedCatalogEntry = MEDIA_EMBEDDED_CATALOG[key]\n return entry.labels?.[format] ?? entry.label\n }\n return fallback ?? parseOtherEmbeddedKey(key)?.parts.at(-1) ?? key\n}\n\n/** The group a key is shown under on a given format. */\nexport function embeddedFieldGroup(\n key: string,\n format: MediaEmbeddedFormat,\n): MediaEmbeddedGroup {\n if (!isCanonicalEmbeddedKey(key)) return 'other'\n const entry: MediaEmbeddedCatalogEntry = MEDIA_EMBEDDED_CATALOG[key]\n return entry.groups?.[format] ?? entry.group\n}\n\n/** How a key's value is edited. Other keys are free text. */\nexport function embeddedFieldKind(key: string): MediaEmbeddedValueKind {\n return isCanonicalEmbeddedKey(key)\n ? (MEDIA_EMBEDDED_CATALOG[key] as MediaEmbeddedCatalogEntry).kind\n : 'text'\n}\n\n/**\n * Whether an edit of `key` can be written into a file of `format`.\n *\n * The one place the answer lives: the reader stamps `editable` with it, the\n * drawer offers the \"add a field\" choices from it, and the write route\n * refuses anything it says no to — so the three cannot disagree about which\n * fields a JPEG can take.\n */\nexport function embeddedKeyWritable(\n key: string,\n format: MediaEmbeddedFormat,\n): boolean {\n if (!MEDIA_EMBEDDED_WRITABLE_FORMATS.has(format)) return false\n if (isCanonicalEmbeddedKey(key)) {\n const entry: MediaEmbeddedCatalogEntry = MEDIA_EMBEDDED_CATALOG[key]\n return Boolean(entry.writable?.includes(format))\n }\n const other = parseOtherEmbeddedKey(key)\n return Boolean(other && OTHER_WRITABLE[other.namespace]?.includes(format))\n}\n\n/**\n * The canonical keys a file of this format can take, whether or not it\n * holds them yet — what the drawer offers under \"Add a field\".\n */\nexport function embeddedWritableKeys(\n format: MediaEmbeddedFormat,\n): MediaEmbeddedCanonicalKey[] {\n return (Object.keys(MEDIA_EMBEDDED_CATALOG) as MediaEmbeddedCanonicalKey[])\n .filter((key) => embeddedKeyWritable(key, format))\n}\n\n/** A strict ISO 8601 date or date-time, the only date a writer accepts. */\nconst ISO_DATE =\n /^\\d{4}-\\d{2}-\\d{2}(?:T\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d{1,3})?)?(?:Z|[+-]\\d{2}:\\d{2})?)?$/\n\n/**\n * Validate an edit before it reaches a writer, or say why it cannot.\n *\n * Runs in the drawer (to explain) and in the write route (to refuse), so\n * the messages are written for the person who typed the value.\n */\nexport function sanitizeEmbeddedPatch(\n format: MediaEmbeddedFormat,\n patch: unknown,\n): { patch: MediaEmbeddedPatch } | { error: string } {\n if (!MEDIA_EMBEDDED_WRITABLE_FORMATS.has(format)) {\n return { error: 'This kind of file cannot take metadata edits.' }\n }\n if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {\n return { error: 'Nothing to change.' }\n }\n const out: MediaEmbeddedPatch = {}\n for (const [rawKey, raw] of Object.entries(patch as Record<string, unknown>)) {\n const key = rawKey.trim()\n if (!embeddedKeyWritable(key, format)) {\n return {\n error: `${embeddedFieldLabel(key, format)} cannot be written into this file.`,\n }\n }\n const label = embeddedFieldLabel(key, format)\n if (raw === null) {\n out[key] = null\n continue\n }\n const entry: MediaEmbeddedCatalogEntry | undefined = isCanonicalEmbeddedKey(\n key,\n )\n ? MEDIA_EMBEDDED_CATALOG[key]\n : undefined\n if (entry?.removeOnly) {\n return { error: `${label} can only be removed.` }\n }\n const kind = embeddedFieldKind(key)\n if (kind === 'list') {\n const items = (Array.isArray(raw) ? raw : String(raw).split(/[,;\\n]/))\n .map((item) => String(item).trim())\n .filter(Boolean)\n if (items.length > MEDIA_EMBEDDED_MAX_LIST_ITEMS) {\n return { error: `${label} holds at most ${MEDIA_EMBEDDED_MAX_LIST_ITEMS} entries.` }\n }\n if (items.some((item) => item.length > MEDIA_EMBEDDED_MAX_LIST_ITEM_LENGTH)) {\n return { error: `An entry in ${label} is too long.` }\n }\n out[key] = items.length ? [...new Set(items)] : null\n continue\n }\n if (typeof raw !== 'string') {\n return { error: `${label} must be text.` }\n }\n const value = raw.trim()\n if (!value) {\n out[key] = null\n continue\n }\n if (value.length > MEDIA_EMBEDDED_MAX_VALUE_LENGTH) {\n return { error: `${label} is too long.` }\n }\n if (kind === 'date' && !ISO_DATE.test(value)) {\n return { error: `${label} must be a date.` }\n }\n if (kind === 'rating' && !/^(?:-1|[0-5])$/.test(value)) {\n return { error: `${label} must be between 0 and 5.` }\n }\n out[key] = value\n }\n if (!Object.keys(out).length) return { error: 'Nothing to change.' }\n return { patch: out }\n}\n\n/** The value of a field as one line of text. */\nexport function embeddedFieldText(field: MediaEmbeddedField): string {\n return field.values ? field.values.join(', ') : (field.value ?? '')\n}\n\n/**\n * The stored content types a reader exists for — the drawer asks the\n * server to read a file only when this says there is something to find, so\n * a CSV never costs a round trip to learn it has no caption.\n */\nconst READABLE_TYPES = new Set([\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'image/tiff',\n 'image/heic',\n 'image/heif',\n 'image/avif',\n 'image/svg+xml',\n 'application/pdf',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n 'video/mp4',\n 'video/quicktime',\n 'video/webm',\n])\n\nexport function embeddedMetadataReadable(contentType: unknown): boolean {\n return READABLE_TYPES.has(\n String(contentType ?? '').toLowerCase().split(';')[0]?.trim() ?? '',\n )\n}\n\n/**\n * A field's value as the drawer shows it — the stored value is the\n * machine form every writer agrees on, and this is the only place it is\n * dressed for a person.\n */\nexport function embeddedFieldDisplay(\n field: Pick<MediaEmbeddedField, 'key' | 'value' | 'values'>,\n locale?: string,\n): string {\n if (field.values) return field.values.join(', ')\n const value = field.value ?? ''\n switch (embeddedFieldKind(field.key)) {\n case 'date': {\n const dateOnly = /^\\d{4}-\\d{2}-\\d{2}$/.test(value)\n // A date-only value parsed by `Date` is UTC midnight, which renders as\n // the previous day west of Greenwich; noon keeps it on its own date.\n const parsed = new Date(dateOnly ? `${value}T12:00:00` : value)\n if (Number.isNaN(parsed.getTime())) return value\n return parsed.toLocaleString(locale, {\n dateStyle: 'medium',\n ...(dateOnly ? {} : { timeStyle: 'short' }),\n })\n }\n case 'gps': {\n const [lat, lon] = value.split(',').map(Number)\n if (!Number.isFinite(lat) || !Number.isFinite(lon)) return value\n const part = (n: number, positive: string, negative: string) =>\n `${Math.abs(n).toFixed(5)}° ${n < 0 ? negative : positive}`\n return `${part(lat as number, 'N', 'S')}, ${part(lon as number, 'E', 'W')}`\n }\n case 'rating': {\n const stars = Number(value)\n if (stars === -1) return 'Rejected'\n if (!Number.isInteger(stars) || stars < 0 || stars > 5) return value\n return '★'.repeat(stars) + '☆'.repeat(5 - stars)\n }\n default:\n return value\n }\n}\n\n/**\n * Whether a stored record still describes the asset's bytes (AGL-3331).\n * Stale when the reader has changed shape since, or when the bytes were\n * replaced by a path that did not re-read them — both are answered by\n * reading the file again, never by showing the old record.\n */\nexport function embeddedMetadataIsCurrent(\n stored: unknown,\n contentSha256: unknown,\n): stored is MediaEmbeddedMetadata {\n if (!stored || typeof stored !== 'object') return false\n const record = stored as Partial<MediaEmbeddedMetadata>\n if (record.version !== MEDIA_EMBEDDED_METADATA_VERSION) return false\n if (!Array.isArray(record.fields)) return false\n return typeof contentSha256 !== 'string' || record.contentSha256 === contentSha256\n}\n\n/**\n * What a person would type to find a file, most telling first (AGL-3339):\n * its title, its keywords, who made it and where. A search token budget is\n * spent in this order, so a long caption can never crowd out a title. GPS,\n * camera settings, dates and software are left out — nobody searches a\n * library by shutter speed.\n */\nconst SEARCHABLE_EMBEDDED_KEYS: readonly MediaEmbeddedCanonicalKey[] = [\n 'title',\n 'keywords',\n 'headline',\n 'subject',\n 'creator',\n 'credit',\n 'source',\n 'city',\n 'state',\n 'country',\n 'category',\n 'label',\n 'description',\n]\n\n/**\n * The searchable text of a stored record, for the library's search keys\n * (AGL-3339). Empty for anything that is not a current-shape record, so a\n * caller can pass a document field straight through.\n */\nexport function mediaEmbeddedSearchText(stored: unknown): string {\n const fields = (stored as Partial<MediaEmbeddedMetadata> | null)?.fields\n if (!Array.isArray(fields)) return ''\n const byKey = new Map(fields.map((field) => [field?.key, field]))\n return SEARCHABLE_EMBEDDED_KEYS.flatMap((key) => {\n const field = byKey.get(key)\n if (!field) return []\n return Array.isArray(field.values)\n ? field.values.map(String)\n : typeof field.value === 'string'\n ? [field.value]\n : []\n })\n .map((text) => text.trim())\n .filter(Boolean)\n .join(' ')\n}\n\n/** One field as the customer API publishes it (AGL-3339). */\nexport interface MediaEmbeddedPublicField {\n key: string\n label: string\n group: MediaEmbeddedGroup\n /** The value of a single-valued field; `null` for a list. */\n value: string | null\n /** The items of a list-valued field (keywords, creators); else `null`. */\n values: string[] | null\n}\n\n/** `embeddedMetadata` as the customer API publishes it (AGL-3339). */\nexport interface MediaEmbeddedPublicView {\n format: MediaEmbeddedFormat\n /** True when fields were dropped to stay inside the stored caps. */\n truncated: boolean\n fields: MediaEmbeddedPublicField[]\n}\n\n/**\n * The stored record as the customer API and the MCP tools return it\n * (AGL-3339), or `null` when there is no current record for these bytes —\n * never an old file's details under a new file's id.\n *\n * `sources` and `editable` stay behind: they answer the drawer's question\n * (can this be written back, and where from), and the API has no call that\n * writes a file's details.\n */\nexport function mediaEmbeddedPublicView(\n stored: unknown,\n contentSha256: unknown,\n): MediaEmbeddedPublicView | null {\n if (!embeddedMetadataIsCurrent(stored, contentSha256)) return null\n return {\n format: stored.format,\n truncated: stored.truncated === true,\n fields: stored.fields.map((field) => ({\n key: field.key,\n label: field.label,\n group: field.group,\n value: Array.isArray(field.values) ? null : (field.value ?? ''),\n values: Array.isArray(field.values) ? field.values.map(String) : null,\n })),\n }\n}\n"],"names":["MEDIA_EMBEDDED_GROUP_ORDER","MEDIA_EMBEDDED_GROUP_LABELS","description","rights","location","capture","document","technical","other","MEDIA_EMBEDDED_WRITABLE_FORMATS","Set","IMAGE_WRITABLE","IMAGE_AND_PDF","IMAGE_PDF_OOXML","MEDIA_EMBEDDED_CATALOG","title","label","group","kind","writable","headline","labels","pdf","ooxml","subject","keywords","category","comment","instructions","rating","creator","copyright","credit","source","usageTerms","webStatement","city","state","country","gps","removeOnly","gpsAltitude","createdAt","svg","mp4","webm","groups","make","model","lens","exposure","aperture","iso","focalLength","flash","modifiedAt","software","producer","lastModifiedBy","company","pageCount","pdfVersion","orientation","colorProfile","encoder","otherEmbeddedKey","namespace","parts","join","parseOtherEmbeddedKey","key","split","length","includes","OTHER_WRITABLE","xmp","png","MEDIA_EMBEDDED_METADATA_VERSION","MEDIA_EMBEDDED_MAX_FIELDS","MEDIA_EMBEDDED_MAX_VALUE_LENGTH","MEDIA_EMBEDDED_MAX_LIST_ITEMS","MEDIA_EMBEDDED_MAX_LIST_ITEM_LENGTH","isCanonicalEmbeddedKey","Object","prototype","hasOwnProperty","call","embeddedFieldLabel","format","fallback","entry","at","embeddedFieldGroup","embeddedFieldKind","embeddedKeyWritable","has","Boolean","embeddedWritableKeys","keys","filter","ISO_DATE","sanitizeEmbeddedPatch","patch","error","Array","isArray","out","rawKey","raw","entries","trim","undefined","items","String","map","item","some","value","test","embeddedFieldText","field","values","READABLE_TYPES","embeddedMetadataReadable","contentType","toLowerCase","embeddedFieldDisplay","locale","dateOnly","parsed","Date","Number","isNaN","getTime","toLocaleString","dateStyle","timeStyle","lat","lon","isFinite","part","n","positive","negative","Math","abs","toFixed","stars","isInteger","repeat","embeddedMetadataIsCurrent","stored","contentSha256","record","version","fields","SEARCHABLE_EMBEDDED_KEYS","mediaEmbeddedSearchText","byKey","Map","flatMap","get","text","mediaEmbeddedPublicView","truncated"],"mappings":"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"}
@@ -67,7 +67,11 @@ export interface MediaFilterKeys {
67
67
  * array filter.
68
68
  */
69
69
  nameLower: string;
70
- /** Word-prefix tokens of the file name's words, for search. */
70
+ /**
71
+ * Word-prefix tokens of the file name's words, then of the details read
72
+ * from inside the file (AGL-3339), for search — at most
73
+ * `NAME_TOKEN_LIMIT`, the name's first.
74
+ */
71
75
  nameTokens: string[];
72
76
  /** Whether alt text is written — "missing" is a value a query can find. */
73
77
  hasAlt: boolean;
@@ -82,7 +86,19 @@ export interface MediaFilterSource {
82
86
  width?: unknown;
83
87
  height?: unknown;
84
88
  video?: unknown;
89
+ /** The details read from inside the file (AGL-3339), searched after its name. */
90
+ embeddedMetadata?: unknown;
91
+ /** The bytes' digest, which says whether `embeddedMetadata` describes them. */
92
+ contentSha256?: unknown;
85
93
  }
94
+ /**
95
+ * What a library search finds a file by (AGL-3339): every word prefix of its
96
+ * name, then of the details read from inside it — a title, keywords, who
97
+ * made it, where — inside the one `NAME_TOKEN_LIMIT` a document keeps, so a
98
+ * long caption never crowds out the name. Details that describe other bytes
99
+ * than the file's (`embeddedMetadataIsCurrent`) add nothing.
100
+ */
101
+ export declare function mediaNameTokens(fileName: unknown, embeddedMetadata?: unknown, contentSha256?: unknown): string[];
86
102
  /**
87
103
  * What the media library filters, sorts and searches by, derived from the
88
104
  * fields a document already carries (AGL-3327). Every writer — the upload
@@ -19,7 +19,8 @@
19
19
  * image header parser so the upload route can stamp dimensions without
20
20
  * pulling in an image library.
21
21
  */ import { MEDIA_ALT_MAX_LENGTH } from "./media-alt.js";
22
- import { nameSearchKey, nameSearchToken, nameSearchTokens } from "./name-search.js";
22
+ import { embeddedMetadataIsCurrent, mediaEmbeddedSearchText } from "./media-embedded-fields.js";
23
+ import { NAME_TOKEN_LIMIT, nameSearchKey, nameSearchToken, nameSearchTokens } from "./name-search.js";
23
24
  export * from "./media-alt.js";
24
25
  export const MEDIA_KINDS = [
25
26
  'image',
@@ -74,6 +75,25 @@ const positive = (value)=>typeof value === 'number' && Number.isFinite(value) &&
74
75
  */ export function mediaSearchToken(query) {
75
76
  return nameSearchToken(mediaNameWords(query));
76
77
  }
78
+ /**
79
+ * What a library search finds a file by (AGL-3339): every word prefix of its
80
+ * name, then of the details read from inside it — a title, keywords, who
81
+ * made it, where — inside the one `NAME_TOKEN_LIMIT` a document keeps, so a
82
+ * long caption never crowds out the name. Details that describe other bytes
83
+ * than the file's (`embeddedMetadataIsCurrent`) add nothing.
84
+ */ export function mediaNameTokens(fileName, embeddedMetadata, contentSha256) {
85
+ const tokens = new Set(nameSearchTokens(mediaNameWords(fileName)));
86
+ if (!embeddedMetadataIsCurrent(embeddedMetadata, contentSha256)) return [
87
+ ...tokens
88
+ ];
89
+ for (const token of nameSearchTokens(mediaNameWords(mediaEmbeddedSearchText(embeddedMetadata)))){
90
+ if (tokens.size >= NAME_TOKEN_LIMIT) break;
91
+ tokens.add(token);
92
+ }
93
+ return [
94
+ ...tokens
95
+ ];
96
+ }
77
97
  /**
78
98
  * What the media library filters, sorts and searches by, derived from the
79
99
  * fields a document already carries (AGL-3327). Every writer — the upload
@@ -89,7 +109,7 @@ const positive = (value)=>typeof value === 'number' && Number.isFinite(value) &&
89
109
  return {
90
110
  kind: mediaKindOf(media.contentType),
91
111
  nameLower: nameSearchKey(fileName),
92
- nameTokens: nameSearchTokens(mediaNameWords(fileName)),
112
+ nameTokens: mediaNameTokens(fileName, media.embeddedMetadata, media.contentSha256),
93
113
  hasAlt: String((_media_alt = media.alt) != null ? _media_alt : '').trim().length > 0,
94
114
  orientation: mediaOrientationOf(media)
95
115
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-metadata.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Asset metadata helpers (AGL-173): tag normalization shared by the\n * console editor and any future API validation, plus a dependency-free\n * image header parser so the upload route can stamp dimensions without\n * pulling in an image library.\n */\n\nimport { MEDIA_ALT_MAX_LENGTH } from './media-alt'\nimport { nameSearchKey, nameSearchToken, nameSearchTokens } from './name-search'\n\nexport * from './media-alt'\n\n/**\n * The family a stored file belongs to, as the media library's Type filter\n * names it (AGL-3327). Stored on the document as `kind`, because a Firestore\n * query can ask for a value and cannot ask for a prefix beside a list's own\n * sort: `kind == 'image'` sits on any query the library builds, where a\n * `contentType` range would take the one range field the query has.\n *\n * Every type media ingress accepts that is not a picture, a film or a PDF is\n * a document — ZIP, Word, Excel, PowerPoint, CSV, text, Markdown, JSON — and\n * so is anything older than that allowlist.\n */\nexport type MediaKind = 'image' | 'video' | 'pdf' | 'document'\n\nexport const MEDIA_KINDS: readonly MediaKind[] = ['image', 'video', 'pdf', 'document']\n\nexport function mediaKindOf(contentType: unknown): MediaKind {\n const type = String(contentType ?? '').trim().toLowerCase()\n if (type.startsWith('image/')) return 'image'\n if (type.startsWith('video/')) return 'video'\n if (type === 'application/pdf') return 'pdf'\n return 'document'\n}\n\n/** Which way a picture or a film is longer, as the Orientation filter asks. */\nexport type MediaOrientation = 'landscape' | 'portrait' | 'square'\n\nexport const MEDIA_ORIENTATIONS: readonly MediaOrientation[] = [\n 'landscape',\n 'portrait',\n 'square',\n]\n\nconst positive = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n\n/**\n * The orientation of a stored file (AGL-3327), from the pixel size it\n * carries: a picture's `width` and `height`, or a film's `video.width` and\n * `video.height` (a video's own `width`/`height` are never written — see\n * `videoMediaProps`). Null when neither pair is whole, which a query for any\n * orientation then leaves out rather than guesses at.\n */\nexport function mediaOrientationOf(media: {\n width?: unknown\n height?: unknown\n video?: unknown\n}): MediaOrientation | null {\n const video =\n media.video && typeof media.video === 'object'\n ? (media.video as { width?: unknown; height?: unknown })\n : null\n let width = positive(media.width)\n let height = positive(media.height)\n if (width === null || height === null) {\n width = positive(video?.width)\n height = positive(video?.height)\n }\n if (width === null || height === null) return null\n if (width === height) return 'square'\n return width > height ? 'landscape' : 'portrait'\n}\n\n/**\n * A file name as words (AGL-3327): every run of characters that is not a\n * letter or a digit becomes a space, so `hero-banner_2x.png` is the four\n * words a person would search it by. The shared name search splits on\n * spaces only, which suits a person's name and misses nearly every file's.\n */\nexport function mediaNameWords(fileName: unknown): string {\n return String(fileName ?? '')\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .trim()\n}\n\n/**\n * The one token a library search asks for (AGL-3327): the first word of what\n * was typed, folded the way `mediaNameWords` folds a stored name, lower-cased\n * and capped as `nameSearchToken` caps it. A query holds one array filter, so\n * a search is one word.\n */\nexport function mediaSearchToken(query: unknown): string {\n return nameSearchToken(mediaNameWords(query))\n}\n\n/** The derived fields every writer of a media document stores beside it. */\nexport interface MediaFilterKeys {\n kind: MediaKind\n /**\n * `nameSearchKey` of the file name: the Name sort, and the \"starts with\"\n * range a search is served by where the scope clause holds the query's one\n * array filter.\n */\n nameLower: string\n /** Word-prefix tokens of the file name's words, for search. */\n nameTokens: string[]\n /** Whether alt text is written — \"missing\" is a value a query can find. */\n hasAlt: boolean\n /** Which way the file is longer, or null when its size is not known. */\n orientation: MediaOrientation | null\n}\n\n/** What `mediaFilterKeys` reads off a media document. */\nexport interface MediaFilterSource {\n fileName?: unknown\n contentType?: unknown\n alt?: unknown\n width?: unknown\n height?: unknown\n video?: unknown\n}\n\n/**\n * What the media library filters, sorts and searches by, derived from the\n * fields a document already carries (AGL-3327). Every writer — the upload\n * routes, replace, the REST API, restore, the Details drawer — spreads this\n * beside the fields it writes, derived from the document as it will stand\n * after the write, so a query finds every file by it. The backfill\n * (`tools/scripts/backfill-media-filter-keys.mjs`) stamps the documents\n * written before it existed, through `tools/scripts/lib/media-filter-keys.mjs`;\n * both are held to `media-filter-keys.fixtures.json` beside it.\n */\nexport function mediaFilterKeys(media: MediaFilterSource): MediaFilterKeys {\n const fileName = String(media.fileName ?? '')\n return {\n kind: mediaKindOf(media.contentType),\n nameLower: nameSearchKey(fileName),\n nameTokens: nameSearchTokens(mediaNameWords(fileName)),\n hasAlt: String(media.alt ?? '').trim().length > 0,\n orientation: mediaOrientationOf(media),\n }\n}\n\nexport const MEDIA_TAG_MAX_COUNT = 20\nexport const MEDIA_TAG_MAX_LENGTH = 40\n\n/**\n * Trim, lowercase, dedupe, and cap tags. Accepts a comma-separated string\n * or an array; empty and oversized entries are dropped.\n */\nexport function normalizeMediaTags(input: string | string[]): string[] {\n const raw = Array.isArray(input) ? input : String(input ?? '').split(',')\n const seen = new Set<string>()\n const tags: string[] = []\n for (const entry of raw) {\n const tag = String(entry ?? '').trim().toLowerCase()\n if (!tag || tag.length > MEDIA_TAG_MAX_LENGTH || seen.has(tag)) continue\n seen.add(tag)\n tags.push(tag)\n if (tags.length >= MEDIA_TAG_MAX_COUNT) break\n }\n return tags\n}\n\n/**\n * What a placement should store for `alt` once the asset's own alt text is\n * taken into account (AGL-1896) — the DAM asset is the DEFAULT, the\n * placement keeps the override.\n *\n * `AglynHostMedia.alt` has existed since AGL-173 and the library drawer has\n * always been able to set it; what never existed is anybody READING it. Every\n * placement surface asked the author to type alt again from scratch, so the\n * same logo on eight pages needed its alt typed eight times and in practice\n * shipped blank — on a customer's published site.\n *\n * ONE function, called at pick time by every surface, rather than a rule\n * re-derived per surface. The three refusals are the whole contract:\n *\n * * **`decorative` wins outright.** It is the field AGL-1305 added to record\n * \"screen readers should skip this\", and `image.tsx` already forces\n * `alt=\"\"` over any alt text when it is on. Inheriting into a node that has\n * declared itself decorative would put text on a node whose renderer\n * discards it — invisible, and misleading to the next author who opens the\n * panel.\n * * **A non-blank placement alt wins.** That is the per-placement override,\n * and clobbering it is the one failure that would make this feature worse\n * than not having it: the author's sentence about THIS placement is better\n * than the asset's generic one by construction.\n * * **A blank asset alt yields nothing.** Never a fabricated default. The\n * file name is not alt text (\"IMG_4021.jpg\" announced to a screen reader is\n * worse than silence), and nothing here has seen the image. Returning\n * `undefined` is what lets callers omit the key entirely rather than\n * writing `alt: ''`, which on a besigner node is itself an authored value.\n *\n * A blank placement alt DOES inherit, deliberately. Presets ship `alt: ''`\n * (see `card.tsx`), so requiring an absent key would have skipped the single\n * commonest authoring path — dropping a preset and pointing its image at a\n * library asset — and left the issue open for the case it was filed about.\n *\n * @returns the alt to store, or `undefined` when the caller should write\n * nothing at all.\n */\nexport function inheritedMediaAlt(options: {\n /** The alt already on the placement — a node prop, a config field. */\n placementAlt?: unknown\n /** The placement's explicit \"skip me\" intent, when it has one. */\n decorative?: unknown\n /** The chosen DAM asset's stored alt text. */\n assetAlt?: unknown\n}): string | undefined {\n const { placementAlt, decorative, assetAlt } = options ?? {}\n if (decorative === true) return undefined\n if (typeof placementAlt === 'string' && placementAlt.trim()) return undefined\n const inherited = typeof assetAlt === 'string' ? assetAlt.trim() : ''\n if (!inherited) return undefined\n // Capped at the same length the library drawer saves through, so an alt\n // that reaches a placement is one the DAM would also have stored.\n return inherited.slice(0, MEDIA_ALT_MAX_LENGTH)\n}\n\n/**\n * Component ids whose renderer reads intrinsic pixel dimensions off the node.\n *\n * Component ids are persisted in screen documents and never renamed, which is\n * what makes matching on them safe. The list is the gate rather than a\n * decoration: a prop written onto an element that does not destructure it\n * reaches `...rest` and is spread onto the DOM, so an unlisted element would\n * gain an invalid `intrinsicwidth` attribute in its published HTML.\n *\n * ⛔ `video` is NOT here, and its absence is a decision rather than an\n * oversight (AGL-2749). A video needs the pair at least as badly as an image\n * does — `preload=\"none\"` means its metadata never arrives until someone\n * presses play, so the element is zero-height for the whole life of the page\n * without it — but it cannot get it from `width`/`height`. Those are read\n * from the bytes by the server and are absent on every video, deliberately:\n * AGL-2742 kept the client-measured triple in its own `video` record so a\n * reader can tell a server measurement from a browser's report.\n * {@link videoMediaProps} is where a video gets its pair.\n */\nconst INTRINSIC_SIZE_COMPONENT_IDS = new Set(['image'])\n\n/**\n * The intrinsic `width`/`height` to copy onto a node when an author picks a\n * library asset for it (AGL-2486).\n *\n * ## Why the copy happens at pick time, and why it is not the last word\n *\n * The pair rides on the node like every other prop, so the editor canvas, and\n * any page whose asset cannot be read, still reserve a box. It is not the last\n * word. A replace rewrites the asset's `width`/`height` and cannot reach the\n * nodes that copied them, so the tenant composition reads every placed\n * image's document, in one projected batch per page, and lays its current\n * pair over this one (`media-asset-facts.ts`, AGL-2833). What is written here\n * is what a page shows when that read cannot answer.\n *\n * ## Why it matters\n *\n * `image.tsx` lays images out with `width: 100%; height: auto`, under which an\n * `<img>` has NO height until its bytes decode. Without an intrinsic pair the\n * browser has no ratio to reserve a box from, so every image on the page\n * shifts the content below it as it lands.\n *\n * ## The rules\n *\n * * **Both or neither.** A browser derives an aspect-ratio only from the\n * pair; a lone `width` is read as a real dimension and reserves a box of\n * the wrong shape, which is worse than reserving none.\n * * **Only for renderers that read them**, see\n * `INTRINSIC_SIZE_COMPONENT_IDS` — otherwise the prop lands on the DOM.\n * * **Finite and positive.** Upload capture is best-effort, so a media\n * document may carry `0`, a partial capture, or nothing at all; `width=\"0\"`\n * collapses the element.\n * * **`{}` when anything is unknown**, never `{ intrinsicWidth: undefined }`.\n * Callers spread the result into a props object that `updateNodeProps`\n * REPLACES wholesale, so a key present with an undefined value would strip\n * a pair a previous pick had correctly stored.\n *\n * @returns the props to spread, or `{}` when the caller should write nothing.\n */\nexport function intrinsicMediaSize(options: {\n /** The element being written to — its persisted component id. */\n componentId?: unknown\n /** The attribute the picker was opened for; only `src` carries an image. */\n propName?: unknown\n /** The chosen asset's stored pixel width. */\n assetWidth?: unknown\n /** The chosen asset's stored pixel height. */\n assetHeight?: unknown\n}): { intrinsicWidth?: number; intrinsicHeight?: number } {\n const { componentId, propName, assetWidth, assetHeight } = options ?? {}\n if (propName !== 'src') return {}\n if (!INTRINSIC_SIZE_COMPONENT_IDS.has(String(componentId ?? ''))) return {}\n const usable = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value) && value > 0\n if (!usable(assetWidth) || !usable(assetHeight)) return {}\n return { intrinsicWidth: assetWidth, intrinsicHeight: assetHeight }\n}\n\n/** The one element that reads the props below off its node. */\nconst VIDEO_COMPONENT_ID = 'video'\n\n/**\n * The video-only companions to {@link intrinsicMediaSize}, copied onto the\n * node when an author picks a video asset (AGL-2741, rewritten against the\n * real document shape in AGL-2749).\n *\n * Same route as the image dimensions, and like theirs not the last word. A\n * replace rewrites the asset's `video` and `poster` records and cannot reach\n * the nodes that copied them, so the tenant composition reads each placed\n * film's document, in the same batch as the images', and lays its current\n * records over these (`media-asset-facts.ts`, AGL-2807). What is written here\n * is what a page shows when that read cannot answer.\n *\n * ## Why a video does not simply use {@link intrinsicMediaSize}\n *\n * A video's pixel dimensions are NOT in `media.width`/`media.height`. Those\n * are read from the bytes by the server and carry a stronger claim than a\n * browser's report, so AGL-2742 kept the client-measured triple in its own\n * `video` record rather than widening them — a reader that folded the two\n * together would lose the ability to tell which it had. A video therefore\n * reaches its intrinsic pair through here, and `intrinsicMediaSize` finds\n * nothing to copy for one.\n *\n * ## The three rules\n *\n * * **Only the `video` element, only its `src`.** A `durationSeconds` spread\n * onto an element that does not destructure it becomes an invalid attribute\n * in the published HTML.\n * * **The duration is stored in SECONDS**, because the author-facing field is\n * in seconds and a person types 63, not 63000. `videoDurationIso8601` takes\n * milliseconds, and the structured-data builder is the one place that\n * converts back.\n * * **`{}` when unknown**, never a key with an `undefined` value: callers\n * spread this into a props object `updateNodeProps` REPLACES wholesale, so\n * a present-but-undefined key strips what an earlier pick stored.\n *\n * `posterFromSource` is a FLAG rather than a url, deliberately. The generated\n * poster is `?poster=1` on the video's own reference, which\n * {@link mediaPosterSrc} builds without reading anything — and that url is\n * explicitly not a promise the poster exists. This flag IS the promise: it is\n * written only when the document records one, which is what lets\n * `videoPosterSrc` offer the derived url to an `<img>` and to a\n * `thumbnailUrl`, where a 404 would be a broken image and a rich result\n * pointing at nothing.\n *\n * An author's own poster is never touched here. It wins at render time\n * instead (`videoPosterSrc`), so re-picking the SOURCE cannot quietly replace\n * a frame somebody chose.\n *\n * @returns the props to spread, or `{}` when the caller should write nothing.\n */\nexport function videoMediaProps(options: {\n /** The element being written to — its persisted component id. */\n componentId?: unknown\n /** The attribute the picker was opened for; only `src` carries a video. */\n propName?: unknown\n /**\n * The chosen asset's `video` record, as {@link normalizeVideoMetadata}\n * bounds it — `durationMs`, `width` and `height`, all three or none.\n */\n assetVideo?: unknown\n /** The chosen asset's generated `poster` record, when it has one. */\n assetPoster?: unknown\n}): {\n durationSeconds?: number\n intrinsicWidth?: number\n intrinsicHeight?: number\n posterFromSource?: boolean\n} {\n const { componentId, propName, assetVideo, assetPoster } = options ?? {}\n if (propName !== 'src') return {}\n if (String(componentId ?? '') !== VIDEO_COMPONENT_ID) return {}\n const patch: {\n durationSeconds?: number\n intrinsicWidth?: number\n intrinsicHeight?: number\n posterFromSource?: boolean\n } = {}\n // Through the DAM's own validator rather than a second reading of the same\n // three numbers: it is all-or-nothing by design, and re-deriving that rule\n // here is how two files come to disagree about what a partial record means.\n const video = normalizeVideoMetadata(assetVideo)\n if (video) {\n // Never rounds to zero: a sub-second clip is still a clip, and a stored\n // `0` reads as \"no duration\" to everything downstream.\n patch.durationSeconds = Math.max(1, Math.round(video.durationMs / 1000))\n patch.intrinsicWidth = video.width\n patch.intrinsicHeight = video.height\n }\n // A poster RECORD, not a poster url — see the note above. Its mere presence\n // on the document is the whole fact being carried.\n if (assetPoster && typeof assetPoster === 'object') {\n patch.posterFromSource = true\n }\n return patch\n}\n\nexport interface ImageDimensions {\n width: number\n height: number\n}\n\nconst readU32BE = (bytes: Uint8Array, offset: number) =>\n (bytes[offset] << 24) |\n (bytes[offset + 1] << 16) |\n (bytes[offset + 2] << 8) |\n bytes[offset + 3]\n\nconst readU16BE = (bytes: Uint8Array, offset: number) =>\n (bytes[offset] << 8) | bytes[offset + 1]\n\nconst readU16LE = (bytes: Uint8Array, offset: number) =>\n bytes[offset] | (bytes[offset + 1] << 8)\n\n/**\n * Reads pixel dimensions from PNG, JPEG, GIF, and WebP headers. Returns\n * null for anything unrecognized or truncated — callers treat dimensions\n * as best-effort metadata, never a gate.\n */\nexport function readImageDimensions(\n bytes: Uint8Array,\n): ImageDimensions | null {\n if (bytes.length < 24) return null\n\n // PNG: 8-byte signature, IHDR width/height at offsets 16/20.\n if (\n bytes[0] === 0x89 &&\n bytes[1] === 0x50 &&\n bytes[2] === 0x4e &&\n bytes[3] === 0x47\n ) {\n const width = readU32BE(bytes, 16)\n const height = readU32BE(bytes, 20)\n return width > 0 && height > 0 ? { width, height } : null\n }\n\n // GIF87a/GIF89a: little-endian dimensions at offsets 6/8.\n if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {\n const width = readU16LE(bytes, 6)\n const height = readU16LE(bytes, 8)\n return width > 0 && height > 0 ? { width, height } : null\n }\n\n // JPEG: scan segments for a SOFn marker (C0–CF except C4/C8/CC).\n if (bytes[0] === 0xff && bytes[1] === 0xd8) {\n let offset = 2\n while (offset + 9 < bytes.length) {\n if (bytes[offset] !== 0xff) {\n offset += 1\n continue\n }\n const marker = bytes[offset + 1]\n if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {\n offset += 2\n continue\n }\n const length = readU16BE(bytes, offset + 2)\n if (\n marker >= 0xc0 &&\n marker <= 0xcf &&\n marker !== 0xc4 &&\n marker !== 0xc8 &&\n marker !== 0xcc\n ) {\n const height = readU16BE(bytes, offset + 5)\n const width = readU16BE(bytes, offset + 7)\n return width > 0 && height > 0 ? { width, height } : null\n }\n if (length < 2) return null\n offset += 2 + length\n }\n return null\n }\n\n // WebP: RIFF....WEBP then VP8/VP8L/VP8X chunk.\n if (\n bytes[0] === 0x52 &&\n bytes[1] === 0x49 &&\n bytes[2] === 0x46 &&\n bytes[3] === 0x46 &&\n bytes[8] === 0x57 &&\n bytes[9] === 0x45 &&\n bytes[10] === 0x42 &&\n bytes[11] === 0x50\n ) {\n const chunk = String.fromCharCode(\n bytes[12],\n bytes[13],\n bytes[14],\n bytes[15],\n )\n if (chunk === 'VP8X' && bytes.length >= 30) {\n const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16))\n const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16))\n return { width, height }\n }\n if (chunk === 'VP8 ' && bytes.length >= 30) {\n const width = readU16LE(bytes, 26) & 0x3fff\n const height = readU16LE(bytes, 28) & 0x3fff\n return width > 0 && height > 0 ? { width, height } : null\n }\n if (chunk === 'VP8L' && bytes.length >= 25) {\n const bits =\n bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n }\n\n return null\n}\n\n/**\n * What the platform knows about a VIDEO asset (AGL-2742).\n *\n * ## Why this is not `width`/`height` on the document beside an image's\n *\n * An image's dimensions are read from its own header by\n * {@link readImageDimensions}, server-side, from bytes the platform holds.\n * None of that is available for video: the dimensions live in a `moov` atom\n * that an MP4 is free to place at the END of the file, so reading them\n * server-side means fetching up to 200 MB back out of Storage to answer a\n * question worth twelve bytes — the exact download `generateStoredMediaVariants`\n * is written to avoid. So these numbers arrive from the BROWSER, which had\n * the file in hand and a decoder already loaded, and they are consequently\n * client data: every field is bounded here before it reaches a document.\n *\n * They live under their own key rather than widening `width`/`height`\n * because the provenance differs and a reader should be able to tell. A\n * `width` on a media document means \"measured from the bytes\"; a\n * `video.width` means \"reported by the uploader's browser\". Folding the two\n * together would make the weaker claim indistinguishable from the stronger\n * one on every asset in the library.\n */\nexport interface MediaVideoMetadata {\n /** Duration in whole milliseconds. Always finite and positive. */\n durationMs: number\n /** Coded frame width in pixels, after any display-aspect correction. */\n width: number\n height: number\n /**\n * A short label for what produced the file, when the browser offered one\n * (`video/mp4; codecs=\"avc1.640028\"` collapses to `avc1.640028`). Absent\n * far more often than present — no browser API reports the codec of a\n * local file, so this is only ever filled from a `MediaCapabilities` probe\n * or a container sniff, and nothing depends on it.\n */\n codec?: string\n}\n\n/**\n * The upper bound on a stored duration: 24 hours.\n *\n * Not a policy about what may be uploaded — the DAM's ceiling is 200 MB of\n * bytes and says nothing about running time. This is the bound past which a\n * number stops being a duration and starts being a bug, and the specific\n * bug it exists for is real: `HTMLMediaElement.duration` is `Infinity` for a\n * stream and for some WebM files until the element has been seeked to the\n * end. `Infinity` fails the finite test below before it reaches this\n * constant, but a browser that reports a plausible-looking 10^12 instead\n * would otherwise write a document claiming a 31-year film.\n */\nexport const MEDIA_VIDEO_MAX_DURATION_MS = 24 * 60 * 60 * 1000\n\n/** The largest coded dimension accepted, matching the 8K ceiling encoders use. */\nexport const MEDIA_VIDEO_MAX_DIMENSION = 16384\n\n/**\n * Bound a browser's report into something safe to store, or refuse it whole.\n *\n * All-or-nothing on purpose. A partial record — a duration with no\n * dimensions — is worse than none: `VideoObject` JSON-LD would emit a\n * `duration` and omit `width`, and a renderer sizing its container from\n * `video.height` would find the key present and the value absent. The three\n * numbers are produced by one `loadedmetadata` event and are meaningful only\n * together, so they are accepted or rejected together.\n *\n * `codec` is the exception and is dropped rather than refused, for the same\n * reason `formatMediaRef` drops a malformed content pin: it is a label\n * nothing branches on, and losing the whole record over it would trade a\n * working poster and a correct aspect ratio for a cosmetic string.\n */\nexport function normalizeVideoMetadata(\n input: unknown,\n): MediaVideoMetadata | null {\n if (!input || typeof input !== 'object') return null\n const source = input as Record<string, unknown>\n const durationMs = Math.round(Number(source['durationMs']))\n const width = Math.round(Number(source['width']))\n const height = Math.round(Number(source['height']))\n if (!Number.isFinite(durationMs) || durationMs <= 0) return null\n if (durationMs > MEDIA_VIDEO_MAX_DURATION_MS) return null\n if (!Number.isFinite(width) || width <= 0 || width > MEDIA_VIDEO_MAX_DIMENSION)\n return null\n if (\n !Number.isFinite(height) ||\n height <= 0 ||\n height > MEDIA_VIDEO_MAX_DIMENSION\n )\n return null\n const rawCodec = source['codec']\n const codec =\n typeof rawCodec === 'string' && /^[\\w.,\\- ]{1,64}$/.test(rawCodec.trim())\n ? rawCodec.trim()\n : undefined\n return codec ? { durationMs, width, height, codec } : { durationMs, width, height }\n}\n\n/**\n * The `duration` a schema.org `VideoObject` wants: an ISO 8601 duration.\n *\n * Whole seconds, and never a fractional component. Google's structured-data\n * documentation accepts `PT1M30S` and treats sub-second precision as noise,\n * and a `PT1M30.437S` in a rich result is a number nobody asked for. A\n * duration under one second rounds UP to `PT1S` rather than to `PT0S`, which\n * would read as \"no duration\" to a consumer that tests for truthiness.\n */\nexport function videoDurationIso8601(\n durationMs: number | undefined | null,\n): string | undefined {\n const total = Math.max(1, Math.round(Number(durationMs) / 1000))\n if (!Number.isFinite(total)) return undefined\n if (!durationMs || Number(durationMs) <= 0) return undefined\n const hours = Math.floor(total / 3600)\n const minutes = Math.floor((total % 3600) / 60)\n const seconds = total % 60\n const parts = [\n hours ? `${hours}H` : '',\n minutes ? `${minutes}M` : '',\n seconds ? `${seconds}S` : '',\n ].join('')\n return `PT${parts || '0S'}`\n}\n"],"names":["MEDIA_ALT_MAX_LENGTH","nameSearchKey","nameSearchToken","nameSearchTokens","MEDIA_KINDS","mediaKindOf","contentType","type","String","trim","toLowerCase","startsWith","MEDIA_ORIENTATIONS","positive","value","Number","isFinite","mediaOrientationOf","media","video","width","height","mediaNameWords","fileName","replace","mediaSearchToken","query","mediaFilterKeys","kind","nameLower","nameTokens","hasAlt","alt","length","orientation","MEDIA_TAG_MAX_COUNT","MEDIA_TAG_MAX_LENGTH","normalizeMediaTags","input","raw","Array","isArray","split","seen","Set","tags","entry","tag","has","add","push","inheritedMediaAlt","options","placementAlt","decorative","assetAlt","undefined","inherited","slice","INTRINSIC_SIZE_COMPONENT_IDS","intrinsicMediaSize","componentId","propName","assetWidth","assetHeight","usable","intrinsicWidth","intrinsicHeight","VIDEO_COMPONENT_ID","videoMediaProps","assetVideo","assetPoster","patch","normalizeVideoMetadata","durationSeconds","Math","max","round","durationMs","posterFromSource","readU32BE","bytes","offset","readU16BE","readU16LE","readImageDimensions","marker","chunk","fromCharCode","bits","MEDIA_VIDEO_MAX_DURATION_MS","MEDIA_VIDEO_MAX_DIMENSION","source","rawCodec","codec","test","videoDurationIso8601","total","hours","floor","minutes","seconds","parts","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-metadata.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Asset metadata helpers (AGL-173): tag normalization shared by the\n * console editor and any future API validation, plus a dependency-free\n * image header parser so the upload route can stamp dimensions without\n * pulling in an image library.\n */\n\nimport { MEDIA_ALT_MAX_LENGTH } from './media-alt'\nimport { embeddedMetadataIsCurrent, mediaEmbeddedSearchText } from './media-embedded-fields'\nimport { NAME_TOKEN_LIMIT, nameSearchKey, nameSearchToken, nameSearchTokens } from './name-search'\n\nexport * from './media-alt'\n\n/**\n * The family a stored file belongs to, as the media library's Type filter\n * names it (AGL-3327). Stored on the document as `kind`, because a Firestore\n * query can ask for a value and cannot ask for a prefix beside a list's own\n * sort: `kind == 'image'` sits on any query the library builds, where a\n * `contentType` range would take the one range field the query has.\n *\n * Every type media ingress accepts that is not a picture, a film or a PDF is\n * a document — ZIP, Word, Excel, PowerPoint, CSV, text, Markdown, JSON — and\n * so is anything older than that allowlist.\n */\nexport type MediaKind = 'image' | 'video' | 'pdf' | 'document'\n\nexport const MEDIA_KINDS: readonly MediaKind[] = ['image', 'video', 'pdf', 'document']\n\nexport function mediaKindOf(contentType: unknown): MediaKind {\n const type = String(contentType ?? '').trim().toLowerCase()\n if (type.startsWith('image/')) return 'image'\n if (type.startsWith('video/')) return 'video'\n if (type === 'application/pdf') return 'pdf'\n return 'document'\n}\n\n/** Which way a picture or a film is longer, as the Orientation filter asks. */\nexport type MediaOrientation = 'landscape' | 'portrait' | 'square'\n\nexport const MEDIA_ORIENTATIONS: readonly MediaOrientation[] = [\n 'landscape',\n 'portrait',\n 'square',\n]\n\nconst positive = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n\n/**\n * The orientation of a stored file (AGL-3327), from the pixel size it\n * carries: a picture's `width` and `height`, or a film's `video.width` and\n * `video.height` (a video's own `width`/`height` are never written — see\n * `videoMediaProps`). Null when neither pair is whole, which a query for any\n * orientation then leaves out rather than guesses at.\n */\nexport function mediaOrientationOf(media: {\n width?: unknown\n height?: unknown\n video?: unknown\n}): MediaOrientation | null {\n const video =\n media.video && typeof media.video === 'object'\n ? (media.video as { width?: unknown; height?: unknown })\n : null\n let width = positive(media.width)\n let height = positive(media.height)\n if (width === null || height === null) {\n width = positive(video?.width)\n height = positive(video?.height)\n }\n if (width === null || height === null) return null\n if (width === height) return 'square'\n return width > height ? 'landscape' : 'portrait'\n}\n\n/**\n * A file name as words (AGL-3327): every run of characters that is not a\n * letter or a digit becomes a space, so `hero-banner_2x.png` is the four\n * words a person would search it by. The shared name search splits on\n * spaces only, which suits a person's name and misses nearly every file's.\n */\nexport function mediaNameWords(fileName: unknown): string {\n return String(fileName ?? '')\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .trim()\n}\n\n/**\n * The one token a library search asks for (AGL-3327): the first word of what\n * was typed, folded the way `mediaNameWords` folds a stored name, lower-cased\n * and capped as `nameSearchToken` caps it. A query holds one array filter, so\n * a search is one word.\n */\nexport function mediaSearchToken(query: unknown): string {\n return nameSearchToken(mediaNameWords(query))\n}\n\n/** The derived fields every writer of a media document stores beside it. */\nexport interface MediaFilterKeys {\n kind: MediaKind\n /**\n * `nameSearchKey` of the file name: the Name sort, and the \"starts with\"\n * range a search is served by where the scope clause holds the query's one\n * array filter.\n */\n nameLower: string\n /**\n * Word-prefix tokens of the file name's words, then of the details read\n * from inside the file (AGL-3339), for search — at most\n * `NAME_TOKEN_LIMIT`, the name's first.\n */\n nameTokens: string[]\n /** Whether alt text is written — \"missing\" is a value a query can find. */\n hasAlt: boolean\n /** Which way the file is longer, or null when its size is not known. */\n orientation: MediaOrientation | null\n}\n\n/** What `mediaFilterKeys` reads off a media document. */\nexport interface MediaFilterSource {\n fileName?: unknown\n contentType?: unknown\n alt?: unknown\n width?: unknown\n height?: unknown\n video?: unknown\n /** The details read from inside the file (AGL-3339), searched after its name. */\n embeddedMetadata?: unknown\n /** The bytes' digest, which says whether `embeddedMetadata` describes them. */\n contentSha256?: unknown\n}\n\n/**\n * What a library search finds a file by (AGL-3339): every word prefix of its\n * name, then of the details read from inside it — a title, keywords, who\n * made it, where — inside the one `NAME_TOKEN_LIMIT` a document keeps, so a\n * long caption never crowds out the name. Details that describe other bytes\n * than the file's (`embeddedMetadataIsCurrent`) add nothing.\n */\nexport function mediaNameTokens(\n fileName: unknown,\n embeddedMetadata?: unknown,\n contentSha256?: unknown,\n): string[] {\n const tokens = new Set(nameSearchTokens(mediaNameWords(fileName)))\n if (!embeddedMetadataIsCurrent(embeddedMetadata, contentSha256)) return [...tokens]\n for (const token of nameSearchTokens(mediaNameWords(mediaEmbeddedSearchText(embeddedMetadata)))) {\n if (tokens.size >= NAME_TOKEN_LIMIT) break\n tokens.add(token)\n }\n return [...tokens]\n}\n\n/**\n * What the media library filters, sorts and searches by, derived from the\n * fields a document already carries (AGL-3327). Every writer — the upload\n * routes, replace, the REST API, restore, the Details drawer — spreads this\n * beside the fields it writes, derived from the document as it will stand\n * after the write, so a query finds every file by it. The backfill\n * (`tools/scripts/backfill-media-filter-keys.mjs`) stamps the documents\n * written before it existed, through `tools/scripts/lib/media-filter-keys.mjs`;\n * both are held to `media-filter-keys.fixtures.json` beside it.\n */\nexport function mediaFilterKeys(media: MediaFilterSource): MediaFilterKeys {\n const fileName = String(media.fileName ?? '')\n return {\n kind: mediaKindOf(media.contentType),\n nameLower: nameSearchKey(fileName),\n nameTokens: mediaNameTokens(fileName, media.embeddedMetadata, media.contentSha256),\n hasAlt: String(media.alt ?? '').trim().length > 0,\n orientation: mediaOrientationOf(media),\n }\n}\n\nexport const MEDIA_TAG_MAX_COUNT = 20\nexport const MEDIA_TAG_MAX_LENGTH = 40\n\n/**\n * Trim, lowercase, dedupe, and cap tags. Accepts a comma-separated string\n * or an array; empty and oversized entries are dropped.\n */\nexport function normalizeMediaTags(input: string | string[]): string[] {\n const raw = Array.isArray(input) ? input : String(input ?? '').split(',')\n const seen = new Set<string>()\n const tags: string[] = []\n for (const entry of raw) {\n const tag = String(entry ?? '').trim().toLowerCase()\n if (!tag || tag.length > MEDIA_TAG_MAX_LENGTH || seen.has(tag)) continue\n seen.add(tag)\n tags.push(tag)\n if (tags.length >= MEDIA_TAG_MAX_COUNT) break\n }\n return tags\n}\n\n/**\n * What a placement should store for `alt` once the asset's own alt text is\n * taken into account (AGL-1896) — the DAM asset is the DEFAULT, the\n * placement keeps the override.\n *\n * `AglynHostMedia.alt` has existed since AGL-173 and the library drawer has\n * always been able to set it; what never existed is anybody READING it. Every\n * placement surface asked the author to type alt again from scratch, so the\n * same logo on eight pages needed its alt typed eight times and in practice\n * shipped blank — on a customer's published site.\n *\n * ONE function, called at pick time by every surface, rather than a rule\n * re-derived per surface. The three refusals are the whole contract:\n *\n * * **`decorative` wins outright.** It is the field AGL-1305 added to record\n * \"screen readers should skip this\", and `image.tsx` already forces\n * `alt=\"\"` over any alt text when it is on. Inheriting into a node that has\n * declared itself decorative would put text on a node whose renderer\n * discards it — invisible, and misleading to the next author who opens the\n * panel.\n * * **A non-blank placement alt wins.** That is the per-placement override,\n * and clobbering it is the one failure that would make this feature worse\n * than not having it: the author's sentence about THIS placement is better\n * than the asset's generic one by construction.\n * * **A blank asset alt yields nothing.** Never a fabricated default. The\n * file name is not alt text (\"IMG_4021.jpg\" announced to a screen reader is\n * worse than silence), and nothing here has seen the image. Returning\n * `undefined` is what lets callers omit the key entirely rather than\n * writing `alt: ''`, which on a besigner node is itself an authored value.\n *\n * A blank placement alt DOES inherit, deliberately. Presets ship `alt: ''`\n * (see `card.tsx`), so requiring an absent key would have skipped the single\n * commonest authoring path — dropping a preset and pointing its image at a\n * library asset — and left the issue open for the case it was filed about.\n *\n * @returns the alt to store, or `undefined` when the caller should write\n * nothing at all.\n */\nexport function inheritedMediaAlt(options: {\n /** The alt already on the placement — a node prop, a config field. */\n placementAlt?: unknown\n /** The placement's explicit \"skip me\" intent, when it has one. */\n decorative?: unknown\n /** The chosen DAM asset's stored alt text. */\n assetAlt?: unknown\n}): string | undefined {\n const { placementAlt, decorative, assetAlt } = options ?? {}\n if (decorative === true) return undefined\n if (typeof placementAlt === 'string' && placementAlt.trim()) return undefined\n const inherited = typeof assetAlt === 'string' ? assetAlt.trim() : ''\n if (!inherited) return undefined\n // Capped at the same length the library drawer saves through, so an alt\n // that reaches a placement is one the DAM would also have stored.\n return inherited.slice(0, MEDIA_ALT_MAX_LENGTH)\n}\n\n/**\n * Component ids whose renderer reads intrinsic pixel dimensions off the node.\n *\n * Component ids are persisted in screen documents and never renamed, which is\n * what makes matching on them safe. The list is the gate rather than a\n * decoration: a prop written onto an element that does not destructure it\n * reaches `...rest` and is spread onto the DOM, so an unlisted element would\n * gain an invalid `intrinsicwidth` attribute in its published HTML.\n *\n * ⛔ `video` is NOT here, and its absence is a decision rather than an\n * oversight (AGL-2749). A video needs the pair at least as badly as an image\n * does — `preload=\"none\"` means its metadata never arrives until someone\n * presses play, so the element is zero-height for the whole life of the page\n * without it — but it cannot get it from `width`/`height`. Those are read\n * from the bytes by the server and are absent on every video, deliberately:\n * AGL-2742 kept the client-measured triple in its own `video` record so a\n * reader can tell a server measurement from a browser's report.\n * {@link videoMediaProps} is where a video gets its pair.\n */\nconst INTRINSIC_SIZE_COMPONENT_IDS = new Set(['image'])\n\n/**\n * The intrinsic `width`/`height` to copy onto a node when an author picks a\n * library asset for it (AGL-2486).\n *\n * ## Why the copy happens at pick time, and why it is not the last word\n *\n * The pair rides on the node like every other prop, so the editor canvas, and\n * any page whose asset cannot be read, still reserve a box. It is not the last\n * word. A replace rewrites the asset's `width`/`height` and cannot reach the\n * nodes that copied them, so the tenant composition reads every placed\n * image's document, in one projected batch per page, and lays its current\n * pair over this one (`media-asset-facts.ts`, AGL-2833). What is written here\n * is what a page shows when that read cannot answer.\n *\n * ## Why it matters\n *\n * `image.tsx` lays images out with `width: 100%; height: auto`, under which an\n * `<img>` has NO height until its bytes decode. Without an intrinsic pair the\n * browser has no ratio to reserve a box from, so every image on the page\n * shifts the content below it as it lands.\n *\n * ## The rules\n *\n * * **Both or neither.** A browser derives an aspect-ratio only from the\n * pair; a lone `width` is read as a real dimension and reserves a box of\n * the wrong shape, which is worse than reserving none.\n * * **Only for renderers that read them**, see\n * `INTRINSIC_SIZE_COMPONENT_IDS` — otherwise the prop lands on the DOM.\n * * **Finite and positive.** Upload capture is best-effort, so a media\n * document may carry `0`, a partial capture, or nothing at all; `width=\"0\"`\n * collapses the element.\n * * **`{}` when anything is unknown**, never `{ intrinsicWidth: undefined }`.\n * Callers spread the result into a props object that `updateNodeProps`\n * REPLACES wholesale, so a key present with an undefined value would strip\n * a pair a previous pick had correctly stored.\n *\n * @returns the props to spread, or `{}` when the caller should write nothing.\n */\nexport function intrinsicMediaSize(options: {\n /** The element being written to — its persisted component id. */\n componentId?: unknown\n /** The attribute the picker was opened for; only `src` carries an image. */\n propName?: unknown\n /** The chosen asset's stored pixel width. */\n assetWidth?: unknown\n /** The chosen asset's stored pixel height. */\n assetHeight?: unknown\n}): { intrinsicWidth?: number; intrinsicHeight?: number } {\n const { componentId, propName, assetWidth, assetHeight } = options ?? {}\n if (propName !== 'src') return {}\n if (!INTRINSIC_SIZE_COMPONENT_IDS.has(String(componentId ?? ''))) return {}\n const usable = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value) && value > 0\n if (!usable(assetWidth) || !usable(assetHeight)) return {}\n return { intrinsicWidth: assetWidth, intrinsicHeight: assetHeight }\n}\n\n/** The one element that reads the props below off its node. */\nconst VIDEO_COMPONENT_ID = 'video'\n\n/**\n * The video-only companions to {@link intrinsicMediaSize}, copied onto the\n * node when an author picks a video asset (AGL-2741, rewritten against the\n * real document shape in AGL-2749).\n *\n * Same route as the image dimensions, and like theirs not the last word. A\n * replace rewrites the asset's `video` and `poster` records and cannot reach\n * the nodes that copied them, so the tenant composition reads each placed\n * film's document, in the same batch as the images', and lays its current\n * records over these (`media-asset-facts.ts`, AGL-2807). What is written here\n * is what a page shows when that read cannot answer.\n *\n * ## Why a video does not simply use {@link intrinsicMediaSize}\n *\n * A video's pixel dimensions are NOT in `media.width`/`media.height`. Those\n * are read from the bytes by the server and carry a stronger claim than a\n * browser's report, so AGL-2742 kept the client-measured triple in its own\n * `video` record rather than widening them — a reader that folded the two\n * together would lose the ability to tell which it had. A video therefore\n * reaches its intrinsic pair through here, and `intrinsicMediaSize` finds\n * nothing to copy for one.\n *\n * ## The three rules\n *\n * * **Only the `video` element, only its `src`.** A `durationSeconds` spread\n * onto an element that does not destructure it becomes an invalid attribute\n * in the published HTML.\n * * **The duration is stored in SECONDS**, because the author-facing field is\n * in seconds and a person types 63, not 63000. `videoDurationIso8601` takes\n * milliseconds, and the structured-data builder is the one place that\n * converts back.\n * * **`{}` when unknown**, never a key with an `undefined` value: callers\n * spread this into a props object `updateNodeProps` REPLACES wholesale, so\n * a present-but-undefined key strips what an earlier pick stored.\n *\n * `posterFromSource` is a FLAG rather than a url, deliberately. The generated\n * poster is `?poster=1` on the video's own reference, which\n * {@link mediaPosterSrc} builds without reading anything — and that url is\n * explicitly not a promise the poster exists. This flag IS the promise: it is\n * written only when the document records one, which is what lets\n * `videoPosterSrc` offer the derived url to an `<img>` and to a\n * `thumbnailUrl`, where a 404 would be a broken image and a rich result\n * pointing at nothing.\n *\n * An author's own poster is never touched here. It wins at render time\n * instead (`videoPosterSrc`), so re-picking the SOURCE cannot quietly replace\n * a frame somebody chose.\n *\n * @returns the props to spread, or `{}` when the caller should write nothing.\n */\nexport function videoMediaProps(options: {\n /** The element being written to — its persisted component id. */\n componentId?: unknown\n /** The attribute the picker was opened for; only `src` carries a video. */\n propName?: unknown\n /**\n * The chosen asset's `video` record, as {@link normalizeVideoMetadata}\n * bounds it — `durationMs`, `width` and `height`, all three or none.\n */\n assetVideo?: unknown\n /** The chosen asset's generated `poster` record, when it has one. */\n assetPoster?: unknown\n}): {\n durationSeconds?: number\n intrinsicWidth?: number\n intrinsicHeight?: number\n posterFromSource?: boolean\n} {\n const { componentId, propName, assetVideo, assetPoster } = options ?? {}\n if (propName !== 'src') return {}\n if (String(componentId ?? '') !== VIDEO_COMPONENT_ID) return {}\n const patch: {\n durationSeconds?: number\n intrinsicWidth?: number\n intrinsicHeight?: number\n posterFromSource?: boolean\n } = {}\n // Through the DAM's own validator rather than a second reading of the same\n // three numbers: it is all-or-nothing by design, and re-deriving that rule\n // here is how two files come to disagree about what a partial record means.\n const video = normalizeVideoMetadata(assetVideo)\n if (video) {\n // Never rounds to zero: a sub-second clip is still a clip, and a stored\n // `0` reads as \"no duration\" to everything downstream.\n patch.durationSeconds = Math.max(1, Math.round(video.durationMs / 1000))\n patch.intrinsicWidth = video.width\n patch.intrinsicHeight = video.height\n }\n // A poster RECORD, not a poster url — see the note above. Its mere presence\n // on the document is the whole fact being carried.\n if (assetPoster && typeof assetPoster === 'object') {\n patch.posterFromSource = true\n }\n return patch\n}\n\nexport interface ImageDimensions {\n width: number\n height: number\n}\n\nconst readU32BE = (bytes: Uint8Array, offset: number) =>\n (bytes[offset] << 24) |\n (bytes[offset + 1] << 16) |\n (bytes[offset + 2] << 8) |\n bytes[offset + 3]\n\nconst readU16BE = (bytes: Uint8Array, offset: number) =>\n (bytes[offset] << 8) | bytes[offset + 1]\n\nconst readU16LE = (bytes: Uint8Array, offset: number) =>\n bytes[offset] | (bytes[offset + 1] << 8)\n\n/**\n * Reads pixel dimensions from PNG, JPEG, GIF, and WebP headers. Returns\n * null for anything unrecognized or truncated — callers treat dimensions\n * as best-effort metadata, never a gate.\n */\nexport function readImageDimensions(\n bytes: Uint8Array,\n): ImageDimensions | null {\n if (bytes.length < 24) return null\n\n // PNG: 8-byte signature, IHDR width/height at offsets 16/20.\n if (\n bytes[0] === 0x89 &&\n bytes[1] === 0x50 &&\n bytes[2] === 0x4e &&\n bytes[3] === 0x47\n ) {\n const width = readU32BE(bytes, 16)\n const height = readU32BE(bytes, 20)\n return width > 0 && height > 0 ? { width, height } : null\n }\n\n // GIF87a/GIF89a: little-endian dimensions at offsets 6/8.\n if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {\n const width = readU16LE(bytes, 6)\n const height = readU16LE(bytes, 8)\n return width > 0 && height > 0 ? { width, height } : null\n }\n\n // JPEG: scan segments for a SOFn marker (C0–CF except C4/C8/CC).\n if (bytes[0] === 0xff && bytes[1] === 0xd8) {\n let offset = 2\n while (offset + 9 < bytes.length) {\n if (bytes[offset] !== 0xff) {\n offset += 1\n continue\n }\n const marker = bytes[offset + 1]\n if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {\n offset += 2\n continue\n }\n const length = readU16BE(bytes, offset + 2)\n if (\n marker >= 0xc0 &&\n marker <= 0xcf &&\n marker !== 0xc4 &&\n marker !== 0xc8 &&\n marker !== 0xcc\n ) {\n const height = readU16BE(bytes, offset + 5)\n const width = readU16BE(bytes, offset + 7)\n return width > 0 && height > 0 ? { width, height } : null\n }\n if (length < 2) return null\n offset += 2 + length\n }\n return null\n }\n\n // WebP: RIFF....WEBP then VP8/VP8L/VP8X chunk.\n if (\n bytes[0] === 0x52 &&\n bytes[1] === 0x49 &&\n bytes[2] === 0x46 &&\n bytes[3] === 0x46 &&\n bytes[8] === 0x57 &&\n bytes[9] === 0x45 &&\n bytes[10] === 0x42 &&\n bytes[11] === 0x50\n ) {\n const chunk = String.fromCharCode(\n bytes[12],\n bytes[13],\n bytes[14],\n bytes[15],\n )\n if (chunk === 'VP8X' && bytes.length >= 30) {\n const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16))\n const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16))\n return { width, height }\n }\n if (chunk === 'VP8 ' && bytes.length >= 30) {\n const width = readU16LE(bytes, 26) & 0x3fff\n const height = readU16LE(bytes, 28) & 0x3fff\n return width > 0 && height > 0 ? { width, height } : null\n }\n if (chunk === 'VP8L' && bytes.length >= 25) {\n const bits =\n bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n }\n\n return null\n}\n\n/**\n * What the platform knows about a VIDEO asset (AGL-2742).\n *\n * ## Why this is not `width`/`height` on the document beside an image's\n *\n * An image's dimensions are read from its own header by\n * {@link readImageDimensions}, server-side, from bytes the platform holds.\n * None of that is available for video: the dimensions live in a `moov` atom\n * that an MP4 is free to place at the END of the file, so reading them\n * server-side means fetching up to 200 MB back out of Storage to answer a\n * question worth twelve bytes — the exact download `generateStoredMediaVariants`\n * is written to avoid. So these numbers arrive from the BROWSER, which had\n * the file in hand and a decoder already loaded, and they are consequently\n * client data: every field is bounded here before it reaches a document.\n *\n * They live under their own key rather than widening `width`/`height`\n * because the provenance differs and a reader should be able to tell. A\n * `width` on a media document means \"measured from the bytes\"; a\n * `video.width` means \"reported by the uploader's browser\". Folding the two\n * together would make the weaker claim indistinguishable from the stronger\n * one on every asset in the library.\n */\nexport interface MediaVideoMetadata {\n /** Duration in whole milliseconds. Always finite and positive. */\n durationMs: number\n /** Coded frame width in pixels, after any display-aspect correction. */\n width: number\n height: number\n /**\n * A short label for what produced the file, when the browser offered one\n * (`video/mp4; codecs=\"avc1.640028\"` collapses to `avc1.640028`). Absent\n * far more often than present — no browser API reports the codec of a\n * local file, so this is only ever filled from a `MediaCapabilities` probe\n * or a container sniff, and nothing depends on it.\n */\n codec?: string\n}\n\n/**\n * The upper bound on a stored duration: 24 hours.\n *\n * Not a policy about what may be uploaded — the DAM's ceiling is 200 MB of\n * bytes and says nothing about running time. This is the bound past which a\n * number stops being a duration and starts being a bug, and the specific\n * bug it exists for is real: `HTMLMediaElement.duration` is `Infinity` for a\n * stream and for some WebM files until the element has been seeked to the\n * end. `Infinity` fails the finite test below before it reaches this\n * constant, but a browser that reports a plausible-looking 10^12 instead\n * would otherwise write a document claiming a 31-year film.\n */\nexport const MEDIA_VIDEO_MAX_DURATION_MS = 24 * 60 * 60 * 1000\n\n/** The largest coded dimension accepted, matching the 8K ceiling encoders use. */\nexport const MEDIA_VIDEO_MAX_DIMENSION = 16384\n\n/**\n * Bound a browser's report into something safe to store, or refuse it whole.\n *\n * All-or-nothing on purpose. A partial record — a duration with no\n * dimensions — is worse than none: `VideoObject` JSON-LD would emit a\n * `duration` and omit `width`, and a renderer sizing its container from\n * `video.height` would find the key present and the value absent. The three\n * numbers are produced by one `loadedmetadata` event and are meaningful only\n * together, so they are accepted or rejected together.\n *\n * `codec` is the exception and is dropped rather than refused, for the same\n * reason `formatMediaRef` drops a malformed content pin: it is a label\n * nothing branches on, and losing the whole record over it would trade a\n * working poster and a correct aspect ratio for a cosmetic string.\n */\nexport function normalizeVideoMetadata(\n input: unknown,\n): MediaVideoMetadata | null {\n if (!input || typeof input !== 'object') return null\n const source = input as Record<string, unknown>\n const durationMs = Math.round(Number(source['durationMs']))\n const width = Math.round(Number(source['width']))\n const height = Math.round(Number(source['height']))\n if (!Number.isFinite(durationMs) || durationMs <= 0) return null\n if (durationMs > MEDIA_VIDEO_MAX_DURATION_MS) return null\n if (!Number.isFinite(width) || width <= 0 || width > MEDIA_VIDEO_MAX_DIMENSION)\n return null\n if (\n !Number.isFinite(height) ||\n height <= 0 ||\n height > MEDIA_VIDEO_MAX_DIMENSION\n )\n return null\n const rawCodec = source['codec']\n const codec =\n typeof rawCodec === 'string' && /^[\\w.,\\- ]{1,64}$/.test(rawCodec.trim())\n ? rawCodec.trim()\n : undefined\n return codec ? { durationMs, width, height, codec } : { durationMs, width, height }\n}\n\n/**\n * The `duration` a schema.org `VideoObject` wants: an ISO 8601 duration.\n *\n * Whole seconds, and never a fractional component. Google's structured-data\n * documentation accepts `PT1M30S` and treats sub-second precision as noise,\n * and a `PT1M30.437S` in a rich result is a number nobody asked for. A\n * duration under one second rounds UP to `PT1S` rather than to `PT0S`, which\n * would read as \"no duration\" to a consumer that tests for truthiness.\n */\nexport function videoDurationIso8601(\n durationMs: number | undefined | null,\n): string | undefined {\n const total = Math.max(1, Math.round(Number(durationMs) / 1000))\n if (!Number.isFinite(total)) return undefined\n if (!durationMs || Number(durationMs) <= 0) return undefined\n const hours = Math.floor(total / 3600)\n const minutes = Math.floor((total % 3600) / 60)\n const seconds = total % 60\n const parts = [\n hours ? `${hours}H` : '',\n minutes ? `${minutes}M` : '',\n seconds ? `${seconds}S` : '',\n ].join('')\n return `PT${parts || '0S'}`\n}\n"],"names":["MEDIA_ALT_MAX_LENGTH","embeddedMetadataIsCurrent","mediaEmbeddedSearchText","NAME_TOKEN_LIMIT","nameSearchKey","nameSearchToken","nameSearchTokens","MEDIA_KINDS","mediaKindOf","contentType","type","String","trim","toLowerCase","startsWith","MEDIA_ORIENTATIONS","positive","value","Number","isFinite","mediaOrientationOf","media","video","width","height","mediaNameWords","fileName","replace","mediaSearchToken","query","mediaNameTokens","embeddedMetadata","contentSha256","tokens","Set","token","size","add","mediaFilterKeys","kind","nameLower","nameTokens","hasAlt","alt","length","orientation","MEDIA_TAG_MAX_COUNT","MEDIA_TAG_MAX_LENGTH","normalizeMediaTags","input","raw","Array","isArray","split","seen","tags","entry","tag","has","push","inheritedMediaAlt","options","placementAlt","decorative","assetAlt","undefined","inherited","slice","INTRINSIC_SIZE_COMPONENT_IDS","intrinsicMediaSize","componentId","propName","assetWidth","assetHeight","usable","intrinsicWidth","intrinsicHeight","VIDEO_COMPONENT_ID","videoMediaProps","assetVideo","assetPoster","patch","normalizeVideoMetadata","durationSeconds","Math","max","round","durationMs","posterFromSource","readU32BE","bytes","offset","readU16BE","readU16LE","readImageDimensions","marker","chunk","fromCharCode","bits","MEDIA_VIDEO_MAX_DURATION_MS","MEDIA_VIDEO_MAX_DIMENSION","source","rawCodec","codec","test","videoDurationIso8601","total","hours","floor","minutes","seconds","parts","join"],"mappings":"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"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Which Stripe signal arrived. */
18
+ export type PaymentRiskSignalKind = 'early-fraud-warning' | 'radar-review' | 'dispute';
19
+ /** One signal, as stamped on the merchant's record. */
20
+ export interface PaymentRiskSignal {
21
+ kind: PaymentRiskSignalKind;
22
+ /** The EFW, review or dispute id: one entry per signal, whatever redelivers. */
23
+ stripeObjectId: string;
24
+ /** `fraud_type`, the review's `reason`, the dispute's `reason`. */
25
+ detail?: string;
26
+ /** A dispute's outcome once `charge.dispute.closed` lands. */
27
+ outcome?: string;
28
+ atMs: number;
29
+ }
30
+ /** The `paymentRisk` field. */
31
+ export interface PaymentRisk {
32
+ signals: PaymentRiskSignal[];
33
+ latestKind: PaymentRiskSignalKind;
34
+ latestAtMs: number;
35
+ }
36
+ export declare const PAYMENT_RISK_FIELD = "paymentRisk";
37
+ /** The Stripe events that carry a signal, and which kind each is. */
38
+ export declare const PAYMENT_RISK_EVENT_KINDS: Readonly<Record<string, PaymentRiskSignalKind>>;
39
+ /** What a webhook handler needs from one of those events. */
40
+ export interface PaymentRiskEvent {
41
+ signal: PaymentRiskSignal;
42
+ paymentIntentId: string;
43
+ chargeId: string;
44
+ }
45
+ /**
46
+ * The signal an event carries, or null for any other event. Every one of
47
+ * the three objects names its `charge` and `payment_intent`, which is how a
48
+ * plugin finds its own record.
49
+ */
50
+ export declare function paymentRiskEventFrom(type: string, object: unknown, nowMs?: number): PaymentRiskEvent | null;
51
+ /**
52
+ * The field with `signal` added, or null when this signal is already on it
53
+ * — the redelivery answer, so the caller neither writes nor notifies twice.
54
+ */
55
+ export declare function addPaymentRiskSignal(existing: PaymentRisk | null | undefined, signal: PaymentRiskSignal): PaymentRisk | null;
56
+ /**
57
+ * The field with a dispute's outcome recorded, or null when the dispute is
58
+ * not on it or already carries that outcome.
59
+ */
60
+ export declare function settlePaymentRiskDispute(existing: PaymentRisk | null | undefined, disputeId: string, outcome: string): PaymentRisk | null;
61
+ /** Said with every signal, so a warning never reads as money already gone. */
62
+ export declare const NOTHING_MOVED: string;
63
+ /** The merchant-facing title for one signal, e.g. for a notification. */
64
+ export declare function paymentRiskTitle(kind: PaymentRiskSignalKind): string;
65
+ /** The merchant-facing sentence for one signal. */
66
+ export declare function paymentRiskAdvice(kind: PaymentRiskSignalKind): string;
67
+ /**
68
+ * A chip's label and tooltip for the record's latest signal, or null when
69
+ * there is none. `Nothing was refunded` is said every time: the merchant
70
+ * must never read a warning as money already gone.
71
+ */
72
+ export declare function describePaymentRisk(risk: PaymentRisk | null | undefined): {
73
+ label: string;
74
+ detail: string;
75
+ } | null;