@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
package/src/icon.ts ADDED
@@ -0,0 +1,1111 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises"
3
+ import { dirname, resolve } from "node:path"
4
+ import { z } from "zod"
5
+ import { SlopcameraCloudError } from "./cloud-errors.js"
6
+ import {
7
+ createFixedGatewayFetch,
8
+ generateSlopcameraImage,
9
+ resolveSlopcameraGatewayCredential,
10
+ slopcameraGatewayApiBaseUrl,
11
+ type SlopcameraGenerateDependencies,
12
+ } from "./generate.js"
13
+ import {
14
+ resolveVectorizeLimits,
15
+ VectorizeDeadline,
16
+ vectorizeHardLimits,
17
+ } from "./vectorize/limits.js"
18
+ import { normalizedHexColor, parseHexColor } from "./vectorize/metrics.js"
19
+ import { encodeTracePng, loadRaster } from "./vectorize/pixels.js"
20
+ import type { VectorizeReceipt } from "./vectorize/types.js"
21
+ import { vectorizeImage } from "./vectorize/vectorize.js"
22
+
23
+ /**
24
+ * Two-class product-identity generation for developer-tool surfaces.
25
+ *
26
+ * `mark` produces a compact symbol built from a few bold masses that must
27
+ * stay legible at favicon and header sizes. `illustration` produces the
28
+ * related isometric artwork for marketing placements: simple structure,
29
+ * bounded fills, and no hairline detail that disappears at 64 px.
30
+ *
31
+ * The pipeline combines four stages. A purpose-specific style-locked prompt
32
+ * produces one bounded Gateway raster. Local pixel processing estimates the
33
+ * background and ink colors, projects every pixel onto the background→ink
34
+ * axis to recover antialiased stroke coverage, drops speckle and elongated
35
+ * boundary components, crops to content, and re-emits canonical
36
+ * ink-on-transparent RGBA for the bounded vectorizer. Both purposes threshold
37
+ * that alpha to crisp binary coverage before tracing, producing portable
38
+ * path-only SVGs without mask references. Deterministic geometry gates then
39
+ * reject candidates whose mass, aspect, or path count cannot serve the
40
+ * declared purpose. A purpose-specific vision critique reviews the rendered
41
+ * SVG and its prompt fix feeds the next attempt. Failed generations retry
42
+ * with concrete corrections instead of shipping silently.
43
+ */
44
+
45
+ export const slopcameraIconDefaultInk = "#2474d4"
46
+ export const slopcameraIconPanel = "#f7f8fb"
47
+ export const slopcameraIconDefaultRounds = 2
48
+ export const slopcameraIconMaximumRounds = 4
49
+ export const slopcameraIconSubjectMaximumBytes = 1_024
50
+ export const slopcameraIconCritiqueDefaultModel = "google/gemini-3-flash"
51
+ export const slopcameraIconCritiqueTimeoutMs = 120_000
52
+ const iconCritiqueMaximumResponseBytes = 8 * 1024 * 1024
53
+ const iconPreviewMaximumEdge = 448
54
+ const iconCoverageAlphaFloor = 24
55
+ const iconCropAlphaFloor = 16
56
+ const iconMarginRatio = 0.08
57
+
58
+ const MARK_CRITIQUE_SYSTEM = `You are a strict design reviewer for small product marks.
59
+
60
+ Style contract:
61
+ - A distinctive, immediately recognizable silhouette or geometric symbol.
62
+ - One to three bold masses with optional negative-space cuts and one ink color.
63
+ - No fine line art, hatching, texture, tiny holes, text, shading, gradients, shadows, or background objects.
64
+ - Clear at 16 px, balanced at 32 px, centered with optical rather than excessive margin.
65
+
66
+ Judge the attached rendered mark against the contract and whether it clearly depicts the requested subject. Return pass only when it is ready for favicons, application icons, headers, and compact project cards. Score is an integer from 0 to 100. When it fails, make promptFix a concrete image-prompt correction that addresses the listed problems.`
67
+
68
+ const ILLUSTRATION_CRITIQUE_SYSTEM = `You are a strict design reviewer for product-brand illustrations.
69
+
70
+ Style contract:
71
+ - A simple isometric illustration of the requested subject that can belong to the same visual family as a separate brand mark. Do not require or include the brand mark inside the illustration.
72
+ - Uniform medium-weight structure in exactly one ink color, with at most three large filled planes.
73
+ - No hairlines, hatching, texture, tiny repeated detail, shading, gradients, shadows, text, or stray background objects.
74
+ - No important feature may disappear at 64 px. Center the subject with a modest clear margin.
75
+
76
+ Judge the attached rendered illustration against the contract and whether it clearly depicts the requested subject. Return pass only when it is ready for a marketing header or feature section. Score is an integer from 0 to 100. When it fails, make promptFix a concrete image-prompt correction that addresses the listed problems.`
77
+
78
+ export interface IconCritique {
79
+ readonly pass: boolean
80
+ readonly problems: readonly string[]
81
+ readonly promptFix: string
82
+ readonly resolvedModel: string | null
83
+ readonly score: number
84
+ }
85
+
86
+ export interface IconAttemptReceipt {
87
+ readonly critiqueError?: string
88
+ readonly critiqueModel?: string
89
+ readonly pass?: boolean
90
+ readonly problems?: readonly string[]
91
+ readonly requestId: string
92
+ readonly round: number
93
+ readonly score?: number
94
+ readonly status: "selected" | "candidate" | "failed"
95
+ readonly svgSha256?: string
96
+ readonly vectorize?: VectorizeReceipt
97
+ readonly warnings: readonly string[]
98
+ }
99
+
100
+ export interface SlopcameraIconReceipt {
101
+ readonly attempts: readonly IconAttemptReceipt[]
102
+ readonly ink: string
103
+ readonly model: string
104
+ readonly outputPath: string
105
+ readonly purpose: SlopcameraIconPurpose
106
+ readonly rasterPath?: string
107
+ readonly receiptVersion: 1
108
+ readonly rounds: number
109
+ readonly selectedRound: number
110
+ readonly subject: string
111
+ readonly svgSha256: string
112
+ }
113
+
114
+ export type SlopcameraIconPurpose = "illustration" | "mark"
115
+
116
+ export interface GenerateSlopcameraIconInput {
117
+ readonly critiqueModel?: string
118
+ readonly inheritedFileDescriptors?: readonly number[]
119
+ readonly ink?: string
120
+ readonly keepRaster?: boolean
121
+ readonly model?: string
122
+ readonly outputPath: string
123
+ readonly purpose?: SlopcameraIconPurpose
124
+ readonly rounds?: number
125
+ readonly signal?: AbortSignal
126
+ readonly subject: string
127
+ }
128
+
129
+ export interface IconCandidate {
130
+ readonly png: Uint8Array
131
+ readonly requestId: string
132
+ readonly svg: string
133
+ readonly vectorize: VectorizeReceipt
134
+ readonly warnings: readonly string[]
135
+ }
136
+
137
+ interface IconLanguageRuntime {
138
+ readonly Output: {
139
+ object(input: {
140
+ readonly description: string
141
+ readonly name: string
142
+ readonly schema: z.ZodType
143
+ }): unknown
144
+ }
145
+ readonly createGateway: (settings: Readonly<{
146
+ apiKey: string
147
+ baseURL: typeof slopcameraGatewayApiBaseUrl
148
+ fetch: (
149
+ input: string | URL | Request,
150
+ init?: RequestInit,
151
+ ) => Promise<Response>
152
+ }>) => { languageModel(model: string): unknown }
153
+ readonly generateText: (
154
+ input: Readonly<Record<string, unknown>>,
155
+ ) => Promise<unknown>
156
+ }
157
+
158
+ export interface SlopcameraIconDependencies
159
+ extends SlopcameraGenerateDependencies {
160
+ readonly critique?: (
161
+ input: Readonly<{
162
+ ink: string
163
+ model: string
164
+ png: Uint8Array
165
+ purpose: SlopcameraIconPurpose
166
+ signal?: AbortSignal
167
+ subject: string
168
+ }>,
169
+ ) => Promise<IconCritique>
170
+ readonly generate?: typeof generateSlopcameraImage
171
+ readonly loadLanguageRuntime?: () => Promise<IconLanguageRuntime>
172
+ readonly rasterize?: (svg: string) => Promise<Uint8Array>
173
+ readonly vectorize?: typeof vectorizeImage
174
+ }
175
+
176
+ function invalidArgument(message: string): never {
177
+ throw new SlopcameraCloudError("INVALID_ARGUMENT", message)
178
+ }
179
+
180
+ function isRecord(value: unknown): value is Record<string, unknown> {
181
+ return typeof value === "object" && value !== null && !Array.isArray(value)
182
+ }
183
+
184
+ function isObjectLike(value: unknown): value is Record<string, unknown> {
185
+ return (
186
+ (typeof value === "object" && value !== null) ||
187
+ typeof value === "function"
188
+ )
189
+ }
190
+
191
+ function validateSubject(value: unknown): string {
192
+ if (
193
+ typeof value !== "string" ||
194
+ value.trim().length === 0 ||
195
+ /[\u0000-\u001f\u007f]/u.test(value) ||
196
+ Buffer.byteLength(value, "utf8") > slopcameraIconSubjectMaximumBytes
197
+ ) {
198
+ invalidArgument(
199
+ `Subject must be non-empty text no more than ${slopcameraIconSubjectMaximumBytes} UTF-8 bytes.`,
200
+ )
201
+ }
202
+ return value.trim()
203
+ }
204
+
205
+ function validateIconModel(value: unknown, name: string): string {
206
+ if (
207
+ typeof value !== "string" ||
208
+ value.length < 3 ||
209
+ value.length > 256 ||
210
+ !/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._:-]*$/iu.test(value)
211
+ ) {
212
+ invalidArgument(`${name} must be a bounded Vercel AI Gateway provider/model id.`)
213
+ }
214
+ return value
215
+ }
216
+
217
+ function validateRounds(value: number | undefined): number {
218
+ const rounds = value ?? slopcameraIconDefaultRounds
219
+ if (
220
+ !Number.isInteger(rounds) ||
221
+ rounds < 1 ||
222
+ rounds > slopcameraIconMaximumRounds
223
+ ) {
224
+ invalidArgument(`rounds must be an integer from 1 through ${slopcameraIconMaximumRounds}.`)
225
+ }
226
+ return rounds
227
+ }
228
+
229
+ export function iconPromptFor(
230
+ subject: string,
231
+ options: Readonly<{
232
+ feedback?: string
233
+ ink?: string
234
+ purpose?: SlopcameraIconPurpose
235
+ }> = {},
236
+ ): string {
237
+ const ink = normalizedHexColor(options.ink ?? slopcameraIconDefaultInk)
238
+ const purpose = options.purpose ?? "illustration"
239
+ const sections = purpose === "mark"
240
+ ? [
241
+ `A single small product mark for ${subject}.`,
242
+ "Style rules: front-facing or simple isometric geometry; a distinctive silhouette built from one to three bold masses; " +
243
+ `one single ink color ${ink} on a flat near-white background ${slopcameraIconPanel}; ` +
244
+ "negative space may separate major parts; no thin outlines, hatching, texture, tiny holes, shading, gradients, shadows, text, border, container shape, or extra objects; " +
245
+ "clear and recognizable at 16 pixels, balanced at 32 pixels, centered with a modest optical margin.",
246
+ ]
247
+ : [
248
+ `A single minimal product-brand illustration of ${subject}.`,
249
+ "Style rules: simple orthographic isometric projection; uniform medium-weight structural lines and at most three large filled planes; " +
250
+ `one single ink color ${ink} on a flat near-white background ${slopcameraIconPanel}; ` +
251
+ "no hairlines, hatching, texture, tiny repeated detail, shading, gradients, shadows, text, border, or extra objects; " +
252
+ "every important feature remains visible at 64 pixels; centered with a modest clear margin; clean geometric edges.",
253
+ ]
254
+ const feedback = options.feedback?.trim()
255
+ if (feedback !== undefined && feedback.length > 0) {
256
+ sections.push(`The previous attempt was rejected. Correct it: ${feedback}`)
257
+ }
258
+ return sections.join("\n\n")
259
+ }
260
+
261
+ function medianChannel(values: readonly number[]): number {
262
+ const sorted = [...values].sort((left, right) => left - right)
263
+ return sorted[Math.floor(sorted.length / 2)]!
264
+ }
265
+
266
+ function borderBackground(
267
+ rgba: Uint8Array,
268
+ width: number,
269
+ height: number,
270
+ ): readonly [number, number, number] {
271
+ const thickness = Math.max(1, Math.round(Math.min(width, height) * 0.02))
272
+ const step = Math.max(
273
+ 1,
274
+ Math.floor((2 * thickness * (width + height)) / 8_192),
275
+ )
276
+ const red: number[] = []
277
+ const green: number[] = []
278
+ const blue: number[] = []
279
+ let sampled = 0
280
+ const collect = (x: number, y: number): void => {
281
+ if (sampled % step !== 0) {
282
+ sampled += 1
283
+ return
284
+ }
285
+ sampled += 1
286
+ const index = (y * width + x) * 4
287
+ const alpha = rgba[index + 3]!
288
+ if (alpha < 128) return
289
+ red.push(rgba[index]!)
290
+ green.push(rgba[index + 1]!)
291
+ blue.push(rgba[index + 2]!)
292
+ }
293
+ for (let y = 0; y < height; y += 1) {
294
+ for (let x = 0; x < width; x += 1) {
295
+ if (
296
+ x < thickness ||
297
+ x >= width - thickness ||
298
+ y < thickness ||
299
+ y >= height - thickness
300
+ ) {
301
+ collect(x, y)
302
+ }
303
+ }
304
+ }
305
+ if (red.length === 0) {
306
+ // A fully transparent border means the source is already cut-out line
307
+ // art. Treat it as near-white: coverage is still gated by source alpha.
308
+ return [252, 252, 252]
309
+ }
310
+ return [medianChannel(red), medianChannel(green), medianChannel(blue)]
311
+ }
312
+
313
+ function measuredInkColor(
314
+ rgba: Uint8Array,
315
+ width: number,
316
+ height: number,
317
+ background: readonly [number, number, number],
318
+ ): readonly [number, number, number] {
319
+ const total = width * height
320
+ const step = Math.max(1, Math.floor(total / 1_000_000))
321
+ const bins = new Map<number, number[]>()
322
+ const counts = new Map<number, number>()
323
+ for (let pixel = 0; pixel < total; pixel += step) {
324
+ const index = pixel * 4
325
+ if (rgba[index + 3]! < 128) continue
326
+ const dr = rgba[index]! - background[0]
327
+ const dg = rgba[index + 1]! - background[1]
328
+ const db = rgba[index + 2]! - background[2]
329
+ if (Math.sqrt(dr * dr + dg * dg + db * db) < 44) continue
330
+ const key =
331
+ ((rgba[index]! >> 3) << 10) |
332
+ ((rgba[index + 1]! >> 3) << 5) |
333
+ (rgba[index + 2]! >> 3)
334
+ counts.set(key, (counts.get(key) ?? 0) + 1)
335
+ const bucket = bins.get(key) ?? []
336
+ if (bucket.length < 4_096) {
337
+ bucket.push(rgba[index]!, rgba[index + 1]!, rgba[index + 2]!)
338
+ bins.set(key, bucket)
339
+ }
340
+ }
341
+ const dominant = [...counts.entries()].sort(
342
+ (left, right) => right[1] - left[1] || left[0] - right[0],
343
+ )[0]
344
+ if (dominant === undefined) {
345
+ throw new SlopcameraCloudError(
346
+ "GENERATION_INVALID_RESPONSE",
347
+ "The generated icon raster contains no ink strokes.",
348
+ )
349
+ }
350
+ const bucket = bins.get(dominant[0])!
351
+ const red: number[] = []
352
+ const green: number[] = []
353
+ const blue: number[] = []
354
+ for (let index = 0; index + 2 < bucket.length; index += 3) {
355
+ red.push(bucket[index]!)
356
+ green.push(bucket[index + 1]!)
357
+ blue.push(bucket[index + 2]!)
358
+ }
359
+ return [medianChannel(red), medianChannel(green), medianChannel(blue)]
360
+ }
361
+
362
+ function dropSpeckles(
363
+ alpha: Uint8Array,
364
+ width: number,
365
+ height: number,
366
+ ): number {
367
+ const minimumComponent = Math.max(8, Math.floor(width * height * 0.00004))
368
+ const seen = new Uint8Array(width * height)
369
+ const queue = new Int32Array(width * height)
370
+ let removed = 0
371
+ for (let start = 0; start < alpha.length; start += 1) {
372
+ if (alpha[start]! < iconCoverageAlphaFloor || seen[start] === 1) continue
373
+ let head = 0
374
+ let tail = 0
375
+ queue[tail] = start
376
+ tail += 1
377
+ seen[start] = 1
378
+ const component: number[] = []
379
+ let minimumX = width
380
+ let maximumX = -1
381
+ let minimumY = height
382
+ let maximumY = -1
383
+ while (head < tail) {
384
+ const pixel = queue[head]!
385
+ head += 1
386
+ component.push(pixel)
387
+ const x = pixel % width
388
+ const y = Math.floor(pixel / width)
389
+ minimumX = Math.min(minimumX, x)
390
+ maximumX = Math.max(maximumX, x)
391
+ minimumY = Math.min(minimumY, y)
392
+ maximumY = Math.max(maximumY, y)
393
+ const neighbors = [
394
+ x > 0 ? pixel - 1 : -1,
395
+ x < width - 1 ? pixel + 1 : -1,
396
+ y > 0 ? pixel - width : -1,
397
+ y < height - 1 ? pixel + width : -1,
398
+ ]
399
+ for (const neighbor of neighbors) {
400
+ if (
401
+ neighbor >= 0 &&
402
+ seen[neighbor] === 0 &&
403
+ alpha[neighbor]! >= iconCoverageAlphaFloor
404
+ ) {
405
+ seen[neighbor] = 1
406
+ queue[tail] = neighbor
407
+ tail += 1
408
+ }
409
+ }
410
+ }
411
+ const componentWidth = maximumX - minimumX + 1
412
+ const componentHeight = maximumY - minimumY + 1
413
+ const spansWidth = minimumX === 0 && maximumX === width - 1
414
+ const spansHeight = minimumY === 0 && maximumY === height - 1
415
+ const elongatedBorderArtifact =
416
+ (spansWidth || spansHeight) &&
417
+ Math.max(componentWidth / componentHeight, componentHeight / componentWidth) > 8
418
+ if (component.length < minimumComponent || elongatedBorderArtifact) {
419
+ removed += 1
420
+ for (const pixel of component) alpha[pixel] = 0
421
+ }
422
+ }
423
+ return removed
424
+ }
425
+
426
+ export interface IconLineArtExtraction {
427
+ readonly background: string
428
+ readonly coverageRatio: number
429
+ readonly height: number
430
+ readonly measuredInk: string
431
+ readonly pixels: Uint8Array
432
+ readonly removedComponents: number
433
+ readonly sourceHeight: number
434
+ readonly sourceWidth: number
435
+ readonly width: number
436
+ }
437
+
438
+ export function iconVisualGateProblems(
439
+ purpose: SlopcameraIconPurpose,
440
+ metrics: Readonly<Pick<IconLineArtExtraction, "coverageRatio" | "height" | "width">>,
441
+ pathCount: number,
442
+ ): readonly string[] {
443
+ const problems: string[] = []
444
+ const aspectRatio = Math.max(metrics.width / metrics.height, metrics.height / metrics.width)
445
+ if (aspectRatio > 1.8) problems.push("the subject is too narrow or elongated")
446
+ if (purpose === "mark") {
447
+ if (metrics.coverageRatio < 0.14) problems.push("the mark has too little bold visual mass")
448
+ if (metrics.coverageRatio > 0.72) problems.push("the mark has too little negative space")
449
+ if (pathCount > 12) problems.push("the mark has too many separate vector paths")
450
+ } else {
451
+ if (metrics.coverageRatio < 0.035) problems.push("the illustration lines are too sparse or thin")
452
+ if (metrics.coverageRatio > 0.62) problems.push("the illustration is too visually dense")
453
+ if (pathCount > 48) problems.push("the illustration has too much vector detail")
454
+ }
455
+ return problems
456
+ }
457
+
458
+ /**
459
+ * Normalize a generated raster into canonical ink-on-transparent RGBA.
460
+ * Background is the border median; ink is the dominant far-from-background
461
+ * color; each pixel's alpha is its coverage projected onto the
462
+ * background→ink axis, preserving antialiased stroke edges for the tracer.
463
+ */
464
+ export function extractIconLineArt(
465
+ rgba: Uint8Array,
466
+ width: number,
467
+ height: number,
468
+ options: Readonly<{ hardEdges?: boolean; ink?: string }> = {},
469
+ ): IconLineArtExtraction {
470
+ if (
471
+ rgba.length === 0 ||
472
+ rgba.length % 4 !== 0 ||
473
+ !Number.isInteger(width) ||
474
+ !Number.isInteger(height) ||
475
+ width < 1 ||
476
+ height < 1 ||
477
+ rgba.length !== width * height * 4
478
+ ) {
479
+ invalidArgument("Icon line-art extraction requires a nonempty RGBA raster.")
480
+ }
481
+ const ink = normalizedHexColor(options.ink ?? slopcameraIconDefaultInk)
482
+ const [inkRed, inkGreen, inkBlue] = parseHexColor(ink)
483
+ const background = borderBackground(rgba, width, height)
484
+ const measured = measuredInkColor(rgba, width, height, background)
485
+ const axis = [
486
+ measured[0] - background[0],
487
+ measured[1] - background[1],
488
+ measured[2] - background[2],
489
+ ] as const
490
+ let axisLength2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]
491
+ let direction: readonly [number, number, number] = axis
492
+ if (axisLength2 < 27) {
493
+ direction = [-1, -1, -1]
494
+ axisLength2 = 3
495
+ }
496
+ const alpha = new Uint8Array(width * height)
497
+ let coverageMass = 0
498
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
499
+ const index = pixel * 4
500
+ const sourceAlpha = rgba[index + 3]! / 255
501
+ const coverage = Math.max(
502
+ 0,
503
+ Math.min(
504
+ 1,
505
+ ((rgba[index]! - background[0]) * direction[0] +
506
+ (rgba[index + 1]! - background[1]) * direction[1] +
507
+ (rgba[index + 2]! - background[2]) * direction[2]) /
508
+ axisLength2,
509
+ ),
510
+ )
511
+ const value = Math.round(255 * coverage * sourceAlpha)
512
+ alpha[pixel] = value
513
+ coverageMass += value
514
+ }
515
+ if (coverageMass === 0) {
516
+ throw new SlopcameraCloudError(
517
+ "GENERATION_INVALID_RESPONSE",
518
+ "The generated icon raster contains no ink coverage.",
519
+ )
520
+ }
521
+ const removedComponents = dropSpeckles(alpha, width, height)
522
+
523
+ let minimumX = width
524
+ let minimumY = height
525
+ let maximumX = -1
526
+ let maximumY = -1
527
+ for (let y = 0; y < height; y += 1) {
528
+ for (let x = 0; x < width; x += 1) {
529
+ if (alpha[y * width + x]! >= iconCropAlphaFloor) {
530
+ if (x < minimumX) minimumX = x
531
+ if (x > maximumX) maximumX = x
532
+ if (y < minimumY) minimumY = y
533
+ if (y > maximumY) maximumY = y
534
+ }
535
+ }
536
+ }
537
+ if (maximumX < 0) {
538
+ throw new SlopcameraCloudError(
539
+ "GENERATION_INVALID_RESPONSE",
540
+ "The generated icon raster contains no ink coverage.",
541
+ )
542
+ }
543
+ const margin = Math.round(
544
+ Math.max(maximumX - minimumX + 1, maximumY - minimumY + 1) *
545
+ iconMarginRatio,
546
+ )
547
+ const cropX = Math.max(0, minimumX - margin)
548
+ const cropY = Math.max(0, minimumY - margin)
549
+ const cropWidth = Math.min(width, maximumX + 1 + margin) - cropX
550
+ const cropHeight = Math.min(height, maximumY + 1 + margin) - cropY
551
+ const pixels = new Uint8Array(cropWidth * cropHeight * 4)
552
+ let coverage = 0
553
+ for (let y = 0; y < cropHeight; y += 1) {
554
+ for (let x = 0; x < cropWidth; x += 1) {
555
+ const sourceIndex = (cropY + y) * width + cropX + x
556
+ const targetIndex = (y * cropWidth + x) * 4
557
+ const measuredValue = alpha[sourceIndex]!
558
+ const value = options.hardEdges === true
559
+ ? (measuredValue >= iconCoverageAlphaFloor ? 255 : 0)
560
+ : measuredValue
561
+ coverage += value
562
+ pixels[targetIndex] = inkRed
563
+ pixels[targetIndex + 1] = inkGreen
564
+ pixels[targetIndex + 2] = inkBlue
565
+ pixels[targetIndex + 3] = value
566
+ }
567
+ }
568
+ const toHex = (channel: number): string =>
569
+ channel.toString(16).padStart(2, "0")
570
+ return {
571
+ background: `#${background.map(toHex).join("")}`,
572
+ coverageRatio: coverage / (cropWidth * cropHeight * 255),
573
+ height: cropHeight,
574
+ measuredInk: `#${measured.map(toHex).join("")}`,
575
+ pixels,
576
+ removedComponents,
577
+ sourceHeight: height,
578
+ sourceWidth: width,
579
+ width: cropWidth,
580
+ }
581
+ }
582
+
583
+ const iconCritiqueSchema = z.object({
584
+ pass: z.boolean(),
585
+ problems: z.array(z.string()),
586
+ promptFix: z.string(),
587
+ score: z.number(),
588
+ })
589
+
590
+ function boundedText(value: string, maximum: number): string {
591
+ return value.replace(/[\u0000-\u001f\u007f]/gu, " ").slice(0, maximum)
592
+ }
593
+
594
+ function parseIconCritique(
595
+ output: unknown,
596
+ resolvedModel: string | null,
597
+ ): IconCritique {
598
+ const parsed = iconCritiqueSchema.safeParse(output)
599
+ if (!parsed.success) {
600
+ throw new SlopcameraCloudError(
601
+ "GENERATION_INVALID_RESPONSE",
602
+ "The icon critique returned an invalid bounded object.",
603
+ )
604
+ }
605
+ const score = parsed.data.score
606
+ if (!Number.isFinite(score)) {
607
+ throw new SlopcameraCloudError(
608
+ "GENERATION_INVALID_RESPONSE",
609
+ "The icon critique returned an invalid score.",
610
+ )
611
+ }
612
+ return {
613
+ pass: parsed.data.pass,
614
+ problems: parsed.data.problems
615
+ .slice(0, 8)
616
+ .map(problem => boundedText(problem, 300)),
617
+ promptFix: boundedText(parsed.data.promptFix, 2_000),
618
+ resolvedModel,
619
+ score: Math.max(0, Math.min(100, score)),
620
+ }
621
+ }
622
+
623
+ async function loadIconLanguageRuntime(): Promise<IconLanguageRuntime> {
624
+ let aiModule: unknown
625
+ let gatewayModule: unknown
626
+ try {
627
+ ;[aiModule, gatewayModule] = await Promise.all([
628
+ import("ai-v7"),
629
+ import("@ai-sdk/gateway-v4"),
630
+ ])
631
+ } catch {
632
+ throw new SlopcameraCloudError(
633
+ "GENERATION_FAILED",
634
+ "The Vercel AI Gateway runtime is unavailable.",
635
+ )
636
+ }
637
+ if (
638
+ !isRecord(aiModule) ||
639
+ !isRecord(gatewayModule) ||
640
+ typeof aiModule.generateText !== "function" ||
641
+ !isRecord(aiModule.Output) ||
642
+ typeof aiModule.Output.object !== "function" ||
643
+ typeof gatewayModule.createGateway !== "function"
644
+ ) {
645
+ throw new SlopcameraCloudError(
646
+ "GENERATION_FAILED",
647
+ "The Vercel AI Gateway runtime is unavailable.",
648
+ )
649
+ }
650
+ const createGateway = gatewayModule.createGateway as (
651
+ settings: Readonly<Record<string, unknown>>,
652
+ ) => unknown
653
+ return {
654
+ Output: aiModule.Output as IconLanguageRuntime["Output"],
655
+ createGateway: settings => {
656
+ const provider = createGateway(settings)
657
+ if (!isObjectLike(provider) || typeof provider.languageModel !== "function") {
658
+ throw new SlopcameraCloudError(
659
+ "GENERATION_FAILED",
660
+ "The Vercel AI Gateway runtime is unavailable.",
661
+ )
662
+ }
663
+ const languageModel = provider.languageModel as (id: string) => unknown
664
+ return { languageModel: id => languageModel.call(provider, id) }
665
+ },
666
+ generateText: aiModule.generateText as IconLanguageRuntime["generateText"],
667
+ }
668
+ }
669
+
670
+ function disableAiSdkWarningLogging(): void {
671
+ (
672
+ globalThis as typeof globalThis & {
673
+ AI_SDK_LOG_WARNINGS?: false
674
+ }
675
+ ).AI_SDK_LOG_WARNINGS = false
676
+ }
677
+
678
+ function resolvedModelId(value: unknown): string | null {
679
+ const modelId = isRecord(value) ? value.modelId : undefined
680
+ return typeof modelId === "string" &&
681
+ modelId.length > 0 &&
682
+ modelId.length <= 256
683
+ ? modelId
684
+ : null
685
+ }
686
+
687
+ export async function critiqueIconRaster(
688
+ input: Readonly<{
689
+ ink: string
690
+ model: string
691
+ png: Uint8Array
692
+ purpose: SlopcameraIconPurpose
693
+ signal?: AbortSignal
694
+ subject: string
695
+ }>,
696
+ dependencies: SlopcameraIconDependencies,
697
+ ): Promise<IconCritique> {
698
+ const runtime = await (dependencies.loadLanguageRuntime ??
699
+ loadIconLanguageRuntime)()
700
+ const controller = new AbortController()
701
+ const abort = (): void => controller.abort(input.signal?.reason)
702
+ input.signal?.addEventListener("abort", abort, { once: true })
703
+ if (input.signal?.aborted === true) abort()
704
+ const timer = setTimeout(
705
+ () => controller.abort(),
706
+ slopcameraIconCritiqueTimeoutMs,
707
+ )
708
+ try {
709
+ disableAiSdkWarningLogging()
710
+ const apiKey = resolveSlopcameraGatewayCredential(
711
+ dependencies.environment,
712
+ ).token
713
+ const gateway = runtime.createGateway({
714
+ apiKey,
715
+ baseURL: slopcameraGatewayApiBaseUrl,
716
+ fetch: createFixedGatewayFetch({
717
+ ...(dependencies.fetch === undefined
718
+ ? {}
719
+ : { fetch: dependencies.fetch }),
720
+ maximumResponseBytes: iconCritiqueMaximumResponseBytes,
721
+ }),
722
+ })
723
+ const output = runtime.Output.object({
724
+ description: `One bounded style critique for a rendered product ${input.purpose}.`,
725
+ name: "slopcamera_icon_critique",
726
+ schema: iconCritiqueSchema,
727
+ })
728
+ const result = await runtime.generateText({
729
+ abortSignal: controller.signal,
730
+ maxOutputTokens: 2_048,
731
+ maxRetries: 0,
732
+ messages: [
733
+ {
734
+ content: [
735
+ {
736
+ text:
737
+ `Subject: ${input.subject}\nPurpose: ${input.purpose}\nInk: ${input.ink}\n` +
738
+ `Judge this rendered ${input.purpose} against the style contract.`,
739
+ type: "text",
740
+ },
741
+ {
742
+ data: input.png,
743
+ mediaType: "image/png",
744
+ type: "file",
745
+ },
746
+ ],
747
+ role: "user",
748
+ },
749
+ ],
750
+ model: gateway.languageModel(input.model),
751
+ output,
752
+ providerOptions: {
753
+ gateway: {
754
+ disallowPromptTraining: true,
755
+ tags: ["slopcamera", "icon-critique", "v1"],
756
+ zeroDataRetention: true,
757
+ },
758
+ },
759
+ system: input.purpose === "mark"
760
+ ? MARK_CRITIQUE_SYSTEM
761
+ : ILLUSTRATION_CRITIQUE_SYSTEM,
762
+ temperature: 0,
763
+ })
764
+ return parseIconCritique(
765
+ isRecord(result) ? result.output : undefined,
766
+ resolvedModelId(isRecord(result) ? result.response : undefined),
767
+ )
768
+ } catch (error) {
769
+ if (error instanceof SlopcameraCloudError) throw error
770
+ throw new SlopcameraCloudError(
771
+ "GENERATION_FAILED",
772
+ "The icon critique request failed; it was not retried.",
773
+ )
774
+ } finally {
775
+ clearTimeout(timer)
776
+ input.signal?.removeEventListener("abort", abort)
777
+ }
778
+ }
779
+
780
+ const markPreviewSmallSizes = [16, 32] as const
781
+
782
+ async function renderIconPreview(
783
+ svg: string,
784
+ purpose: SlopcameraIconPurpose,
785
+ ): Promise<Uint8Array> {
786
+ try {
787
+ const sharp = (await import("sharp")).default
788
+ const source = sharp(Buffer.from(svg), {
789
+ density: 96,
790
+ failOn: "error",
791
+ limitInputPixels: vectorizeHardLimits.maxDecodedPixels,
792
+ })
793
+ if (purpose === "illustration") {
794
+ return Uint8Array.from(
795
+ await source
796
+ .resize(iconPreviewMaximumEdge, iconPreviewMaximumEdge, {
797
+ fit: "inside",
798
+ })
799
+ .flatten({ background: slopcameraIconPanel })
800
+ .png({ compressionLevel: 9 })
801
+ .toBuffer(),
802
+ )
803
+ }
804
+ const large = await source
805
+ .clone()
806
+ .resize(iconPreviewMaximumEdge, iconPreviewMaximumEdge, {
807
+ fit: "inside",
808
+ })
809
+ .flatten({ background: slopcameraIconPanel })
810
+ .png({ compressionLevel: 9 })
811
+ .toBuffer()
812
+ const smallRenders: { input: Buffer; top: number; left: number }[] = []
813
+ let cursor = 16
814
+ for (const size of markPreviewSmallSizes) {
815
+ const rendered = await sharp(Buffer.from(svg), {
816
+ density: 96,
817
+ failOn: "error",
818
+ limitInputPixels: vectorizeHardLimits.maxDecodedPixels,
819
+ })
820
+ .resize(size, size, { fit: "inside" })
821
+ .flatten({ background: slopcameraIconPanel })
822
+ .png({ compressionLevel: 9 })
823
+ .toBuffer()
824
+ smallRenders.push({
825
+ input: await sharp({
826
+ create: {
827
+ background: { b: 0xfb, channels: 3, g: 0xf8, r: 0xf7 },
828
+ channels: 3,
829
+ height: size + 32,
830
+ width: size + 32,
831
+ },
832
+ })
833
+ .composite([{ input: rendered, left: 16, top: 16 }])
834
+ .png()
835
+ .toBuffer(),
836
+ left: iconPreviewMaximumEdge + 16,
837
+ top: cursor,
838
+ })
839
+ cursor += size + 48
840
+ }
841
+ return Uint8Array.from(
842
+ await sharp({
843
+ create: {
844
+ background: { b: 0xfb, channels: 3, g: 0xf8, r: 0xf7 },
845
+ channels: 3,
846
+ height: Math.max(iconPreviewMaximumEdge, cursor + 16),
847
+ width: iconPreviewMaximumEdge + 16 + 96,
848
+ },
849
+ })
850
+ .composite([{ input: large, left: 0, top: 0 }, ...smallRenders])
851
+ .png({ compressionLevel: 9 })
852
+ .toBuffer(),
853
+ )
854
+ } catch (error) {
855
+ throw new SlopcameraCloudError(
856
+ "GENERATION_FAILED",
857
+ "The canonical icon SVG could not be rendered for critique.",
858
+ { cause: error },
859
+ )
860
+ }
861
+ }
862
+
863
+ async function writeAtomically(
864
+ path: string,
865
+ value: string | Uint8Array,
866
+ ): Promise<string> {
867
+ const absolutePath = resolve(path)
868
+ await mkdir(dirname(absolutePath), { recursive: true })
869
+ const temporaryPath = `${absolutePath}.${randomUUID()}.tmp`
870
+ try {
871
+ await writeFile(temporaryPath, value, { flag: "wx" })
872
+ await rename(temporaryPath, absolutePath)
873
+ return absolutePath
874
+ } catch (error) {
875
+ await rm(temporaryPath, { force: true }).catch(() => undefined)
876
+ throw new SlopcameraCloudError(
877
+ "OUTPUT_WRITE_FAILED",
878
+ `Slopcamera could not atomically write ${absolutePath}.`,
879
+ { cause: error },
880
+ )
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Generate one canonical product mark or marketing illustration: bounded
886
+ * Gateway raster → local normalization → supervised VTracer trace →
887
+ * deterministic purpose gate → optional purpose-specific vision critique.
888
+ * Only derived artifacts ever leave the machine; the uploaded critique image
889
+ * is Slopcamera's own rendered output, never user media.
890
+ */
891
+ export async function generateSlopcameraIcon(
892
+ input: GenerateSlopcameraIconInput,
893
+ dependencies: SlopcameraIconDependencies = {},
894
+ ): Promise<SlopcameraIconReceipt> {
895
+ const subject = validateSubject(input.subject)
896
+ if (
897
+ typeof input.outputPath !== "string" ||
898
+ input.outputPath.length < 1 ||
899
+ input.outputPath.length > 4_096 ||
900
+ input.outputPath.includes("\0") ||
901
+ !input.outputPath.toLowerCase().endsWith(".svg")
902
+ ) {
903
+ invalidArgument("outputPath must be a bounded local path ending in .svg.")
904
+ }
905
+ const model = validateIconModel(input.model ?? "recraft/recraft-v4.1-utility", "model")
906
+ const rounds = validateRounds(input.rounds)
907
+ const purpose = input.purpose ?? "illustration"
908
+ if (purpose !== "illustration" && purpose !== "mark") {
909
+ invalidArgument("purpose must be illustration or mark.")
910
+ }
911
+ const ink = normalizedHexColor(input.ink ?? slopcameraIconDefaultInk)
912
+ const critiqueModel =
913
+ input.critiqueModel === undefined
914
+ ? slopcameraIconCritiqueDefaultModel
915
+ : validateIconModel(input.critiqueModel, "critiqueModel")
916
+ const critiqueEnabled = rounds > 1 || input.critiqueModel !== undefined
917
+ const generate = dependencies.generate ?? generateSlopcameraImage
918
+ const vectorize = dependencies.vectorize ?? vectorizeImage
919
+ const critique = dependencies.critique ??
920
+ ((critiqueInput: {
921
+ ink: string
922
+ model: string
923
+ png: Uint8Array
924
+ purpose: SlopcameraIconPurpose
925
+ signal?: AbortSignal
926
+ subject: string
927
+ }) => critiqueIconRaster(critiqueInput, dependencies))
928
+ const limits = resolveVectorizeLimits({})
929
+
930
+ const attempts: IconAttemptReceipt[] = []
931
+ const candidates: {
932
+ candidate: IconCandidate
933
+ critique: IconCritique | null
934
+ extraction: IconLineArtExtraction
935
+ round: number
936
+ }[] = []
937
+ let feedback: string | undefined
938
+ let lastError: unknown
939
+ for (let round = 1; round <= rounds; round += 1) {
940
+ if (input.signal?.aborted === true) {
941
+ throw new SlopcameraCloudError(
942
+ "GENERATION_FAILED",
943
+ "Icon generation was cancelled.",
944
+ )
945
+ }
946
+ const prompt = iconPromptFor(subject, {
947
+ ink,
948
+ purpose,
949
+ ...(feedback === undefined ? {} : { feedback }),
950
+ })
951
+ let candidate: IconCandidate
952
+ let extraction: IconLineArtExtraction
953
+ let attemptRequestId = ""
954
+ let attemptProblems: readonly string[] = []
955
+ try {
956
+ const generated = await generate(
957
+ {
958
+ model,
959
+ prompt,
960
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
961
+ },
962
+ dependencies,
963
+ )
964
+ attemptRequestId = generated.requestId
965
+ const bytes = Buffer.from(generated.image.base64, "base64")
966
+ const raster = await loadRaster(
967
+ Uint8Array.from(bytes),
968
+ limits,
969
+ new VectorizeDeadline(limits.maxDurationMs),
970
+ )
971
+ extraction = extractIconLineArt(raster.pixels, raster.width, raster.height, {
972
+ hardEdges: true,
973
+ ink,
974
+ })
975
+ const png = await encodeTracePng(
976
+ extraction.pixels,
977
+ extraction.width,
978
+ extraction.height,
979
+ )
980
+ const traced = await vectorize(png, {
981
+ ...(input.inheritedFileDescriptors === undefined
982
+ ? {}
983
+ : { inheritedFileDescriptors: input.inheritedFileDescriptors }),
984
+ })
985
+ const visualProblems = iconVisualGateProblems(
986
+ purpose,
987
+ extraction,
988
+ traced.receipt.pathCount,
989
+ )
990
+ if (visualProblems.length > 0) {
991
+ feedback = visualProblems.join("; ")
992
+ attemptProblems = visualProblems
993
+ throw new SlopcameraCloudError(
994
+ "GENERATION_INVALID_RESPONSE",
995
+ `Generated ${purpose} failed its visual gate: ${feedback}.`,
996
+ )
997
+ }
998
+ candidate = {
999
+ png,
1000
+ requestId: generated.requestId,
1001
+ svg: traced.svg,
1002
+ vectorize: traced.receipt,
1003
+ warnings: generated.warnings,
1004
+ }
1005
+ } catch (error) {
1006
+ lastError = error
1007
+ attempts.push({
1008
+ ...(attemptProblems.length === 0 ? {} : { problems: attemptProblems }),
1009
+ requestId: attemptRequestId,
1010
+ round,
1011
+ status: "failed",
1012
+ warnings: [],
1013
+ })
1014
+ continue
1015
+ }
1016
+
1017
+ let review: IconCritique | null = null
1018
+ let critiqueError: string | undefined
1019
+ if (critiqueEnabled) {
1020
+ try {
1021
+ const preview = dependencies.rasterize === undefined
1022
+ ? await renderIconPreview(candidate.svg, purpose)
1023
+ : await dependencies.rasterize(candidate.svg)
1024
+ review = await critique({
1025
+ ink,
1026
+ model: critiqueModel,
1027
+ png: preview,
1028
+ purpose,
1029
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
1030
+ subject,
1031
+ })
1032
+ } catch (error) {
1033
+ critiqueError =
1034
+ error instanceof SlopcameraCloudError
1035
+ ? error.code
1036
+ : "critique-failed"
1037
+ review = null
1038
+ }
1039
+ }
1040
+ candidates.push({ candidate, critique: review, extraction, round })
1041
+ attempts.push({
1042
+ ...(critiqueError === undefined ? {} : { critiqueError }),
1043
+ ...(review === null ? {} : { critiqueModel: review.resolvedModel ?? critiqueModel }),
1044
+ ...(review === null ? {} : { pass: review.pass }),
1045
+ ...(review === null ? {} : { problems: review.problems }),
1046
+ requestId: candidate.requestId,
1047
+ round,
1048
+ ...(review === null ? {} : { score: review.score }),
1049
+ status: "candidate",
1050
+ svgSha256: createHash("sha256").update(candidate.svg).digest("hex"),
1051
+ vectorize: candidate.vectorize,
1052
+ warnings: candidate.warnings,
1053
+ })
1054
+ if (review === null || review.pass) break
1055
+ feedback = [...review.problems, review.promptFix]
1056
+ .filter(part => part.length > 0)
1057
+ .join("; ")
1058
+ }
1059
+
1060
+ if (candidates.length === 0) {
1061
+ if (lastError instanceof Error) throw lastError
1062
+ throw new SlopcameraCloudError(
1063
+ "GENERATION_FAILED",
1064
+ "Every icon generation attempt failed.",
1065
+ )
1066
+ }
1067
+ const eligibleCandidates = candidates.filter(
1068
+ ({ critique: review }) => review === null || review.pass,
1069
+ )
1070
+ if (eligibleCandidates.length === 0) {
1071
+ throw new SlopcameraCloudError(
1072
+ "GENERATION_INVALID_RESPONSE",
1073
+ `Every generated ${purpose} failed its design critique.`,
1074
+ )
1075
+ }
1076
+ const selected = eligibleCandidates.sort(
1077
+ (left, right) =>
1078
+ (right.critique?.score ?? -1) - (left.critique?.score ?? -1) ||
1079
+ right.round - left.round,
1080
+ )[0]!
1081
+ const selectedAttempt = attempts.findIndex(
1082
+ attempt => attempt.round === selected.round && attempt.status === "candidate",
1083
+ )
1084
+ if (selectedAttempt >= 0) {
1085
+ attempts[selectedAttempt] = {
1086
+ ...attempts[selectedAttempt]!,
1087
+ status: "selected",
1088
+ }
1089
+ }
1090
+ const outputPath = await writeAtomically(input.outputPath, selected.candidate.svg)
1091
+ const receipt: SlopcameraIconReceipt = {
1092
+ attempts,
1093
+ ink,
1094
+ model,
1095
+ outputPath,
1096
+ purpose,
1097
+ receiptVersion: 1,
1098
+ rounds,
1099
+ selectedRound: selected.round,
1100
+ subject,
1101
+ svgSha256: createHash("sha256").update(selected.candidate.svg).digest("hex"),
1102
+ }
1103
+ if (input.keepRaster === true) {
1104
+ const rasterPath = input.outputPath.replace(/\.svg$/iu, ".lineart.png")
1105
+ return {
1106
+ ...receipt,
1107
+ rasterPath: await writeAtomically(rasterPath, selected.candidate.png),
1108
+ }
1109
+ }
1110
+ return receipt
1111
+ }