@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.
- package/README.md +42 -26
- package/apps/desktop/README.md +1 -1
- package/apps/desktop/application/html-overlay-renderer.ts +6 -0
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +63 -1
- package/apps/desktop/cli/command-host-resources.ts +5 -0
- package/apps/desktop/cli/commands.ts +27 -1
- package/apps/desktop/cli/help.ts +43 -21
- 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/main.ts +1 -0
- package/apps/desktop/cli/menubar-status.ts +156 -0
- package/apps/desktop/cli/menubar.ts +88 -136
- package/apps/desktop/cli/portable-surface.ts +55 -1
- package/apps/desktop/dist/cli/main.js +353 -328
- package/apps/desktop/html-overlay/film.ts +418 -0
- package/dist/cli.js +19 -9
- package/dist/generate.js +1 -1
- package/dist/{index-8bhp7cj2.js → index-842t02pp.js} +1 -1
- package/dist/{index-jab2mg0s.js → index-nv3byqa1.js} +20 -4
- package/dist/index-p74xy76d.js +3 -0
- package/dist/{index-24w75s6a.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 +5 -5
- 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 +14 -8
- package/skills/slopcamera/SKILL.md +3 -2
- package/skills/slopcamera/references/install.md +3 -3
- package/skills/slopcamera/references/launch-films.md +154 -0
- package/skills/slopcamera/references/visual-communication.md +1 -1
- package/src/cli.ts +138 -19
- package/src/generate.ts +44 -1
- package/src/generation-pricing.ts +237 -0
- 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/support.ts +6 -0
- package/src/version.ts +1 -1
- package/dist/index-77fjfg9f.js +0 -3
package/src/icon-set.ts
ADDED
|
@@ -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
|
+
}
|