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