@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.
- package/README.md +29 -9
- package/apps/desktop/application/context.ts +6 -0
- package/apps/desktop/application/default-registry.ts +26 -0
- package/apps/desktop/application/operation.ts +14 -1
- package/apps/desktop/application/operations/index.ts +4 -0
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +514 -11
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +29 -1
- package/apps/desktop/cli/commands.ts +1188 -4
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +172 -6
- package/apps/desktop/cli/main.ts +3 -3
- package/apps/desktop/cli/portable-surface.ts +6 -1
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +268 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/semantic-builder.ts +24 -0
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +725 -273
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +25 -429
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-cgpkyjqa.js +3 -0
- package/dist/index-f0ptv8dm.js +7 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index.js +1 -226
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/package.json +7 -5
- package/skills/slopcamera/SKILL.md +6 -1
- package/skills/slopcamera/references/directed-scenes.md +22 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +222 -4
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -0
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1642 -130
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +233 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +109 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +618 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/version.ts +1 -1
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7131pg1c.js +0 -1820
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-fava6pge.js +0 -192
- package/dist/index-h1k0fnjq.js +0 -2363
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-zfnddgay.js +0 -2029
- 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
|
+
}
|