@hraness/slopcamera 3.2.8 → 3.3.1

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 (150) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-cgpkyjqa.js +3 -0
  59. package/dist/index-f0ptv8dm.js +7 -0
  60. package/dist/index-grk47tgs.js +3 -0
  61. package/dist/index-qry58nj2.js +10 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/package.json +7 -5
  69. package/skills/slopcamera/SKILL.md +6 -1
  70. package/skills/slopcamera/references/directed-scenes.md +22 -2
  71. package/skills/slopcamera/references/gateway-media.md +15 -1
  72. package/skills/slopcamera/references/image-galleries.md +166 -0
  73. package/skills/slopcamera/references/install.md +1 -1
  74. package/skills/slopcamera/references/parametric-design.md +14 -0
  75. package/skills/slopcamera/references/scene-building.md +32 -0
  76. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  77. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  78. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  79. package/src/capability-manifest.ts +361 -0
  80. package/src/cli.ts +222 -4
  81. package/src/code/canonical-json.ts +9 -0
  82. package/src/generate.ts +2 -2
  83. package/src/icon.ts +1111 -0
  84. package/src/image-gallery.ts +1049 -0
  85. package/src/index.ts +11 -0
  86. package/src/mcp/boundary.ts +11 -11
  87. package/src/mcp/index.ts +3 -0
  88. package/src/mcp/server.ts +2 -1
  89. package/src/mcp/tools.ts +1642 -130
  90. package/src/mcp/types.ts +19 -0
  91. package/src/operations.ts +412 -0
  92. package/src/portable-capability-manifest.ts +67 -0
  93. package/src/scene-gallery.ts +184 -0
  94. package/src/spatial-scene/asset-admission.ts +183 -0
  95. package/src/spatial-scene/audit-rendered.ts +692 -0
  96. package/src/spatial-scene/audit.ts +617 -0
  97. package/src/spatial-scene/behavior-audit.ts +252 -0
  98. package/src/spatial-scene/behavior-bake.ts +233 -0
  99. package/src/spatial-scene/behavior-fns.ts +585 -0
  100. package/src/spatial-scene/behavior-gallery.ts +109 -0
  101. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  102. package/src/spatial-scene/behavior-trace.ts +325 -0
  103. package/src/spatial-scene/behavior.ts +618 -0
  104. package/src/spatial-scene/build.ts +632 -0
  105. package/src/spatial-scene/camera-rig.ts +125 -0
  106. package/src/spatial-scene/camera-track.ts +4 -2
  107. package/src/spatial-scene/character.ts +124 -0
  108. package/src/spatial-scene/contracts.ts +75 -7
  109. package/src/spatial-scene/design-templates.ts +351 -0
  110. package/src/spatial-scene/design.ts +327 -0
  111. package/src/spatial-scene/direction-compile.ts +584 -0
  112. package/src/spatial-scene/direction.ts +122 -0
  113. package/src/spatial-scene/effects.ts +149 -0
  114. package/src/spatial-scene/evaluate.ts +109 -29
  115. package/src/spatial-scene/gallery.ts +277 -0
  116. package/src/spatial-scene/generate.ts +239 -0
  117. package/src/spatial-scene/geometry-native.ts +165 -0
  118. package/src/spatial-scene/geometry.ts +1216 -0
  119. package/src/spatial-scene/gltf.ts +600 -77
  120. package/src/spatial-scene/identity.ts +55 -2
  121. package/src/spatial-scene/index.ts +36 -0
  122. package/src/spatial-scene/inspect.ts +35 -6
  123. package/src/spatial-scene/material-lighting.ts +609 -0
  124. package/src/spatial-scene/math.ts +21 -4
  125. package/src/spatial-scene/motion-evidence.ts +88 -0
  126. package/src/spatial-scene/parametric.ts +765 -0
  127. package/src/spatial-scene/particle-preparation.ts +183 -0
  128. package/src/spatial-scene/particle.ts +195 -0
  129. package/src/spatial-scene/patch.ts +109 -15
  130. package/src/spatial-scene/performance.ts +1613 -0
  131. package/src/spatial-scene/probe.ts +137 -0
  132. package/src/spatial-scene/recipe-pack.ts +86 -0
  133. package/src/spatial-scene/render-effects.ts +240 -0
  134. package/src/spatial-scene/review.ts +639 -0
  135. package/src/spatial-scene/simulation-bake.ts +206 -0
  136. package/src/spatial-scene/simulation.ts +187 -0
  137. package/src/spatial-scene/solve.ts +366 -0
  138. package/src/spatial-scene/temporal-audit.ts +354 -0
  139. package/src/version.ts +1 -1
  140. package/dist/index-42zsesc1.js +0 -2257
  141. package/dist/index-7131pg1c.js +0 -1820
  142. package/dist/index-7308egqr.js +0 -48
  143. package/dist/index-8txs6fkn.js +0 -712
  144. package/dist/index-fava6pge.js +0 -192
  145. package/dist/index-h1k0fnjq.js +0 -2363
  146. package/dist/index-r7gdhmsp.js +0 -424
  147. package/dist/index-sh6xbav6.js +0 -1219
  148. package/dist/index-z1w83f81.js +0 -4
  149. package/dist/index-zfnddgay.js +0 -2029
  150. package/dist/skill-install-dh0ntqba.js +0 -10
@@ -0,0 +1,1049 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { link, mkdir, rm, writeFile } from "node:fs/promises"
3
+ import { dirname, join, resolve } from "node:path"
4
+ import sharp from "sharp"
5
+ import { SlopcameraCloudError } from "./cloud-errors.js"
6
+ import type { SpatialSceneDiffEntry } from "./spatial-scene/patch.js"
7
+ import {
8
+ generateSlopcameraImage,
9
+ slopcameraImageModels,
10
+ slopcameraMaximumPromptBytes,
11
+ type GeneratedSlopcameraImage,
12
+ type SlopcameraGenerateDependencies,
13
+ } from "./generate.js"
14
+
15
+ /**
16
+ * Parallel visual-candidate galleries for agent review.
17
+ *
18
+ * One bounded batch produces several independent candidate rasters — texture
19
+ * maps, skyboxes, backdrops, sprites, or plain design images — and composes
20
+ * them into a single labelled contact sheet. Each cell carries a stable
21
+ * candidate id so an agent can inspect the sheet once and then promote a
22
+ * chosen candidate file into a scene asset or design slot explicitly. The
23
+ * gallery never edits authored source: generation and selection stay
24
+ * separate, and every candidate keeps its exact prompt, request id, media
25
+ * type, and digest in the receipt.
26
+ */
27
+
28
+ export const slopcameraGalleryKinds = Object.freeze([
29
+ "image",
30
+ "texture",
31
+ "skybox",
32
+ "backdrop",
33
+ "sprite",
34
+ ] as const)
35
+ export type SlopcameraGalleryKind = (typeof slopcameraGalleryKinds)[number]
36
+
37
+ export const slopcameraGalleryAxes = Object.freeze([
38
+ "style",
39
+ "palette",
40
+ "material",
41
+ "lighting",
42
+ "mood",
43
+ "detail",
44
+ ] as const)
45
+ export type SlopcameraGalleryAxis = (typeof slopcameraGalleryAxes)[number]
46
+
47
+ export const slopcameraGalleryLimits = Object.freeze({
48
+ candidates: 16,
49
+ concurrency: 4,
50
+ subjectBytes: 2_048,
51
+ candidatePromptBytes: slopcameraMaximumPromptBytes,
52
+ variantBytes: 1_024,
53
+ idLength: 64,
54
+ cellEdgeMin: 64,
55
+ cellEdgeMax: 1_024,
56
+ cellEdgeDefault: 512,
57
+ labelHeight: 28,
58
+ cellGap: 8,
59
+ galleryPixels: 33_554_432,
60
+ candidatePixels: 33_554_432,
61
+ candidateEdge: 8_192,
62
+ })
63
+
64
+ interface GalleryKindContract {
65
+ readonly aspect: readonly [number, number]
66
+ readonly frame: (subject: string) => string
67
+ readonly rules: string
68
+ }
69
+
70
+ const GALLERY_KIND_CONTRACTS: Record<SlopcameraGalleryKind, GalleryKindContract> = {
71
+ image: {
72
+ aspect: [1, 1],
73
+ frame: subject => subject,
74
+ rules:
75
+ "Clean readable composition; no text, captions, borders, or watermark.",
76
+ },
77
+ texture: {
78
+ aspect: [1, 1],
79
+ frame: subject => `Seamless tileable texture of ${subject}.`,
80
+ rules:
81
+ "Flat orthographic top-down material surface, uniform diffuse lighting, " +
82
+ "edge-to-edge surface detail that tiles in both directions; no shadows, " +
83
+ "no specular highlights, no vignette, no borders, no objects, no text.",
84
+ },
85
+ skybox: {
86
+ aspect: [2, 1],
87
+ frame: subject => `Seamless equirectangular 360-degree panorama of ${subject}.`,
88
+ rules:
89
+ "Full spherical environment in 2:1 equirectangular projection, continuous " +
90
+ "horizon exactly at the vertical center, left and right edges wrapping " +
91
+ "seamlessly; no seams, no text, no watermark, no foreground frame.",
92
+ },
93
+ backdrop: {
94
+ aspect: [16, 9],
95
+ frame: subject => `Flat scenic backdrop plate of ${subject}.`,
96
+ rules:
97
+ "Edge-to-edge environment plate for compositing behind foreground " +
98
+ "subjects, consistent perspective and lighting across the frame; no " +
99
+ "borders, no text, no watermark.",
100
+ },
101
+ sprite: {
102
+ aspect: [1, 1],
103
+ frame: subject => `A single ${subject} on a plain solid neutral background.`,
104
+ rules:
105
+ "One centered subject, fully visible with a clean silhouette, flat even " +
106
+ "lighting; no shadows, no extra objects, no text, no watermark.",
107
+ },
108
+ }
109
+
110
+ const GALLERY_AXIS_DEFAULTS: Record<SlopcameraGalleryAxis, readonly string[]> = {
111
+ style: ["photorealistic", "stylized painterly", "flat minimal", "hand-drawn"],
112
+ palette: ["warm earthy palette", "cool desaturated palette", "neutral grayscale palette", "vivid saturated palette"],
113
+ material: ["rough weathered surface", "smooth polished surface", "organic natural surface", "synthetic industrial surface"],
114
+ lighting: ["soft diffuse lighting", "dramatic directional lighting", "flat ambient lighting", "golden-hour lighting"],
115
+ mood: ["serene calm mood", "dramatic mood", "playful mood", "mysterious mood"],
116
+ detail: ["minimal sparse detail", "balanced detail", "intricate dense detail"],
117
+ }
118
+
119
+ const SKYBOX_AXIS_DEFAULTS: Partial<Record<SlopcameraGalleryAxis, readonly string[]>> = {
120
+ material: ["clear air", "light haze", "thin clouds", "dense dramatic clouds"],
121
+ lighting: ["golden hour", "overcast midday", "clear night sky", "midday sun"],
122
+ }
123
+
124
+ function axisValues(kind: SlopcameraGalleryKind, axis: SlopcameraGalleryAxis): readonly string[] {
125
+ return kind === "skybox"
126
+ ? SKYBOX_AXIS_DEFAULTS[axis] ?? GALLERY_AXIS_DEFAULTS[axis]
127
+ : GALLERY_AXIS_DEFAULTS[axis]
128
+ }
129
+
130
+ function invalidArgument(message: string): never {
131
+ throw new SlopcameraCloudError("INVALID_ARGUMENT", message)
132
+ }
133
+
134
+ function isRecord(value: unknown): value is Record<string, unknown> {
135
+ return typeof value === "object" && value !== null && !Array.isArray(value)
136
+ }
137
+
138
+ function boundedText(value: unknown, name: string, maximumBytes: number): string {
139
+ if (
140
+ typeof value !== "string" ||
141
+ value.trim().length === 0 ||
142
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value) ||
143
+ Buffer.byteLength(value, "utf8") > maximumBytes
144
+ ) {
145
+ invalidArgument(`${name} must be non-empty text no more than ${maximumBytes} UTF-8 bytes.`)
146
+ }
147
+ return value.trim()
148
+ }
149
+
150
+ export function galleryCandidateId(value: unknown): string {
151
+ const id = boundedText(value, "Candidate id", slopcameraGalleryLimits.idLength)
152
+ if (!/^[a-z0-9][a-z0-9._-]*$/iu.test(id)) {
153
+ invalidArgument("Candidate id must start with an alphanumeric and contain only alphanumerics, '.', '_', or '-'.")
154
+ }
155
+ return id
156
+ }
157
+
158
+ function slug(value: string): string {
159
+ const slugged = value
160
+ .toLowerCase()
161
+ .split(/[^a-z0-9]+/u)
162
+ .filter(part => part.length > 0)
163
+ .join("-")
164
+ .slice(0, slopcameraGalleryLimits.idLength)
165
+ return slugged.length === 0 ? "candidate" : slugged
166
+ }
167
+
168
+ export interface SlopcameraGalleryAxisSpec {
169
+ readonly axis: SlopcameraGalleryAxis
170
+ readonly values?: readonly string[]
171
+ }
172
+
173
+ export interface SlopcameraGalleryExplicitCandidate {
174
+ readonly id: string
175
+ readonly prompt?: string
176
+ readonly variant?: string
177
+ }
178
+
179
+ export interface SlopcameraGalleryPlanInput {
180
+ readonly subject: string
181
+ readonly kind?: SlopcameraGalleryKind
182
+ readonly count?: number
183
+ readonly vary?: readonly (SlopcameraGalleryAxisSpec | SlopcameraGalleryAxis | unknown)[]
184
+ readonly candidates?: readonly (SlopcameraGalleryExplicitCandidate | unknown)[]
185
+ readonly tiled?: boolean
186
+ }
187
+
188
+ export interface SlopcameraGalleryCandidate {
189
+ readonly index: number
190
+ readonly id: string
191
+ readonly prompt: string
192
+ readonly label: string
193
+ }
194
+
195
+ export interface SlopcameraGalleryPlan {
196
+ readonly subject: string
197
+ readonly kind: SlopcameraGalleryKind
198
+ readonly aspect: readonly [number, number]
199
+ readonly axes: readonly { readonly axis: SlopcameraGalleryAxis; readonly values: readonly string[] }[]
200
+ readonly candidates: readonly SlopcameraGalleryCandidate[]
201
+ readonly tiled: boolean
202
+ }
203
+
204
+ function galleryKind(value: unknown): SlopcameraGalleryKind {
205
+ const kind = value ?? "image"
206
+ if (typeof kind !== "string" || !slopcameraGalleryKinds.includes(kind as SlopcameraGalleryKind)) {
207
+ invalidArgument(`kind must be one of: ${slopcameraGalleryKinds.join(", ")}.`)
208
+ }
209
+ return kind as SlopcameraGalleryKind
210
+ }
211
+
212
+ /**
213
+ * Build the style-locked prompt for one candidate. An explicit candidate
214
+ * `prompt` replaces the template verbatim; a `variant` phrase is appended as
215
+ * the variation direction inside the same contract.
216
+ */
217
+ export function galleryPromptFor(
218
+ subject: string,
219
+ kind: SlopcameraGalleryKind,
220
+ variant?: string,
221
+ ): string {
222
+ const contract = GALLERY_KIND_CONTRACTS[kind]
223
+ const sections = [contract.frame(subject), `Rules: ${contract.rules}`]
224
+ const direction = variant?.trim()
225
+ if (direction !== undefined && direction.length > 0) {
226
+ sections.push(`Variation direction: ${direction}.`)
227
+ }
228
+ return sections.join("\n\n")
229
+ }
230
+
231
+ function axisSpec(value: unknown): SlopcameraGalleryAxisSpec {
232
+ if (typeof value === "string") {
233
+ const axis = value.trim()
234
+ if (!slopcameraGalleryAxes.includes(axis as SlopcameraGalleryAxis)) {
235
+ invalidArgument(`vary axis must be one of: ${slopcameraGalleryAxes.join(", ")}.`)
236
+ }
237
+ return { axis: axis as SlopcameraGalleryAxis }
238
+ }
239
+ if (!isRecord(value)) invalidArgument("vary entries must be axis names or {axis, values} objects.")
240
+ const axis = value.axis
241
+ if (!slopcameraGalleryAxes.includes(axis as SlopcameraGalleryAxis)) {
242
+ invalidArgument(`vary axis must be one of: ${slopcameraGalleryAxes.join(", ")}.`)
243
+ }
244
+ const values = value.values
245
+ if (values !== undefined) {
246
+ if (!Array.isArray(values) || values.length < 1 || values.length > slopcameraGalleryLimits.candidates) {
247
+ invalidArgument("vary values must be a bounded non-empty list.")
248
+ }
249
+ return {
250
+ axis: axis as SlopcameraGalleryAxis,
251
+ values: values.map(item => boundedText(item, "vary value", slopcameraGalleryLimits.variantBytes)),
252
+ }
253
+ }
254
+ return { axis: axis as SlopcameraGalleryAxis }
255
+ }
256
+
257
+ function explicitCandidate(value: unknown): SlopcameraGalleryExplicitCandidate {
258
+ if (!isRecord(value)) invalidArgument("candidates entries must be {id, prompt|variant} objects.")
259
+ const id = galleryCandidateId(value.id)
260
+ const prompt = value.prompt
261
+ const variant = value.variant
262
+ if (prompt !== undefined && variant !== undefined) {
263
+ invalidArgument("A candidate supplies prompt or variant, not both.")
264
+ }
265
+ if (prompt === undefined && variant === undefined) {
266
+ invalidArgument("A candidate requires a prompt or a variant.")
267
+ }
268
+ return {
269
+ id,
270
+ ...(prompt === undefined ? {} : { prompt: boundedText(prompt, "candidate prompt", slopcameraGalleryLimits.candidatePromptBytes) }),
271
+ ...(variant === undefined ? {} : { variant: boundedText(variant, "candidate variant", slopcameraGalleryLimits.variantBytes) }),
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Resolve the bounded deterministic candidate list. Automatic `--vary` axes
277
+ * take a cartesian product of their values (later axes vary fastest); an
278
+ * explicit candidate list supplies ids and prompt/variant pairs directly.
279
+ */
280
+ export function planSlopcameraGallery(input: SlopcameraGalleryPlanInput): SlopcameraGalleryPlan {
281
+ if (!isRecord(input)) invalidArgument("Gallery input must be an object.")
282
+ const subject = boundedText(input.subject, "subject", slopcameraGalleryLimits.subjectBytes)
283
+ const kind = galleryKind(input.kind)
284
+ const aspect = GALLERY_KIND_CONTRACTS[kind].aspect
285
+ const vary = (input.vary ?? []).map(axisSpec)
286
+ const explicit = input.candidates?.map(explicitCandidate)
287
+ if (explicit !== undefined && vary.length > 0) {
288
+ invalidArgument("candidates and vary are mutually exclusive.")
289
+ }
290
+ if (explicit !== undefined && input.count !== undefined) {
291
+ invalidArgument("candidates and count are mutually exclusive.")
292
+ }
293
+ if (vary.length > slopcameraGalleryAxes.length || new Set(vary.map(spec => spec.axis)).size !== vary.length) {
294
+ invalidArgument("vary axes must be unique.")
295
+ }
296
+ const count = input.count
297
+ if (count !== undefined && (!Number.isInteger(count) || count < 1 || count > slopcameraGalleryLimits.candidates)) {
298
+ invalidArgument(`count must be an integer from 1 through ${slopcameraGalleryLimits.candidates}.`)
299
+ }
300
+ const tiled = input.tiled
301
+ if (tiled !== undefined && typeof tiled !== "boolean") {
302
+ invalidArgument("tiled must be a boolean when set.")
303
+ }
304
+
305
+ const axes = vary.map(spec => ({
306
+ axis: spec.axis,
307
+ values: spec.values ?? axisValues(kind, spec.axis).slice(0, slopcameraGalleryLimits.candidates),
308
+ }))
309
+ const prompts: { id: string; prompt: string; label: string }[] = []
310
+ if (explicit !== undefined) {
311
+ if (explicit.length < 1 || explicit.length > slopcameraGalleryLimits.candidates) {
312
+ invalidArgument(`candidates must contain 1 through ${slopcameraGalleryLimits.candidates} entries.`)
313
+ }
314
+ for (const candidate of explicit) {
315
+ prompts.push({
316
+ id: candidate.id,
317
+ prompt: candidate.prompt ?? galleryPromptFor(subject, kind, candidate.variant),
318
+ label: candidate.id,
319
+ })
320
+ }
321
+ } else if (axes.length > 0) {
322
+ const total = axes.reduce((product, spec) => product * spec.values.length, 1)
323
+ if (total < 1 || total > slopcameraGalleryLimits.candidates) {
324
+ invalidArgument(`vary expands to ${String(total)} candidates; the bound is ${slopcameraGalleryLimits.candidates}.`)
325
+ }
326
+ const seen = new Set<string>()
327
+ for (let combination = 0; combination < total; combination++) {
328
+ let remainder = combination
329
+ const parts: string[] = []
330
+ for (let index = axes.length - 1; index >= 0; index--) {
331
+ const spec = axes[index]!
332
+ const value = spec.values[remainder % spec.values.length]!
333
+ remainder = Math.floor(remainder / spec.values.length)
334
+ parts.unshift(`${spec.axis}: ${value}`)
335
+ }
336
+ const direction = parts.join("; ")
337
+ let id = slug(parts.map(part => part.split(": ")[1]!).join("-"))
338
+ if (seen.has(id)) id = `${id}-${String(combination + 1)}`
339
+ seen.add(id)
340
+ prompts.push({ id, prompt: galleryPromptFor(subject, kind, direction), label: id })
341
+ }
342
+ } else {
343
+ const requested = count ?? 4
344
+ for (let index = 0; index < requested; index++) {
345
+ const id = `candidate-${String(index + 1)}`
346
+ prompts.push({ id, prompt: galleryPromptFor(subject, kind), label: id })
347
+ }
348
+ }
349
+ return {
350
+ subject,
351
+ kind,
352
+ aspect,
353
+ axes,
354
+ candidates: prompts.map((candidate, index) => ({ index: index + 1, ...candidate })),
355
+ tiled: tiled ?? kind === "texture",
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Parse the shared `--vary` CLI grammar: `axis[=v1,v2][;axis2...]`. Both the
361
+ * portable and desktop command surfaces accept the same spelling.
362
+ */
363
+ export function parseSlopcameraGalleryVary(
364
+ value: string,
365
+ ): SlopcameraGalleryAxisSpec[] {
366
+ const specs: SlopcameraGalleryAxisSpec[] = []
367
+ for (const part of value.split(";")) {
368
+ const trimmed = part.trim()
369
+ if (trimmed === "") continue
370
+ const [axis, values] = trimmed.split("=", 2)
371
+ if (!slopcameraGalleryAxes.includes(axis!.trim() as SlopcameraGalleryAxis)) {
372
+ invalidArgument(`vary axis must be one of: ${slopcameraGalleryAxes.join(", ")}.`)
373
+ }
374
+ if (values === undefined) {
375
+ specs.push({ axis: axis!.trim() as SlopcameraGalleryAxis })
376
+ continue
377
+ }
378
+ const list = values
379
+ .split(",")
380
+ .map((item) => item.trim())
381
+ .filter((item) => item.length > 0)
382
+ if (list.length === 0 || list.length > slopcameraGalleryLimits.candidates) {
383
+ invalidArgument("vary values must be a non-empty bounded comma-separated list.")
384
+ }
385
+ specs.push({ axis: axis!.trim() as SlopcameraGalleryAxis, values: list })
386
+ }
387
+ if (specs.length === 0) {
388
+ invalidArgument("vary requires at least one axis, e.g. 'style; palette=warm,cool'.")
389
+ }
390
+ return specs
391
+ }
392
+
393
+ /* Deterministic 5×7 bitmap label font. Gallery labels must render identically
394
+ * on every host, so text is drawn pixel-by-pixel rather than through a
395
+ * platform font stack. */
396
+ const FONT_5X7: Readonly<Record<string, readonly string[]>> = {
397
+ " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
398
+ "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
399
+ "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
400
+ "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
401
+ "3": ["11111", "00010", "00100", "00010", "00001", "10001", "01110"],
402
+ "4": ["00010", "00110", "01010", "11111", "00010", "00010", "00010"],
403
+ "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
404
+ "6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
405
+ "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
406
+ "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
407
+ "9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
408
+ "a": ["00000", "00000", "01110", "00001", "01111", "10001", "01111"],
409
+ "b": ["10000", "10000", "10110", "11001", "10001", "10001", "11110"],
410
+ "c": ["00000", "00000", "01110", "10000", "10000", "10001", "01110"],
411
+ "d": ["00001", "00001", "01101", "10011", "10001", "10001", "01111"],
412
+ "e": ["00000", "00000", "01110", "10001", "11111", "10000", "01110"],
413
+ "f": ["00110", "01001", "01000", "11100", "01000", "01000", "01000"],
414
+ "g": ["00000", "01111", "10001", "10001", "01111", "00001", "01110"],
415
+ "h": ["10000", "10000", "10110", "11001", "10001", "10001", "10001"],
416
+ "i": ["00100", "00000", "01100", "00100", "00100", "00100", "01110"],
417
+ "j": ["00010", "00000", "00110", "00010", "00010", "10010", "01100"],
418
+ "k": ["10000", "10000", "10010", "10100", "11000", "10100", "10010"],
419
+ "l": ["01100", "00100", "00100", "00100", "00100", "00100", "01110"],
420
+ "m": ["00000", "00000", "11010", "10101", "10101", "10101", "10101"],
421
+ "n": ["00000", "00000", "10110", "11001", "10001", "10001", "10001"],
422
+ "o": ["00000", "00000", "01110", "10001", "10001", "10001", "01110"],
423
+ "p": ["00000", "00000", "11110", "10001", "11110", "10000", "10000"],
424
+ "q": ["00000", "00000", "01101", "10011", "01111", "00001", "00001"],
425
+ "r": ["00000", "00000", "10110", "11001", "10000", "10000", "10000"],
426
+ "s": ["00000", "00000", "01111", "10000", "01110", "00001", "11110"],
427
+ "t": ["01000", "01000", "11100", "01000", "01000", "01001", "00110"],
428
+ "u": ["00000", "00000", "10001", "10001", "10001", "10011", "01101"],
429
+ "v": ["00000", "00000", "10001", "10001", "10001", "01010", "00100"],
430
+ "w": ["00000", "00000", "10001", "10101", "10101", "10101", "01010"],
431
+ "x": ["00000", "00000", "10001", "01010", "00100", "01010", "10001"],
432
+ "y": ["00000", "00000", "10001", "10001", "01111", "00001", "01110"],
433
+ "z": ["00000", "00000", "11111", "00010", "00100", "01000", "11111"],
434
+ "#": ["01010", "01010", "11111", "01010", "11111", "01010", "01010"],
435
+ "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
436
+ "_": ["00000", "00000", "00000", "00000", "00000", "00000", "11111"],
437
+ ".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100"],
438
+ ":": ["00000", "01100", "01100", "00000", "01100", "01100", "00000"],
439
+ "=": ["00000", "00000", "11111", "00000", "11111", "00000", "00000"],
440
+ "/": ["00001", "00010", "00010", "00100", "01000", "01000", "10000"],
441
+ "?": ["01110", "10001", "00001", "00010", "00100", "00000", "00100"],
442
+ }
443
+
444
+ function labelPixels(text: string, width: number, height: number): Buffer {
445
+ const scale = 2
446
+ const rgba = Buffer.alloc(width * height * 4)
447
+ for (let pixel = 0; pixel < width * height; pixel++) {
448
+ const index = pixel * 4
449
+ rgba[index] = 18
450
+ rgba[index + 1] = 21
451
+ rgba[index + 2] = 27
452
+ rgba[index + 3] = 255
453
+ }
454
+ const draw = (character: string, originX: number): void => {
455
+ const glyph = FONT_5X7[character] ?? FONT_5X7["?"]!
456
+ const originY = Math.floor((height - 7 * scale) / 2)
457
+ for (let row = 0; row < 7; row++) {
458
+ for (let column = 0; column < 5; column++) {
459
+ if (glyph[row]![column] !== "1") continue
460
+ for (let dy = 0; dy < scale; dy++) {
461
+ for (let dx = 0; dx < scale; dx++) {
462
+ const x = originX + column * scale + dx
463
+ const y = originY + row * scale + dy
464
+ if (x < 0 || x >= width || y < 0 || y >= height) continue
465
+ const index = (y * width + x) * 4
466
+ rgba[index] = 235
467
+ rgba[index + 1] = 238
468
+ rgba[index + 2] = 244
469
+ rgba[index + 3] = 255
470
+ }
471
+ }
472
+ }
473
+ }
474
+ }
475
+ const advance = 6 * scale
476
+ const maximum = Math.floor((width - 8) / advance)
477
+ const sanitized = text.toLowerCase().replace(/[^a-z0-9 #._:=/?-]/gu, "?").slice(0, Math.max(0, maximum))
478
+ for (let index = 0; index < sanitized.length; index++) {
479
+ draw(sanitized[index]!, 4 + index * advance)
480
+ }
481
+ return rgba
482
+ }
483
+
484
+ function mediaExtension(mediaType: string): string {
485
+ if (mediaType === "image/png") return "png"
486
+ if (mediaType === "image/jpeg") return "jpg"
487
+ if (mediaType === "image/webp") return "webp"
488
+ invalidArgument("Candidate media type is unsupported.")
489
+ return "png"
490
+ }
491
+
492
+ async function atomicPublish(path: string, bytes: Uint8Array | string): Promise<void> {
493
+ const temporaryPath = join(
494
+ dirname(path),
495
+ `.${randomUUID()}.slopcamera-gallery.tmp`,
496
+ )
497
+ try {
498
+ await writeFile(temporaryPath, bytes, { flag: "wx" })
499
+ await link(temporaryPath, path)
500
+ } catch {
501
+ throw new SlopcameraCloudError(
502
+ "OUTPUT_WRITE_FAILED",
503
+ "Slopcamera could not atomically write a gallery output; existing files are never replaced.",
504
+ )
505
+ } finally {
506
+ await rm(temporaryPath, { force: true }).catch(() => undefined)
507
+ }
508
+ }
509
+
510
+ export interface SlopcameraGalleryInput extends SlopcameraGalleryPlanInput {
511
+ readonly model?: string
512
+ readonly outputDir: string
513
+ readonly cellEdge?: number
514
+ readonly timeoutMs?: number
515
+ readonly signal?: AbortSignal
516
+ }
517
+
518
+ export interface SlopcameraGalleryDependencies extends SlopcameraGenerateDependencies {
519
+ readonly generate?: typeof generateSlopcameraImage
520
+ }
521
+
522
+ export interface SlopcameraGalleryCandidateReceipt {
523
+ readonly index: number
524
+ readonly id: string
525
+ readonly label: string
526
+ readonly prompt: string
527
+ readonly status: "generated" | "failed"
528
+ readonly path?: string
529
+ readonly sha256?: string
530
+ readonly bytes?: number
531
+ readonly mediaType?: string
532
+ readonly requestId?: string
533
+ readonly warnings?: readonly string[]
534
+ readonly error?: string
535
+ /** Durable provenance record for the candidate, when the host keeps one. */
536
+ readonly job?: string
537
+ /** The cell repeats the candidate 2×2 so tile seams are reviewable. */
538
+ readonly tiled?: boolean
539
+ /**
540
+ * Rendered review evidence composited into the cell, distinct from the
541
+ * generated candidate identified by `path`/`sha256`. Promotion must always
542
+ * target the candidate artifact, never this render.
543
+ */
544
+ readonly cellImage?: {
545
+ readonly path?: string
546
+ readonly sha256: string
547
+ readonly bytes: number
548
+ readonly mediaType: string
549
+ }
550
+ /**
551
+ * Scene-variant provenance: the typed patch applied to the base scene and
552
+ * the derived scene identity. Present only on `slopcamera.scene-gallery`
553
+ * receipts, where `path`/`sha256` identify the derived scene document.
554
+ */
555
+ readonly scene?: {
556
+ readonly patchSha256: string
557
+ readonly sceneSha256: string
558
+ readonly diff: readonly SpatialSceneDiffEntry[]
559
+ }
560
+ readonly cell?: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
561
+ }
562
+
563
+ export interface SlopcameraGalleryReceipt {
564
+ readonly kind: "slopcamera.image-gallery" | "slopcamera.scene-gallery"
565
+ readonly schemaVersion: 1
566
+ readonly subject: string
567
+ readonly galleryKind: SlopcameraGalleryKind | "scene"
568
+ readonly model: string
569
+ readonly provider: "vercel-ai-gateway" | "local"
570
+ /** Present on `slopcamera.scene-gallery` receipts: the authored base scene. */
571
+ readonly baseSceneSha256?: string
572
+ readonly cell: { readonly width: number; readonly height: number; readonly labelHeight: number }
573
+ readonly axes: readonly { readonly axis: SlopcameraGalleryAxis; readonly values: readonly string[] }[]
574
+ readonly candidates: readonly SlopcameraGalleryCandidateReceipt[]
575
+ readonly counts: { readonly requested: number; readonly generated: number; readonly failed: number }
576
+ readonly gallery: {
577
+ readonly path: string
578
+ readonly sha256: string
579
+ readonly bytes: number
580
+ readonly mediaType: "image/png"
581
+ readonly width: number
582
+ readonly height: number
583
+ }
584
+ readonly receiptPath: string
585
+ readonly clientMaxRetries: 0
586
+ }
587
+
588
+ /**
589
+ * One settled candidate outcome. `path` and `sha256` are set when the caller
590
+ * already published the bytes durably (for example through a tracked Gateway
591
+ * artifact job); otherwise the composition step publishes `bytes` into the
592
+ * gallery output directory itself. `job` optionally names the durable
593
+ * provenance record an agent can inspect before promoting the candidate.
594
+ */
595
+ export interface SlopcameraGalleryResolvedCandidate {
596
+ readonly index: number
597
+ readonly id: string
598
+ readonly label: string
599
+ readonly prompt: string
600
+ readonly status: "generated" | "failed"
601
+ readonly bytes?: Uint8Array
602
+ readonly mediaType?: string
603
+ readonly requestId?: string
604
+ readonly warnings?: readonly string[]
605
+ readonly error?: string
606
+ readonly job?: string
607
+ readonly path?: string
608
+ readonly sha256?: string
609
+ /**
610
+ * A rendered review image (for example a probe-scene still) shown in the
611
+ * cell instead of the raw candidate. `path`/`sha256` identify the durable
612
+ * render artifact; the candidate's own `path`/`sha256` still record the
613
+ * generated source bytes so promotion never picks the review render.
614
+ */
615
+ readonly cellImage?: {
616
+ readonly bytes: Uint8Array
617
+ readonly mediaType: string
618
+ readonly path?: string
619
+ readonly sha256?: string
620
+ }
621
+ /**
622
+ * Scene-variant provenance carried into the receipt row. When set the
623
+ * candidate artifact is the derived scene document (`mediaType`
624
+ * `application/json`) and `cellImage` is its rendered beauty still.
625
+ */
626
+ readonly scene?: {
627
+ readonly patchSha256: string
628
+ readonly sceneSha256: string
629
+ readonly diff: readonly SpatialSceneDiffEntry[]
630
+ }
631
+ }
632
+
633
+ async function generateCandidate(
634
+ candidate: SlopcameraGalleryCandidate,
635
+ model: string,
636
+ input: SlopcameraGalleryInput,
637
+ dependencies: SlopcameraGalleryDependencies,
638
+ ): Promise<SlopcameraGalleryResolvedCandidate> {
639
+ const generate = dependencies.generate ?? generateSlopcameraImage
640
+ try {
641
+ const generated: GeneratedSlopcameraImage = await generate(
642
+ {
643
+ model,
644
+ prompt: candidate.prompt,
645
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
646
+ ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
647
+ },
648
+ dependencies,
649
+ )
650
+ const bytes = Buffer.from(generated.image.base64, "base64")
651
+ return {
652
+ index: candidate.index,
653
+ id: candidate.id,
654
+ label: candidate.label,
655
+ prompt: candidate.prompt,
656
+ status: "generated",
657
+ bytes,
658
+ mediaType: generated.image.mediaType,
659
+ requestId: generated.requestId,
660
+ warnings: generated.warnings,
661
+ }
662
+ } catch (error) {
663
+ return {
664
+ index: candidate.index,
665
+ id: candidate.id,
666
+ label: candidate.label,
667
+ prompt: candidate.prompt,
668
+ status: "failed",
669
+ error: error instanceof Error ? error.message : String(error),
670
+ }
671
+ }
672
+ }
673
+
674
+ /** Bounded worker pool: at most `concurrency` paid requests in flight. */
675
+ async function mapBounded<In, Out>(
676
+ items: readonly In[],
677
+ concurrency: number,
678
+ worker: (item: In) => Promise<Out>,
679
+ ): Promise<Out[]> {
680
+ const results = new Array<Out>(items.length)
681
+ let next = 0
682
+ await Promise.all(
683
+ Array.from({ length: Math.min(concurrency, items.length) }, async () => {
684
+ while (next < items.length) {
685
+ const index = next++
686
+ results[index] = await worker(items[index]!)
687
+ }
688
+ }),
689
+ )
690
+ return results
691
+ }
692
+
693
+ function galleryModel(value: unknown): string {
694
+ const model = value ?? slopcameraImageModels[1]
695
+ if (
696
+ typeof model !== "string" ||
697
+ model.length > 256 ||
698
+ !/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._:-]*$/iu.test(model)
699
+ ) {
700
+ invalidArgument("model must be a bounded Vercel AI Gateway provider/model id.")
701
+ }
702
+ return model
703
+ }
704
+
705
+ function galleryCellEdge(value: unknown): number {
706
+ const cellEdge = value ?? slopcameraGalleryLimits.cellEdgeDefault
707
+ if (
708
+ typeof cellEdge !== "number" ||
709
+ !Number.isInteger(cellEdge) ||
710
+ cellEdge < slopcameraGalleryLimits.cellEdgeMin ||
711
+ cellEdge > slopcameraGalleryLimits.cellEdgeMax
712
+ ) {
713
+ invalidArgument(`cellEdge must be an integer from ${String(slopcameraGalleryLimits.cellEdgeMin)} through ${String(slopcameraGalleryLimits.cellEdgeMax)}.`)
714
+ }
715
+ return cellEdge
716
+ }
717
+
718
+ function galleryOutputDir(value: unknown): string {
719
+ if (
720
+ typeof value !== "string" ||
721
+ value.length < 1 ||
722
+ value.length > 4_096 ||
723
+ value.includes("\0")
724
+ ) {
725
+ invalidArgument("outputDir must be a non-empty bounded local directory path.")
726
+ }
727
+ return resolve(value)
728
+ }
729
+
730
+ export async function generateSlopcameraImageGallery(
731
+ rawInput: unknown,
732
+ dependencies: SlopcameraGalleryDependencies = {},
733
+ ): Promise<SlopcameraGalleryReceipt> {
734
+ if (!isRecord(rawInput)) invalidArgument("Gallery input must be an object.")
735
+ const input = rawInput as unknown as SlopcameraGalleryInput
736
+ const outputDir = galleryOutputDir(input.outputDir)
737
+ const model = galleryModel(input.model)
738
+ const cellEdge = galleryCellEdge(input.cellEdge)
739
+ const plan = planSlopcameraGallery(input)
740
+ await mkdir(outputDir, { recursive: true })
741
+
742
+ const generated = await mapBounded(
743
+ plan.candidates,
744
+ slopcameraGalleryLimits.concurrency,
745
+ async candidate => await generateCandidate(candidate, model, input, dependencies),
746
+ )
747
+ return await composeSlopcameraImageGallery({
748
+ candidates: generated,
749
+ cellEdge,
750
+ model,
751
+ outputDir,
752
+ plan,
753
+ })
754
+ }
755
+
756
+ /**
757
+ * Receipt identity for producers that compose the same labelled contact sheet
758
+ * without the Gateway image lane — currently `slopcamera ai scene gallery`,
759
+ * whose candidates are derived scene documents rather than generated images.
760
+ */
761
+ export interface SlopcameraSceneGalleryReceiptSpec {
762
+ readonly kind: "slopcamera.scene-gallery"
763
+ readonly baseSceneSha256: string
764
+ }
765
+
766
+ /**
767
+ * Compose settled candidates into the labelled contact sheet and receipt.
768
+ * Hosts that publish candidates through their own durable artifact lane set
769
+ * `path`/`sha256`/`job` per candidate; candidates without `path` are published
770
+ * into `outputDir` by this seam. Either way every candidate row keeps its
771
+ * prompt, digest, and provenance so review and promotion stay explicit.
772
+ */
773
+ export async function composeSlopcameraImageGallery(input: {
774
+ readonly candidates: readonly SlopcameraGalleryResolvedCandidate[]
775
+ readonly cellEdge?: number
776
+ readonly model: string
777
+ readonly outputDir: string
778
+ readonly plan: SlopcameraGalleryPlan
779
+ readonly receipt?: SlopcameraSceneGalleryReceiptSpec
780
+ }): Promise<SlopcameraGalleryReceipt> {
781
+ const outputDir = galleryOutputDir(input.outputDir)
782
+ const model = galleryModel(input.model)
783
+ const cellEdge = galleryCellEdge(input.cellEdge)
784
+ const { plan } = input
785
+ if (!isRecord(plan) || !Array.isArray(plan.candidates) || plan.candidates.length === 0) {
786
+ invalidArgument("plan must be a non-empty gallery plan from planSlopcameraGallery.")
787
+ }
788
+ const generated = input.candidates
789
+ await mkdir(outputDir, { recursive: true })
790
+
791
+ const labelHeight = slopcameraGalleryLimits.labelHeight
792
+ const cellWidth = cellEdge
793
+ const cellHeight = Math.round(cellEdge * plan.aspect[1] / plan.aspect[0])
794
+ const cellBodyHeight = cellHeight + labelHeight
795
+ const columns = Math.max(1, Math.ceil(Math.sqrt(generated.length)))
796
+ const rows = Math.ceil(generated.length / columns)
797
+ const gap = slopcameraGalleryLimits.cellGap
798
+ const sheetWidth = columns * cellWidth + (columns - 1) * gap
799
+ const sheetHeight = rows * cellBodyHeight + (rows - 1) * gap
800
+ if (sheetWidth * sheetHeight > slopcameraGalleryLimits.galleryPixels) {
801
+ invalidArgument("Gallery sheet exceeds its pixel budget; reduce count or cellEdge.")
802
+ }
803
+
804
+ const receiptCandidates: SlopcameraGalleryCandidateReceipt[] = []
805
+ const overlays: { input: Buffer; left: number; top: number }[] = []
806
+ for (const candidate of generated) {
807
+ const column = (candidate.index - 1) % columns
808
+ const row = Math.floor((candidate.index - 1) / columns)
809
+ const x = column * (cellWidth + gap)
810
+ const y = row * (cellBodyHeight + gap)
811
+ const labelText = candidate.status === "failed"
812
+ ? `#${String(candidate.index)} ${candidate.label} failed`
813
+ : `#${String(candidate.index)} ${candidate.label}`
814
+ overlays.push({
815
+ input: await sharp(labelPixels(labelText, cellWidth, labelHeight), {
816
+ raw: { width: cellWidth, height: labelHeight, channels: 4 },
817
+ }).png().toBuffer(),
818
+ left: x,
819
+ top: y,
820
+ })
821
+ if (candidate.status === "generated") {
822
+ if (candidate.bytes === undefined || candidate.mediaType === undefined) {
823
+ invalidArgument("A generated gallery candidate requires its bytes and media type.")
824
+ }
825
+ const path = candidate.path ?? join(
826
+ outputDir,
827
+ `candidate-${String(candidate.index).padStart(2, "0")}-${slug(candidate.id)}.${mediaExtension(candidate.mediaType)}`,
828
+ )
829
+ if (candidate.path === undefined) {
830
+ await atomicPublish(path, candidate.bytes)
831
+ }
832
+ const sha256 = createHash("sha256").update(candidate.bytes).digest("hex")
833
+ if (candidate.sha256 !== undefined && candidate.sha256 !== sha256) {
834
+ invalidArgument("A pre-published gallery candidate digest does not match its bytes.")
835
+ }
836
+ let cellSource: ReturnType<typeof sharp>
837
+ let cellReceipt: SlopcameraGalleryCandidateReceipt["cellImage"]
838
+ if (candidate.cellImage !== undefined) {
839
+ const cellSha256 = createHash("sha256").update(candidate.cellImage.bytes).digest("hex")
840
+ if (
841
+ candidate.cellImage.sha256 !== undefined
842
+ && candidate.cellImage.sha256 !== cellSha256
843
+ ) {
844
+ invalidArgument("A gallery cell image digest does not match its bytes.")
845
+ }
846
+ const cellCandidate = sharp(candidate.cellImage.bytes, {
847
+ limitInputPixels: slopcameraGalleryLimits.candidatePixels,
848
+ failOn: "warning",
849
+ })
850
+ const cellMetadata = await cellCandidate.metadata()
851
+ if (
852
+ cellMetadata.width === undefined || cellMetadata.height === undefined
853
+ || cellMetadata.width > slopcameraGalleryLimits.candidateEdge
854
+ || cellMetadata.height > slopcameraGalleryLimits.candidateEdge
855
+ || cellMetadata.width * cellMetadata.height > slopcameraGalleryLimits.candidatePixels
856
+ ) {
857
+ invalidArgument("A gallery cell image exceeds its raster bounds.")
858
+ }
859
+ cellSource = cellCandidate
860
+ cellReceipt = {
861
+ sha256: cellSha256,
862
+ bytes: candidate.cellImage.bytes.byteLength,
863
+ mediaType: candidate.cellImage.mediaType,
864
+ ...(candidate.cellImage.path === undefined
865
+ ? {}
866
+ : { path: candidate.cellImage.path }),
867
+ }
868
+ } else {
869
+ // Without a rendered cell the candidate itself is rasterized, so it
870
+ // must be a bounded image. Non-raster candidates (derived scene
871
+ // documents) are only admitted when a rendered cell image exists.
872
+ const source = sharp(candidate.bytes, {
873
+ limitInputPixels: slopcameraGalleryLimits.candidatePixels,
874
+ failOn: "warning",
875
+ })
876
+ const metadata = await source.metadata()
877
+ if (
878
+ metadata.width === undefined || metadata.height === undefined ||
879
+ metadata.width > slopcameraGalleryLimits.candidateEdge ||
880
+ metadata.height > slopcameraGalleryLimits.candidateEdge ||
881
+ metadata.width * metadata.height > slopcameraGalleryLimits.candidatePixels
882
+ ) {
883
+ invalidArgument("A generated candidate exceeds its raster bounds.")
884
+ }
885
+ cellSource = source
886
+ }
887
+ const resized = await cellSource
888
+ .resize(cellWidth, cellHeight, { fit: "cover" })
889
+ .png()
890
+ .toBuffer()
891
+ const cellImage = plan.tiled && candidate.cellImage === undefined
892
+ ? await (async () => {
893
+ const tile = await sharp(resized)
894
+ .resize(Math.ceil(cellWidth / 2), Math.ceil(cellHeight / 2), { fit: "fill" })
895
+ .png()
896
+ .toBuffer()
897
+ const positions = [0, Math.floor(cellWidth / 2)]
898
+ const rows = [0, Math.floor(cellHeight / 2)]
899
+ return sharp({
900
+ create: {
901
+ width: cellWidth,
902
+ height: cellHeight,
903
+ channels: 4,
904
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
905
+ },
906
+ })
907
+ .composite(rows.flatMap(top =>
908
+ positions.map(left => ({ input: tile, left, top })),
909
+ ))
910
+ .png()
911
+ .toBuffer()
912
+ })()
913
+ : resized
914
+ overlays.push({ input: cellImage, left: x, top: y + labelHeight })
915
+ receiptCandidates.push({
916
+ index: candidate.index,
917
+ id: candidate.id,
918
+ label: candidate.label,
919
+ prompt: candidate.prompt,
920
+ status: "generated",
921
+ path,
922
+ sha256,
923
+ bytes: candidate.bytes.byteLength,
924
+ mediaType: candidate.mediaType,
925
+ ...(candidate.requestId === undefined ? {} : { requestId: candidate.requestId }),
926
+ ...(candidate.warnings === undefined ? {} : { warnings: candidate.warnings }),
927
+ ...(candidate.job === undefined ? {} : { job: candidate.job }),
928
+ ...(plan.tiled && candidate.cellImage === undefined ? { tiled: true } : {}),
929
+ ...(cellReceipt === undefined ? {} : { cellImage: cellReceipt }),
930
+ ...(candidate.scene === undefined ? {} : { scene: candidate.scene }),
931
+ cell: { x, y, width: cellWidth, height: cellBodyHeight },
932
+ })
933
+ } else {
934
+ const hasRetainedArtifact = candidate.bytes !== undefined
935
+ || candidate.mediaType !== undefined
936
+ || candidate.path !== undefined
937
+ || candidate.sha256 !== undefined
938
+ let retainedArtifact: {
939
+ readonly bytes: number
940
+ readonly mediaType: string
941
+ readonly path: string
942
+ readonly sha256: string
943
+ } | undefined
944
+ if (hasRetainedArtifact) {
945
+ if (
946
+ candidate.bytes === undefined
947
+ || candidate.mediaType === undefined
948
+ || candidate.path === undefined
949
+ ) {
950
+ invalidArgument("A failed gallery candidate artifact requires bytes, media type, and path.")
951
+ }
952
+ const sha256 = createHash("sha256").update(candidate.bytes).digest("hex")
953
+ if (candidate.sha256 !== undefined && candidate.sha256 !== sha256) {
954
+ invalidArgument("A failed gallery candidate artifact digest does not match its bytes.")
955
+ }
956
+ retainedArtifact = {
957
+ bytes: candidate.bytes.byteLength,
958
+ mediaType: candidate.mediaType,
959
+ path: candidate.path,
960
+ sha256,
961
+ }
962
+ }
963
+ const blank = Buffer.alloc(cellWidth * cellHeight * 4)
964
+ for (let pixel = 0; pixel < cellWidth * cellHeight; pixel++) {
965
+ const index = pixel * 4
966
+ blank[index] = 48
967
+ blank[index + 1] = 52
968
+ blank[index + 2] = 60
969
+ blank[index + 3] = 255
970
+ }
971
+ overlays.push({
972
+ input: await sharp(blank, {
973
+ raw: { width: cellWidth, height: cellHeight, channels: 4 },
974
+ }).png().toBuffer(),
975
+ left: x,
976
+ top: y + labelHeight,
977
+ })
978
+ receiptCandidates.push({
979
+ index: candidate.index,
980
+ id: candidate.id,
981
+ label: candidate.label,
982
+ prompt: candidate.prompt,
983
+ status: "failed",
984
+ ...retainedArtifact,
985
+ ...(candidate.requestId === undefined ? {} : { requestId: candidate.requestId }),
986
+ ...(candidate.warnings === undefined ? {} : { warnings: candidate.warnings }),
987
+ ...(candidate.error === undefined ? {} : { error: candidate.error }),
988
+ ...(candidate.job === undefined ? {} : { job: candidate.job }),
989
+ ...(candidate.scene === undefined ? {} : { scene: candidate.scene }),
990
+ cell: { x, y, width: cellWidth, height: cellBodyHeight },
991
+ })
992
+ }
993
+ }
994
+
995
+ const sheet = await sharp({
996
+ create: {
997
+ width: sheetWidth,
998
+ height: sheetHeight,
999
+ channels: 4,
1000
+ background: { r: 10, g: 12, b: 16, alpha: 255 },
1001
+ },
1002
+ })
1003
+ .composite(overlays)
1004
+ .png()
1005
+ .toBuffer()
1006
+ const galleryPath = join(outputDir, "gallery.png")
1007
+ await atomicPublish(galleryPath, sheet)
1008
+ const gallerySha256 = createHash("sha256").update(sheet).digest("hex")
1009
+
1010
+ const failed = generated.filter(candidate => candidate.status === "failed").length
1011
+ const receiptPath = join(outputDir, "receipt.json")
1012
+ const receipt: SlopcameraGalleryReceipt = {
1013
+ kind: input.receipt?.kind ?? "slopcamera.image-gallery",
1014
+ schemaVersion: 1,
1015
+ subject: plan.subject,
1016
+ galleryKind: input.receipt === undefined ? plan.kind : "scene",
1017
+ model,
1018
+ provider: input.receipt === undefined ? "vercel-ai-gateway" : "local",
1019
+ ...(input.receipt === undefined
1020
+ ? {}
1021
+ : { baseSceneSha256: input.receipt.baseSceneSha256 }),
1022
+ cell: { width: cellWidth, height: cellHeight, labelHeight },
1023
+ axes: plan.axes,
1024
+ candidates: receiptCandidates,
1025
+ counts: {
1026
+ requested: generated.length,
1027
+ generated: generated.length - failed,
1028
+ failed,
1029
+ },
1030
+ gallery: {
1031
+ path: galleryPath,
1032
+ sha256: gallerySha256,
1033
+ bytes: sheet.byteLength,
1034
+ mediaType: "image/png",
1035
+ width: sheetWidth,
1036
+ height: sheetHeight,
1037
+ },
1038
+ receiptPath,
1039
+ clientMaxRetries: 0,
1040
+ }
1041
+ await atomicPublish(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`)
1042
+ if (failed === generated.length) {
1043
+ throw new SlopcameraCloudError(
1044
+ "GENERATION_FAILED",
1045
+ `Every gallery candidate failed; see ${receiptPath} for the retained attempt record.`,
1046
+ )
1047
+ }
1048
+ return receipt
1049
+ }