@hraness/slopcamera 3.5.0 → 3.7.0

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 (52) hide show
  1. package/README.md +42 -26
  2. package/apps/desktop/README.md +1 -1
  3. package/apps/desktop/application/html-overlay-renderer.ts +6 -0
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/cli/args.ts +63 -1
  6. package/apps/desktop/cli/command-host-resources.ts +5 -0
  7. package/apps/desktop/cli/commands.ts +27 -1
  8. package/apps/desktop/cli/help.ts +43 -21
  9. package/apps/desktop/cli/html-film-init.ts +158 -0
  10. package/apps/desktop/cli/html-film-names.ts +17 -0
  11. package/apps/desktop/cli/html-film.ts +471 -0
  12. package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
  13. package/apps/desktop/cli/html-scene.ts +3 -3
  14. package/apps/desktop/cli/main.ts +1 -0
  15. package/apps/desktop/cli/menubar-status.ts +156 -0
  16. package/apps/desktop/cli/menubar.ts +88 -136
  17. package/apps/desktop/cli/portable-surface.ts +55 -1
  18. package/apps/desktop/dist/cli/main.js +353 -328
  19. package/apps/desktop/html-overlay/film.ts +418 -0
  20. package/dist/cli.js +19 -9
  21. package/dist/generate.js +1 -1
  22. package/dist/{index-8bhp7cj2.js → index-842t02pp.js} +1 -1
  23. package/dist/{index-jab2mg0s.js → index-nv3byqa1.js} +20 -4
  24. package/dist/index-p74xy76d.js +3 -0
  25. package/dist/{index-24w75s6a.js → index-y8spxs77.js} +10 -7
  26. package/dist/index.js +1 -1
  27. package/dist/operations.js +1 -1
  28. package/dist/workflow.js +1 -1
  29. package/docs/README.md +5 -5
  30. package/examples/html/launch-film/README.md +51 -0
  31. package/examples/html/launch-film/build.ts +158 -0
  32. package/examples/html/launch-film/film.css +593 -0
  33. package/examples/html/launch-film/film.html +85 -0
  34. package/examples/html/launch-film/film.js +410 -0
  35. package/examples/html/launch-film/film.json +63 -0
  36. package/examples/html/launch-film/mockups.tsx +74 -0
  37. package/examples/html/launch-film/timeline.ts +57 -0
  38. package/package.json +14 -8
  39. package/skills/slopcamera/SKILL.md +3 -2
  40. package/skills/slopcamera/references/install.md +3 -3
  41. package/skills/slopcamera/references/launch-films.md +154 -0
  42. package/skills/slopcamera/references/visual-communication.md +1 -1
  43. package/src/cli.ts +138 -19
  44. package/src/generate.ts +44 -1
  45. package/src/generation-pricing.ts +237 -0
  46. package/src/icon-set.ts +1086 -0
  47. package/src/icon.ts +396 -59
  48. package/src/index.ts +1 -0
  49. package/src/operations.ts +52 -0
  50. package/src/support.ts +6 -0
  51. package/src/version.ts +1 -1
  52. package/dist/index-77fjfg9f.js +0 -3
@@ -0,0 +1,1086 @@
1
+ import { createHash } from "node:crypto"
2
+ import { readFile } from "node:fs/promises"
3
+ import { resolve } from "node:path"
4
+ import { z } from "zod"
5
+ import { SlopcameraCloudError } from "./cloud-errors.js"
6
+ import {
7
+ collectSlopcameraIconCandidates,
8
+ extractIconLineArt,
9
+ loadIconLanguageRuntime,
10
+ measureIconCandidateMetrics,
11
+ renderIconPreview,
12
+ slopcameraIconContexts,
13
+ slopcameraIconCritiqueDefaultModel,
14
+ slopcameraIconCritiqueTimeoutMs,
15
+ slopcameraIconDefaultInk,
16
+ slopcameraIconMaximumRounds,
17
+ slopcameraIconPanel,
18
+ slopcameraIconSubjectMaximumBytes,
19
+ writeAtomically,
20
+ type IconAttemptReceipt,
21
+ type IconCandidateRecord,
22
+ type IconMeasuredMetrics,
23
+ type SlopcameraIconContext,
24
+ type SlopcameraIconDependencies,
25
+ type SlopcameraIconPurpose,
26
+ } from "./icon.js"
27
+ import {
28
+ createFixedGatewayFetch,
29
+ resolveSlopcameraGatewayCredential,
30
+ slopcameraGatewayApiBaseUrl,
31
+ } from "./generate.js"
32
+ import { normalizedHexColor } from "./vectorize/metrics.js"
33
+ import {
34
+ resolveVectorizeLimits,
35
+ VectorizeDeadline,
36
+ vectorizeHardLimits,
37
+ } from "./vectorize/limits.js"
38
+ import { loadRaster } from "./vectorize/pixels.js"
39
+
40
+ /**
41
+ * Set-level icon generation. `slopcamera image icon` evaluates one icon at a
42
+ * time, which is exactly why shipped families drift in density and line
43
+ * weight. This module composes the per-icon pipeline into a set: every member
44
+ * collects a small pool of gated candidates, joint selection minimizes the
45
+ * measured spread toward the family's median ink coverage and stroke weight,
46
+ * a contact-sheet vision critique reviews the whole family at once, and
47
+ * out-of-family members are regenerated with directional feedback — all
48
+ * inside the same credential, retry, and bounded-response contracts.
49
+ */
50
+
51
+ export const slopcameraIconSetMaximumMembers = 24
52
+ export const slopcameraIconSetMaximumSetRounds = 4
53
+ export const slopcameraIconSetDefaultSetRounds = 3
54
+ export const slopcameraIconSetDefaultPool = 2
55
+ export const slopcameraIconSetMaximumNameBytes = 120
56
+ export const slopcameraIconSetSlugPattern = /^[a-z0-9][a-z0-9-]{0,62}$/u
57
+ const iconSetSheetCellEdge = 288
58
+ const iconSetSheetGutter = 24
59
+ const iconSetSheetColumns = 4
60
+ const iconSetCoverageTolerance = 0.4
61
+ const iconSetStrokeTolerance = 0.4
62
+ const iconSetCoverageFloor = 0.06
63
+ const iconSetStrokeFloorPx = 4
64
+
65
+ const SET_CRITIQUE_SYSTEM = `You are a strict design reviewer judging whether a set of product illustrations belongs to one visual family.
66
+
67
+ The attached contact sheet shows every selected member of the set in row-major reading order, one subject per cell, in the order the message lists their slugs.
68
+
69
+ Intended family contract:
70
+ - Simple orthographic isometric projection in exactly one ink color.
71
+ - Uniform medium-weight structural strokes with at most three large filled planes.
72
+ - No hairlines, hatching, texture, tiny repeated detail, shading, gradients, shadows, text, borders, or background objects.
73
+
74
+ Judge the family as a whole, not each icon in isolation: matching stroke weight, matching ink density and level of detail, consistent projection angle and margins, and no member that looks drawn by a different hand. An icon that is individually fine but visibly sparser, denser, thinner-stroked, or more detailed than the family fails the set.
75
+
76
+ Return pass only when the full sheet reads as one family and every member still depicts its subject. For each member that breaks the family, give concrete problems and a promptFix describing how its image prompt should change.`
77
+
78
+ const INLINE_SET_CRITIQUE_CLAUSE = `
79
+
80
+ Context note: this is an inline family — every member renders beside text at about 32 to 48 pixels. Judge each cell as a small chip: bold flat or slightly dimensional pictograms satisfy the contract when their silhouettes stay recognizable at that size. Do not demand isometric depth or the detail level of a larger card illustration; consistency of ink, stroke weight, and mass balance across the sheet is what makes them one family.`
81
+
82
+ export interface SlopcameraIconSetMemberInput {
83
+ readonly context?: SlopcameraIconContext
84
+ readonly purpose?: SlopcameraIconPurpose
85
+ readonly slug: string
86
+ readonly subject: string
87
+ }
88
+
89
+ /**
90
+ * A reference member is an already-admitted family icon (slug + local SVG
91
+ * path resolved against the manifest). References are never regenerated or
92
+ * written: they join the measured median, the family band, and the
93
+ * contact-sheet critique so a new or partially refreshed set converges
94
+ * toward the family it will ship beside.
95
+ */
96
+ export interface SlopcameraIconSetReferenceInput {
97
+ readonly slug: string
98
+ readonly svg: string
99
+ }
100
+
101
+ export interface SlopcameraIconSetSpec {
102
+ readonly context?: SlopcameraIconContext
103
+ readonly ink?: string
104
+ readonly members: readonly SlopcameraIconSetMemberInput[]
105
+ readonly name?: string
106
+ readonly references?: readonly SlopcameraIconSetReferenceInput[]
107
+ }
108
+
109
+ export interface IconSetMemberVerdict {
110
+ readonly pass: boolean
111
+ readonly problems: readonly string[]
112
+ readonly promptFix: string
113
+ readonly slug: string
114
+ }
115
+
116
+ export interface IconSetCritique {
117
+ readonly familyProblems: readonly string[]
118
+ readonly members: readonly IconSetMemberVerdict[]
119
+ readonly pass: boolean
120
+ readonly resolvedModel: string | null
121
+ }
122
+
123
+ export interface IconSetCandidateReceipt {
124
+ readonly metrics: IconMeasuredMetrics
125
+ readonly round: number
126
+ readonly score?: number
127
+ readonly selected: boolean
128
+ }
129
+
130
+ export interface SlopcameraIconSetMemberReceipt {
131
+ readonly attempts: readonly IconAttemptReceipt[]
132
+ readonly candidates: readonly IconSetCandidateReceipt[]
133
+ readonly context?: SlopcameraIconContext
134
+ readonly metrics: IconMeasuredMetrics
135
+ readonly outputPath: string
136
+ readonly purpose: SlopcameraIconPurpose
137
+ readonly regenerated: number
138
+ readonly slug: string
139
+ readonly subject: string
140
+ readonly svgSha256: string
141
+ }
142
+
143
+ export interface SlopcameraIconSetReceipt {
144
+ readonly context?: SlopcameraIconContext
145
+ readonly ink: string
146
+ readonly members: readonly SlopcameraIconSetMemberReceipt[]
147
+ readonly model: string
148
+ readonly name?: string
149
+ readonly outputDir: string
150
+ readonly receiptVersion: 1
151
+ readonly setRoundsUsed: number
152
+ readonly setCritiques: readonly IconSetCritique[]
153
+ /** How many contact-sheet critiques were lost to transport errors. */
154
+ readonly setCritiqueErrors?: number
155
+ readonly target: {
156
+ readonly coverageRatio: number
157
+ readonly strokePx: number
158
+ }
159
+ }
160
+
161
+ export interface GenerateSlopcameraIconSetInput {
162
+ readonly candidatesPerMember?: number
163
+ readonly context?: SlopcameraIconContext
164
+ readonly critiqueModel?: string
165
+ readonly inheritedFileDescriptors?: readonly number[]
166
+ readonly ink?: string
167
+ readonly keepRaster?: boolean
168
+ /** Directory the spec's reference SVG paths resolve against. */
169
+ readonly manifestDir?: string
170
+ readonly model?: string
171
+ readonly outputDir: string
172
+ readonly purpose?: SlopcameraIconPurpose
173
+ readonly rounds?: number
174
+ readonly setRounds?: number
175
+ readonly signal?: AbortSignal
176
+ readonly spec: SlopcameraIconSetSpec
177
+ }
178
+
179
+ export interface SlopcameraIconSetDependencies
180
+ extends SlopcameraIconDependencies {
181
+ readonly collectCandidates?: typeof collectSlopcameraIconCandidates
182
+ readonly composeSheet?: (
183
+ members: ReadonlyArray<{ slug: string; svg: string }>,
184
+ ) => Promise<Uint8Array>
185
+ readonly setCritique?: (input: Readonly<{
186
+ context?: SlopcameraIconContext
187
+ ink: string
188
+ members: readonly string[]
189
+ model: string
190
+ png: Uint8Array
191
+ signal?: AbortSignal
192
+ }>) => Promise<IconSetCritique>
193
+ }
194
+
195
+ interface MemberLane {
196
+ readonly context: SlopcameraIconContext | undefined
197
+ readonly input: SlopcameraIconSetMemberInput
198
+ readonly purpose: SlopcameraIconPurpose
199
+ attempts: IconAttemptReceipt[]
200
+ eligible: IconCandidateRecord[]
201
+ regenerated: number
202
+ }
203
+
204
+ /**
205
+ * An admitted reference icon: fixed metrics and artwork that anchor the
206
+ * family target and appear in the contact sheet without being regenerated.
207
+ */
208
+ interface SetAnchor {
209
+ readonly metrics: IconMeasuredMetrics
210
+ readonly slug: string
211
+ readonly svg: string
212
+ }
213
+
214
+ function invalidArgument(message: string): never {
215
+ throw new SlopcameraCloudError("INVALID_ARGUMENT", message)
216
+ }
217
+
218
+ function isRecord(value: unknown): value is Record<string, unknown> {
219
+ return typeof value === "object" && value !== null && !Array.isArray(value)
220
+ }
221
+
222
+ function parseSetMember(value: unknown, index: number): SlopcameraIconSetMemberInput {
223
+ if (!isRecord(value)) {
224
+ invalidArgument(`Icon set member ${index} must be an object.`)
225
+ }
226
+ const slug = value.slug
227
+ if (typeof slug !== "string" || !slopcameraIconSetSlugPattern.test(slug)) {
228
+ invalidArgument(
229
+ `Icon set member ${index} slug must be lowercase kebab-case, at most 63 characters.`,
230
+ )
231
+ }
232
+ const subject = value.subject
233
+ if (
234
+ typeof subject !== "string" ||
235
+ subject.trim().length === 0 ||
236
+ Buffer.byteLength(subject, "utf8") > slopcameraIconSubjectMaximumBytes
237
+ ) {
238
+ invalidArgument(`Icon set member "${slug}" needs a bounded non-empty subject.`)
239
+ }
240
+ const purpose = value.purpose
241
+ if (
242
+ purpose !== undefined &&
243
+ purpose !== "illustration" &&
244
+ purpose !== "mark"
245
+ ) {
246
+ invalidArgument(`Icon set member "${slug}" purpose must be illustration or mark.`)
247
+ }
248
+ const context = value.context
249
+ if (
250
+ context !== undefined &&
251
+ (typeof context !== "string" ||
252
+ !slopcameraIconContexts.includes(context as SlopcameraIconContext))
253
+ ) {
254
+ invalidArgument(
255
+ `Icon set member "${slug}" context must be one of: ${slopcameraIconContexts.join(", ")}.`,
256
+ )
257
+ }
258
+ return {
259
+ ...(context === undefined
260
+ ? {}
261
+ : { context: context as SlopcameraIconContext }),
262
+ ...(purpose === undefined ? {} : { purpose }),
263
+ slug,
264
+ subject: subject.trim(),
265
+ }
266
+ }
267
+
268
+ /** Parse an untrusted icon-set manifest into a bounded, validated spec. */
269
+ export function parseSlopcameraIconSetSpec(
270
+ value: unknown,
271
+ ): SlopcameraIconSetSpec {
272
+ if (!isRecord(value)) {
273
+ invalidArgument("Icon set manifest must be a JSON object.")
274
+ }
275
+ const name = value.name
276
+ if (
277
+ name !== undefined &&
278
+ (typeof name !== "string" ||
279
+ name.trim().length === 0 ||
280
+ Buffer.byteLength(name, "utf8") > slopcameraIconSetMaximumNameBytes ||
281
+ /[/\\\u0000-\u001f\u007f]/u.test(name))
282
+ ) {
283
+ invalidArgument("Icon set name must be a bounded portable label.")
284
+ }
285
+ const ink = value.ink
286
+ if (
287
+ ink !== undefined &&
288
+ (typeof ink !== "string" || !/^#[a-f0-9]{3}(?:[a-f0-9]{3})?$/iu.test(ink))
289
+ ) {
290
+ invalidArgument("Icon set ink must be a #rgb or #rrggbb color.")
291
+ }
292
+ const context = value.context
293
+ if (
294
+ context !== undefined &&
295
+ (typeof context !== "string" ||
296
+ !slopcameraIconContexts.includes(context as SlopcameraIconContext))
297
+ ) {
298
+ invalidArgument(
299
+ `Icon set context must be one of: ${slopcameraIconContexts.join(", ")}.`,
300
+ )
301
+ }
302
+ const members = value.members
303
+ if (!Array.isArray(members) || members.length === 0) {
304
+ invalidArgument("Icon set members must be a non-empty array.")
305
+ }
306
+ if (members.length > slopcameraIconSetMaximumMembers) {
307
+ invalidArgument(
308
+ `Icon sets are bounded to ${slopcameraIconSetMaximumMembers} members.`,
309
+ )
310
+ }
311
+ const parsed = members.map((member, index) => parseSetMember(member, index))
312
+ const seen = new Set<string>()
313
+ for (const member of parsed) {
314
+ if (seen.has(member.slug)) {
315
+ invalidArgument(`Icon set slug "${member.slug}" is duplicated.`)
316
+ }
317
+ seen.add(member.slug)
318
+ }
319
+ const references = value.references
320
+ const parsedReferences: SlopcameraIconSetReferenceInput[] = []
321
+ if (references !== undefined) {
322
+ if (!Array.isArray(references) || references.length > 8) {
323
+ invalidArgument("Icon set references must be an array of at most 8 entries.")
324
+ }
325
+ for (const [index, reference] of (references as unknown[]).entries()) {
326
+ if (!isRecord(reference)) {
327
+ invalidArgument(`Icon set reference ${index} must be an object.`)
328
+ }
329
+ const slug = reference.slug
330
+ if (typeof slug !== "string" || !slopcameraIconSetSlugPattern.test(slug)) {
331
+ invalidArgument(
332
+ `Icon set reference ${index} slug must be lowercase kebab-case, at most 63 characters.`,
333
+ )
334
+ }
335
+ if (seen.has(slug)) {
336
+ invalidArgument(`Icon set slug "${slug}" is duplicated.`)
337
+ }
338
+ seen.add(slug)
339
+ const svg = reference.svg
340
+ if (
341
+ typeof svg !== "string" ||
342
+ svg.length < 1 ||
343
+ svg.length > 4_096 ||
344
+ svg.includes("\0") ||
345
+ !svg.toLowerCase().endsWith(".svg")
346
+ ) {
347
+ invalidArgument(`Icon set reference "${slug}" needs a bounded .svg path.`)
348
+ }
349
+ parsedReferences.push({ slug, svg })
350
+ }
351
+ }
352
+ return {
353
+ ...(context === undefined
354
+ ? {}
355
+ : { context: context as SlopcameraIconContext }),
356
+ ...(ink === undefined ? {} : { ink }),
357
+ members: parsed,
358
+ ...(name === undefined ? {} : { name: (name as string).trim() }),
359
+ ...(parsedReferences.length === 0
360
+ ? {}
361
+ : { references: parsedReferences }),
362
+ }
363
+ }
364
+
365
+ const iconSetCritiqueSchema = z.object({
366
+ familyProblems: z.array(z.string()),
367
+ members: z.array(
368
+ z.object({
369
+ pass: z.boolean(),
370
+ problems: z.array(z.string()),
371
+ promptFix: z.string(),
372
+ slug: z.string(),
373
+ }),
374
+ ),
375
+ pass: z.boolean(),
376
+ })
377
+
378
+ function boundedText(value: string, maximum: number): string {
379
+ return value.replace(/[\u0000-\u001f\u007f]/gu, " ").slice(0, maximum)
380
+ }
381
+
382
+ function parseIconSetCritique(
383
+ output: unknown,
384
+ resolvedModel: string | null,
385
+ memberSlugs: ReadonlySet<string>,
386
+ ): IconSetCritique {
387
+ const parsed = iconSetCritiqueSchema.safeParse(output)
388
+ if (!parsed.success) {
389
+ throw new SlopcameraCloudError(
390
+ "GENERATION_INVALID_RESPONSE",
391
+ "The icon-set critique returned an invalid bounded object.",
392
+ )
393
+ }
394
+ const members: IconSetMemberVerdict[] = []
395
+ const seen = new Set<string>()
396
+ for (const member of parsed.data.members) {
397
+ if (!memberSlugs.has(member.slug) || seen.has(member.slug)) continue
398
+ seen.add(member.slug)
399
+ members.push({
400
+ pass: member.pass,
401
+ problems: member.problems
402
+ .slice(0, 8)
403
+ .map(problem => boundedText(problem, 300)),
404
+ promptFix: boundedText(member.promptFix, 2_000),
405
+ slug: member.slug,
406
+ })
407
+ }
408
+ return {
409
+ familyProblems: parsed.data.familyProblems
410
+ .slice(0, 8)
411
+ .map(problem => boundedText(problem, 300)),
412
+ members,
413
+ pass: parsed.data.pass,
414
+ resolvedModel,
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Send the rendered contact sheet to the critique model for a family-level
420
+ * review. Same credential, retry, and bounded-response contract as the
421
+ * per-icon critique; only Slopcamera's own rendered output is uploaded.
422
+ */
423
+ export async function critiqueSlopcameraIconSet(
424
+ input: Readonly<{
425
+ context?: SlopcameraIconContext
426
+ ink: string
427
+ members: readonly string[]
428
+ model: string
429
+ png: Uint8Array
430
+ signal?: AbortSignal
431
+ }>,
432
+ dependencies: SlopcameraIconDependencies,
433
+ ): Promise<IconSetCritique> {
434
+ const runtime = await (dependencies.loadLanguageRuntime ??
435
+ loadIconLanguageRuntime)()
436
+ const controller = new AbortController()
437
+ const abort = (): void => controller.abort(input.signal?.reason)
438
+ input.signal?.addEventListener("abort", abort, { once: true })
439
+ if (input.signal?.aborted === true) abort()
440
+ const timer = setTimeout(
441
+ () => controller.abort(),
442
+ slopcameraIconCritiqueTimeoutMs,
443
+ )
444
+ try {
445
+ const apiKey = resolveSlopcameraGatewayCredential(
446
+ dependencies.environment,
447
+ ).token
448
+ const gateway = runtime.createGateway({
449
+ apiKey,
450
+ baseURL: slopcameraGatewayApiBaseUrl,
451
+ fetch: createFixedGatewayFetch({
452
+ ...(dependencies.fetch === undefined
453
+ ? {}
454
+ : { fetch: dependencies.fetch }),
455
+ maximumResponseBytes: 8 * 1024 * 1024,
456
+ }),
457
+ })
458
+ const output = runtime.Output.object({
459
+ description:
460
+ "One bounded family-consistency critique for a rendered icon set contact sheet.",
461
+ name: "slopcamera_icon_set_critique",
462
+ schema: iconSetCritiqueSchema,
463
+ })
464
+ const result = await runtime.generateText({
465
+ abortSignal: controller.signal,
466
+ maxOutputTokens: 4_096,
467
+ maxRetries: 0,
468
+ messages: [
469
+ {
470
+ content: [
471
+ {
472
+ text:
473
+ `Ink: ${input.ink}\n` +
474
+ (input.context === undefined
475
+ ? ""
476
+ : `Context: ${input.context}\n`) +
477
+ `Members in row-major order: ${input.members.join(", ")}\n` +
478
+ "Judge this contact sheet against the family contract.",
479
+ type: "text",
480
+ },
481
+ {
482
+ data: input.png,
483
+ mediaType: "image/png",
484
+ type: "file",
485
+ },
486
+ ],
487
+ role: "user",
488
+ },
489
+ ],
490
+ model: gateway.languageModel(input.model),
491
+ output,
492
+ providerOptions: {
493
+ gateway: {
494
+ disallowPromptTraining: true,
495
+ tags: ["slopcamera", "icon-set-critique", "v1"],
496
+ zeroDataRetention: true,
497
+ },
498
+ },
499
+ system:
500
+ input.context === "inline"
501
+ ? SET_CRITIQUE_SYSTEM + INLINE_SET_CRITIQUE_CLAUSE
502
+ : SET_CRITIQUE_SYSTEM,
503
+ temperature: 0,
504
+ })
505
+ return parseIconSetCritique(
506
+ isRecord(result) ? result.output : undefined,
507
+ resolvedModelId(isRecord(result) ? result.response : undefined),
508
+ new Set(input.members),
509
+ )
510
+ } catch (error) {
511
+ if (error instanceof SlopcameraCloudError) throw error
512
+ throw new SlopcameraCloudError(
513
+ "GENERATION_FAILED",
514
+ "The icon-set critique request failed; it was not retried.",
515
+ { cause: error },
516
+ )
517
+ } finally {
518
+ clearTimeout(timer)
519
+ input.signal?.removeEventListener("abort", abort)
520
+ }
521
+ }
522
+
523
+ function resolvedModelId(value: unknown): string | null {
524
+ const modelId = isRecord(value) ? value.modelId : undefined
525
+ return typeof modelId === "string" &&
526
+ modelId.length > 0 &&
527
+ modelId.length <= 256
528
+ ? modelId
529
+ : null
530
+ }
531
+
532
+ async function composeIconSetSheet(
533
+ members: ReadonlyArray<{ slug: string; svg: string }>,
534
+ ): Promise<Uint8Array> {
535
+ const sharp = (await import("sharp")).default
536
+ const cell = iconSetSheetCellEdge
537
+ const gutter = iconSetSheetGutter
538
+ const columns = Math.min(iconSetSheetColumns, members.length)
539
+ const rows = Math.ceil(members.length / columns)
540
+ const width = columns * cell + (columns + 1) * gutter
541
+ const height = rows * cell + (rows + 1) * gutter
542
+ const [panelRed, panelGreen, panelBlue] = [0xf7, 0xf8, 0xfb]
543
+ const composites: { input: Buffer; left: number; top: number }[] = []
544
+ for (let index = 0; index < members.length; index += 1) {
545
+ const member = members[index]!
546
+ const rendered = await sharp(Buffer.from(member.svg), {
547
+ density: 96,
548
+ failOn: "error",
549
+ limitInputPixels: vectorizeHardLimits.maxDecodedPixels,
550
+ })
551
+ .resize(cell, cell, { fit: "inside" })
552
+ .flatten({ background: slopcameraIconPanel })
553
+ .png({ compressionLevel: 9 })
554
+ .toBuffer()
555
+ const column = index % columns
556
+ const row = Math.floor(index / columns)
557
+ composites.push({
558
+ input: rendered,
559
+ left: gutter + column * (cell + gutter),
560
+ top: gutter + row * (cell + gutter),
561
+ })
562
+ }
563
+ return Uint8Array.from(
564
+ await sharp({
565
+ create: {
566
+ background: { b: panelBlue, g: panelGreen, r: panelRed },
567
+ channels: 3,
568
+ height,
569
+ width,
570
+ },
571
+ })
572
+ .composite(composites)
573
+ .png({ compressionLevel: 9 })
574
+ .toBuffer(),
575
+ )
576
+ }
577
+
578
+ function medianMetric(values: readonly number[]): number {
579
+ const sorted = [...values].sort((left, right) => left - right)
580
+ const middle = Math.floor(sorted.length / 2)
581
+ return sorted.length % 2 === 1
582
+ ? sorted[middle]!
583
+ : (sorted[middle - 1]! + sorted[middle]!) / 2
584
+ }
585
+
586
+ function coverageToleranceBand(target: number): readonly [number, number] {
587
+ const span = Math.max(iconSetCoverageFloor, target * iconSetCoverageTolerance)
588
+ return [target - span, target + span]
589
+ }
590
+
591
+ function strokeToleranceBand(target: number): readonly [number, number] {
592
+ const span = Math.max(iconSetStrokeFloorPx, target * iconSetStrokeTolerance)
593
+ return [target - span, target + span]
594
+ }
595
+
596
+ function familyDistance(
597
+ metrics: IconMeasuredMetrics,
598
+ target: Readonly<{ coverageRatio: number; strokePx: number }>,
599
+ ): number {
600
+ const coverageSpan = Math.max(
601
+ iconSetCoverageFloor,
602
+ target.coverageRatio * iconSetCoverageTolerance,
603
+ )
604
+ const strokeSpan = Math.max(
605
+ iconSetStrokeFloorPx,
606
+ target.strokePx * iconSetStrokeTolerance,
607
+ )
608
+ return (
609
+ Math.abs(metrics.coverageRatio - target.coverageRatio) / coverageSpan +
610
+ Math.abs(metrics.strokePx - target.strokePx) / strokeSpan
611
+ )
612
+ }
613
+
614
+ function pickMemberCandidate(
615
+ eligible: readonly IconCandidateRecord[],
616
+ target: Readonly<{ coverageRatio: number; strokePx: number }> | null,
617
+ ): IconCandidateRecord {
618
+ // A failed individual critique halves a candidate's quality rank: it stays
619
+ // usable (set mode collects with requireCritiquePass off) but loses ties to
620
+ // a critique-passing sibling candidate.
621
+ const quality = (record: IconCandidateRecord): number =>
622
+ record.critique === null || record.critique === undefined
623
+ ? -1
624
+ : record.critique.score * (record.critique.pass ? 1 : 0.5)
625
+ const ranked = [...eligible].sort((left, right) => {
626
+ if (target !== null) {
627
+ const distance =
628
+ familyDistance(left.metrics, target) -
629
+ familyDistance(right.metrics, target)
630
+ if (distance !== 0) return distance
631
+ }
632
+ return quality(right) - quality(left) || right.round - left.round
633
+ })
634
+ return ranked[0]!
635
+ }
636
+
637
+ /**
638
+ * Joint selection uses a leave-one-out family center: each member picks the
639
+ * candidate closest to the median of the *other* members' current picks, so
640
+ * an outlier is pulled toward its siblings instead of dragging the shared
641
+ * target toward itself. Gauss–Seidel passes over the manifest-ordered lanes
642
+ * keep the result deterministic.
643
+ */
644
+ function jointSelect(
645
+ lanes: readonly MemberLane[],
646
+ anchors: readonly SetAnchor[],
647
+ ): Readonly<{ picks: Map<string, IconCandidateRecord>; target: { coverageRatio: number; strokePx: number } }> {
648
+ const picks = new Map(
649
+ lanes.map(lane => [lane.input.slug, pickMemberCandidate(lane.eligible, null)]),
650
+ )
651
+ for (let pass = 0; pass < 4; pass += 1) {
652
+ let changed = false
653
+ for (const lane of lanes) {
654
+ const siblings = [
655
+ ...lanes
656
+ .filter(candidate => candidate !== lane)
657
+ .map(candidate => picks.get(candidate.input.slug)!.metrics),
658
+ ...anchors.map(anchor => anchor.metrics),
659
+ ]
660
+ const own = picks.get(lane.input.slug)!
661
+ const target =
662
+ siblings.length === 0
663
+ ? { coverageRatio: own.metrics.coverageRatio, strokePx: own.metrics.strokePx }
664
+ : {
665
+ coverageRatio: medianMetric(
666
+ siblings.map(metrics => metrics.coverageRatio),
667
+ ),
668
+ strokePx: medianMetric(
669
+ siblings.map(metrics => metrics.strokePx),
670
+ ),
671
+ }
672
+ const pick = pickMemberCandidate(lane.eligible, target)
673
+ if (pick !== own) {
674
+ picks.set(lane.input.slug, pick)
675
+ changed = true
676
+ }
677
+ }
678
+ if (!changed) break
679
+ }
680
+ const familyMetrics = [
681
+ ...[...picks.values()].map(record => record.metrics),
682
+ ...anchors.map(anchor => anchor.metrics),
683
+ ]
684
+ const target = {
685
+ coverageRatio: medianMetric(
686
+ familyMetrics.map(metrics => metrics.coverageRatio),
687
+ ),
688
+ strokePx: medianMetric(familyMetrics.map(metrics => metrics.strokePx)),
689
+ }
690
+ return { picks, target }
691
+ }
692
+
693
+ function outOfFamilyMembers(
694
+ lanes: readonly MemberLane[],
695
+ picks: ReadonlyMap<string, IconCandidateRecord>,
696
+ target: Readonly<{ coverageRatio: number; strokePx: number }>,
697
+ ): readonly string[] {
698
+ const coverageBand = coverageToleranceBand(target.coverageRatio)
699
+ const strokeBand = strokeToleranceBand(target.strokePx)
700
+ const outliers: string[] = []
701
+ for (const lane of lanes) {
702
+ const pick = picks.get(lane.input.slug)
703
+ if (pick === undefined) continue
704
+ if (
705
+ pick.metrics.coverageRatio < coverageBand[0] ||
706
+ pick.metrics.coverageRatio > coverageBand[1] ||
707
+ pick.metrics.strokePx < strokeBand[0] ||
708
+ pick.metrics.strokePx > strokeBand[1]
709
+ ) {
710
+ outliers.push(lane.input.slug)
711
+ }
712
+ }
713
+ return outliers
714
+ }
715
+
716
+ function directionalFamilyFeedback(
717
+ metrics: IconMeasuredMetrics,
718
+ target: Readonly<{ coverageRatio: number; strokePx: number }>,
719
+ familyProblems: readonly string[],
720
+ ): string {
721
+ const fixes: string[] = []
722
+ if (metrics.coverageRatio < target.coverageRatio) {
723
+ fixes.push(
724
+ "increase visual density to match the rest of the set: slightly more structural linework or one more filled plane",
725
+ )
726
+ } else if (metrics.coverageRatio > target.coverageRatio) {
727
+ fixes.push(
728
+ "reduce visual density to match the rest of the set: fewer planes and less interior detail",
729
+ )
730
+ }
731
+ if (metrics.strokePx < target.strokePx) {
732
+ fixes.push("use thicker uniform strokes like the rest of the set")
733
+ } else if (metrics.strokePx > target.strokePx) {
734
+ fixes.push("use thinner uniform strokes like the rest of the set")
735
+ }
736
+ const prefix = fixes.length === 0 ? [] : [`Match the family: ${fixes.join("; ")}.`]
737
+ return [...prefix, ...familyProblems]
738
+ .filter(part => part.length > 0)
739
+ .join(" ")
740
+ }
741
+
742
+ /**
743
+ * Generate a coherent icon family: per-member bounded candidate pools,
744
+ * joint selection toward the family's measured median, measured-band
745
+ * enforcement, and a contact-sheet vision critique. Members that leave the
746
+ * family band are regenerated with directional feedback inside a bounded
747
+ * number of set rounds. Fails closed when the set cannot converge.
748
+ */
749
+ export async function generateSlopcameraIconSet(
750
+ input: GenerateSlopcameraIconSetInput,
751
+ dependencies: SlopcameraIconSetDependencies = {},
752
+ ): Promise<SlopcameraIconSetReceipt> {
753
+ if (
754
+ typeof input.outputDir !== "string" ||
755
+ input.outputDir.length < 1 ||
756
+ input.outputDir.length > 4_096 ||
757
+ input.outputDir.includes("\0")
758
+ ) {
759
+ invalidArgument("outputDir must be a bounded local directory path.")
760
+ }
761
+ const rounds = input.rounds ?? 2
762
+ if (
763
+ !Number.isInteger(rounds) ||
764
+ rounds < 1 ||
765
+ rounds > slopcameraIconMaximumRounds
766
+ ) {
767
+ invalidArgument(`rounds must be an integer from 1 through ${slopcameraIconMaximumRounds}.`)
768
+ }
769
+ const requestedPool =
770
+ input.candidatesPerMember ?? slopcameraIconSetDefaultPool
771
+ if (
772
+ !Number.isInteger(requestedPool) ||
773
+ requestedPool < 1 ||
774
+ requestedPool > slopcameraIconMaximumRounds
775
+ ) {
776
+ invalidArgument(
777
+ `candidatesPerMember must be an integer from 1 through ${slopcameraIconMaximumRounds}.`,
778
+ )
779
+ }
780
+ // A pool can never hold more passing candidates than paid attempts.
781
+ const poolSize = Math.min(requestedPool, rounds)
782
+ const setRounds = input.setRounds ?? slopcameraIconSetDefaultSetRounds
783
+ if (
784
+ !Number.isInteger(setRounds) ||
785
+ setRounds < 1 ||
786
+ setRounds > slopcameraIconSetMaximumSetRounds
787
+ ) {
788
+ invalidArgument(
789
+ `setRounds must be an integer from 1 through ${slopcameraIconSetMaximumSetRounds}.`,
790
+ )
791
+ }
792
+ const ink = normalizedHexColor(input.ink ?? input.spec.ink ?? slopcameraIconDefaultInk)
793
+ const model = input.model ?? "recraft/recraft-v4.1-utility"
794
+ const critiqueModel = input.critiqueModel ?? slopcameraIconCritiqueDefaultModel
795
+ const critiqueEnabled = rounds > 1 || input.critiqueModel !== undefined
796
+ const collect = dependencies.collectCandidates ?? collectSlopcameraIconCandidates
797
+ const composeSheet = dependencies.composeSheet ?? composeIconSetSheet
798
+ const setCritique =
799
+ dependencies.setCritique ??
800
+ ((critiqueInput: {
801
+ context?: SlopcameraIconContext
802
+ ink: string
803
+ members: readonly string[]
804
+ model: string
805
+ png: Uint8Array
806
+ signal?: AbortSignal
807
+ }) => critiqueSlopcameraIconSet(critiqueInput, dependencies))
808
+
809
+ const lanes: MemberLane[] = input.spec.members.map(member => ({
810
+ attempts: [],
811
+ context:
812
+ member.context ?? input.spec.context ?? input.context ?? undefined,
813
+ eligible: [],
814
+ input: member,
815
+ purpose: member.purpose ?? input.purpose ?? "illustration",
816
+ regenerated: 0,
817
+ }))
818
+
819
+ const anchors: SetAnchor[] = []
820
+ const limits = resolveVectorizeLimits({})
821
+ for (const reference of input.spec.references ?? []) {
822
+ const referencePath = resolve(input.manifestDir ?? ".", reference.svg)
823
+ let svg: string
824
+ try {
825
+ svg = await readFile(referencePath, "utf8")
826
+ } catch {
827
+ throw new SlopcameraCloudError(
828
+ "INVALID_ARGUMENT",
829
+ `Icon set reference "${reference.slug}" could not be read: ${reference.svg}.`,
830
+ )
831
+ }
832
+ if (Buffer.byteLength(svg, "utf8") > 256 * 1024) {
833
+ invalidArgument(`Icon set reference "${reference.slug}" exceeds 256 KiB.`)
834
+ }
835
+ const png =
836
+ dependencies.rasterize === undefined
837
+ ? await renderIconPreview(svg, "illustration")
838
+ : await dependencies.rasterize(svg)
839
+ const raster = await loadRaster(
840
+ png,
841
+ limits,
842
+ new VectorizeDeadline(limits.maxDurationMs),
843
+ )
844
+ const extraction = extractIconLineArt(raster.pixels, raster.width, raster.height, {
845
+ hardEdges: true,
846
+ ink,
847
+ })
848
+ const pathCount = (svg.match(/<path/gu) ?? []).length
849
+ anchors.push({
850
+ metrics: measureIconCandidateMetrics(extraction, svg, pathCount),
851
+ slug: reference.slug,
852
+ svg,
853
+ })
854
+ }
855
+
856
+ const collectForLane = async (
857
+ lane: MemberLane,
858
+ initialFeedback?: string,
859
+ ): Promise<void> => {
860
+ const collected = await collect(
861
+ {
862
+ candidatePool: poolSize,
863
+ ...(lane.context === undefined ? {} : { context: lane.context }),
864
+ ...(input.critiqueModel === undefined
865
+ ? {}
866
+ : { critiqueModel: input.critiqueModel }),
867
+ requireCritiquePass: false,
868
+ ...(initialFeedback === undefined ? {} : { initialFeedback }),
869
+ ...(input.inheritedFileDescriptors === undefined
870
+ ? {}
871
+ : { inheritedFileDescriptors: input.inheritedFileDescriptors }),
872
+ ink,
873
+ model,
874
+ purpose: lane.purpose,
875
+ rounds,
876
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
877
+ subject: lane.input.subject,
878
+ },
879
+ dependencies,
880
+ )
881
+ lane.attempts.push(...collected.attempts)
882
+ lane.eligible.push(...collected.eligible)
883
+ }
884
+
885
+ // Phase one: every member gets its own bounded candidate pool.
886
+ for (const lane of lanes) {
887
+ try {
888
+ await collectForLane(lane)
889
+ } catch (error) {
890
+ const detail =
891
+ error instanceof Error ? ` Last error: ${error.message}` : ""
892
+ throw new SlopcameraCloudError(
893
+ "GENERATION_FAILED",
894
+ `Icon set member "${lane.input.slug}" produced no gated candidates.${detail}`,
895
+ { cause: error },
896
+ )
897
+ }
898
+ if (lane.eligible.length === 0) {
899
+ throw new SlopcameraCloudError(
900
+ "GENERATION_INVALID_RESPONSE",
901
+ `Icon set member "${lane.input.slug}" produced no gate-passing candidates.`,
902
+ )
903
+ }
904
+ }
905
+
906
+ const setCritiques: IconSetCritique[] = []
907
+ let setCritiqueErrors = 0
908
+ let setRoundsUsed = 0
909
+ for (let setRound = 1; setRound <= setRounds; setRound += 1) {
910
+ setRoundsUsed = setRound
911
+ const { picks, target } = jointSelect(lanes, anchors)
912
+ const outliers = outOfFamilyMembers(lanes, picks, target)
913
+ const fixes = new Map<string, string>()
914
+ if (outliers.length > 0) {
915
+ for (const slug of outliers) {
916
+ const pick = picks.get(slug)!
917
+ fixes.set(
918
+ slug,
919
+ directionalFamilyFeedback(pick.metrics, target, []),
920
+ )
921
+ }
922
+ } else if (critiqueEnabled) {
923
+ const sheet = await composeSheet([
924
+ ...lanes.map(lane => ({
925
+ slug: lane.input.slug,
926
+ svg: picks.get(lane.input.slug)!.candidate.svg,
927
+ })),
928
+ ...anchors.map(anchor => ({ slug: anchor.slug, svg: anchor.svg })),
929
+ ])
930
+ let verdict: IconSetCritique | null
931
+ try {
932
+ verdict = await setCritique({
933
+ ...(input.spec.context === undefined
934
+ ? {}
935
+ : { context: input.spec.context }),
936
+ ink,
937
+ members: lanes.map(lane => lane.input.slug),
938
+ model: critiqueModel,
939
+ png: sheet,
940
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
941
+ })
942
+ } catch {
943
+ // A critique transport failure burns the round but is not fatal: the
944
+ // measured band still constrains picks, and the next set round retries
945
+ // the sheet. The receipt records how many verdicts were lost.
946
+ setCritiqueErrors += 1
947
+ continue
948
+ }
949
+ setCritiques.push(verdict)
950
+ if (verdict.pass && verdict.members.every(member => member.pass)) {
951
+ break
952
+ }
953
+ for (const member of verdict.members) {
954
+ if (member.pass) continue
955
+ const fix = [member.promptFix, ...member.problems]
956
+ .filter(part => part.length > 0)
957
+ .join("; ")
958
+ fixes.set(member.slug, fix || "match the rest of the set")
959
+ }
960
+ if (fixes.size === 0 && verdict.familyProblems.length === 0) {
961
+ break
962
+ }
963
+ if (fixes.size === 0) {
964
+ // The model complained about the family without blaming a member;
965
+ // steer the measured outlier if one exists, else the lowest scorer.
966
+ const worst = [...picks.entries()].sort(
967
+ (left, right) =>
968
+ familyDistance(right[1].metrics, target) -
969
+ familyDistance(left[1].metrics, target),
970
+ )[0]!
971
+ fixes.set(
972
+ worst[0],
973
+ directionalFamilyFeedback(
974
+ worst[1].metrics,
975
+ target,
976
+ verdict.familyProblems,
977
+ ),
978
+ )
979
+ }
980
+ } else {
981
+ break
982
+ }
983
+ if (setRound === setRounds) break
984
+ for (const [slug, fix] of fixes) {
985
+ const lane = lanes.find(candidate => candidate.input.slug === slug)!
986
+ lane.regenerated += 1
987
+ try {
988
+ await collectForLane(lane, fix)
989
+ } catch {
990
+ // A failed regen round keeps the lane's existing candidates; the
991
+ // selection below still enforces the measured band where possible.
992
+ }
993
+ }
994
+ }
995
+
996
+ const { picks, target } = jointSelect(lanes, anchors)
997
+ const finalOutliers = outOfFamilyMembers(lanes, picks, target)
998
+ const unresolvedCritique = setCritiques.length > 0 &&
999
+ !setCritiques[setCritiques.length - 1]!.pass
1000
+ ? setCritiques[setCritiques.length - 1]!
1001
+ .members.filter(member => !member.pass)
1002
+ .map(member => member.slug)
1003
+ : []
1004
+ const outputDir = resolve(input.outputDir)
1005
+ const buildReceipt = (): SlopcameraIconSetReceipt => {
1006
+ const memberReceipts: SlopcameraIconSetMemberReceipt[] = lanes.map(lane => {
1007
+ const pick = picks.get(lane.input.slug)!
1008
+ return {
1009
+ attempts: lane.attempts,
1010
+ candidates: lane.eligible.map(record => ({
1011
+ metrics: record.metrics,
1012
+ round: record.round,
1013
+ ...(record.critique === null
1014
+ ? {}
1015
+ : { pass: record.critique.pass, score: record.critique.score }),
1016
+ selected: record === pick,
1017
+ })),
1018
+ ...(lane.context === undefined ? {} : { context: lane.context }),
1019
+ metrics: pick.metrics,
1020
+ outputPath: `${outputDir}/${lane.input.slug}.svg`,
1021
+ purpose: lane.purpose,
1022
+ regenerated: lane.regenerated,
1023
+ slug: lane.input.slug,
1024
+ subject: lane.input.subject,
1025
+ svgSha256: createHash("sha256").update(pick.candidate.svg).digest("hex"),
1026
+ }
1027
+ })
1028
+ const receipt: SlopcameraIconSetReceipt = {
1029
+ ...(input.spec.context === undefined && input.context === undefined
1030
+ ? {}
1031
+ : { context: input.spec.context ?? input.context }),
1032
+ ink,
1033
+ members: memberReceipts,
1034
+ model,
1035
+ ...(input.spec.name === undefined ? {} : { name: input.spec.name }),
1036
+ outputDir,
1037
+ receiptVersion: 1,
1038
+ setCritiques,
1039
+ ...(setCritiqueErrors === 0 ? {} : { setCritiqueErrors }),
1040
+ setRoundsUsed,
1041
+ target,
1042
+ }
1043
+ return receipt
1044
+ }
1045
+ if (finalOutliers.length > 0 || unresolvedCritique.length > 0) {
1046
+ const unresolved = [...new Set([...finalOutliers, ...unresolvedCritique])]
1047
+ // Failed runs still publish their diagnostic receipt: the per-member
1048
+ // candidate metrics and set critiques are exactly what a maintainer needs
1049
+ // to see why the family could not converge, and they record the paid
1050
+ // attempts that produced them.
1051
+ await writeAtomically(
1052
+ `${outputDir}/${input.spec.name ?? "icon-set"}.failed.receipt.json`,
1053
+ JSON.stringify(
1054
+ { ...buildReceipt(), unresolvedMembers: unresolved },
1055
+ null,
1056
+ 2,
1057
+ ),
1058
+ )
1059
+ throw new SlopcameraCloudError(
1060
+ "GENERATION_INVALID_RESPONSE",
1061
+ `The icon set did not converge inside ${setRounds} set rounds; ` +
1062
+ `unresolved members: ${unresolved.join(", ")}. ` +
1063
+ `Diagnostic receipt: ${outputDir}/${input.spec.name ?? "icon-set"}.failed.receipt.json`,
1064
+ )
1065
+ }
1066
+
1067
+ for (const lane of lanes) {
1068
+ const pick = picks.get(lane.input.slug)!
1069
+ await writeAtomically(
1070
+ `${outputDir}/${lane.input.slug}.svg`,
1071
+ pick.candidate.svg,
1072
+ )
1073
+ if (input.keepRaster === true) {
1074
+ await writeAtomically(
1075
+ `${outputDir}/${lane.input.slug}.lineart.png`,
1076
+ pick.candidate.png,
1077
+ )
1078
+ }
1079
+ }
1080
+ const receipt = buildReceipt()
1081
+ await writeAtomically(
1082
+ `${outputDir}/${input.spec.name ?? "icon-set"}.receipt.json`,
1083
+ JSON.stringify(receipt, null, 2),
1084
+ )
1085
+ return receipt
1086
+ }