@hraness/slopcamera 3.6.0 → 3.8.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 (57) hide show
  1. package/README.md +49 -33
  2. package/apps/desktop/README.md +3 -10
  3. package/apps/desktop/application/html-overlay-renderer.ts +6 -0
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/application/spatial-assets.ts +1 -1
  6. package/apps/desktop/cli/{menubar-status.ts → activity-status.ts} +24 -21
  7. package/apps/desktop/cli/args.ts +62 -10
  8. package/apps/desktop/cli/command-host-resources.ts +5 -1
  9. package/apps/desktop/cli/commands.ts +26 -9
  10. package/apps/desktop/cli/desktop-status.ts +271 -0
  11. package/apps/desktop/cli/help.ts +36 -28
  12. package/apps/desktop/cli/html-film-init.ts +158 -0
  13. package/apps/desktop/cli/html-film-names.ts +17 -0
  14. package/apps/desktop/cli/html-film.ts +471 -0
  15. package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
  16. package/apps/desktop/cli/html-scene.ts +3 -3
  17. package/apps/desktop/cli/legacy-login.ts +184 -0
  18. package/apps/desktop/cli/main.ts +9 -0
  19. package/apps/desktop/cli/outputs.ts +19 -0
  20. package/apps/desktop/cli/portable-surface.ts +55 -1
  21. package/apps/desktop/cli/registry.ts +314 -0
  22. package/apps/desktop/cli/verbs.ts +193 -0
  23. package/apps/desktop/dist/cli/main.js +338 -316
  24. package/apps/desktop/html-overlay/film.ts +418 -0
  25. package/dist/cli.js +13 -2
  26. package/dist/generate.js +1 -1
  27. package/dist/{index-4z9y2wa2.js → index-842t02pp.js} +1 -1
  28. package/dist/{index-qxfexvj3.js → index-k9bjw80z.js} +20 -4
  29. package/dist/index-p74xy76d.js +3 -0
  30. package/dist/{index-fevf4ppk.js → index-y8spxs77.js} +10 -7
  31. package/dist/index.js +1 -1
  32. package/dist/operations.js +1 -1
  33. package/dist/workflow.js +1 -1
  34. package/docs/README.md +6 -6
  35. package/examples/html/launch-film/README.md +51 -0
  36. package/examples/html/launch-film/build.ts +158 -0
  37. package/examples/html/launch-film/film.css +593 -0
  38. package/examples/html/launch-film/film.html +85 -0
  39. package/examples/html/launch-film/film.js +410 -0
  40. package/examples/html/launch-film/film.json +63 -0
  41. package/examples/html/launch-film/mockups.tsx +74 -0
  42. package/examples/html/launch-film/timeline.ts +57 -0
  43. package/package.json +24 -11
  44. package/skills/slopcamera/SKILL.md +4 -3
  45. package/skills/slopcamera/references/install.md +3 -3
  46. package/skills/slopcamera/references/launch-films.md +158 -0
  47. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  48. package/skills/slopcamera/references/visual-communication.md +1 -1
  49. package/src/cli.ts +125 -9
  50. package/src/generation-pricing.ts +21 -1
  51. package/src/icon-set.ts +1086 -0
  52. package/src/icon.ts +396 -59
  53. package/src/index.ts +1 -0
  54. package/src/operations.ts +52 -0
  55. package/src/version.ts +1 -1
  56. package/apps/desktop/cli/menubar.ts +0 -272
  57. package/dist/index-nxtmy18t.js +0 -3
package/src/icon.ts CHANGED
@@ -54,6 +54,67 @@ const iconPreviewMaximumEdge = 448
54
54
  const iconCoverageAlphaFloor = 24
55
55
  const iconCropAlphaFloor = 16
56
56
  const iconMarginRatio = 0.08
57
+ const iconMetricNormalizationEdge = 512
58
+ const iconStrokeMinimumRemaining = 0.1
59
+
60
+ /**
61
+ * Presentation contexts tune the illustration prompt and its deterministic
62
+ * gates to the surface the artwork lands on. `card` matches the marketing
63
+ * topic-card pattern (about 88 px), `hero` allows richer planes for large
64
+ * headers, and `inline` demands the boldest, simplest silhouettes for
65
+ * text-sized slots. Marks keep their own small-size contract and ignore
66
+ * context.
67
+ */
68
+ export type SlopcameraIconContext = "card" | "hero" | "inline"
69
+ export const slopcameraIconContexts: readonly SlopcameraIconContext[] = [
70
+ "card",
71
+ "hero",
72
+ "inline",
73
+ ]
74
+
75
+ export interface SlopcameraIconContextProfile {
76
+ readonly aspectMaximum: number
77
+ readonly coverageMaximum: number
78
+ readonly coverageMinimum: number
79
+ readonly pathCountMaximum: number
80
+ readonly prompt: string
81
+ readonly strokePxMinimum: number
82
+ }
83
+
84
+ export const slopcameraIconContextProfiles: Readonly<
85
+ Record<SlopcameraIconContext, SlopcameraIconContextProfile>
86
+ > = Object.freeze({
87
+ card: {
88
+ aspectMaximum: 1.6,
89
+ coverageMaximum: 0.55,
90
+ coverageMinimum: 0.08,
91
+ pathCountMaximum: 64,
92
+ prompt:
93
+ "The illustration sits in a compact feature card rendered about 88 pixels tall beside sibling cards. " +
94
+ "Keep the silhouette readable at that size: medium-bold uniform strokes, a few large planes, and minimal interior detail.",
95
+ strokePxMinimum: 8,
96
+ },
97
+ hero: {
98
+ aspectMaximum: 1.8,
99
+ coverageMaximum: 0.65,
100
+ coverageMinimum: 0.05,
101
+ pathCountMaximum: 96,
102
+ prompt:
103
+ "The illustration leads a marketing hero at roughly 300 pixels or larger. " +
104
+ "Slightly richer planes are acceptable, but stay inside the single-ink isometric family.",
105
+ strokePxMinimum: 5,
106
+ },
107
+ inline: {
108
+ aspectMaximum: 1.5,
109
+ coverageMaximum: 0.55,
110
+ coverageMinimum: 0.14,
111
+ pathCountMaximum: 32,
112
+ prompt:
113
+ "The illustration appears inline beside text at about 32 to 48 pixels. " +
114
+ "Reduce the subject to one or two bold masses with no interior detail.",
115
+ strokePxMinimum: 12,
116
+ },
117
+ })
57
118
 
58
119
  const MARK_CRITIQUE_SYSTEM = `You are a strict design reviewer for small product marks.
59
120
 
@@ -75,6 +136,10 @@ Style contract:
75
136
 
76
137
  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
138
 
139
+ const INLINE_CRITIQUE_CLAUSE = `
140
+
141
+ Context note: this illustration renders inline beside text at about 32 to 48 pixels. Judge silhouette legibility at that size above isometric depth — a flat bold pictogram satisfies the contract when it reads clearly and still carries one ink color on the flat near-white background.`
142
+
78
143
  export interface IconCritique {
79
144
  readonly pass: boolean
80
145
  readonly problems: readonly string[]
@@ -86,6 +151,7 @@ export interface IconCritique {
86
151
  export interface IconAttemptReceipt {
87
152
  readonly critiqueError?: string
88
153
  readonly critiqueModel?: string
154
+ readonly metrics?: IconMeasuredMetrics
89
155
  readonly pass?: boolean
90
156
  readonly problems?: readonly string[]
91
157
  readonly requestId: string
@@ -114,6 +180,8 @@ export interface SlopcameraIconReceipt {
114
180
  export type SlopcameraIconPurpose = "illustration" | "mark"
115
181
 
116
182
  export interface GenerateSlopcameraIconInput {
183
+ readonly candidatePool?: number
184
+ readonly context?: SlopcameraIconContext
117
185
  readonly critiqueModel?: string
118
186
  readonly inheritedFileDescriptors?: readonly number[]
119
187
  readonly ink?: string
@@ -134,7 +202,65 @@ export interface IconCandidate {
134
202
  readonly warnings: readonly string[]
135
203
  }
136
204
 
137
- interface IconLanguageRuntime {
205
+ /**
206
+ * Deterministic measurements taken from the normalized line art and its
207
+ * traced SVG, normalized to the 512 px edge the set machinery compares on.
208
+ * These are the quantities a family must share: ink density inside the
209
+ * content box, normalized stroke weight, aspect, and vector detail.
210
+ */
211
+ export interface IconMeasuredMetrics {
212
+ readonly aspectRatio: number
213
+ readonly bytes: number
214
+ readonly coverageRatio: number
215
+ readonly pathCount: number
216
+ readonly strokePx: number
217
+ }
218
+
219
+ export interface IconCandidateRecord {
220
+ readonly candidate: IconCandidate
221
+ readonly critique: IconCritique | null
222
+ readonly extraction: IconLineArtExtraction
223
+ readonly metrics: IconMeasuredMetrics
224
+ readonly round: number
225
+ }
226
+
227
+ export interface CollectSlopcameraIconCandidatesInput {
228
+ readonly candidatePool?: number
229
+ readonly context?: SlopcameraIconContext
230
+ readonly critiqueModel?: string
231
+ readonly initialFeedback?: string
232
+ readonly inheritedFileDescriptors?: readonly number[]
233
+ readonly ink?: string
234
+ readonly model?: string
235
+ readonly purpose?: SlopcameraIconPurpose
236
+ /**
237
+ * When false, a failed design critique keeps the candidate eligible but
238
+ * ranked below critique-passing candidates — set generation uses this so
239
+ * the family contact-sheet critique stays the authority over individual
240
+ * verdicts. Defaults to true, preserving single-icon fail-closed behavior.
241
+ */
242
+ readonly requireCritiquePass?: boolean
243
+ readonly rounds?: number
244
+ readonly signal?: AbortSignal
245
+ readonly subject: string
246
+ }
247
+
248
+ export interface CollectSlopcameraIconCandidatesResult {
249
+ readonly attempts: readonly IconAttemptReceipt[]
250
+ readonly candidates: readonly IconCandidateRecord[]
251
+ readonly eligible: readonly IconCandidateRecord[]
252
+ readonly resolved: {
253
+ readonly context: SlopcameraIconContext | undefined
254
+ readonly critiqueModel: string
255
+ readonly ink: string
256
+ readonly model: string
257
+ readonly purpose: SlopcameraIconPurpose
258
+ readonly rounds: number
259
+ readonly subject: string
260
+ }
261
+ }
262
+
263
+ export interface IconLanguageRuntime {
138
264
  readonly Output: {
139
265
  object(input: {
140
266
  readonly description: string
@@ -229,6 +355,7 @@ function validateRounds(value: number | undefined): number {
229
355
  export function iconPromptFor(
230
356
  subject: string,
231
357
  options: Readonly<{
358
+ context?: SlopcameraIconContext
232
359
  feedback?: string
233
360
  ink?: string
234
361
  purpose?: SlopcameraIconPurpose
@@ -246,10 +373,18 @@ export function iconPromptFor(
246
373
  ]
247
374
  : [
248
375
  `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.",
376
+ options.context === "inline"
377
+ ? "Style rules: a bold flat pictogram or slightly dimensional shape; uniform bold structural lines with at most two large filled masses; " +
378
+ `one single ink color ${ink} on a flat near-white background ${slopcameraIconPanel}; ` +
379
+ "no hairlines, hatching, texture, repeated detail, shading, gradients, shadows, text, border, or extra objects; " +
380
+ "the silhouette must remain recognizable at 32 pixels; centered with a modest clear margin; clean geometric edges."
381
+ : "Style rules: simple orthographic isometric projection; uniform medium-weight structural lines and at most three large filled planes; " +
382
+ `one single ink color ${ink} on a flat near-white background ${slopcameraIconPanel}; ` +
383
+ "no hairlines, hatching, texture, tiny repeated detail, shading, gradients, shadows, text, border, or extra objects; " +
384
+ "every important feature remains visible at 64 pixels; centered with a modest clear margin; clean geometric edges.",
385
+ ...(options.context === undefined
386
+ ? []
387
+ : [`Placement: ${slopcameraIconContextProfiles[options.context].prompt}`]),
253
388
  ]
254
389
  const feedback = options.feedback?.trim()
255
390
  if (feedback !== undefined && feedback.length > 0) {
@@ -437,24 +572,119 @@ export interface IconLineArtExtraction {
437
572
 
438
573
  export function iconVisualGateProblems(
439
574
  purpose: SlopcameraIconPurpose,
440
- metrics: Readonly<Pick<IconLineArtExtraction, "coverageRatio" | "height" | "width">>,
575
+ metrics: Readonly<
576
+ Pick<IconLineArtExtraction, "coverageRatio" | "height" | "width"> & {
577
+ strokePx?: number
578
+ }
579
+ >,
441
580
  pathCount: number,
581
+ context?: SlopcameraIconContext,
442
582
  ): readonly string[] {
443
583
  const problems: string[] = []
444
584
  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
585
  if (purpose === "mark") {
586
+ if (aspectRatio > 1.8) problems.push("the subject is too narrow or elongated")
447
587
  if (metrics.coverageRatio < 0.14) problems.push("the mark has too little bold visual mass")
448
588
  if (metrics.coverageRatio > 0.72) problems.push("the mark has too little negative space")
449
589
  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")
590
+ return problems
591
+ }
592
+ const profile =
593
+ context === undefined ? undefined : slopcameraIconContextProfiles[context]
594
+ const aspectMaximum = profile?.aspectMaximum ?? 1.8
595
+ const coverageMinimum = profile?.coverageMinimum ?? 0.035
596
+ const coverageMaximum = profile?.coverageMaximum ?? 0.62
597
+ const pathCountMaximum = profile?.pathCountMaximum ?? 48
598
+ const placement = context === undefined ? "" : ` for the ${context} context`
599
+ if (aspectRatio > aspectMaximum) {
600
+ problems.push(`the subject is too narrow or elongated${placement}`)
601
+ }
602
+ if (metrics.coverageRatio < coverageMinimum) {
603
+ problems.push(`the illustration lines are too sparse or thin${placement}`)
604
+ }
605
+ if (metrics.coverageRatio > coverageMaximum) {
606
+ problems.push(`the illustration is too visually dense${placement}`)
607
+ }
608
+ if (pathCount > pathCountMaximum) {
609
+ problems.push(`the illustration has too much vector detail${placement}`)
610
+ }
611
+ if (
612
+ profile !== undefined &&
613
+ metrics.strokePx !== undefined &&
614
+ metrics.strokePx < profile.strokePxMinimum
615
+ ) {
616
+ problems.push(`the illustration strokes are too thin${placement}`)
454
617
  }
455
618
  return problems
456
619
  }
457
620
 
621
+ /**
622
+ * Estimate the dominant stroke width of extracted line art. Repeated binary
623
+ * erosion sheds one pixel from each side per round; the round count until
624
+ * coverage collapses approximates half the stroke width. The result is
625
+ * normalized to the shared 512 px comparison edge so set members measured at
626
+ * different source resolutions compare on the same scale.
627
+ */
628
+ export function estimateIconStrokePixels(
629
+ extraction: Readonly<Pick<IconLineArtExtraction, "height" | "pixels" | "width">>,
630
+ ): number {
631
+ const { height, pixels, width } = extraction
632
+ if (width < 3 || height < 3) return 0
633
+ let mask = new Uint8Array(width * height)
634
+ let remaining = 0
635
+ for (let index = 0; index < mask.length; index += 1) {
636
+ if (pixels[index * 4 + 3]! >= iconCoverageAlphaFloor) {
637
+ mask[index] = 1
638
+ remaining += 1
639
+ }
640
+ }
641
+ if (remaining === 0) return 0
642
+ const initial = remaining
643
+ let rounds = 0
644
+ while (rounds < 32 && remaining > initial * iconStrokeMinimumRemaining) {
645
+ const next = new Uint8Array(mask.length)
646
+ let count = 0
647
+ for (let y = 1; y < height - 1; y += 1) {
648
+ for (let x = 1; x < width - 1; x += 1) {
649
+ const index = y * width + x
650
+ if (
651
+ mask[index] === 1 &&
652
+ mask[index - 1] === 1 &&
653
+ mask[index + 1] === 1 &&
654
+ mask[index - width] === 1 &&
655
+ mask[index + width] === 1
656
+ ) {
657
+ next[index] = 1
658
+ count += 1
659
+ }
660
+ }
661
+ }
662
+ mask = next
663
+ remaining = count
664
+ rounds += 1
665
+ }
666
+ const sourceStroke = rounds * 2
667
+ const scale = iconMetricNormalizationEdge / Math.max(width, height)
668
+ return sourceStroke * scale
669
+ }
670
+
671
+ export function measureIconCandidateMetrics(
672
+ extraction: Readonly<Pick<IconLineArtExtraction, "coverageRatio" | "height" | "pixels" | "width">>,
673
+ svg: string,
674
+ pathCount: number,
675
+ ): IconMeasuredMetrics {
676
+ return {
677
+ aspectRatio: Math.max(
678
+ extraction.width / extraction.height,
679
+ extraction.height / extraction.width,
680
+ ),
681
+ bytes: Buffer.byteLength(svg, "utf8"),
682
+ coverageRatio: extraction.coverageRatio,
683
+ pathCount,
684
+ strokePx: estimateIconStrokePixels(extraction),
685
+ }
686
+ }
687
+
458
688
  /**
459
689
  * Normalize a generated raster into canonical ink-on-transparent RGBA.
460
690
  * Background is the border median; ink is the dominant far-from-background
@@ -620,7 +850,7 @@ function parseIconCritique(
620
850
  }
621
851
  }
622
852
 
623
- async function loadIconLanguageRuntime(): Promise<IconLanguageRuntime> {
853
+ export async function loadIconLanguageRuntime(): Promise<IconLanguageRuntime> {
624
854
  let aiModule: unknown
625
855
  let gatewayModule: unknown
626
856
  try {
@@ -686,6 +916,7 @@ function resolvedModelId(value: unknown): string | null {
686
916
 
687
917
  export async function critiqueIconRaster(
688
918
  input: Readonly<{
919
+ context?: SlopcameraIconContext
689
920
  ink: string
690
921
  model: string
691
922
  png: Uint8Array
@@ -735,6 +966,9 @@ export async function critiqueIconRaster(
735
966
  {
736
967
  text:
737
968
  `Subject: ${input.subject}\nPurpose: ${input.purpose}\nInk: ${input.ink}\n` +
969
+ (input.context === undefined
970
+ ? ""
971
+ : `Context: ${input.context}\n`) +
738
972
  `Judge this rendered ${input.purpose} against the style contract.`,
739
973
  type: "text",
740
974
  },
@@ -758,7 +992,9 @@ export async function critiqueIconRaster(
758
992
  },
759
993
  system: input.purpose === "mark"
760
994
  ? MARK_CRITIQUE_SYSTEM
761
- : ILLUSTRATION_CRITIQUE_SYSTEM,
995
+ : input.context === "inline"
996
+ ? ILLUSTRATION_CRITIQUE_SYSTEM + INLINE_CRITIQUE_CLAUSE
997
+ : ILLUSTRATION_CRITIQUE_SYSTEM,
762
998
  temperature: 0,
763
999
  })
764
1000
  return parseIconCritique(
@@ -779,7 +1015,7 @@ export async function critiqueIconRaster(
779
1015
 
780
1016
  const markPreviewSmallSizes = [16, 32] as const
781
1017
 
782
- async function renderIconPreview(
1018
+ export async function renderIconPreview(
783
1019
  svg: string,
784
1020
  purpose: SlopcameraIconPurpose,
785
1021
  ): Promise<Uint8Array> {
@@ -860,7 +1096,7 @@ async function renderIconPreview(
860
1096
  }
861
1097
  }
862
1098
 
863
- async function writeAtomically(
1099
+ export async function writeAtomically(
864
1100
  path: string,
865
1101
  value: string | Uint8Array,
866
1102
  ): Promise<string> {
@@ -881,34 +1117,61 @@ async function writeAtomically(
881
1117
  }
882
1118
  }
883
1119
 
1120
+ function validateCandidatePool(
1121
+ value: number | undefined,
1122
+ rounds: number,
1123
+ ): number {
1124
+ const pool = value ?? 1
1125
+ if (
1126
+ !Number.isInteger(pool) ||
1127
+ pool < 1 ||
1128
+ pool > slopcameraIconMaximumRounds ||
1129
+ pool > rounds
1130
+ ) {
1131
+ invalidArgument(
1132
+ `candidatePool must be an integer from 1 through ${Math.min(rounds, slopcameraIconMaximumRounds)}.`,
1133
+ )
1134
+ }
1135
+ return pool
1136
+ }
1137
+
1138
+ function validateIconContext(
1139
+ value: unknown,
1140
+ ): SlopcameraIconContext | undefined {
1141
+ if (value === undefined) return undefined
1142
+ if (
1143
+ typeof value !== "string" ||
1144
+ !slopcameraIconContexts.includes(value as SlopcameraIconContext)
1145
+ ) {
1146
+ invalidArgument(
1147
+ `context must be one of: ${slopcameraIconContexts.join(", ")}.`,
1148
+ )
1149
+ }
1150
+ return value as SlopcameraIconContext
1151
+ }
1152
+
884
1153
  /**
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.
1154
+ * Run the bounded generate → extract → trace → gate → critique loop and keep
1155
+ * every candidate that clears the deterministic gate. `candidatePool`
1156
+ * controls how many critique-passing (or critique-free) candidates to retain
1157
+ * before stopping; one reproduces the historic stop-at-first-pass behavior.
1158
+ * Set generation asks for a small pool so joint selection can pick the best
1159
+ * family member instead of the single highest score.
890
1160
  */
891
- export async function generateSlopcameraIcon(
892
- input: GenerateSlopcameraIconInput,
1161
+ export async function collectSlopcameraIconCandidates(
1162
+ input: CollectSlopcameraIconCandidatesInput,
893
1163
  dependencies: SlopcameraIconDependencies = {},
894
- ): Promise<SlopcameraIconReceipt> {
1164
+ ): Promise<CollectSlopcameraIconCandidatesResult> {
895
1165
  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
1166
  const model = validateIconModel(input.model ?? "recraft/recraft-v4.1-utility", "model")
906
1167
  const rounds = validateRounds(input.rounds)
907
1168
  const purpose = input.purpose ?? "illustration"
908
1169
  if (purpose !== "illustration" && purpose !== "mark") {
909
1170
  invalidArgument("purpose must be illustration or mark.")
910
1171
  }
1172
+ const context = validateIconContext(input.context)
911
1173
  const ink = normalizedHexColor(input.ink ?? slopcameraIconDefaultInk)
1174
+ const pool = validateCandidatePool(input.candidatePool, rounds)
912
1175
  const critiqueModel =
913
1176
  input.critiqueModel === undefined
914
1177
  ? slopcameraIconCritiqueDefaultModel
@@ -928,13 +1191,10 @@ export async function generateSlopcameraIcon(
928
1191
  const limits = resolveVectorizeLimits({})
929
1192
 
930
1193
  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
1194
+ const candidates: IconCandidateRecord[] = []
1195
+ const eligible: IconCandidateRecord[] = []
1196
+ let feedback: string | undefined =
1197
+ input.initialFeedback?.trim() || undefined
938
1198
  let lastError: unknown
939
1199
  for (let round = 1; round <= rounds; round += 1) {
940
1200
  if (input.signal?.aborted === true) {
@@ -946,10 +1206,12 @@ export async function generateSlopcameraIcon(
946
1206
  const prompt = iconPromptFor(subject, {
947
1207
  ink,
948
1208
  purpose,
1209
+ ...(context === undefined ? {} : { context }),
949
1210
  ...(feedback === undefined ? {} : { feedback }),
950
1211
  })
951
1212
  let candidate: IconCandidate
952
1213
  let extraction: IconLineArtExtraction
1214
+ let metrics: IconMeasuredMetrics
953
1215
  let attemptRequestId = ""
954
1216
  let attemptProblems: readonly string[] = []
955
1217
  try {
@@ -982,10 +1244,16 @@ export async function generateSlopcameraIcon(
982
1244
  ? {}
983
1245
  : { inheritedFileDescriptors: input.inheritedFileDescriptors }),
984
1246
  })
1247
+ metrics = measureIconCandidateMetrics(
1248
+ extraction,
1249
+ traced.svg,
1250
+ traced.receipt.pathCount,
1251
+ )
985
1252
  const visualProblems = iconVisualGateProblems(
986
1253
  purpose,
987
- extraction,
1254
+ { ...extraction, strokePx: metrics.strokePx },
988
1255
  traced.receipt.pathCount,
1256
+ context,
989
1257
  )
990
1258
  if (visualProblems.length > 0) {
991
1259
  feedback = visualProblems.join("; ")
@@ -1022,6 +1290,7 @@ export async function generateSlopcameraIcon(
1022
1290
  ? await renderIconPreview(candidate.svg, purpose)
1023
1291
  : await dependencies.rasterize(candidate.svg)
1024
1292
  review = await critique({
1293
+ ...(context === undefined ? {} : { context }),
1025
1294
  ink,
1026
1295
  model: critiqueModel,
1027
1296
  png: preview,
@@ -1037,10 +1306,18 @@ export async function generateSlopcameraIcon(
1037
1306
  review = null
1038
1307
  }
1039
1308
  }
1040
- candidates.push({ candidate, critique: review, extraction, round })
1309
+ const record: IconCandidateRecord = {
1310
+ candidate,
1311
+ critique: review,
1312
+ extraction,
1313
+ metrics,
1314
+ round,
1315
+ }
1316
+ candidates.push(record)
1041
1317
  attempts.push({
1042
1318
  ...(critiqueError === undefined ? {} : { critiqueError }),
1043
1319
  ...(review === null ? {} : { critiqueModel: review.resolvedModel ?? critiqueModel }),
1320
+ metrics,
1044
1321
  ...(review === null ? {} : { pass: review.pass }),
1045
1322
  ...(review === null ? {} : { problems: review.problems }),
1046
1323
  requestId: candidate.requestId,
@@ -1051,10 +1328,15 @@ export async function generateSlopcameraIcon(
1051
1328
  vectorize: candidate.vectorize,
1052
1329
  warnings: candidate.warnings,
1053
1330
  })
1054
- if (review === null || review.pass) break
1055
- feedback = [...review.problems, review.promptFix]
1056
- .filter(part => part.length > 0)
1057
- .join("; ")
1331
+ if (review !== null && !review.pass) {
1332
+ feedback = [...review.problems, review.promptFix]
1333
+ .filter(part => part.length > 0)
1334
+ .join("; ")
1335
+ }
1336
+ if (review === null || review.pass || input.requireCritiquePass === false) {
1337
+ eligible.push(record)
1338
+ if (eligible.length >= pool && (review === null || review.pass)) break
1339
+ }
1058
1340
  }
1059
1341
 
1060
1342
  if (candidates.length === 0) {
@@ -1064,40 +1346,95 @@ export async function generateSlopcameraIcon(
1064
1346
  "Every icon generation attempt failed.",
1065
1347
  )
1066
1348
  }
1067
- const eligibleCandidates = candidates.filter(
1068
- ({ critique: review }) => review === null || review.pass,
1349
+ return {
1350
+ attempts,
1351
+ candidates,
1352
+ eligible,
1353
+ resolved: {
1354
+ context,
1355
+ critiqueModel,
1356
+ ink,
1357
+ model,
1358
+ purpose,
1359
+ rounds,
1360
+ subject,
1361
+ },
1362
+ }
1363
+ }
1364
+
1365
+ /**
1366
+ * Generate one canonical product mark or marketing illustration: bounded
1367
+ * Gateway raster → local normalization → supervised VTracer trace →
1368
+ * deterministic purpose gate → optional purpose-specific vision critique.
1369
+ * Only derived artifacts ever leave the machine; the uploaded critique image
1370
+ * is Slopcamera's own rendered output, never user media.
1371
+ */
1372
+ export async function generateSlopcameraIcon(
1373
+ input: GenerateSlopcameraIconInput,
1374
+ dependencies: SlopcameraIconDependencies = {},
1375
+ ): Promise<SlopcameraIconReceipt> {
1376
+ if (
1377
+ typeof input.outputPath !== "string" ||
1378
+ input.outputPath.length < 1 ||
1379
+ input.outputPath.length > 4_096 ||
1380
+ input.outputPath.includes("\0") ||
1381
+ !input.outputPath.toLowerCase().endsWith(".svg")
1382
+ ) {
1383
+ invalidArgument("outputPath must be a bounded local path ending in .svg.")
1384
+ }
1385
+ const { attempts, eligible, resolved } = await collectSlopcameraIconCandidates(
1386
+ {
1387
+ ...(input.candidatePool === undefined
1388
+ ? {}
1389
+ : { candidatePool: input.candidatePool }),
1390
+ ...(input.context === undefined ? {} : { context: input.context }),
1391
+ ...(input.critiqueModel === undefined
1392
+ ? {}
1393
+ : { critiqueModel: input.critiqueModel }),
1394
+ ...(input.inheritedFileDescriptors === undefined
1395
+ ? {}
1396
+ : { inheritedFileDescriptors: input.inheritedFileDescriptors }),
1397
+ ...(input.ink === undefined ? {} : { ink: input.ink }),
1398
+ ...(input.model === undefined ? {} : { model: input.model }),
1399
+ ...(input.purpose === undefined ? {} : { purpose: input.purpose }),
1400
+ ...(input.rounds === undefined ? {} : { rounds: input.rounds }),
1401
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
1402
+ subject: input.subject,
1403
+ },
1404
+ dependencies,
1069
1405
  )
1070
- if (eligibleCandidates.length === 0) {
1406
+ if (eligible.length === 0) {
1071
1407
  throw new SlopcameraCloudError(
1072
1408
  "GENERATION_INVALID_RESPONSE",
1073
- `Every generated ${purpose} failed its design critique.`,
1409
+ `Every generated ${resolved.purpose} failed its design critique.`,
1074
1410
  )
1075
1411
  }
1076
- const selected = eligibleCandidates.sort(
1412
+ const selected = [...eligible].sort(
1077
1413
  (left, right) =>
1078
1414
  (right.critique?.score ?? -1) - (left.critique?.score ?? -1) ||
1079
1415
  right.round - left.round,
1080
1416
  )[0]!
1081
- const selectedAttempt = attempts.findIndex(
1417
+ const attemptList = [...attempts]
1418
+ const selectedAttempt = attemptList.findIndex(
1082
1419
  attempt => attempt.round === selected.round && attempt.status === "candidate",
1083
1420
  )
1084
1421
  if (selectedAttempt >= 0) {
1085
- attempts[selectedAttempt] = {
1086
- ...attempts[selectedAttempt]!,
1422
+ attemptList[selectedAttempt] = {
1423
+ ...attemptList[selectedAttempt]!,
1087
1424
  status: "selected",
1088
1425
  }
1089
1426
  }
1090
1427
  const outputPath = await writeAtomically(input.outputPath, selected.candidate.svg)
1091
1428
  const receipt: SlopcameraIconReceipt = {
1092
- attempts,
1093
- ink,
1094
- model,
1429
+ attempts: attemptList,
1430
+ ink: resolved.ink,
1431
+ model: resolved.model,
1095
1432
  outputPath,
1096
- purpose,
1433
+ purpose: resolved.purpose,
1097
1434
  receiptVersion: 1,
1098
- rounds,
1435
+ rounds: resolved.rounds,
1099
1436
  selectedRound: selected.round,
1100
- subject,
1437
+ subject: resolved.subject,
1101
1438
  svgSha256: createHash("sha256").update(selected.candidate.svg).digest("hex"),
1102
1439
  }
1103
1440
  if (input.keepRaster === true) {
package/src/index.ts CHANGED
@@ -166,6 +166,7 @@ export * from "./drawing.js"
166
166
  export * from "./generate.js"
167
167
  export * from "./host-resources.js"
168
168
  export * from "./icon.js"
169
+ export * from "./icon-set.js"
169
170
  export * from "./image-gallery.js"
170
171
  export * from "./operations.js"
171
172
  export * from "./portable-capability-manifest.js"