@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.
- package/README.md +49 -33
- package/apps/desktop/README.md +3 -10
- package/apps/desktop/application/html-overlay-renderer.ts +6 -0
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/application/spatial-assets.ts +1 -1
- package/apps/desktop/cli/{menubar-status.ts → activity-status.ts} +24 -21
- package/apps/desktop/cli/args.ts +62 -10
- package/apps/desktop/cli/command-host-resources.ts +5 -1
- package/apps/desktop/cli/commands.ts +26 -9
- package/apps/desktop/cli/desktop-status.ts +271 -0
- package/apps/desktop/cli/help.ts +36 -28
- package/apps/desktop/cli/html-film-init.ts +158 -0
- package/apps/desktop/cli/html-film-names.ts +17 -0
- package/apps/desktop/cli/html-film.ts +471 -0
- package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
- package/apps/desktop/cli/html-scene.ts +3 -3
- package/apps/desktop/cli/legacy-login.ts +184 -0
- package/apps/desktop/cli/main.ts +9 -0
- package/apps/desktop/cli/outputs.ts +19 -0
- package/apps/desktop/cli/portable-surface.ts +55 -1
- package/apps/desktop/cli/registry.ts +314 -0
- package/apps/desktop/cli/verbs.ts +193 -0
- package/apps/desktop/dist/cli/main.js +338 -316
- package/apps/desktop/html-overlay/film.ts +418 -0
- package/dist/cli.js +13 -2
- package/dist/generate.js +1 -1
- package/dist/{index-4z9y2wa2.js → index-842t02pp.js} +1 -1
- package/dist/{index-qxfexvj3.js → index-k9bjw80z.js} +20 -4
- package/dist/index-p74xy76d.js +3 -0
- package/dist/{index-fevf4ppk.js → index-y8spxs77.js} +10 -7
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/README.md +6 -6
- package/examples/html/launch-film/README.md +51 -0
- package/examples/html/launch-film/build.ts +158 -0
- package/examples/html/launch-film/film.css +593 -0
- package/examples/html/launch-film/film.html +85 -0
- package/examples/html/launch-film/film.js +410 -0
- package/examples/html/launch-film/film.json +63 -0
- package/examples/html/launch-film/mockups.tsx +74 -0
- package/examples/html/launch-film/timeline.ts +57 -0
- package/package.json +24 -11
- package/skills/slopcamera/SKILL.md +4 -3
- package/skills/slopcamera/references/install.md +3 -3
- package/skills/slopcamera/references/launch-films.md +158 -0
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/visual-communication.md +1 -1
- package/src/cli.ts +125 -9
- package/src/generation-pricing.ts +21 -1
- package/src/icon-set.ts +1086 -0
- package/src/icon.ts +396 -59
- package/src/index.ts +1 -0
- package/src/operations.ts +52 -0
- package/src/version.ts +1 -1
- package/apps/desktop/cli/menubar.ts +0 -272
- 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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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<
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
:
|
|
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
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
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
|
|
892
|
-
input:
|
|
1161
|
+
export async function collectSlopcameraIconCandidates(
|
|
1162
|
+
input: CollectSlopcameraIconCandidatesInput,
|
|
893
1163
|
dependencies: SlopcameraIconDependencies = {},
|
|
894
|
-
): Promise<
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1086
|
-
...
|
|
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"
|