@hraness/slopcamera 3.8.1 → 3.9.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/NOTICE.md +14 -0
- package/README.md +14 -6
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/capability-manifest.ts +15 -2
- package/apps/desktop/cli/commands.ts +1 -1
- package/apps/desktop/cli/help.ts +23 -4
- package/apps/desktop/cli/portable-surface.ts +1 -0
- package/apps/desktop/cli/verbs.ts +4 -0
- package/apps/desktop/dist/cli/main.js +306 -284
- package/dist/cli.js +24 -4
- package/dist/{index-842t02pp.js → index-m6g0ca9b.js} +1 -1
- package/dist/{index-y8spxs77.js → index-mgychzh3.js} +6 -3
- package/dist/{index-6jrxaser.js → index-rxx27q8b.js} +4 -4
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/package.json +4 -2
- package/skills/slopcamera/SKILL.md +2 -0
- package/skills/slopcamera/references/customization.md +8 -1
- package/skills/slopcamera/references/icon-place.md +76 -0
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +10 -2
- package/skills/slopcamera/references/soundtracks.md +58 -0
- package/src/bounded-file.ts +135 -0
- package/src/cli.ts +191 -0
- package/src/icon-scene.ts +695 -0
- package/src/index.ts +2 -0
- package/src/mcp/tools.ts +311 -0
- package/src/mcp/types.ts +4 -0
- package/src/operations.ts +306 -0
- package/src/soundtrack.ts +489 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalJson,
|
|
3
|
+
compileProgram,
|
|
4
|
+
CONSTRUCTION_VERSION,
|
|
5
|
+
CONSTRUCTION_VERSION_V1,
|
|
6
|
+
createRecipe,
|
|
7
|
+
parseProgram,
|
|
8
|
+
programDigest,
|
|
9
|
+
RECIPE_VERSION,
|
|
10
|
+
renderConstruction,
|
|
11
|
+
replayRecipe,
|
|
12
|
+
type CompiledDrawing,
|
|
13
|
+
type ConstructionProgram,
|
|
14
|
+
type StyleOptions,
|
|
15
|
+
} from "@hraness/iconplace"
|
|
16
|
+
import { COLLECTION_VERSION } from "@hraness/iconplace/collections"
|
|
17
|
+
import {
|
|
18
|
+
createSceneRecipe,
|
|
19
|
+
DEFAULT_SCENE_RENDER,
|
|
20
|
+
parseScene,
|
|
21
|
+
replaySceneRecipe,
|
|
22
|
+
renderScene,
|
|
23
|
+
SCENE_RECIPE_VERSION,
|
|
24
|
+
SCENE_VERSION,
|
|
25
|
+
sceneFromComposition,
|
|
26
|
+
solveScene,
|
|
27
|
+
type SceneDocument,
|
|
28
|
+
type SceneRecipe,
|
|
29
|
+
type SceneRenderOptions,
|
|
30
|
+
type SceneSource,
|
|
31
|
+
} from "@hraness/iconplace/scene"
|
|
32
|
+
import {
|
|
33
|
+
BoundedFileError,
|
|
34
|
+
decodeUtf8Source,
|
|
35
|
+
hasPathCollision,
|
|
36
|
+
publishReplaceableFile,
|
|
37
|
+
readBoundedFile,
|
|
38
|
+
sha256Hex,
|
|
39
|
+
} from "./bounded-file.js"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Deterministic vector icon scenes behind the `slopcamera.icon.compose` and
|
|
43
|
+
* `slopcamera.icon.render` operations.
|
|
44
|
+
*
|
|
45
|
+
* Construction, scene solving, drawing and recipe replay come from the
|
|
46
|
+
* exact-version `@hraness/iconplace` library, called in process. Nothing is
|
|
47
|
+
* spawned, fetched or evaluated. Slopcamera owns the operation codes, input
|
|
48
|
+
* bounds, file publication, the inert-SVG gate and the receipts; the library's
|
|
49
|
+
* own document formats are accepted as foreign input and parsed strictly.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** The exact admitted library release. A test pins this to the lockfile. */
|
|
53
|
+
export const slopcameraIconEngine = Object.freeze({
|
|
54
|
+
package: "@hraness/iconplace",
|
|
55
|
+
version: "0.1.0",
|
|
56
|
+
} as const)
|
|
57
|
+
|
|
58
|
+
export const slopcameraIconSceneLimits = Object.freeze({
|
|
59
|
+
sourceBytes: 1024 * 1024,
|
|
60
|
+
outputBytes: 4 * 1024 * 1024,
|
|
61
|
+
sizeMin: 16,
|
|
62
|
+
sizeMax: 1_024,
|
|
63
|
+
diagnostics: 64,
|
|
64
|
+
notes: 32,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** Scene palettes. A test pins this list to the admitted library's. */
|
|
68
|
+
export const slopcameraIconPalettes = Object.freeze(["original", "ink", "earth", "night"] as const)
|
|
69
|
+
export type SlopcameraIconPalette = (typeof slopcameraIconPalettes)[number]
|
|
70
|
+
|
|
71
|
+
/** Construction-program drawing languages. A test pins this list to the admitted library's. */
|
|
72
|
+
export const slopcameraIconLanguages = Object.freeze([
|
|
73
|
+
"outline", "sketch", "pen", "crosshatch", "engraving", "stipple", "halftone",
|
|
74
|
+
"glyph", "flat", "shaded", "figure", "cutpaper", "woodcut", "offset",
|
|
75
|
+
] as const)
|
|
76
|
+
export type SlopcameraIconLanguage = (typeof slopcameraIconLanguages)[number]
|
|
77
|
+
export const slopcameraIconDefaultLanguage: SlopcameraIconLanguage = "outline"
|
|
78
|
+
|
|
79
|
+
export type SlopcameraIconSourceFormat =
|
|
80
|
+
| "scene"
|
|
81
|
+
| "collection"
|
|
82
|
+
| "program"
|
|
83
|
+
| "scene-recipe"
|
|
84
|
+
| "program-recipe"
|
|
85
|
+
|
|
86
|
+
export type SlopcameraIconForm = "scene" | "program"
|
|
87
|
+
|
|
88
|
+
export class SlopcameraIconError extends Error {
|
|
89
|
+
readonly code: "INVALID_ICON_SOURCE" | "INVALID_ICON_INPUT" | "UNSAFE_ICON_OUTPUT"
|
|
90
|
+
|
|
91
|
+
constructor(code: SlopcameraIconError["code"], message: string) {
|
|
92
|
+
super(message)
|
|
93
|
+
this.name = "SlopcameraIconError"
|
|
94
|
+
this.code = code
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SlopcameraIconFileRecord {
|
|
99
|
+
readonly path: string
|
|
100
|
+
readonly sha256: string
|
|
101
|
+
readonly bytes: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface SlopcameraIconSourceRecord extends SlopcameraIconFileRecord {
|
|
105
|
+
readonly format: SlopcameraIconSourceFormat
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SlopcameraIconDiagnostic {
|
|
109
|
+
readonly code: string
|
|
110
|
+
readonly message: string
|
|
111
|
+
readonly target: string | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SlopcameraIconGeometry {
|
|
115
|
+
readonly regions: number
|
|
116
|
+
readonly components: number
|
|
117
|
+
readonly apertures: number
|
|
118
|
+
readonly vertices: number
|
|
119
|
+
readonly inkArea: number
|
|
120
|
+
readonly bounds: readonly [number, number, number, number]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SlopcameraIconComposeInput {
|
|
124
|
+
readonly sourcePath: string
|
|
125
|
+
readonly outputPath?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SlopcameraIconComposeReceipt {
|
|
129
|
+
readonly receiptVersion: 1
|
|
130
|
+
readonly operation: "slopcamera.icon.compose"
|
|
131
|
+
readonly engine: typeof slopcameraIconEngine
|
|
132
|
+
readonly source: SlopcameraIconSourceRecord
|
|
133
|
+
readonly form: SlopcameraIconForm
|
|
134
|
+
readonly title: string
|
|
135
|
+
/** Scene nodes or program parts. */
|
|
136
|
+
readonly elementCount: number
|
|
137
|
+
readonly digests: {
|
|
138
|
+
/** Library digest of the canonical scene or construction program. */
|
|
139
|
+
readonly document: string
|
|
140
|
+
/** Library digest of the compiled geometry. */
|
|
141
|
+
readonly geometry: string
|
|
142
|
+
}
|
|
143
|
+
readonly geometry: SlopcameraIconGeometry
|
|
144
|
+
readonly diagnostics: readonly SlopcameraIconDiagnostic[]
|
|
145
|
+
/** The canonical solved document, written only when an output path was supplied. */
|
|
146
|
+
readonly output: SlopcameraIconFileRecord | null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface SlopcameraIconRenderInput {
|
|
150
|
+
readonly sourcePath: string
|
|
151
|
+
readonly outputPath: string
|
|
152
|
+
readonly recipePath?: string
|
|
153
|
+
readonly size?: number
|
|
154
|
+
readonly background?: boolean
|
|
155
|
+
readonly palette?: SlopcameraIconPalette
|
|
156
|
+
readonly language?: SlopcameraIconLanguage
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface SlopcameraIconRenderReceipt {
|
|
160
|
+
readonly receiptVersion: 1
|
|
161
|
+
readonly operation: "slopcamera.icon.render"
|
|
162
|
+
readonly engine: typeof slopcameraIconEngine
|
|
163
|
+
readonly source: SlopcameraIconSourceRecord
|
|
164
|
+
readonly form: SlopcameraIconForm
|
|
165
|
+
/** True when the source was a recipe whose recorded digests were reproduced. */
|
|
166
|
+
readonly replayed: boolean
|
|
167
|
+
readonly title: string
|
|
168
|
+
readonly render: {
|
|
169
|
+
readonly size: number
|
|
170
|
+
readonly background: boolean
|
|
171
|
+
readonly palette: SlopcameraIconPalette | null
|
|
172
|
+
readonly language: string
|
|
173
|
+
}
|
|
174
|
+
readonly width: number
|
|
175
|
+
readonly height: number
|
|
176
|
+
readonly digests: {
|
|
177
|
+
readonly document: string
|
|
178
|
+
readonly geometry: string
|
|
179
|
+
readonly svg: string
|
|
180
|
+
}
|
|
181
|
+
readonly output: SlopcameraIconFileRecord
|
|
182
|
+
readonly recipe: SlopcameraIconFileRecord | null
|
|
183
|
+
readonly notes: readonly string[]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface SceneReplay {
|
|
187
|
+
readonly recipe: SceneRecipe
|
|
188
|
+
readonly svg: string
|
|
189
|
+
readonly width: number
|
|
190
|
+
readonly height: number
|
|
191
|
+
readonly options: SceneRenderOptions
|
|
192
|
+
readonly style: StyleOptions
|
|
193
|
+
readonly notes: readonly string[]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface ProgramReplay {
|
|
197
|
+
readonly svg: string
|
|
198
|
+
readonly style: StyleOptions
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type LoadedIconSource =
|
|
202
|
+
| {
|
|
203
|
+
readonly form: "scene"
|
|
204
|
+
readonly format: "scene" | "collection" | "scene-recipe"
|
|
205
|
+
readonly scene: SceneDocument
|
|
206
|
+
/** A recipe's embedded component snapshot, independent of catalog changes. */
|
|
207
|
+
readonly sources?: SceneSource[]
|
|
208
|
+
readonly replay?: SceneReplay
|
|
209
|
+
}
|
|
210
|
+
| {
|
|
211
|
+
readonly form: "program"
|
|
212
|
+
readonly format: "program" | "program-recipe"
|
|
213
|
+
readonly drawing: CompiledDrawing
|
|
214
|
+
readonly replay?: ProgramReplay
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function invalidSource(message: string): never {
|
|
218
|
+
throw new SlopcameraIconError("INVALID_ICON_SOURCE", message)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function invalidInput(message: string): never {
|
|
222
|
+
throw new SlopcameraIconError("INVALID_ICON_INPUT", message)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function libraryMessage(error: unknown): string {
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
227
|
+
// Library messages describe the rejected data; keep them short and printable.
|
|
228
|
+
const printable = [...message]
|
|
229
|
+
.filter((character) => {
|
|
230
|
+
const code = character.codePointAt(0) ?? 0
|
|
231
|
+
return code >= 0x20 && code !== 0x7f
|
|
232
|
+
})
|
|
233
|
+
.join("")
|
|
234
|
+
return printable.length > 240 ? `${printable.slice(0, 237)}...` : printable
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
238
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseSourceJson(bytes: Uint8Array): Record<string, unknown> {
|
|
242
|
+
let value: unknown
|
|
243
|
+
try {
|
|
244
|
+
value = JSON.parse(decodeUtf8Source(bytes, "Icon source"))
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error instanceof BoundedFileError) throw error
|
|
247
|
+
invalidSource("Icon source is not valid JSON.")
|
|
248
|
+
}
|
|
249
|
+
if (!isRecord(value) || typeof value.version !== "string") {
|
|
250
|
+
invalidSource("Icon source must be a JSON object with a version field.")
|
|
251
|
+
}
|
|
252
|
+
return value
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function loadIconSource(bytes: Uint8Array): LoadedIconSource {
|
|
256
|
+
const value = parseSourceJson(bytes)
|
|
257
|
+
const version = value.version as string
|
|
258
|
+
try {
|
|
259
|
+
if (version === SCENE_VERSION) {
|
|
260
|
+
return { form: "scene", format: "scene", scene: parseScene(value) }
|
|
261
|
+
}
|
|
262
|
+
if (version === COLLECTION_VERSION) {
|
|
263
|
+
return { form: "scene", format: "collection", scene: parseScene(sceneFromComposition(value)) }
|
|
264
|
+
}
|
|
265
|
+
if (version === CONSTRUCTION_VERSION || version === CONSTRUCTION_VERSION_V1) {
|
|
266
|
+
return { form: "program", format: "program", drawing: compileProgram(parseProgram(value)) }
|
|
267
|
+
}
|
|
268
|
+
if (version === SCENE_RECIPE_VERSION) {
|
|
269
|
+
const replayed = replaySceneRecipe(value)
|
|
270
|
+
return {
|
|
271
|
+
form: "scene",
|
|
272
|
+
format: "scene-recipe",
|
|
273
|
+
scene: replayed.recipe.scene,
|
|
274
|
+
sources: replayed.recipe.sources,
|
|
275
|
+
replay: {
|
|
276
|
+
recipe: replayed.recipe,
|
|
277
|
+
svg: replayed.svg,
|
|
278
|
+
width: replayed.width,
|
|
279
|
+
height: replayed.height,
|
|
280
|
+
options: replayed.options,
|
|
281
|
+
style: replayed.style,
|
|
282
|
+
notes: replayed.notes,
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (version === RECIPE_VERSION) {
|
|
287
|
+
const replayed = replayRecipe(value)
|
|
288
|
+
return {
|
|
289
|
+
form: "program",
|
|
290
|
+
format: "program-recipe",
|
|
291
|
+
drawing: replayed.drawing,
|
|
292
|
+
replay: { svg: replayed.svg, style: replayed.style },
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
invalidSource(`Icon source was rejected: ${libraryMessage(error)}`)
|
|
297
|
+
}
|
|
298
|
+
return invalidSource(
|
|
299
|
+
"Icon source version must be a scene, collection, construction program, or recipe version.",
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function geometryOf(drawing: CompiledDrawing): SlopcameraIconGeometry {
|
|
304
|
+
const { facts } = drawing
|
|
305
|
+
return {
|
|
306
|
+
regions: facts.regions,
|
|
307
|
+
components: facts.components,
|
|
308
|
+
apertures: facts.apertures,
|
|
309
|
+
vertices: facts.vertices,
|
|
310
|
+
inkArea: facts.inkArea,
|
|
311
|
+
bounds: [facts.bounds[0], facts.bounds[1], facts.bounds[2], facts.bounds[3]],
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Key-sorted, indented JSON so written documents diff and hash stably. */
|
|
316
|
+
function stableDocumentText(value: unknown): string {
|
|
317
|
+
return `${JSON.stringify(JSON.parse(canonicalJson(value)), null, 2)}\n`
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const allowedSvgElements = new Set([
|
|
321
|
+
"svg", "g", "defs", "clipPath", "mask", "title", "desc",
|
|
322
|
+
"path", "rect", "circle", "ellipse", "line", "polyline", "polygon",
|
|
323
|
+
])
|
|
324
|
+
const allowedSvgAttributes = new Set([
|
|
325
|
+
"xmlns", "width", "height", "viewBox", "role", "id", "d", "fill", "fill-rule",
|
|
326
|
+
"fill-opacity", "clip-path", "clip-rule", "mask", "opacity", "stroke",
|
|
327
|
+
"stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-dasharray",
|
|
328
|
+
"stroke-opacity", "stroke-miterlimit", "transform", "x", "y", "rx", "ry",
|
|
329
|
+
"cx", "cy", "r", "x1", "y1", "x2", "y2", "points", "preserveAspectRatio",
|
|
330
|
+
])
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Admit only a closed vocabulary of static shape elements and presentation
|
|
334
|
+
* attributes. Scripts, styles, links, images, foreign objects, event
|
|
335
|
+
* handlers and non-local URL references fail closed.
|
|
336
|
+
*/
|
|
337
|
+
export function assertInertIconSvg(svg: string): void {
|
|
338
|
+
if (Buffer.byteLength(svg, "utf8") > slopcameraIconSceneLimits.outputBytes) {
|
|
339
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG exceeds the output byte limit.")
|
|
340
|
+
}
|
|
341
|
+
if (!svg.startsWith("<svg ") || !svg.trimEnd().endsWith("</svg>")) {
|
|
342
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered output is not a single SVG document.")
|
|
343
|
+
}
|
|
344
|
+
if (/<!|<\?|&(?!(?:amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-f]{1,6});)/iu.test(svg)) {
|
|
345
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG contains declarations or unknown entities.")
|
|
346
|
+
}
|
|
347
|
+
const ids = new Set<string>()
|
|
348
|
+
const references: string[] = []
|
|
349
|
+
for (const tag of scanSvgTags(svg)) {
|
|
350
|
+
if (!allowedSvgElements.has(tag.name)) {
|
|
351
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", `Rendered SVG contains a disallowed <${tag.name}> element.`)
|
|
352
|
+
}
|
|
353
|
+
for (const [attributeName, value] of tag.attributes) {
|
|
354
|
+
if (!allowedSvgAttributes.has(attributeName)) {
|
|
355
|
+
throw new SlopcameraIconError(
|
|
356
|
+
"UNSAFE_ICON_OUTPUT",
|
|
357
|
+
`Rendered SVG contains a disallowed ${attributeName} attribute.`,
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
if (attributeName === "id") ids.add(value)
|
|
361
|
+
references.push(...urlReferences(value))
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const target of references) {
|
|
365
|
+
if (!/^#[A-Za-z0-9_-]+$/u.test(target) || !ids.has(target.slice(1))) {
|
|
366
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG references a non-local resource.")
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
interface SvgTag {
|
|
372
|
+
readonly name: string
|
|
373
|
+
readonly attributes: readonly (readonly [string, string])[]
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const isSvgSpace = (character: string | undefined): boolean =>
|
|
377
|
+
character === " " || character === "\t" || character === "\n" || character === "\r"
|
|
378
|
+
const isSvgNameStart = (character: string | undefined): boolean =>
|
|
379
|
+
character !== undefined && /^[A-Za-z]$/u.test(character)
|
|
380
|
+
const isSvgNameCharacter = (character: string | undefined): boolean =>
|
|
381
|
+
character !== undefined && /^[\w:.-]$/u.test(character)
|
|
382
|
+
const isSvgAttributeNameCharacter = (character: string | undefined): boolean =>
|
|
383
|
+
character !== undefined && !isSvgSpace(character) && character !== "=" && character !== "/" && character !== ">"
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Scan every opening tag in one linear pass. Each tag must be a name followed
|
|
387
|
+
* by whitespace-separated `name="value"` attributes and an optional `/`;
|
|
388
|
+
* anything else is malformed. Closing tags are skipped after a name check.
|
|
389
|
+
*/
|
|
390
|
+
function scanSvgTags(svg: string): SvgTag[] {
|
|
391
|
+
const malformed = (): never => {
|
|
392
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG contains a malformed element.")
|
|
393
|
+
}
|
|
394
|
+
const tags: SvgTag[] = []
|
|
395
|
+
let index = svg.indexOf("<")
|
|
396
|
+
while (index !== -1) {
|
|
397
|
+
let cursor = index + 1
|
|
398
|
+
if (svg[cursor] === "/") {
|
|
399
|
+
cursor += 1
|
|
400
|
+
if (!isSvgNameStart(svg[cursor])) malformed()
|
|
401
|
+
while (isSvgNameCharacter(svg[cursor])) cursor += 1
|
|
402
|
+
while (isSvgSpace(svg[cursor])) cursor += 1
|
|
403
|
+
if (svg[cursor] !== ">") malformed()
|
|
404
|
+
index = svg.indexOf("<", cursor + 1)
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
if (!isSvgNameStart(svg[cursor])) malformed()
|
|
408
|
+
const nameStart = cursor
|
|
409
|
+
while (isSvgNameCharacter(svg[cursor])) cursor += 1
|
|
410
|
+
const name = svg.slice(nameStart, cursor)
|
|
411
|
+
const attributes: (readonly [string, string])[] = []
|
|
412
|
+
for (;;) {
|
|
413
|
+
const spaceStart = cursor
|
|
414
|
+
while (isSvgSpace(svg[cursor])) cursor += 1
|
|
415
|
+
if (svg[cursor] === "/" && svg[cursor + 1] === ">") {
|
|
416
|
+
cursor += 1
|
|
417
|
+
break
|
|
418
|
+
}
|
|
419
|
+
if (svg[cursor] === ">") break
|
|
420
|
+
if (cursor === spaceStart) malformed()
|
|
421
|
+
const attributeStart = cursor
|
|
422
|
+
while (isSvgAttributeNameCharacter(svg[cursor])) cursor += 1
|
|
423
|
+
if (cursor === attributeStart || svg[cursor] !== "=" || svg[cursor + 1] !== "\"") malformed()
|
|
424
|
+
const valueStart = cursor + 2
|
|
425
|
+
const valueEnd = svg.indexOf("\"", valueStart)
|
|
426
|
+
if (valueEnd === -1) malformed()
|
|
427
|
+
const value = svg.slice(valueStart, valueEnd)
|
|
428
|
+
if (value.includes("<")) malformed()
|
|
429
|
+
attributes.push([svg.slice(attributeStart, cursor), value])
|
|
430
|
+
cursor = valueEnd + 1
|
|
431
|
+
}
|
|
432
|
+
tags.push({ name, attributes })
|
|
433
|
+
index = svg.indexOf("<", cursor + 1)
|
|
434
|
+
}
|
|
435
|
+
return tags
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Every `url(...)` target in one attribute value, found without backtracking. */
|
|
439
|
+
function urlReferences(value: string): string[] {
|
|
440
|
+
const targets: string[] = []
|
|
441
|
+
let index = value.indexOf("url(")
|
|
442
|
+
while (index !== -1) {
|
|
443
|
+
const close = value.indexOf(")", index + 4)
|
|
444
|
+
if (close === -1) {
|
|
445
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG references a non-local resource.")
|
|
446
|
+
}
|
|
447
|
+
targets.push(value.slice(index + 4, close))
|
|
448
|
+
index = value.indexOf("url(", close + 1)
|
|
449
|
+
}
|
|
450
|
+
return targets
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function svgDimensions(svg: string): { readonly width: number; readonly height: number } {
|
|
454
|
+
const root = /^<svg\b[^>]*>/u.exec(svg)?.[0] ?? ""
|
|
455
|
+
const width = Number(/\swidth="(\d+)"/u.exec(root)?.[1])
|
|
456
|
+
const height = Number(/\sheight="(\d+)"/u.exec(root)?.[1])
|
|
457
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) {
|
|
458
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG does not declare integer dimensions.")
|
|
459
|
+
}
|
|
460
|
+
return { width, height }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function parseSize(value: number | undefined, fallback: number): number {
|
|
464
|
+
const size = value ?? fallback
|
|
465
|
+
if (
|
|
466
|
+
!Number.isSafeInteger(size)
|
|
467
|
+
|| size < slopcameraIconSceneLimits.sizeMin
|
|
468
|
+
|| size > slopcameraIconSceneLimits.sizeMax
|
|
469
|
+
) {
|
|
470
|
+
invalidInput(
|
|
471
|
+
`size must be an integer from ${String(slopcameraIconSceneLimits.sizeMin)} through ${String(slopcameraIconSceneLimits.sizeMax)}.`,
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
return size
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function readIconSource(path: string): Promise<{
|
|
478
|
+
readonly bytes: Uint8Array
|
|
479
|
+
readonly record: Omit<SlopcameraIconSourceRecord, "format">
|
|
480
|
+
}> {
|
|
481
|
+
const bytes = await readBoundedFile(path, slopcameraIconSceneLimits.sourceBytes, "Icon source")
|
|
482
|
+
return { bytes, record: { path, sha256: sha256Hex(bytes), bytes: bytes.byteLength } }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function publish(path: string, text: string): Promise<SlopcameraIconFileRecord> {
|
|
486
|
+
const bytes = Buffer.from(text, "utf8")
|
|
487
|
+
if (bytes.byteLength > slopcameraIconSceneLimits.outputBytes) {
|
|
488
|
+
throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Icon output exceeds the output byte limit.")
|
|
489
|
+
}
|
|
490
|
+
await publishReplaceableFile(path, bytes)
|
|
491
|
+
return { path, sha256: sha256Hex(bytes), bytes: bytes.byteLength }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function requireExtension(path: string, extension: string, label: string): void {
|
|
495
|
+
if (!path.toLowerCase().endsWith(extension)) invalidInput(`${label} must end in ${extension}.`)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Parse and solve one icon scene, collection, construction program or recipe.
|
|
500
|
+
* With an output path, publish the canonical solved document that render
|
|
501
|
+
* accepts; without one, write nothing.
|
|
502
|
+
*/
|
|
503
|
+
export async function composeSlopcameraIcon(
|
|
504
|
+
input: SlopcameraIconComposeInput,
|
|
505
|
+
): Promise<SlopcameraIconComposeReceipt> {
|
|
506
|
+
if (input.outputPath !== undefined) {
|
|
507
|
+
requireExtension(input.outputPath, ".json", "outputPath")
|
|
508
|
+
if (await hasPathCollision([input.sourcePath, input.outputPath])) {
|
|
509
|
+
invalidInput("outputPath must differ from sourcePath.")
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const { bytes, record } = await readIconSource(input.sourcePath)
|
|
513
|
+
const loaded = loadIconSource(bytes)
|
|
514
|
+
let document: SceneDocument | ConstructionProgram
|
|
515
|
+
let drawing: CompiledDrawing
|
|
516
|
+
let documentDigest: string
|
|
517
|
+
let diagnostics: SlopcameraIconDiagnostic[]
|
|
518
|
+
let elementCount: number
|
|
519
|
+
let title: string
|
|
520
|
+
try {
|
|
521
|
+
if (loaded.form === "scene") {
|
|
522
|
+
const solution = solveScene(loaded.scene, loaded.sources)
|
|
523
|
+
document = solution.scene
|
|
524
|
+
drawing = solution.drawing
|
|
525
|
+
documentDigest = createSceneRecipe(solution.scene, DEFAULT_SCENE_RENDER).digests.scene
|
|
526
|
+
elementCount = solution.scene.nodes.length
|
|
527
|
+
title = solution.scene.title
|
|
528
|
+
diagnostics = solution.diagnostics.map((diagnostic) => ({
|
|
529
|
+
code: diagnostic.code,
|
|
530
|
+
message: libraryMessage(diagnostic.message),
|
|
531
|
+
target: diagnostic.node,
|
|
532
|
+
}))
|
|
533
|
+
} else {
|
|
534
|
+
document = loaded.drawing.program
|
|
535
|
+
drawing = loaded.drawing
|
|
536
|
+
documentDigest = programDigest(loaded.drawing.program)
|
|
537
|
+
elementCount = loaded.drawing.program.parts.length
|
|
538
|
+
title = loaded.drawing.program.title
|
|
539
|
+
diagnostics = loaded.drawing.issues.map((issue) => ({
|
|
540
|
+
code: issue.code,
|
|
541
|
+
message: libraryMessage(issue.message),
|
|
542
|
+
target: issue.part ?? null,
|
|
543
|
+
}))
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (error instanceof SlopcameraIconError) throw error
|
|
547
|
+
invalidSource(`Icon source could not be solved: ${libraryMessage(error)}`)
|
|
548
|
+
}
|
|
549
|
+
const output = input.outputPath === undefined
|
|
550
|
+
? null
|
|
551
|
+
: await publish(input.outputPath, stableDocumentText(document))
|
|
552
|
+
return {
|
|
553
|
+
receiptVersion: 1,
|
|
554
|
+
operation: "slopcamera.icon.compose",
|
|
555
|
+
engine: slopcameraIconEngine,
|
|
556
|
+
source: { ...record, format: loaded.format },
|
|
557
|
+
form: loaded.form,
|
|
558
|
+
title,
|
|
559
|
+
elementCount,
|
|
560
|
+
digests: { document: documentDigest, geometry: drawing.digest },
|
|
561
|
+
geometry: geometryOf(drawing),
|
|
562
|
+
diagnostics: diagnostics.slice(0, slopcameraIconSceneLimits.diagnostics),
|
|
563
|
+
output,
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Draw one icon source to inert SVG. A recipe replays under its recorded
|
|
569
|
+
* settings and must reproduce its recorded digests; any other source takes
|
|
570
|
+
* bounded render settings. Optionally publish a replayable recipe.
|
|
571
|
+
*/
|
|
572
|
+
export async function renderSlopcameraIcon(
|
|
573
|
+
input: SlopcameraIconRenderInput,
|
|
574
|
+
): Promise<SlopcameraIconRenderReceipt> {
|
|
575
|
+
requireExtension(input.outputPath, ".svg", "outputPath")
|
|
576
|
+
if (input.recipePath !== undefined) requireExtension(input.recipePath, ".json", "recipePath")
|
|
577
|
+
if (await hasPathCollision([input.sourcePath, input.outputPath, input.recipePath])) {
|
|
578
|
+
invalidInput("sourcePath, outputPath and recipePath must name different files.")
|
|
579
|
+
}
|
|
580
|
+
const { bytes, record } = await readIconSource(input.sourcePath)
|
|
581
|
+
const loaded = loadIconSource(bytes)
|
|
582
|
+
const replayed = loaded.format === "scene-recipe" || loaded.format === "program-recipe"
|
|
583
|
+
if (
|
|
584
|
+
replayed
|
|
585
|
+
&& (input.size !== undefined
|
|
586
|
+
|| input.background !== undefined
|
|
587
|
+
|| input.palette !== undefined
|
|
588
|
+
|| input.language !== undefined)
|
|
589
|
+
) {
|
|
590
|
+
invalidInput("A recipe fixes its render settings; omit size, background, palette and language.")
|
|
591
|
+
}
|
|
592
|
+
if (loaded.form === "scene" && input.language !== undefined) {
|
|
593
|
+
invalidInput("language applies only to construction programs; scenes use their family's language.")
|
|
594
|
+
}
|
|
595
|
+
if (loaded.form === "program" && input.palette !== undefined) {
|
|
596
|
+
invalidInput("palette applies only to scenes.")
|
|
597
|
+
}
|
|
598
|
+
if (input.palette !== undefined && !slopcameraIconPalettes.includes(input.palette)) {
|
|
599
|
+
invalidInput(`palette must be one of ${slopcameraIconPalettes.join(", ")}.`)
|
|
600
|
+
}
|
|
601
|
+
if (input.language !== undefined && !slopcameraIconLanguages.includes(input.language)) {
|
|
602
|
+
invalidInput(`language must be one of ${slopcameraIconLanguages.join(", ")}.`)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let svg: string
|
|
606
|
+
let width: number
|
|
607
|
+
let height: number
|
|
608
|
+
let recipeValue: unknown
|
|
609
|
+
let digests: SlopcameraIconRenderReceipt["digests"]
|
|
610
|
+
let render: SlopcameraIconRenderReceipt["render"]
|
|
611
|
+
let notes: readonly string[]
|
|
612
|
+
let title: string
|
|
613
|
+
try {
|
|
614
|
+
if (loaded.form === "scene") {
|
|
615
|
+
let recipe: SceneRecipe
|
|
616
|
+
let options: SceneRenderOptions
|
|
617
|
+
let language: string
|
|
618
|
+
if (loaded.replay !== undefined) {
|
|
619
|
+
;({ recipe, svg, width, height, options, notes } = loaded.replay)
|
|
620
|
+
language = loaded.replay.style.language
|
|
621
|
+
} else {
|
|
622
|
+
options = {
|
|
623
|
+
size: parseSize(input.size, DEFAULT_SCENE_RENDER.size),
|
|
624
|
+
background: input.background ?? DEFAULT_SCENE_RENDER.background,
|
|
625
|
+
palette: input.palette ?? DEFAULT_SCENE_RENDER.palette,
|
|
626
|
+
}
|
|
627
|
+
const drawn = renderScene(loaded.scene, options)
|
|
628
|
+
recipe = createSceneRecipe(loaded.scene, options, drawn.solution.sources, drawn.style)
|
|
629
|
+
;({ svg, width, height, notes } = drawn)
|
|
630
|
+
language = drawn.style.language
|
|
631
|
+
}
|
|
632
|
+
recipeValue = recipe
|
|
633
|
+
digests = {
|
|
634
|
+
document: recipe.digests.scene,
|
|
635
|
+
geometry: recipe.digests.geometry,
|
|
636
|
+
svg: recipe.digests.svg,
|
|
637
|
+
}
|
|
638
|
+
render = {
|
|
639
|
+
size: options.size,
|
|
640
|
+
background: options.background,
|
|
641
|
+
palette: options.palette,
|
|
642
|
+
language,
|
|
643
|
+
}
|
|
644
|
+
title = loaded.scene.title
|
|
645
|
+
} else {
|
|
646
|
+
const style: StyleOptions = loaded.replay?.style ?? {
|
|
647
|
+
language: input.language ?? slopcameraIconDefaultLanguage,
|
|
648
|
+
outputPx: parseSize(input.size, 512),
|
|
649
|
+
background: input.background ?? false,
|
|
650
|
+
}
|
|
651
|
+
const drawn = loaded.replay ?? renderConstruction(loaded.drawing, style)
|
|
652
|
+
const recipe = createRecipe(loaded.drawing, style)
|
|
653
|
+
svg = drawn.svg
|
|
654
|
+
;({ width, height } = svgDimensions(svg))
|
|
655
|
+
recipeValue = recipe
|
|
656
|
+
digests = {
|
|
657
|
+
document: recipe.digests.program,
|
|
658
|
+
geometry: recipe.digests.geometry,
|
|
659
|
+
svg: recipe.digests.svg,
|
|
660
|
+
}
|
|
661
|
+
render = {
|
|
662
|
+
size: style.outputPx ?? width,
|
|
663
|
+
background: style.background ?? false,
|
|
664
|
+
palette: null,
|
|
665
|
+
language: style.language,
|
|
666
|
+
}
|
|
667
|
+
notes = "notes" in drawn ? drawn.notes : []
|
|
668
|
+
title = loaded.drawing.program.title
|
|
669
|
+
}
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (error instanceof SlopcameraIconError) throw error
|
|
672
|
+
invalidSource(`Icon source could not be drawn: ${libraryMessage(error)}`)
|
|
673
|
+
}
|
|
674
|
+
assertInertIconSvg(svg)
|
|
675
|
+
const output = await publish(input.outputPath, svg)
|
|
676
|
+
const recipe = input.recipePath === undefined
|
|
677
|
+
? null
|
|
678
|
+
: await publish(input.recipePath, stableDocumentText(recipeValue))
|
|
679
|
+
return {
|
|
680
|
+
receiptVersion: 1,
|
|
681
|
+
operation: "slopcamera.icon.render",
|
|
682
|
+
engine: slopcameraIconEngine,
|
|
683
|
+
source: { ...record, format: loaded.format },
|
|
684
|
+
form: loaded.form,
|
|
685
|
+
replayed,
|
|
686
|
+
title,
|
|
687
|
+
render,
|
|
688
|
+
width,
|
|
689
|
+
height,
|
|
690
|
+
digests,
|
|
691
|
+
output,
|
|
692
|
+
recipe,
|
|
693
|
+
notes: notes.slice(0, slopcameraIconSceneLimits.notes).map(libraryMessage),
|
|
694
|
+
}
|
|
695
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -166,11 +166,13 @@ export * from "./drawing.js"
|
|
|
166
166
|
export * from "./generate.js"
|
|
167
167
|
export * from "./host-resources.js"
|
|
168
168
|
export * from "./icon.js"
|
|
169
|
+
export * from "./icon-scene.js"
|
|
169
170
|
export * from "./icon-set.js"
|
|
170
171
|
export * from "./image-gallery.js"
|
|
171
172
|
export * from "./operations.js"
|
|
172
173
|
export * from "./portable-capability-manifest.js"
|
|
173
174
|
export * from "./scene-gallery.js"
|
|
175
|
+
export * from "./soundtrack.js"
|
|
174
176
|
export * from "./studio/index.js"
|
|
175
177
|
export * from "./visual-style.js"
|
|
176
178
|
export * from "./workflow.js"
|