@elytracms/studio 0.0.64 → 0.0.65

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 (123) hide show
  1. package/convex/assets.ts +11 -0
  2. package/convex/delivery.ts +5 -0
  3. package/convex/schema.ts +23 -0
  4. package/dist/components/assets/asset-thumb.d.ts +4 -2
  5. package/dist/components/assets/asset-thumb.js +26 -4
  6. package/dist/components/assets/asset-thumb.js.map +1 -1
  7. package/dist/components/assets/assets-module.js +48 -2
  8. package/dist/components/assets/assets-module.js.map +1 -1
  9. package/dist/components/assets/assets-store.d.ts +11 -1
  10. package/dist/components/assets/assets-store.js +18 -1
  11. package/dist/components/assets/assets-store.js.map +1 -1
  12. package/dist/components/builder/blocks-model.d.ts +11 -0
  13. package/dist/components/builder/blocks-model.js +26 -8
  14. package/dist/components/builder/blocks-model.js.map +1 -1
  15. package/dist/components/builder/canvas-chrome-css.js +90 -8
  16. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  17. package/dist/components/builder/canvas-inline-editing.d.ts +7 -0
  18. package/dist/components/builder/canvas-inline-editing.js +3 -1
  19. package/dist/components/builder/canvas-inline-editing.js.map +1 -1
  20. package/dist/components/builder/canvas-overlay-geometry.d.ts +72 -0
  21. package/dist/components/builder/canvas-overlay-geometry.js +80 -0
  22. package/dist/components/builder/canvas-overlay-geometry.js.map +1 -0
  23. package/dist/components/builder/canvas-rect-tracking.d.ts +124 -0
  24. package/dist/components/builder/canvas-rect-tracking.js +198 -0
  25. package/dist/components/builder/canvas-rect-tracking.js.map +1 -0
  26. package/dist/components/builder/canvas.js +61 -12
  27. package/dist/components/builder/canvas.js.map +1 -1
  28. package/dist/components/builder/composition-field-editor.js +22 -3
  29. package/dist/components/builder/composition-field-editor.js.map +1 -1
  30. package/dist/components/builder/delete-key.d.ts +38 -0
  31. package/dist/components/builder/delete-key.js +46 -0
  32. package/dist/components/builder/delete-key.js.map +1 -0
  33. package/dist/components/builder/drag-session.d.ts +8 -0
  34. package/dist/components/builder/drag-session.js +15 -0
  35. package/dist/components/builder/drag-session.js.map +1 -1
  36. package/dist/components/builder/insert-affordance-context.d.ts +19 -0
  37. package/dist/components/builder/insert-affordance-context.js +7 -0
  38. package/dist/components/builder/insert-affordance-context.js.map +1 -0
  39. package/dist/components/builder/insert-context.d.ts +40 -0
  40. package/dist/components/builder/insert-context.js +86 -3
  41. package/dist/components/builder/insert-context.js.map +1 -1
  42. package/dist/components/builder/node-title.d.ts +60 -0
  43. package/dist/components/builder/node-title.js +135 -0
  44. package/dist/components/builder/node-title.js.map +1 -0
  45. package/dist/components/builder/page-tree.js +21 -3
  46. package/dist/components/builder/page-tree.js.map +1 -1
  47. package/dist/components/builder/prop-selection.d.ts +52 -0
  48. package/dist/components/builder/prop-selection.js +38 -0
  49. package/dist/components/builder/prop-selection.js.map +1 -0
  50. package/dist/components/builder/prop-sync-context.d.ts +49 -0
  51. package/dist/components/builder/prop-sync-context.js +23 -0
  52. package/dist/components/builder/prop-sync-context.js.map +1 -0
  53. package/dist/components/builder/property-inspector.js +23 -1
  54. package/dist/components/builder/property-inspector.js.map +1 -1
  55. package/dist/components/builder/reuse-context.js +1 -1
  56. package/dist/components/builder/reuse-context.js.map +1 -1
  57. package/dist/components/builder/selection-overlay.d.ts +79 -0
  58. package/dist/components/builder/selection-overlay.js +244 -0
  59. package/dist/components/builder/selection-overlay.js.map +1 -0
  60. package/dist/components/builder/spacing-handles.d.ts +57 -1
  61. package/dist/components/builder/spacing-handles.js +142 -26
  62. package/dist/components/builder/spacing-handles.js.map +1 -1
  63. package/dist/components/cms/asset-picker.js +11 -5
  64. package/dist/components/cms/asset-picker.js.map +1 -1
  65. package/dist/components/cms/rich-text/block-style.d.ts +48 -0
  66. package/dist/components/cms/rich-text/block-style.js +30 -0
  67. package/dist/components/cms/rich-text/block-style.js.map +1 -0
  68. package/dist/components/cms/rich-text/embed-picker.js +4 -3
  69. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  70. package/dist/components/cms/rich-text/extensions.d.ts +2 -0
  71. package/dist/components/cms/rich-text/extensions.js +4 -0
  72. package/dist/components/cms/rich-text/extensions.js.map +1 -1
  73. package/dist/components/cms/rich-text/font-size-control.d.ts +31 -0
  74. package/dist/components/cms/rich-text/font-size-control.js +40 -0
  75. package/dist/components/cms/rich-text/font-size-control.js.map +1 -0
  76. package/dist/components/cms/rich-text/font-size.d.ts +16 -0
  77. package/dist/components/cms/rich-text/font-size.js +40 -0
  78. package/dist/components/cms/rich-text/font-size.js.map +1 -0
  79. package/dist/components/cms/rich-text/toolbar-state.d.ts +3 -0
  80. package/dist/components/cms/rich-text/toolbar-state.js +3 -0
  81. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
  82. package/dist/components/cms/rich-text/toolbar.d.ts +13 -0
  83. package/dist/components/cms/rich-text/toolbar.js +17 -5
  84. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  85. package/dist/components/components/component-detail.js +3 -3
  86. package/dist/components/components/component-detail.js.map +1 -1
  87. package/dist/components/components/component-preview.js +1 -1
  88. package/dist/components/components/component-preview.js.map +1 -1
  89. package/dist/components/components/registry-browser.js +2 -2
  90. package/dist/components/components/registry-browser.js.map +1 -1
  91. package/dist/components/components/taxonomy.js +3 -2
  92. package/dist/components/components/taxonomy.js.map +1 -1
  93. package/dist/components/components/transfer-dialog.js +1 -1
  94. package/dist/components/components/transfer-dialog.js.map +1 -1
  95. package/dist/lib/assets/backfill-thumbs.d.ts +67 -0
  96. package/dist/lib/assets/backfill-thumbs.js +120 -0
  97. package/dist/lib/assets/backfill-thumbs.js.map +1 -0
  98. package/dist/lib/assets/delete-asset.d.ts +2 -1
  99. package/dist/lib/assets/delete-asset.js +12 -0
  100. package/dist/lib/assets/delete-asset.js.map +1 -1
  101. package/dist/lib/assets/index.d.ts +2 -0
  102. package/dist/lib/assets/index.js +2 -0
  103. package/dist/lib/assets/index.js.map +1 -1
  104. package/dist/lib/assets/thumbnail.d.ts +145 -0
  105. package/dist/lib/assets/thumbnail.js +220 -0
  106. package/dist/lib/assets/thumbnail.js.map +1 -0
  107. package/dist/lib/assets/upload.d.ts +9 -0
  108. package/dist/lib/assets/upload.js +31 -0
  109. package/dist/lib/assets/upload.js.map +1 -1
  110. package/dist/lib/i18n/messages.de.js +16 -0
  111. package/dist/lib/i18n/messages.de.js.map +1 -1
  112. package/dist/lib/i18n/messages.en.d.ts +14 -0
  113. package/dist/lib/i18n/messages.en.js +16 -0
  114. package/dist/lib/i18n/messages.en.js.map +1 -1
  115. package/dist/lib/persistence/convex-adapter.js +4 -0
  116. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  117. package/dist/lib/persona/labels.d.ts +9 -5
  118. package/dist/lib/persona/labels.js +13 -8
  119. package/dist/lib/persona/labels.js.map +1 -1
  120. package/dist/lib/references/references-store.js +7 -1
  121. package/dist/lib/references/references-store.js.map +1 -1
  122. package/package.json +2 -2
  123. package/src/styles/studio.css +13 -0
@@ -0,0 +1,120 @@
1
+ import { generateVariants, planLqip, planThumb } from './thumbnail.js';
2
+ /**
3
+ * The assets-rail "Generate previews" backfill (EC-400 AD-1 + EC-420 AD-4).
4
+ * Existing libraries predate the upload-time previews, so an explicit,
5
+ * deterministic maintenance action fetches each original ONCE, runs the SAME
6
+ * decode path as upload, and patches the record. It is idempotent PER VARIANT
7
+ * (EC-420 AD-4): an asset needing only the lqip gets one fetch of the original
8
+ * and fills just what's missing; an asset that already has both is skipped;
9
+ * non-images / SVGs / (for the thumb) already-small images never qualify. The run
10
+ * is sequential with visible progress and cancellable. Opportunistic per-open
11
+ * generation was REJECTED (it hides egress in random editor sessions and is
12
+ * untestable).
13
+ *
14
+ * The write goes through the injected {@link AssetRepository} — in the studio the
15
+ * EC-141 operations facade — so the patch flows through the one operations layer /
16
+ * TanStack Query cache (AD-5 / AD-17), never a direct backend client. The pure
17
+ * cores ({@link assetsNeedingVariants}, {@link assetVariantUpsertInput}) are
18
+ * node-tested; the byte fetch + canvas encode stay in the injectable shell.
19
+ */
20
+ /** An image record still missing a thumb whose {@link planThumb} decision is `encode`. */
21
+ function needsThumb(asset) {
22
+ return asset.thumb === undefined && planThumb(asset).kind === 'encode';
23
+ }
24
+ /** An image record still missing an lqip whose {@link planLqip} decision is `encode`. */
25
+ function needsLqip(asset) {
26
+ return asset.lqip === undefined && planLqip(asset).kind === 'encode';
27
+ }
28
+ /**
29
+ * The assets that still need at least one variant (pure, node-tested idempotence
30
+ * core, EC-420 AD-4): an image record missing a needed thumb OR a needed lqip.
31
+ * Records already carrying both, non-images, SVGs, and (for the thumb) already-
32
+ * small images are silently excluded — re-running on a done library is a no-op.
33
+ */
34
+ export function assetsNeedingVariants(assets) {
35
+ return assets.filter((asset) => needsThumb(asset) || needsLqip(asset));
36
+ }
37
+ /** The variants an asset is missing — what the per-variant backfill asks the encoder for. */
38
+ export function variantWantFor(asset) {
39
+ return { thumb: needsThumb(asset), lqip: needsLqip(asset) };
40
+ }
41
+ /**
42
+ * The full upsert input that re-states an existing record with freshly generated
43
+ * variants attached — the targeted patch path for the backfill, within the
44
+ * existing repository/`assets.upsert` contract (EC-400 AD-4 / EC-420). Mirrors
45
+ * `assetMetadataUpsertInput`: every current column is restated so the full-replace
46
+ * upsert preserves it. A newly generated variant overlays; a variant the record
47
+ * ALREADY had (the other half of a per-variant fill) is preserved from `current`.
48
+ */
49
+ export function assetVariantUpsertInput(current, variants) {
50
+ const thumb = variants.thumb ?? current.thumb;
51
+ const lqip = variants.lqip ?? current.lqip;
52
+ return {
53
+ id: current.id,
54
+ filename: current.filename,
55
+ contentType: current.contentType,
56
+ byteSize: current.byteSize,
57
+ storageKey: current.storageKey,
58
+ ...(current.width !== undefined ? { width: current.width } : {}),
59
+ ...(current.height !== undefined ? { height: current.height } : {}),
60
+ ...(current.url !== undefined ? { url: current.url } : {}),
61
+ ...(current.alt !== undefined ? { alt: current.alt } : {}),
62
+ ...(current.title !== undefined ? { title: current.title } : {}),
63
+ ...(current.focalPoint !== undefined ? { focalPoint: current.focalPoint } : {}),
64
+ ...(current.meta !== undefined ? { meta: current.meta } : {}),
65
+ ...(thumb !== undefined ? { thumb } : {}),
66
+ ...(lqip !== undefined ? { lqip } : {}),
67
+ };
68
+ }
69
+ /**
70
+ * Run the backfill sequentially over a project's images that still need a variant.
71
+ * Resolves with the final tally; never throws (per-asset failures are counted, not
72
+ * fatal). One fetch per candidate serves both variants; only the missing ones are
73
+ * (re)generated (EC-420 AD-4), so an asset with a thumb but no lqip is patched with
74
+ * just the lqip and its existing thumb blob is left untouched.
75
+ */
76
+ export async function runVariantBackfill(deps, projectId) {
77
+ const all = await deps.assets.listAssets(projectId);
78
+ const candidates = assetsNeedingVariants(all);
79
+ const generate = deps.generate ??
80
+ ((source, meta, want) => generateVariants({ blobs: deps.blobs }, source, meta, want));
81
+ const progress = { total: candidates.length, done: 0, generated: 0, skipped: 0, failed: 0 };
82
+ deps.onProgress?.({ ...progress });
83
+ for (const asset of candidates) {
84
+ if (deps.signal?.cancelled) {
85
+ progress.cancelled = true;
86
+ break;
87
+ }
88
+ deps.onProgress?.({ ...progress, current: asset.filename });
89
+ try {
90
+ const bytes = await deps.blobs.get(asset.storageKey);
91
+ if (!bytes) {
92
+ progress.failed += 1;
93
+ }
94
+ else {
95
+ const source = new Blob([bytes], { type: asset.contentType });
96
+ const variants = await generate(source, {
97
+ contentType: asset.contentType,
98
+ ...(asset.width !== undefined ? { width: asset.width } : {}),
99
+ ...(asset.height !== undefined ? { height: asset.height } : {}),
100
+ }, variantWantFor(asset));
101
+ if (variants.thumb === undefined && variants.lqip === undefined) {
102
+ progress.skipped += 1;
103
+ }
104
+ else {
105
+ const patched = await deps.assets.upsertAsset(projectId, assetVariantUpsertInput(asset, variants));
106
+ deps.onGenerated?.(patched);
107
+ progress.generated += 1;
108
+ }
109
+ }
110
+ }
111
+ catch {
112
+ progress.failed += 1;
113
+ }
114
+ progress.done += 1;
115
+ deps.onProgress?.({ ...progress });
116
+ }
117
+ deps.onProgress?.({ ...progress });
118
+ return progress;
119
+ }
120
+ //# sourceMappingURL=backfill-thumbs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backfill-thumbs.js","sourceRoot":"","sources":["../../../src/lib/assets/backfill-thumbs.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n AssetRecord,\n AssetRepository,\n BlobStorageAdapter,\n NewAssetInput,\n} from '@elytracms/core/persistence'\nimport { generateVariants, planLqip, planThumb } from './thumbnail'\nimport type { GeneratedVariants, ThumbSource, VariantWant } from './thumbnail'\n\n/**\n * The assets-rail \"Generate previews\" backfill (EC-400 AD-1 + EC-420 AD-4).\n * Existing libraries predate the upload-time previews, so an explicit,\n * deterministic maintenance action fetches each original ONCE, runs the SAME\n * decode path as upload, and patches the record. It is idempotent PER VARIANT\n * (EC-420 AD-4): an asset needing only the lqip gets one fetch of the original\n * and fills just what's missing; an asset that already has both is skipped;\n * non-images / SVGs / (for the thumb) already-small images never qualify. The run\n * is sequential with visible progress and cancellable. Opportunistic per-open\n * generation was REJECTED (it hides egress in random editor sessions and is\n * untestable).\n *\n * The write goes through the injected {@link AssetRepository} — in the studio the\n * EC-141 operations facade — so the patch flows through the one operations layer /\n * TanStack Query cache (AD-5 / AD-17), never a direct backend client. The pure\n * cores ({@link assetsNeedingVariants}, {@link assetVariantUpsertInput}) are\n * node-tested; the byte fetch + canvas encode stay in the injectable shell.\n */\n\n/** An image record still missing a thumb whose {@link planThumb} decision is `encode`. */\nfunction needsThumb(asset: AssetRecord): boolean {\n return asset.thumb === undefined && planThumb(asset).kind === 'encode'\n}\n\n/** An image record still missing an lqip whose {@link planLqip} decision is `encode`. */\nfunction needsLqip(asset: AssetRecord): boolean {\n return asset.lqip === undefined && planLqip(asset).kind === 'encode'\n}\n\n/**\n * The assets that still need at least one variant (pure, node-tested idempotence\n * core, EC-420 AD-4): an image record missing a needed thumb OR a needed lqip.\n * Records already carrying both, non-images, SVGs, and (for the thumb) already-\n * small images are silently excluded — re-running on a done library is a no-op.\n */\nexport function assetsNeedingVariants(assets: readonly AssetRecord[]): AssetRecord[] {\n return assets.filter((asset) => needsThumb(asset) || needsLqip(asset))\n}\n\n/** The variants an asset is missing — what the per-variant backfill asks the encoder for. */\nexport function variantWantFor(asset: AssetRecord): VariantWant {\n return { thumb: needsThumb(asset), lqip: needsLqip(asset) }\n}\n\n/**\n * The full upsert input that re-states an existing record with freshly generated\n * variants attached — the targeted patch path for the backfill, within the\n * existing repository/`assets.upsert` contract (EC-400 AD-4 / EC-420). Mirrors\n * `assetMetadataUpsertInput`: every current column is restated so the full-replace\n * upsert preserves it. A newly generated variant overlays; a variant the record\n * ALREADY had (the other half of a per-variant fill) is preserved from `current`.\n */\nexport function assetVariantUpsertInput(\n current: AssetRecord,\n variants: GeneratedVariants,\n): NewAssetInput {\n const thumb = variants.thumb ?? current.thumb\n const lqip = variants.lqip ?? current.lqip\n return {\n id: current.id,\n filename: current.filename,\n contentType: current.contentType,\n byteSize: current.byteSize,\n storageKey: current.storageKey,\n ...(current.width !== undefined ? { width: current.width } : {}),\n ...(current.height !== undefined ? { height: current.height } : {}),\n ...(current.url !== undefined ? { url: current.url } : {}),\n ...(current.alt !== undefined ? { alt: current.alt } : {}),\n ...(current.title !== undefined ? { title: current.title } : {}),\n ...(current.focalPoint !== undefined ? { focalPoint: current.focalPoint } : {}),\n ...(current.meta !== undefined ? { meta: current.meta } : {}),\n ...(thumb !== undefined ? { thumb } : {}),\n ...(lqip !== undefined ? { lqip } : {}),\n }\n}\n\n/** Live tally the \"Generate previews\" UI renders (EC-400 AD-1). */\nexport interface BackfillProgress {\n /** How many assets need at least one variant (the candidate set size). */\n total: number\n /** How many candidates have been processed (generated + skipped + failed). */\n done: number\n /** How many candidates had at least one variant generated and patched in. */\n generated: number\n /** Candidates the re-plan/encode declined (e.g. undecodable) — silent, valid. */\n skipped: number\n /** Candidates whose fetch/encode/patch errored — surfaced, not fatal. */\n failed: number\n /** Filename of the asset currently in flight (for the progress label). */\n current?: string\n /** Set once the run stops early because the caller cancelled. */\n cancelled?: boolean\n}\n\n/** A mutable cancellation flag the UI flips to stop the sequential run (AD-1). */\nexport interface BackfillSignal {\n cancelled: boolean\n}\n\nexport interface BackfillDeps {\n /** Metadata reads + the variant patch write (the operations facade in the studio). */\n assets: AssetRepository\n /** Blob boundary: fetch each original's bytes, store the thumb variant. */\n blobs: BlobStorageAdapter\n /**\n * Injectable variant producer; defaults to {@link generateVariants} over `blobs`.\n * Receives the per-asset `want` so it produces ONLY the missing variants. Tests\n * pass a deterministic fake so the iterator runs in node.\n */\n generate?: (source: Blob, meta: ThumbSource, want: VariantWant) => Promise<GeneratedVariants>\n /** Fired with each patched record so the caller can update its cache in place. */\n onGenerated?: (asset: AssetRecord) => void\n /** Fired after each candidate (and once at the end) to drive the progress UI. */\n onProgress?: (progress: BackfillProgress) => void\n /** Cooperative cancellation — checked before each candidate (AD-1). */\n signal?: BackfillSignal\n}\n\n/**\n * Run the backfill sequentially over a project's images that still need a variant.\n * Resolves with the final tally; never throws (per-asset failures are counted, not\n * fatal). One fetch per candidate serves both variants; only the missing ones are\n * (re)generated (EC-420 AD-4), so an asset with a thumb but no lqip is patched with\n * just the lqip and its existing thumb blob is left untouched.\n */\nexport async function runVariantBackfill(\n deps: BackfillDeps,\n projectId: string,\n): Promise<BackfillProgress> {\n const all = await deps.assets.listAssets(projectId)\n const candidates = assetsNeedingVariants(all)\n const generate =\n deps.generate ??\n ((source: Blob, meta: ThumbSource, want: VariantWant) =>\n generateVariants({ blobs: deps.blobs }, source, meta, want))\n\n const progress: BackfillProgress = { total: candidates.length, done: 0, generated: 0, skipped: 0, failed: 0 }\n deps.onProgress?.({ ...progress })\n\n for (const asset of candidates) {\n if (deps.signal?.cancelled) {\n progress.cancelled = true\n break\n }\n deps.onProgress?.({ ...progress, current: asset.filename })\n try {\n const bytes = await deps.blobs.get(asset.storageKey)\n if (!bytes) {\n progress.failed += 1\n } else {\n const source = new Blob([bytes as BlobPart], { type: asset.contentType })\n const variants = await generate(\n source,\n {\n contentType: asset.contentType,\n ...(asset.width !== undefined ? { width: asset.width } : {}),\n ...(asset.height !== undefined ? { height: asset.height } : {}),\n },\n variantWantFor(asset),\n )\n if (variants.thumb === undefined && variants.lqip === undefined) {\n progress.skipped += 1\n } else {\n const patched = await deps.assets.upsertAsset(projectId, assetVariantUpsertInput(asset, variants))\n deps.onGenerated?.(patched)\n progress.generated += 1\n }\n }\n } catch {\n progress.failed += 1\n }\n progress.done += 1\n deps.onProgress?.({ ...progress })\n }\n\n deps.onProgress?.({ ...progress })\n return progress\n}\n"]}
@@ -6,7 +6,8 @@ import type { AssetRepository, BlobStorageAdapter, ReferenceIndexRepository } fr
6
6
  * `{ type: 'asset' }` target) — index-accurate, replacing the interim string
7
7
  * scan (`find-asset-usages`), which could miss rich-text asset hrefs and
8
8
  * false-positive on unrelated strings. Deleting an unused asset removes bytes
9
- * (blob) AND the metadata record. Results are structured data — never throws.
9
+ * (blob) — original AND the EC-400 thumb variant blob — plus the metadata
10
+ * record. Results are structured data — never throws.
10
11
  */
11
12
  export interface DeleteAssetDeps {
12
13
  assets: AssetRepository;
@@ -15,6 +15,18 @@ export async function deleteAsset(deps, projectId, assetId, options = {}) {
15
15
  // Bytes first, metadata last: a failure midway leaves a visible metadata
16
16
  // record (re-deletable) rather than invisible orphaned bytes.
17
17
  await deps.blobs.delete(asset.storageKey);
18
+ // EC-400 (AD-4): the downscaled preview is a SECOND stored blob — delete it
19
+ // too. Best-effort: a leftover thumb blob is harmless wasted storage (its
20
+ // metadata is going), so a thumb-delete failure must not block removing the
21
+ // asset the way an orphaned original would.
22
+ if (asset.thumb) {
23
+ try {
24
+ await deps.blobs.delete(asset.thumb.storageKey);
25
+ }
26
+ catch {
27
+ // Swallow — the asset delete proceeds; the orphaned thumb blob is inert.
28
+ }
29
+ }
18
30
  await deps.assets.removeAsset(projectId, assetId);
19
31
  return { outcome: 'deleted' };
20
32
  }
@@ -1 +1 @@
1
- {"version":3,"file":"delete-asset.js","sourceRoot":"","sources":["../../../src/lib/assets/delete-asset.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAA;AAoCjE,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAqB,EACrB,SAAiB,EACjB,OAAe,EACf,UAA8B,EAAE;IAEhC,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;QAC7D,IAAI,CAAC,KAAK;YAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAA;QAE3C,8EAA8E;QAC9E,iFAAiF;QACjF,mFAAmF;QACnF,IAAI,OAAO,CAAC,YAAY,KAAK,IAAI,EAAE,CAAC;YAClC,MAAM,UAAU,GAAG,MAAM,eAAe,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,CAAC,CAAA;YAClE,IAAI,UAAU,GAAG,CAAC;gBAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAA;QAC/D,CAAC;QAED,yEAAyE;QACzE,8DAA8D;QAC9D,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;QACzC,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;QACjD,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,CAAA;IAC/B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,mBAAmB;YAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAA;QACzE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IAC9F,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,eAAe,CAC5B,IAAqB,EACrB,SAAiB,EACjB,OAAe;IAEf,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAA;IACxF,MAAM,QAAQ,GAAG,IAAI,GAAG,CACtB,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,IAAI,KAAK,CAAC,MAAM,CAAC,EAAE,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,CAChF,CAAA;IACD,OAAO,QAAQ,CAAC,IAAI,CAAA;AACtB,CAAC","sourcesContent":["import { RecordNotFoundError } from '@elytracms/core/persistence'\nimport type {\n AssetRepository,\n BlobStorageAdapter,\n ReferenceIndexRepository,\n} from '@elytracms/core/persistence'\n\n/**\n * Reference-respecting asset deletion (EC-151, reworked EC-202). Deleting an\n * asset that is referenced is blocked until explicitly confirmed; the usage\n * count comes from the **EC-155 reference index** (`references.inbound` for the\n * `{ type: 'asset' }` target) — index-accurate, replacing the interim string\n * scan (`find-asset-usages`), which could miss rich-text asset hrefs and\n * false-positive on unrelated strings. Deleting an unused asset removes bytes\n * (blob) AND the metadata record. Results are structured data — never throws.\n */\n\nexport interface DeleteAssetDeps {\n assets: AssetRepository\n blobs: BlobStorageAdapter\n /** The EC-155 reference index — the accurate inbound-usage source (EC-202). */\n references: Pick<ReferenceIndexRepository, 'inbound'>\n}\n\nexport type DeleteAssetResult =\n | { outcome: 'deleted' }\n /** The asset is referenced and `confirmInUse` was not set: nothing was removed. */\n | { outcome: 'blocked'; usageCount: number }\n | { outcome: 'not-found' }\n | { outcome: 'error'; message: string }\n\nexport interface DeleteAssetOptions {\n /** Caller has seen the usage count and explicitly confirmed deletion. */\n confirmInUse?: boolean\n}\n\nexport async function deleteAsset(\n deps: DeleteAssetDeps,\n projectId: string,\n assetId: string,\n options: DeleteAssetOptions = {},\n): Promise<DeleteAssetResult> {\n try {\n const asset = await deps.assets.findAsset(projectId, assetId)\n if (!asset) return { outcome: 'not-found' }\n\n // Only consult the reference index when the caller has NOT already confirmed.\n // The delete dialog enumerates usages up-front and confirms with `confirmInUse`,\n // so a confirmed delete skips this redundant inbound query entirely (EC-301 perf).\n if (options.confirmInUse !== true) {\n const usageCount = await assetUsageCount(deps, projectId, assetId)\n if (usageCount > 0) return { outcome: 'blocked', usageCount }\n }\n\n // Bytes first, metadata last: a failure midway leaves a visible metadata\n // record (re-deletable) rather than invisible orphaned bytes.\n await deps.blobs.delete(asset.storageKey)\n await deps.assets.removeAsset(projectId, assetId)\n return { outcome: 'deleted' }\n } catch (error) {\n if (error instanceof RecordNotFoundError) return { outcome: 'not-found' }\n return { outcome: 'error', message: error instanceof Error ? error.message : String(error) }\n }\n}\n\n/**\n * Distinct inbound usages of an asset from the reference index (EC-202). One\n * `source + path` (e.g. `post:hello · values/cover`) can carry both a draft and\n * a published index entry — that is a single usage, so variants collapse before\n * counting (the gate is \"is this referenced\", not \"how many index rows\").\n */\nasync function assetUsageCount(\n deps: DeleteAssetDeps,\n projectId: string,\n assetId: string,\n): Promise<number> {\n const inbound = await deps.references.inbound(projectId, { type: 'asset', id: assetId })\n const distinct = new Set(\n inbound.map((entry) => `${entry.source.type}:${entry.source.id}:${entry.path}`),\n )\n return distinct.size\n}\n"]}
1
+ {"version":3,"file":"delete-asset.js","sourceRoot":"","sources":["../../../src/lib/assets/delete-asset.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAA;AAqCjE,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAqB,EACrB,SAAiB,EACjB,OAAe,EACf,UAA8B,EAAE;IAEhC,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;QAC7D,IAAI,CAAC,KAAK;YAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAA;QAE3C,8EAA8E;QAC9E,iFAAiF;QACjF,mFAAmF;QACnF,IAAI,OAAO,CAAC,YAAY,KAAK,IAAI,EAAE,CAAC;YAClC,MAAM,UAAU,GAAG,MAAM,eAAe,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,CAAC,CAAA;YAClE,IAAI,UAAU,GAAG,CAAC;gBAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAA;QAC/D,CAAC;QAED,yEAAyE;QACzE,8DAA8D;QAC9D,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;QACzC,4EAA4E;QAC5E,0EAA0E;QAC1E,4EAA4E;QAC5E,4CAA4C;QAC5C,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YAChB,IAAI,CAAC;gBACH,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;YACjD,CAAC;YAAC,MAAM,CAAC;gBACP,yEAAyE;YAC3E,CAAC;QACH,CAAC;QACD,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;QACjD,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,CAAA;IAC/B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,mBAAmB;YAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAA;QACzE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IAC9F,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,eAAe,CAC5B,IAAqB,EACrB,SAAiB,EACjB,OAAe;IAEf,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAA;IACxF,MAAM,QAAQ,GAAG,IAAI,GAAG,CACtB,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,IAAI,KAAK,CAAC,MAAM,CAAC,EAAE,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,CAChF,CAAA;IACD,OAAO,QAAQ,CAAC,IAAI,CAAA;AACtB,CAAC","sourcesContent":["import { RecordNotFoundError } from '@elytracms/core/persistence'\nimport type {\n AssetRepository,\n BlobStorageAdapter,\n ReferenceIndexRepository,\n} from '@elytracms/core/persistence'\n\n/**\n * Reference-respecting asset deletion (EC-151, reworked EC-202). Deleting an\n * asset that is referenced is blocked until explicitly confirmed; the usage\n * count comes from the **EC-155 reference index** (`references.inbound` for the\n * `{ type: 'asset' }` target) — index-accurate, replacing the interim string\n * scan (`find-asset-usages`), which could miss rich-text asset hrefs and\n * false-positive on unrelated strings. Deleting an unused asset removes bytes\n * (blob) — original AND the EC-400 thumb variant blob — plus the metadata\n * record. Results are structured data — never throws.\n */\n\nexport interface DeleteAssetDeps {\n assets: AssetRepository\n blobs: BlobStorageAdapter\n /** The EC-155 reference index — the accurate inbound-usage source (EC-202). */\n references: Pick<ReferenceIndexRepository, 'inbound'>\n}\n\nexport type DeleteAssetResult =\n | { outcome: 'deleted' }\n /** The asset is referenced and `confirmInUse` was not set: nothing was removed. */\n | { outcome: 'blocked'; usageCount: number }\n | { outcome: 'not-found' }\n | { outcome: 'error'; message: string }\n\nexport interface DeleteAssetOptions {\n /** Caller has seen the usage count and explicitly confirmed deletion. */\n confirmInUse?: boolean\n}\n\nexport async function deleteAsset(\n deps: DeleteAssetDeps,\n projectId: string,\n assetId: string,\n options: DeleteAssetOptions = {},\n): Promise<DeleteAssetResult> {\n try {\n const asset = await deps.assets.findAsset(projectId, assetId)\n if (!asset) return { outcome: 'not-found' }\n\n // Only consult the reference index when the caller has NOT already confirmed.\n // The delete dialog enumerates usages up-front and confirms with `confirmInUse`,\n // so a confirmed delete skips this redundant inbound query entirely (EC-301 perf).\n if (options.confirmInUse !== true) {\n const usageCount = await assetUsageCount(deps, projectId, assetId)\n if (usageCount > 0) return { outcome: 'blocked', usageCount }\n }\n\n // Bytes first, metadata last: a failure midway leaves a visible metadata\n // record (re-deletable) rather than invisible orphaned bytes.\n await deps.blobs.delete(asset.storageKey)\n // EC-400 (AD-4): the downscaled preview is a SECOND stored blob — delete it\n // too. Best-effort: a leftover thumb blob is harmless wasted storage (its\n // metadata is going), so a thumb-delete failure must not block removing the\n // asset the way an orphaned original would.\n if (asset.thumb) {\n try {\n await deps.blobs.delete(asset.thumb.storageKey)\n } catch {\n // Swallow — the asset delete proceeds; the orphaned thumb blob is inert.\n }\n }\n await deps.assets.removeAsset(projectId, assetId)\n return { outcome: 'deleted' }\n } catch (error) {\n if (error instanceof RecordNotFoundError) return { outcome: 'not-found' }\n return { outcome: 'error', message: error instanceof Error ? error.message : String(error) }\n }\n}\n\n/**\n * Distinct inbound usages of an asset from the reference index (EC-202). One\n * `source + path` (e.g. `post:hello · values/cover`) can carry both a draft and\n * a published index entry — that is a single usage, so variants collapse before\n * counting (the gate is \"is this referenced\", not \"how many index rows\").\n */\nasync function assetUsageCount(\n deps: DeleteAssetDeps,\n projectId: string,\n assetId: string,\n): Promise<number> {\n const inbound = await deps.references.inbound(projectId, { type: 'asset', id: assetId })\n const distinct = new Set(\n inbound.map((entry) => `${entry.source.type}:${entry.source.id}:${entry.path}`),\n )\n return distinct.size\n}\n"]}
@@ -5,5 +5,7 @@
5
5
  */
6
6
  export * from './upload-state.js';
7
7
  export * from './image-dimensions.js';
8
+ export * from './thumbnail.js';
9
+ export * from './backfill-thumbs.js';
8
10
  export * from './upload.js';
9
11
  export * from './delete-asset.js';
@@ -5,6 +5,8 @@
5
5
  */
6
6
  export * from './upload-state.js';
7
7
  export * from './image-dimensions.js';
8
+ export * from './thumbnail.js';
9
+ export * from './backfill-thumbs.js';
8
10
  export * from './upload.js';
9
11
  export * from './delete-asset.js';
10
12
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/assets/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA","sourcesContent":["/**\n * Asset upload, deletion, and usage logic (EC-151). Pure/injectable modules:\n * the studio UI consumes these; storage is reached only through the\n * `BlobStorageAdapter` boundary.\n */\nexport * from './upload-state'\nexport * from './image-dimensions'\nexport * from './upload'\nexport * from './delete-asset'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/assets/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,mBAAmB,CAAA;AACjC,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA","sourcesContent":["/**\n * Asset upload, deletion, and usage logic (EC-151). Pure/injectable modules:\n * the studio UI consumes these; storage is reached only through the\n * `BlobStorageAdapter` boundary.\n */\nexport * from './upload-state'\nexport * from './image-dimensions'\nexport * from './thumbnail'\nexport * from './backfill-thumbs'\nexport * from './upload'\nexport * from './delete-asset'\n"]}
@@ -0,0 +1,145 @@
1
+ import type { AssetThumbVariant, BlobStorageAdapter } from '@elytracms/core/persistence';
2
+ /**
3
+ * Client-side preview generation (EC-400 thumb + EC-420 LQIP). Every studio image
4
+ * surface used to stream the full-size Convex serve URL in a plain `<img>`, so a
5
+ * page of big originals cost heavy egress. At the moment an upload already has the
6
+ * bytes in hand — and again from the assets rail's "Generate previews" backfill —
7
+ * ONE `createImageBitmap` decode yields BOTH variants:
8
+ *
9
+ * - **thumb** (EC-400): a ≤480px-long-edge re-encode stored as a SECOND blob;
10
+ * editorial-only (studio grids/pickers/table cells render it instead of the
11
+ * original). NEVER leaves the studio.
12
+ * - **lqip** (EC-420): a ≤24px, low-quality re-encode base64'd into a tiny `data:`
13
+ * URI (hard-capped at {@link ASSET_LQIP_MAX_LENGTH} chars) and stored INLINE on
14
+ * the record. Unlike the thumb it rides the delivery wire (AD-2): the
15
+ * `@elytracms/next` image seam feeds it to `next/image` as `placeholder="blur"`.
16
+ *
17
+ * The decisions (`planThumb` / `planLqip`) are pure, node-testable cores; the
18
+ * canvas re-encode (`canvasVariantEncoder`) is a thin, injectable DOM shell that
19
+ * decodes once and draws each requested target; the orchestrator
20
+ * (`generateVariants`) plans → encodes (one decode) → stores the thumb blob +
21
+ * builds the lqip data URI, and is testable in node with a fake encoder over an
22
+ * in-memory blob store.
23
+ *
24
+ * Ratified spec: thumb ≤480px, quality ~0.8 (EC-400 AD-2). lqip ≤24px, quality
25
+ * ~0.45 (EC-420 AD-1); UNLIKE the thumb it is generated even for images already
26
+ * ≤480px (any raster benefits from blur-up). Both prefer `image/webp` with an
27
+ * `image/jpeg` fallback (the ACTUAL returned blob type is recorded — Safari has
28
+ * historically ignored the requested type). Skips are silent and valid — not
29
+ * validation issues; a generation failure never fails the upload.
30
+ */
31
+ /** The ratified maximum thumbnail long-edge, in pixels (EC-400 AD-2). */
32
+ export declare const THUMB_MAX_EDGE = 480;
33
+ /** The ratified re-encode quality for the lossy thumbnail codecs (EC-400 AD-2). */
34
+ export declare const THUMB_QUALITY = 0.8;
35
+ /** The ratified maximum LQIP long-edge, in pixels (EC-420 AD-1). */
36
+ export declare const LQIP_MAX_EDGE = 24;
37
+ /** The ratified re-encode quality for the LQIP placeholder (EC-420 AD-1). */
38
+ export declare const LQIP_QUALITY = 0.45;
39
+ /** The re-encode target size a variant needs, or the reason it is skipped. */
40
+ export type ThumbPlan = {
41
+ kind: 'skip';
42
+ reason: 'non-image' | 'vector' | 'no-dimensions' | 'already-small';
43
+ } | {
44
+ kind: 'encode';
45
+ width: number;
46
+ height: number;
47
+ };
48
+ /** The LQIP plan — like {@link ThumbPlan} but WITHOUT the `already-small` skip (AD-3). */
49
+ export type LqipPlan = {
50
+ kind: 'skip';
51
+ reason: 'non-image' | 'vector' | 'no-dimensions';
52
+ } | {
53
+ kind: 'encode';
54
+ width: number;
55
+ height: number;
56
+ };
57
+ /** The metadata `planThumb` / `planLqip` and the encoders reason over. */
58
+ export interface ThumbSource {
59
+ contentType: string;
60
+ width?: number;
61
+ height?: number;
62
+ }
63
+ /**
64
+ * Decide whether an asset needs a downscaled thumb, and to what size (EC-400
65
+ * AD-2/AD-3). Pure and deterministic — the node-tested core of the skip rules.
66
+ * A returned `encode` plan preserves aspect ratio, fitting the long edge to
67
+ * {@link THUMB_MAX_EDGE}; the short edge rounds to at least one pixel.
68
+ */
69
+ export declare function planThumb(source: ThumbSource): ThumbPlan;
70
+ /**
71
+ * Decide whether an asset needs an LQIP blur placeholder, and to what size (EC-420
72
+ * AD-1/AD-3). Pure and deterministic. Mirrors {@link planThumb}'s skips (non-image,
73
+ * SVG, no dimensions) but — UNLIKE the thumb — has NO `already-small` skip: any
74
+ * raster benefits from delivery blur-up, so even a ≤480px image gets one (fit to
75
+ * {@link LQIP_MAX_EDGE}, never upscaled).
76
+ */
77
+ export declare function planLqip(source: ThumbSource): LqipPlan;
78
+ /** The bytes + ACTUAL mime type + dimensions an encoder produced for one variant. */
79
+ export interface EncodedVariant {
80
+ bytes: Uint8Array;
81
+ /** The blob's real type — NOT necessarily the requested one (AD-2 Safari note). */
82
+ contentType: string;
83
+ width: number;
84
+ height: number;
85
+ }
86
+ /** A re-encode target the encoder draws into (per-variant quality). */
87
+ export interface EncodeTarget {
88
+ width: number;
89
+ height: number;
90
+ quality: number;
91
+ }
92
+ /** Which variants to draw+encode from the single decode, at their target sizes. */
93
+ export interface VariantTargets {
94
+ thumb?: EncodeTarget;
95
+ lqip?: EncodeTarget;
96
+ }
97
+ /** The encoded output of one decode: whichever requested variants encoded. */
98
+ export interface EncodedVariants {
99
+ thumb?: EncodedVariant;
100
+ lqip?: EncodedVariant;
101
+ }
102
+ /**
103
+ * Turn source image bytes into downscaled preview bytes for BOTH variants from a
104
+ * single decode. Returns whichever variants encoded (an empty object when the
105
+ * source cannot be decoded/encoded) — never throws; the caller treats a missing
106
+ * variant as "no preview", never a failure. Injectable so the orchestrator + the
107
+ * upload/backfill flows unit-test in node without a browser.
108
+ */
109
+ export type VariantEncoder = (source: Blob, targets: VariantTargets) => Promise<EncodedVariants>;
110
+ /**
111
+ * The default browser encoder: ONE `createImageBitmap` decode → per-variant 2D
112
+ * canvas → `toBlob` (EC-420: the same decode yields thumb AND lqip). A thin DOM
113
+ * shell, deliberately untested in node (the orchestrator is tested with a fake
114
+ * encoder). Any failure resolves an empty object, so a preview never breaks an
115
+ * upload.
116
+ */
117
+ export declare const canvasVariantEncoder: VariantEncoder;
118
+ /** Blob storage + the (optionally injected) encoder the orchestrator needs. */
119
+ export interface VariantGenerationDeps {
120
+ blobs: BlobStorageAdapter;
121
+ /** Defaults to {@link canvasVariantEncoder}; tests inject a deterministic fake. */
122
+ encode?: VariantEncoder;
123
+ }
124
+ /** Which variants the orchestrator should PRODUCE (backfill fills only the missing ones). */
125
+ export interface VariantWant {
126
+ thumb?: boolean;
127
+ lqip?: boolean;
128
+ }
129
+ /** The stored/inlined variants a generation run produced (either may be absent). */
130
+ export interface GeneratedVariants {
131
+ /** The stored downscaled thumb (a second blob's key/url + dims), or absent. */
132
+ thumb?: AssetThumbVariant;
133
+ /** The LQIP blur-placeholder `data:` URI (≤ cap), or absent. */
134
+ lqip?: string;
135
+ }
136
+ /**
137
+ * Plan → encode (ONE decode) → store/inline BOTH variants (EC-400 + EC-420). The
138
+ * thumb rides a SECOND `store()` call (the same blob boundary the upload uses); the
139
+ * lqip is inlined as a capped base64 `data:` URI needing no blob store at all.
140
+ * `want` narrows which variants to produce — the per-variant backfill (AD-4) asks
141
+ * for only the missing one so an existing thumb/lqip is never regenerated. Returns
142
+ * whichever variants succeeded; any skip/failure simply omits that variant (never
143
+ * throws — a preview must never fail an upload).
144
+ */
145
+ export declare function generateVariants(deps: VariantGenerationDeps, source: Blob, meta: ThumbSource, want?: VariantWant): Promise<GeneratedVariants>;
@@ -0,0 +1,220 @@
1
+ import { ASSET_LQIP_MAX_LENGTH } from '@elytracms/core/persistence';
2
+ /**
3
+ * Client-side preview generation (EC-400 thumb + EC-420 LQIP). Every studio image
4
+ * surface used to stream the full-size Convex serve URL in a plain `<img>`, so a
5
+ * page of big originals cost heavy egress. At the moment an upload already has the
6
+ * bytes in hand — and again from the assets rail's "Generate previews" backfill —
7
+ * ONE `createImageBitmap` decode yields BOTH variants:
8
+ *
9
+ * - **thumb** (EC-400): a ≤480px-long-edge re-encode stored as a SECOND blob;
10
+ * editorial-only (studio grids/pickers/table cells render it instead of the
11
+ * original). NEVER leaves the studio.
12
+ * - **lqip** (EC-420): a ≤24px, low-quality re-encode base64'd into a tiny `data:`
13
+ * URI (hard-capped at {@link ASSET_LQIP_MAX_LENGTH} chars) and stored INLINE on
14
+ * the record. Unlike the thumb it rides the delivery wire (AD-2): the
15
+ * `@elytracms/next` image seam feeds it to `next/image` as `placeholder="blur"`.
16
+ *
17
+ * The decisions (`planThumb` / `planLqip`) are pure, node-testable cores; the
18
+ * canvas re-encode (`canvasVariantEncoder`) is a thin, injectable DOM shell that
19
+ * decodes once and draws each requested target; the orchestrator
20
+ * (`generateVariants`) plans → encodes (one decode) → stores the thumb blob +
21
+ * builds the lqip data URI, and is testable in node with a fake encoder over an
22
+ * in-memory blob store.
23
+ *
24
+ * Ratified spec: thumb ≤480px, quality ~0.8 (EC-400 AD-2). lqip ≤24px, quality
25
+ * ~0.45 (EC-420 AD-1); UNLIKE the thumb it is generated even for images already
26
+ * ≤480px (any raster benefits from blur-up). Both prefer `image/webp` with an
27
+ * `image/jpeg` fallback (the ACTUAL returned blob type is recorded — Safari has
28
+ * historically ignored the requested type). Skips are silent and valid — not
29
+ * validation issues; a generation failure never fails the upload.
30
+ */
31
+ /** The ratified maximum thumbnail long-edge, in pixels (EC-400 AD-2). */
32
+ export const THUMB_MAX_EDGE = 480;
33
+ /** The ratified re-encode quality for the lossy thumbnail codecs (EC-400 AD-2). */
34
+ export const THUMB_QUALITY = 0.8;
35
+ /** The ratified maximum LQIP long-edge, in pixels (EC-420 AD-1). */
36
+ export const LQIP_MAX_EDGE = 24;
37
+ /** The ratified re-encode quality for the LQIP placeholder (EC-420 AD-1). */
38
+ export const LQIP_QUALITY = 0.45;
39
+ /** Fit `width`×`height` so its long edge is ≤ `maxEdge` (never upscaling); short edge ≥ 1px. */
40
+ function fitLongEdge(width, height, maxEdge) {
41
+ const scale = Math.min(1, maxEdge / Math.max(width, height));
42
+ return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
43
+ }
44
+ /**
45
+ * Decide whether an asset needs a downscaled thumb, and to what size (EC-400
46
+ * AD-2/AD-3). Pure and deterministic — the node-tested core of the skip rules.
47
+ * A returned `encode` plan preserves aspect ratio, fitting the long edge to
48
+ * {@link THUMB_MAX_EDGE}; the short edge rounds to at least one pixel.
49
+ */
50
+ export function planThumb(source) {
51
+ if (!source.contentType.startsWith('image/'))
52
+ return { kind: 'skip', reason: 'non-image' };
53
+ // SVG is vector — rasterizing loses fidelity and saves nothing (AD-3).
54
+ if (source.contentType === 'image/svg+xml')
55
+ return { kind: 'skip', reason: 'vector' };
56
+ const { width, height } = source;
57
+ if (!width || !height || width <= 0 || height <= 0)
58
+ return { kind: 'skip', reason: 'no-dimensions' };
59
+ // Already small enough — a variant would be a no-op; consumers just use `url`.
60
+ if (Math.max(width, height) <= THUMB_MAX_EDGE)
61
+ return { kind: 'skip', reason: 'already-small' };
62
+ return { kind: 'encode', ...fitLongEdge(width, height, THUMB_MAX_EDGE) };
63
+ }
64
+ /**
65
+ * Decide whether an asset needs an LQIP blur placeholder, and to what size (EC-420
66
+ * AD-1/AD-3). Pure and deterministic. Mirrors {@link planThumb}'s skips (non-image,
67
+ * SVG, no dimensions) but — UNLIKE the thumb — has NO `already-small` skip: any
68
+ * raster benefits from delivery blur-up, so even a ≤480px image gets one (fit to
69
+ * {@link LQIP_MAX_EDGE}, never upscaled).
70
+ */
71
+ export function planLqip(source) {
72
+ if (!source.contentType.startsWith('image/'))
73
+ return { kind: 'skip', reason: 'non-image' };
74
+ if (source.contentType === 'image/svg+xml')
75
+ return { kind: 'skip', reason: 'vector' };
76
+ const { width, height } = source;
77
+ if (!width || !height || width <= 0 || height <= 0)
78
+ return { kind: 'skip', reason: 'no-dimensions' };
79
+ return { kind: 'encode', ...fitLongEdge(width, height, LQIP_MAX_EDGE) };
80
+ }
81
+ /** Promisify the callback-style `HTMLCanvasElement.toBlob`. */
82
+ function canvasToBlob(canvas, type, quality) {
83
+ return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), type, quality));
84
+ }
85
+ /**
86
+ * Draw an already-decoded bitmap into a target-sized 2D canvas and encode it.
87
+ * Prefers `image/webp`; when the browser silently substitutes another type
88
+ * (Safari), retries `image/jpeg` and records whatever type actually came back.
89
+ * Resolves `undefined` on any failure (a valid skip, never a throw).
90
+ */
91
+ async function encodeFromBitmap(bitmap, target) {
92
+ const canvas = document.createElement('canvas');
93
+ canvas.width = target.width;
94
+ canvas.height = target.height;
95
+ const ctx = canvas.getContext('2d');
96
+ if (!ctx)
97
+ return undefined;
98
+ ctx.drawImage(bitmap, 0, 0, target.width, target.height);
99
+ let blob = await canvasToBlob(canvas, 'image/webp', target.quality);
100
+ if (!blob || blob.type !== 'image/webp') {
101
+ blob = await canvasToBlob(canvas, 'image/jpeg', target.quality);
102
+ }
103
+ if (!blob)
104
+ return undefined;
105
+ const bytes = new Uint8Array(await blob.arrayBuffer());
106
+ // Zero-length re-encodes are worthless; discard them.
107
+ if (bytes.byteLength === 0)
108
+ return undefined;
109
+ return {
110
+ bytes,
111
+ // Record the ACTUAL type — never assume the requested one landed (AD-2).
112
+ contentType: blob.type || 'image/jpeg',
113
+ width: target.width,
114
+ height: target.height,
115
+ };
116
+ }
117
+ /**
118
+ * The default browser encoder: ONE `createImageBitmap` decode → per-variant 2D
119
+ * canvas → `toBlob` (EC-420: the same decode yields thumb AND lqip). A thin DOM
120
+ * shell, deliberately untested in node (the orchestrator is tested with a fake
121
+ * encoder). Any failure resolves an empty object, so a preview never breaks an
122
+ * upload.
123
+ */
124
+ export const canvasVariantEncoder = async (source, targets) => {
125
+ const out = {};
126
+ try {
127
+ if (typeof createImageBitmap !== 'function' || typeof document === 'undefined')
128
+ return out;
129
+ const bitmap = await createImageBitmap(source);
130
+ try {
131
+ if (targets.thumb) {
132
+ const encoded = await encodeFromBitmap(bitmap, targets.thumb);
133
+ if (encoded)
134
+ out.thumb = encoded;
135
+ }
136
+ if (targets.lqip) {
137
+ const encoded = await encodeFromBitmap(bitmap, targets.lqip);
138
+ if (encoded)
139
+ out.lqip = encoded;
140
+ }
141
+ }
142
+ finally {
143
+ bitmap.close?.();
144
+ }
145
+ }
146
+ catch {
147
+ // Undecodable — a silent, valid skip, never a thrown failure.
148
+ return out;
149
+ }
150
+ return out;
151
+ };
152
+ /** Base64-encode bytes for a `data:` URI. `undefined` when no encoder is available. */
153
+ function bytesToBase64(bytes) {
154
+ // `btoa` exists in browsers and Node ≥16; absence (exotic runtime) → no lqip.
155
+ if (typeof btoa !== 'function')
156
+ return undefined;
157
+ let binary = '';
158
+ const chunk = 0x8000;
159
+ for (let i = 0; i < bytes.length; i += chunk) {
160
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
161
+ }
162
+ return btoa(binary);
163
+ }
164
+ /** Build a `data:<type>;base64,<payload>` URI from encoded bytes; `undefined` on failure. */
165
+ function toDataUri(encoded) {
166
+ const b64 = bytesToBase64(encoded.bytes);
167
+ return b64 ? `data:${encoded.contentType};base64,${b64}` : undefined;
168
+ }
169
+ /**
170
+ * Plan → encode (ONE decode) → store/inline BOTH variants (EC-400 + EC-420). The
171
+ * thumb rides a SECOND `store()` call (the same blob boundary the upload uses); the
172
+ * lqip is inlined as a capped base64 `data:` URI needing no blob store at all.
173
+ * `want` narrows which variants to produce — the per-variant backfill (AD-4) asks
174
+ * for only the missing one so an existing thumb/lqip is never regenerated. Returns
175
+ * whichever variants succeeded; any skip/failure simply omits that variant (never
176
+ * throws — a preview must never fail an upload).
177
+ */
178
+ export async function generateVariants(deps, source, meta, want = { thumb: true, lqip: true }) {
179
+ const thumbPlan = want.thumb === false ? { kind: 'skip', reason: 'already-small' } : planThumb(meta);
180
+ const lqipPlan = want.lqip === false ? { kind: 'skip', reason: 'no-dimensions' } : planLqip(meta);
181
+ const targets = {};
182
+ if (thumbPlan.kind === 'encode') {
183
+ targets.thumb = { width: thumbPlan.width, height: thumbPlan.height, quality: THUMB_QUALITY };
184
+ }
185
+ if (lqipPlan.kind === 'encode') {
186
+ targets.lqip = { width: lqipPlan.width, height: lqipPlan.height, quality: LQIP_QUALITY };
187
+ }
188
+ if (!targets.thumb && !targets.lqip)
189
+ return {};
190
+ const encode = deps.encode ?? canvasVariantEncoder;
191
+ let encoded;
192
+ try {
193
+ encoded = await encode(source, targets);
194
+ }
195
+ catch {
196
+ return {};
197
+ }
198
+ const result = {};
199
+ // Thumb: a SECOND stored blob (needs a key-minting backend; a put-only backend
200
+ // simply gets no thumb — the lqip still ships).
201
+ if (encoded.thumb && deps.blobs.store) {
202
+ try {
203
+ const storageKey = await deps.blobs.store(encoded.thumb.bytes, encoded.thumb.contentType);
204
+ const url = (await deps.blobs.resolveUrl?.(storageKey)) ?? deps.blobs.url(storageKey);
205
+ result.thumb = { storageKey, url, width: encoded.thumb.width, height: encoded.thumb.height };
206
+ }
207
+ catch {
208
+ // No thumb — never fail the run.
209
+ }
210
+ }
211
+ // Lqip: inline data URI, no blob. Enforce the hard cap; over-cap → skip (AD-1)
212
+ // rather than silently persist an oversized value the schema would reject anyway.
213
+ if (encoded.lqip) {
214
+ const dataUri = toDataUri(encoded.lqip);
215
+ if (dataUri && dataUri.length <= ASSET_LQIP_MAX_LENGTH)
216
+ result.lqip = dataUri;
217
+ }
218
+ return result;
219
+ }
220
+ //# sourceMappingURL=thumbnail.js.map