@deepblend/dsh-blender-contracts 0.2.20 → 0.3.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.
@@ -0,0 +1,57 @@
1
+ /** Model judgments stay separate from deterministic render measurements. */
2
+ export const ARTISTIC_DIMENSIONS = Object.freeze(['geometry', 'materials', 'lighting', 'goalFit'])
3
+
4
+ /** Validate evidence, confidence and the Host's actual reference image inventory.
5
+ * Passing must account for every reference for that dimension; one evidenced
6
+ * mismatch suffices for needs_work. Old callers without references remain valid.
7
+ */
8
+ export function validateArtisticReview(raw, viewIds, minConfidence = 0.8, context = {}) {
9
+ const dimensions = {}
10
+ const problems = []
11
+ const subjectUnavailable = context.subject?.available === false
12
+ if (subjectUnavailable) problems.push(context.subject.reason ?? 'The review subject is unavailable.')
13
+ // This is the Host's actual attachment inventory, not a list proposed by the model.
14
+ const references = new Map((context.referenceImages ?? []).map(reference => [reference.id, reference]))
15
+ const referenceEvidenceValid = (entry, dimension) => {
16
+ const relevant = [...references.values()].filter(reference => reference.purposes?.includes(dimension))
17
+ const ids = entry?.referenceIds
18
+ if (ids === undefined) return relevant.length === 0 || entry?.status === 'unassessable'
19
+ if (!Array.isArray(ids) || new Set(ids).size !== ids.length || !ids.every(id =>
20
+ typeof id === 'string' && references.get(id)?.purposes?.includes(dimension))) return false
21
+ if (entry?.status === 'unassessable' || relevant.length === 0) return true
22
+ return entry?.status === 'pass' ? relevant.every(reference => ids.includes(reference.id)) : ids.length > 0
23
+ }
24
+ const evidenceValid = entry => entry && viewIds.has(entry.viewId) &&
25
+ typeof entry.evidence === 'string' && entry.evidence.trim().length >= 8 &&
26
+ entry.evidence.length <= 2000 && Number.isFinite(entry.confidence) &&
27
+ entry.confidence >= minConfidence && entry.confidence <= 1
28
+ for (const dimension of ARTISTIC_DIMENSIONS) {
29
+ const entry = raw?.dimensions?.[dimension]
30
+ const validReferences = referenceEvidenceValid(entry, dimension)
31
+ const valid = evidenceValid(entry) && validReferences && ['pass', 'needs_work', 'unassessable'].includes(entry.status)
32
+ if (!valid) problems.push(`${dimension}: ${validReferences ? 'missing, invalid or insufficiently confident evidence' : 'missing, unknown or inapplicable referenceIds'}`)
33
+ dimensions[dimension] = valid && !(subjectUnavailable && entry.status === 'pass') ? {
34
+ status: entry.status, viewId: entry.viewId, evidence: entry.evidence.trim(), confidence: entry.confidence,
35
+ ...(entry.referenceIds === undefined ? {} : { referenceIds: [...entry.referenceIds] }),
36
+ } : { status: 'unassessable', viewId: null, evidence: null, confidence: null }
37
+ }
38
+ const comparison = raw?.comparison
39
+ const validComparison = evidenceValid(comparison) && ['improved', 'equivalent', 'regressed', 'unassessable'].includes(comparison.verdict)
40
+ const statuses = Object.values(dimensions).map(entry => entry.status)
41
+ return {
42
+ status: statuses.every(status => status === 'pass') ? 'pass'
43
+ : statuses.includes('needs_work') ? 'needs_work' : 'unassessable',
44
+ dimensions,
45
+ comparison: validComparison && !subjectUnavailable ? {
46
+ verdict: comparison.verdict, viewId: comparison.viewId,
47
+ evidence: comparison.evidence.trim(), confidence: comparison.confidence,
48
+ } : { verdict: 'unassessable', viewId: null, evidence: null, confidence: null },
49
+ problems,
50
+ }
51
+ }
52
+
53
+ /** A newly failing dimension cannot be hidden by an optimistic overall comparison. */
54
+ export function artisticRegressed(before, after) {
55
+ return after.comparison.verdict === 'regressed' || ARTISTIC_DIMENSIONS.some(dimension =>
56
+ before.dimensions[dimension].status === 'pass' && after.dimensions[dimension].status === 'needs_work')
57
+ }
@@ -49,8 +49,11 @@ const SIGNATURES = Object.freeze([
49
49
  // Everything else that is common enough to name, all of it a contradiction for any of the
50
50
  // six importable types.
51
51
  { format: null, magic: 'PK\u0003\u0004', at: 0, label: 'a ZIP archive' },
52
- { format: null, magic: '\u0089PNG\r\n\u001a\n', at: 0, label: 'a PNG image' },
53
- { format: null, magic: '\u00ff\u00d8\u00ff', at: 0, label: 'a JPEG image' },
52
+ { format: 'png', magic: '\u0089PNG\r\n\u001a\n', at: 0, label: 'a PNG image' },
53
+ { format: 'jpg', magic: '\u00ff\u00d8\u00ff', at: 0, label: 'a JPEG image' },
54
+ { format: 'hdr', magic: '#?RADIANCE', at: 0, label: 'a Radiance HDR image' },
55
+ { format: 'hdr', magic: '#?RGBE', at: 0, label: 'a Radiance HDR image' },
56
+ { format: 'exr', magic: '\u0076\u002f\u0031\u0001', at: 0, label: 'an OpenEXR image' },
54
57
  { format: null, magic: '%PDF', at: 0, label: 'a PDF' },
55
58
  { format: null, magic: '\u007fELF', at: 0, label: 'an ELF binary' },
56
59
  { format: null, magic: 'MZ', at: 0, label: 'a Windows executable' },
@@ -124,7 +127,7 @@ export function assetContentVerdict(head, type) {
124
127
  // for another importable type, which is how a `.obj` holding a `.blend` reads, and
125
128
  // including for a format this product cannot import at all, which is how a ZIP named
126
129
  // `.glb` reads.
127
- return described.signature.format === type ? 'agrees' : 'contradicts'
130
+ return described.signature.format === (type === 'jpeg' ? 'jpg' : type) ? 'agrees' : 'contradicts'
128
131
  }
129
132
  // No signature. Text formats are satisfied by text; a binary format is not satisfied by
130
133
  // anything (its magic was required and did not match), but it is not contradicted either,
@@ -0,0 +1,55 @@
1
+ /** Dimensions are local scene units; mesh validity is checked again in Blender. */
2
+ export const HANDLED_CUP_FIELDS = ['height', 'wallThickness', 'baseThickness', 'footRound', 'handleRadius',
3
+ 'handleLower', 'handleUpper', 'rootRadius', 'rootLength', 'rootTension', 'sectionSegments', 'handleSegments', 'rootSegments', 'wallRows']
4
+
5
+ export function resolveHandledCup(spec = {}) {
6
+ const radius = spec.radius ?? .04
7
+ const height = spec.height ?? radius * 2.625
8
+ const wallThickness = spec.wallThickness ?? radius * .075
9
+ const baseThickness = spec.baseThickness ?? radius * .125
10
+ return { ...spec, radius, height, wallThickness, baseThickness,
11
+ footRound: spec.footRound ?? Math.min(wallThickness, baseThickness * .8),
12
+ handleRadius: spec.handleRadius ?? radius * .1375,
13
+ handleLower: spec.handleLower ?? height * (4 / 15),
14
+ handleUpper: spec.handleUpper ?? height * (26 / 35),
15
+ rootRadius: spec.rootRadius ?? radius * .2625, rootLength: spec.rootLength ?? radius * .2,
16
+ rootTension: spec.rootTension ?? 1,
17
+ segments: spec.segments ?? 192, sectionSegments: spec.sectionSegments ?? 96,
18
+ handleSegments: spec.handleSegments ?? 96, rootSegments: spec.rootSegments ?? 32, wallRows: spec.wallRows ?? 40 }
19
+ }
20
+
21
+ export function handledCupIssues(spec) {
22
+ const inputFields = ['radius', 'segments', ...HANDLED_CUP_FIELDS]
23
+ for (const key of inputFields) if (spec[key] !== undefined &&
24
+ (typeof spec[key] !== 'number' || !Number.isFinite(spec[key]) || spec[key] <= 0)) {
25
+ return [`${key} must be a finite positive number`]
26
+ }
27
+ const p = resolveHandledCup(spec)
28
+ const errors = []
29
+ const within = (value, low, high) => value >= low - 1e-12 && value <= high + 1e-12
30
+ const ratios = [
31
+ ['radius', p.radius, .02, .08], ['height/radius', p.height / p.radius, 1.8, 4],
32
+ ['wallThickness/radius', p.wallThickness / p.radius, .025, .15],
33
+ ['baseThickness/radius', p.baseThickness / p.radius, .05, .25],
34
+ ['handleRadius/radius', p.handleRadius / p.radius, .05, .2],
35
+ ['rootRadius/radius', p.rootRadius / p.radius, .12, .35],
36
+ ['rootRadius/handleRadius', p.rootRadius / p.handleRadius, 1.4, 3],
37
+ ['rootLength/rootRadius', p.rootLength / p.rootRadius, .4, 1.2],
38
+ ['footRound/radius', p.footRound / p.radius, .01, .15],
39
+ ]
40
+ for (const [name, value, low, high] of ratios) if (!Number.isFinite(value) || !within(value, low, high)) {
41
+ errors.push(`${name} must be within ${low}–${high}`)
42
+ }
43
+ if (p.rootTension < 1 || p.rootTension > 2.5) errors.push('rootTension must be within 1–2.5')
44
+ const gap = (p.handleUpper - p.handleLower) / 2
45
+ if (gap <= 0 || p.rootLength / gap > .8 + 1e-12) errors.push('handleUpper must exceed handleLower; rootLength/half-gap must be <=0.8')
46
+ if (gap + p.radius * 1e-12 < Math.max(1.1 * p.rootRadius, 2 * p.handleRadius)) errors.push('attachment half-gap must be >=1.1*rootRadius and >=2*handleRadius')
47
+ if (p.handleLower - p.rootRadius + p.radius * 1e-12 < p.footRound + .01 * p.radius) errors.push('lower root footprint must clear the rounded foot by 0.01*radius')
48
+ if (p.handleUpper + p.rootRadius > p.height - p.wallThickness / 2 - .01 * p.radius + p.radius * 1e-12) errors.push('upper root footprint must clear the rounded lip by 0.01*radius')
49
+ for (const [key, low, high, multiple] of [['segments',64,256,4], ['sectionSegments',32,96,4],
50
+ ['handleSegments',24,128,4], ['rootSegments',8,48,1], ['wallRows',16,64,1]]) {
51
+ if (!Number.isInteger(p[key]) || p[key] < low || p[key] > high || p[key] % multiple) errors.push(`${key} must be an integer within ${low}–${high}, divisible by ${multiple}`)
52
+ }
53
+ if (p.footRound > Math.min(p.wallThickness, p.baseThickness * .8) + p.radius * 1e-12) errors.push('footRound must not exceed wallThickness or 0.8*baseThickness')
54
+ return errors
55
+ }
package/lib/index.js CHANGED
@@ -374,9 +374,11 @@ export const BlenderWarningCode = Object.freeze({
374
374
  * before. 3 is M3 (the persistent render job). 4 is M4, which added the UI plane's
375
375
  * read/aggregate methods — the browser half faces the same half-upgraded deployment
376
376
  * the tools do, and it has to say so instead of painting a blank panel over a
377
- * `TypeError` (D59).
377
+ * `TypeError` (D59). 5 adds the asset library's upload, inventory and isolated
378
+ * preview methods; an older Host cannot serve that UI workflow. 6 adds isolated
379
+ * beauty/clay diagnostics; an older Host must not silently ignore that mode.
378
380
  */
379
- export const HOST_API_VERSION = 4
381
+ export const HOST_API_VERSION = 6
380
382
 
381
383
  /** Formats the product intends to support (SPEC §2.2). Used to emit warnings. */
382
384
  export const EXPECTED_IMPORT_FORMATS = Object.freeze(['gltf', 'fbx', 'obj', 'usd'])
@@ -528,14 +530,19 @@ export function warning(code, message, detail) {
528
530
  * They hold NO business state machine — orchestration belongs to
529
531
  * `BlenderOrchestrator` and persistence to the project/revision stores.
530
532
  *
531
- * M0 implements `getCapabilities` and the shared `runBootstrap` transport.
532
- * The remaining methods are declared here as the frozen target shape and are
533
- * implemented in M1+. Calling an unimplemented one throws
534
- * `BlenderErrorCode.UNSUPPORTED_ACTION`, never silently succeeds.
533
+ * The complete, current Host-facing interface and optional local transport
534
+ * extensions are exported as TypeScript types from the public `/sdk` entry.
535
+ * See `deepblend/docs/public-api.md` for cancellation and artifact ownership.
535
536
  *
536
537
  * @typedef {object} BlenderRuntime
537
- * @property {(signal?: AbortSignal) => Promise<BlenderCapabilities>} getCapabilities
538
- * @property {(request: BlenderBootstrapRequest, options?: { signal?: AbortSignal }) => Promise<BlenderBootstrapEnvelope>} runBootstrap
538
+ * @property {(options?: {refresh?: boolean, signal?: AbortSignal}) => Promise<BlenderCapabilities>} getCapabilities
539
+ * @property {() => void} invalidateCapabilities
540
+ * @property {import('./runtime-types.js').BlenderRuntime['resolveEngineKey']} resolveEngineKey
541
+ * @property {import('./runtime-types.js').BlenderRuntime['compileScene']} compileScene
542
+ * @property {import('./runtime-types.js').BlenderRuntime['renderPreview']} renderPreview
543
+ * @property {import('./runtime-types.js').BlenderRuntime['renderViews']} renderViews
544
+ * @property {import('./runtime-types.js').BlenderRuntime['startFrameSequence']} startFrameSequence
545
+ * @property {import('./runtime-types.js').BlenderRuntime['awaitFrameSequence']} awaitFrameSequence
539
546
  * @property {() => void} dispose
540
547
  */
541
548
 
@@ -553,17 +560,20 @@ export function warning(code, message, detail) {
553
560
  * @property {'success'|'error'} status
554
561
  * @property {string} protocolVersion
555
562
  * @property {string|null} jobId
556
- * @property {BlenderCapabilities|null} capabilities
557
- * @property {{ code: string, message: string }|null} error
558
- * @property {string[]} warnings
563
+ * @property {string} action
564
+ * @property {Record<string, unknown>|null} capabilities - raw probe; normalized by getCapabilities.
565
+ * @property {Record<string, unknown>|null} result
566
+ * @property {{ code: string, message: string, detail?: unknown }|null} error
567
+ * @property {{code: string, message: string, detail?: unknown}[]} warnings
568
+ * @property {{code: string, message: string, detail?: unknown}[]} notices
559
569
  */
560
570
 
561
571
  /**
562
572
  * The model- and UI-facing business facade (SPEC §7.2).
563
573
  *
564
- * M0 implements `getCapabilities` only. Everything else is the frozen M1+ target
565
- * and throws `UNSUPPORTED_ACTION` until implemented, so the model can never
566
- * believe a not-yet-built capability worked.
574
+ * The Host implements project/revision transactions, reviews, assets and render
575
+ * jobs. This historical typedef is a minimal view, not the full facade contract;
576
+ * the SDK's BlenderRuntime types describe the separate execution seam.
567
577
  *
568
578
  * @typedef {object} BlenderStudio
569
579
  * @property {(request?: { refresh?: boolean }) => Promise<BlenderCapabilities>} getCapabilities
@@ -676,12 +686,15 @@ export {
676
686
  SCENE_ENGINES,
677
687
  BLENDER_ENGINE_BY_KEY,
678
688
  IMPORT_OPERATOR_BY_ASSET_TYPE,
689
+ IMAGE_ASSET_TYPES,
690
+ ENVIRONMENT_ASSET_TYPES,
679
691
  validateSceneSpec,
680
692
  compileSceneSpec,
681
693
  entityBoundingRadius,
682
694
  sceneProjection,
683
695
  sceneSpecDigest,
684
696
  specHash,
697
+ reviewInputsDigest,
685
698
  summarizeSceneSpec,
686
699
  sceneSpecCanonicalText,
687
700
  } from './scene-spec.js'
@@ -760,6 +773,8 @@ export {
760
773
  runVisualLoop,
761
774
  } from './visual-loop.js'
762
775
 
776
+ export { ARTISTIC_DIMENSIONS, validateArtisticReview, artisticRegressed } from './artistic-review.js'
777
+
763
778
  // ---------------------------------------------------------------------------
764
779
  // M3 surface
765
780
  //
@@ -852,3 +867,5 @@ export {
852
867
  resolveProjectsRoot,
853
868
  managedBlenderCandidates,
854
869
  } from './deployment-paths.js'
870
+
871
+ export { RECIPE_SCHEMA_VERSION, RECIPE_CAPABILITIES, RECIPE_LIMITS, RecipeError, recipeCapabilitiesForScene, validateRecipeManifest, validateRecipePackage, instantiateRecipe } from './recipe.js'
package/lib/recipe.js ADDED
@@ -0,0 +1,253 @@
1
+ /** Portable data recipes. No filesystem, network, dynamic imports or code execution. */
2
+ import { inflateSync } from 'node:zlib'
3
+ import { sha256, sha256Canonical } from './canonical.js'
4
+ import { compileSchema } from './json-schema.js'
5
+ import { decodePng } from './png.js'
6
+ import { validateSceneSpec, compileSceneSpec } from './scene-spec.js'
7
+ import recipeSchema from './schemas/recipe.schema.json' with { type: 'json' }
8
+
9
+ export const RECIPE_SCHEMA_VERSION = 'deepblend.recipe/v1'
10
+ export const RECIPE_CAPABILITIES = Object.freeze([
11
+ 'geometry.primitive', 'geometry.lathe', 'geometry.curve', 'geometry.handled_cup', 'geometry.handled_cup.tension', 'geometry.handled_cup.tension.extended', 'geometry.modifiers',
12
+ 'material.principled', 'material.glass', 'material.emission', 'material.procedural', 'material.procedural.uv', 'material.anisotropy',
13
+ 'animation.transform', 'animation.material',
14
+ ])
15
+ export const RECIPE_LIMITS = Object.freeze({ sceneBytes: 2 * 1024 * 1024, previewBytes: 4 * 1024 * 1024, previewPixels: 4 * 1024 * 1024 })
16
+ const structural = compileSchema(recipeSchema, { id: 'recipe.schema.json' })
17
+ const RESERVED = new Set(['__proto__', 'prototype', 'constructor'])
18
+ const plain = value => value !== null && typeof value === 'object' &&
19
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
20
+ const issue = (code, path, message) => ({ code, path, message })
21
+ const result = (errors, extra = {}) => ({ ok: errors.length === 0, errors, notices: [],
22
+ summary: errors.length ? errors.map(error => `${error.path}: ${error.message}`).join('; ') : 'Recipe valid', ...extra })
23
+
24
+ export class RecipeError extends Error {
25
+ constructor(code, message, details = {}) {
26
+ super(message); this.name = 'RecipeError'; this.code = code; this.details = details
27
+ }
28
+ }
29
+
30
+ /** Capabilities describe what the input actually uses, rather than its author's claims. */
31
+ export function recipeCapabilitiesForScene(spec) {
32
+ const used = new Set()
33
+ for (const entity of spec.entities ?? []) {
34
+ if (entity.type === 'generator') used.add(['lathe', 'curve', 'handled_cup'].includes(entity.generator.shape)
35
+ ? `geometry.${entity.generator.shape}` : 'geometry.primitive')
36
+ if (entity.modifiers?.length || entity.generator?.bevel) used.add('geometry.modifiers')
37
+ if (entity.generator?.shape === 'handled_cup' && (entity.generator.rootTension ?? 1) !== 1) used.add('geometry.handled_cup.tension')
38
+ if (entity.generator?.shape === 'handled_cup' && (entity.generator.rootTension ?? 1) > 1.5) used.add('geometry.handled_cup.tension.extended')
39
+ }
40
+ for (const material of spec.materials ?? []) {
41
+ used.add(`material.${material.shader}`)
42
+ if (material.texture) used.add('material.procedural')
43
+ if (material.texture?.coordinates === 'uv') used.add('material.procedural.uv')
44
+ if (material.tangent !== undefined || material.parameters?.anisotropic !== undefined || material.parameters?.anisotropicRotation !== undefined) used.add('material.anisotropy')
45
+ }
46
+ for (const track of spec.animationTracks ?? []) {
47
+ used.add(track.targetKind === 'material' ? 'animation.material' : 'animation.transform')
48
+ if (track.targetKind === 'material' && ['anisotropic', 'anisotropicRotation'].includes(track.property)) used.add('material.anisotropy')
49
+ }
50
+ return [...used].sort()
51
+ }
52
+
53
+ function parameterValueIssue(parameter, value, path) {
54
+ if (parameter.type === 'color') {
55
+ if (!Array.isArray(value) || value.length !== 3 || Array.from(value).some(channel => !Number.isFinite(channel) || channel < 0 || channel > 1)) {
56
+ return issue('RECIPE_PARAMETER_INVALID', path, 'Expected three scene-linear RGB channels in [0, 1]')
57
+ }
58
+ } else if (!Number.isFinite(value) || value < parameter.minimum || value > parameter.maximum) {
59
+ return issue('RECIPE_PARAMETER_INVALID', path, `Expected a finite number in [${parameter.minimum}, ${parameter.maximum}]`)
60
+ }
61
+ return null
62
+ }
63
+
64
+ export function validateRecipeManifest(manifest, options = {}) {
65
+ const errors = structural(manifest).map(error => issue('RECIPE_MANIFEST_INVALID', error.path, error.message))
66
+ if (errors.length) return result(errors)
67
+ if (RESERVED.has(manifest.id)) errors.push(issue('RECIPE_MANIFEST_INVALID', 'id', 'Reserved object keys are not recipe IDs'))
68
+ for (const [path, value] of [['title', manifest.title], ['description', manifest.description], ['license', manifest.license], ['author.name', manifest.author.name]]) {
69
+ if (!value.trim()) errors.push(issue('RECIPE_MANIFEST_INVALID', path, 'A non-empty declaration is required'))
70
+ }
71
+ for (const [path, address] of [['source.url', manifest.source.url], ['author.url', manifest.author.url]]) {
72
+ if (address === undefined) continue
73
+ try {
74
+ const parsed = new URL(address)
75
+ if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new Error()
76
+ } catch { errors.push(issue('RECIPE_MANIFEST_INVALID', path, 'Expected an HTTP(S) source reference without credentials; URLs are never fetched')) }
77
+ }
78
+ const supported = new Set(options.supportedCapabilities ?? RECIPE_CAPABILITIES)
79
+ const declared = new Set()
80
+ for (const capability of manifest.compatibility.capabilities) {
81
+ if (declared.has(capability)) errors.push(issue('RECIPE_MANIFEST_INVALID', 'compatibility.capabilities', `Duplicate capability ${capability}`))
82
+ declared.add(capability)
83
+ if (!supported.has(capability)) errors.push(issue('RECIPE_INCOMPATIBLE', 'compatibility.capabilities', `Unsupported capability ${capability}`))
84
+ }
85
+ const ids = new Set(), targets = new Set()
86
+ for (const parameter of manifest.parameters) {
87
+ const path = `parameters.${parameter.id}`
88
+ if (ids.has(parameter.id) || RESERVED.has(parameter.id)) errors.push(issue('RECIPE_PARAMETER_INVALID', path, 'Parameter IDs must be unique and must not be reserved object keys'))
89
+ ids.add(parameter.id)
90
+ if (parameter.type === 'number' && parameter.minimum > parameter.maximum) errors.push(issue('RECIPE_PARAMETER_INVALID', path, 'minimum must not exceed maximum'))
91
+ const valueIssue = parameterValueIssue(parameter, parameter.default, `${path}.default`)
92
+ if (valueIssue) errors.push(valueIssue)
93
+ for (const binding of parameter.bindings) {
94
+ const target = binding.kind === 'material' ? `material:${binding.materialId}:${binding.property}` : `render-profile:${binding.profile}:exposure`
95
+ if (targets.has(target)) errors.push(issue('RECIPE_BINDING_INVALID', path, `Target ${target} is bound more than once`))
96
+ targets.add(target)
97
+ const color = binding.kind === 'material' && binding.property === 'baseColor'
98
+ if ((parameter.type === 'color') !== color) errors.push(issue('RECIPE_BINDING_INVALID', path, 'Parameter type does not match the allowlisted property'))
99
+ if (parameter.type === 'number') {
100
+ const range = binding.kind === 'material' ? [0, 1] : [-10, 10]
101
+ if (parameter.minimum < range[0] || parameter.maximum > range[1]) errors.push(issue('RECIPE_BINDING_INVALID', path, `Binding range must stay within [${range}]`))
102
+ }
103
+ }
104
+ }
105
+ return result(errors)
106
+ }
107
+
108
+ function asBytes(value, limit, path) {
109
+ if (!(value instanceof Uint8Array) && typeof value !== 'string') throw new RecipeError('RECIPE_INPUT_INVALID', `${path}: expected bytes or UTF-8 text`)
110
+ const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : Buffer.from(value.buffer, value.byteOffset, value.byteLength)
111
+ if (!bytes.length || bytes.length > limit) throw new RecipeError('RECIPE_INPUT_INVALID', `${path}: byte count must be in [1, ${limit}]`)
112
+ return bytes
113
+ }
114
+
115
+ // Bound PNG expansion before the shared decoder inflates it. A preview is data,
116
+ // but its declared hash alone must not grant it unbounded decompression memory.
117
+ const PNG_CRC_TABLE = Array.from({ length: 256 }, (_, index) => {
118
+ let value = index
119
+ for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
120
+ return value >>> 0
121
+ })
122
+ function pngCrc(bytes, start, end) {
123
+ let value = 0xffffffff
124
+ for (let index = start; index < end; index++) value = PNG_CRC_TABLE[(value ^ bytes[index]) & 255] ^ (value >>> 8)
125
+ return (value ^ 0xffffffff) >>> 0
126
+ }
127
+ function validatePreview(bytes) {
128
+ if (bytes.length < 33 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) throw new Error('Expected PNG bytes')
129
+ let at = 8, width = 0, height = 0, channels = 0, ended = false
130
+ const data = []
131
+ while (at < bytes.length) {
132
+ if (at + 12 > bytes.length) throw new Error('Truncated PNG chunk')
133
+ const size = bytes.readUInt32BE(at), kind = bytes.toString('ascii', at + 4, at + 8), end = at + 8 + size
134
+ if (end + 4 > bytes.length) throw new Error('Truncated PNG payload')
135
+ if (pngCrc(bytes, at + 4, end) !== bytes.readUInt32BE(end)) throw new Error('PNG chunk checksum mismatch')
136
+ if (at === 8 && kind !== 'IHDR') throw new Error('PNG must start with IHDR')
137
+ if (kind === 'IHDR') {
138
+ if (at !== 8 || size !== 13) throw new Error('Invalid PNG IHDR')
139
+ width = bytes.readUInt32BE(at + 8); height = bytes.readUInt32BE(at + 12)
140
+ const depth = bytes[at + 16], color = bytes[at + 17]
141
+ if (depth !== 8 || ![2, 6].includes(color) || bytes[at + 18] || bytes[at + 19] || bytes[at + 20]) throw new Error('Preview must be a non-interlaced 8-bit RGB/RGBA PNG')
142
+ channels = color === 6 ? 4 : 3
143
+ if (!width || !height || width * height > RECIPE_LIMITS.previewPixels) throw new Error('Preview pixel limit exceeded')
144
+ }
145
+ if (kind === 'IDAT') data.push(bytes.subarray(at + 8, end))
146
+ if (kind === 'IEND') {
147
+ if (size !== 0 || end + 4 !== bytes.length) throw new Error('Invalid PNG end or trailing content')
148
+ ended = true
149
+ }
150
+ at = end + 4
151
+ }
152
+ if (!ended || !data.length) throw new Error('PNG is missing image data or IEND')
153
+ const expected = height * (1 + width * channels)
154
+ if (inflateSync(Buffer.concat(data), { maxOutputLength: expected }).length !== expected) throw new Error('PNG scanline length differs from IHDR')
155
+ decodePng(bytes)
156
+ }
157
+
158
+ function bindingsAgainstScene(manifest, spec) {
159
+ const errors = []
160
+ for (const parameter of manifest.parameters) for (const binding of parameter.bindings) {
161
+ const path = `parameters.${parameter.id}.bindings`
162
+ if (binding.kind === 'material') {
163
+ const material = spec.materials?.find(item => item.id === binding.materialId)
164
+ if (!material || !['principled', 'glass'].includes(material.shader) || material.parameters?.[binding.property] === undefined) {
165
+ errors.push(issue('RECIPE_BINDING_INVALID', path, `Material ${binding.materialId} must explicitly declare ${binding.property} on principled/glass`))
166
+ } else {
167
+ const current = parameter.type === 'color' ? material.parameters.baseColor.slice(0, 3) : material.parameters[binding.property]
168
+ if (JSON.stringify(current) !== JSON.stringify(parameter.default)) errors.push(issue('RECIPE_BINDING_INVALID', path, 'Parameter default must match the input scene property'))
169
+ }
170
+ if (spec.animationTracks?.some(track => track.targetKind === 'material' && track.targetEntityId === binding.materialId &&
171
+ (track.property === binding.property || track.property.startsWith(`${binding.property}.`)))) {
172
+ errors.push(issue('RECIPE_BINDING_INVALID', path, 'A parameter must not compete with animation on the same material property'))
173
+ }
174
+ } else if (spec.renderProfiles?.[binding.profile]?.colorManagement?.exposure !== parameter.default) {
175
+ errors.push(issue('RECIPE_BINDING_INVALID', path, `Profile ${binding.profile} must explicitly declare exposure equal to the parameter default`))
176
+ }
177
+ }
178
+ return errors
179
+ }
180
+
181
+ /** Hash actual loaded bytes; the manifest is a claim, never a verification receipt. */
182
+ export function validateRecipePackage(bundle, options = {}) {
183
+ const checked = validateRecipeManifest(bundle?.manifest, options)
184
+ if (!checked.ok) return checked
185
+ const { manifest } = bundle
186
+ const errors = []
187
+ let sceneSpec
188
+ try {
189
+ const sceneBytes = asBytes(bundle.sceneBytes, RECIPE_LIMITS.sceneBytes, 'input')
190
+ const previewBytes = asBytes(bundle.previewBytes, RECIPE_LIMITS.previewBytes, 'preview')
191
+ if (sha256(sceneBytes) !== manifest.input.sha256) errors.push(issue('RECIPE_HASH_MISMATCH', 'input.sha256', 'Loaded SceneSpec bytes differ from the declared hash'))
192
+ if (sha256(previewBytes) !== manifest.preview.sha256) errors.push(issue('RECIPE_HASH_MISMATCH', 'preview.sha256', 'Loaded preview bytes differ from the declared hash'))
193
+ if (errors.length) return result(errors)
194
+ try { validatePreview(previewBytes) } catch (error) { return result([issue('RECIPE_PREVIEW_INVALID', 'preview', error.message)]) }
195
+ try { sceneSpec = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(sceneBytes)) }
196
+ catch { return result([issue('RECIPE_INPUT_INVALID', 'input', 'SceneSpec must be valid UTF-8 JSON')]) }
197
+ const scene = validateSceneSpec(sceneSpec)
198
+ if (!scene.ok) return result(scene.errors.map(error => issue('RECIPE_SCENE_INVALID', `input.${error.path}`, error.message)))
199
+ if (sceneSpec.assets?.length || sceneSpec.entities.some(entity => entity.type === 'asset-instance') ||
200
+ sceneSpec.materials?.some(material => material.images) || sceneSpec.world?.environment ||
201
+ sceneSpec.armatures?.length || sceneSpec.simulation ||
202
+ sceneSpec.entities.some(entity => entity.rigidBody || entity.cloth || entity.softBody || entity.fluid || entity.armatureId) ||
203
+ sceneSpec.animationTracks?.some(track => track.targetKind === 'bone')) {
204
+ errors.push(issue('RECIPE_INPUT_UNSUPPORTED', 'input', 'Recipe v1 supports self-contained procedural scenes without files, rigs or simulations'))
205
+ }
206
+ const declared = new Set(manifest.compatibility.capabilities)
207
+ for (const capability of recipeCapabilitiesForScene(sceneSpec)) {
208
+ if (!declared.has(capability)) errors.push(issue('RECIPE_CAPABILITY_UNDECLARED', 'compatibility.capabilities', `Scene uses undeclared capability ${capability}`))
209
+ }
210
+ errors.push(...bindingsAgainstScene(manifest, sceneSpec))
211
+ if (errors.length) return result(errors)
212
+ const compiled = compileSceneSpec(sceneSpec)
213
+ const compiledCheck = validateSceneSpec(compiled.spec)
214
+ if (!compiledCheck.ok) return result(compiledCheck.errors.map(error => issue('RECIPE_SCENE_INVALID', error.path, error.message)))
215
+ return result([], { sceneSpec, notices: [...scene.notices, ...compiled.notices] })
216
+ } catch (error) { return result([issue(error.code ?? 'RECIPE_INPUT_INVALID', 'input', error.message)]) }
217
+ }
218
+
219
+ /** Return a fresh resolved SceneSpec; only explicit scalar/color slots can change. */
220
+ export function instantiateRecipe(bundle, values = {}, options = {}) {
221
+ const checked = validateRecipePackage(bundle, options)
222
+ if (!checked.ok) throw new RecipeError(checked.errors[0].code, checked.summary, { errors: checked.errors })
223
+ if (!plain(values)) throw new RecipeError('RECIPE_PARAMETER_INVALID', 'Parameter values must be a plain object')
224
+ const { manifest } = bundle
225
+ const known = new Set(manifest.parameters.map(parameter => parameter.id))
226
+ const errors = Object.keys(values).filter(key => !known.has(key) || RESERVED.has(key))
227
+ .map(key => issue('RECIPE_PARAMETER_UNKNOWN', `values.${key}`, 'Unknown parameter'))
228
+ const resolvedValues = {}
229
+ for (const parameter of manifest.parameters) {
230
+ const value = Object.hasOwn(values, parameter.id) ? values[parameter.id] : parameter.default
231
+ const valueIssue = parameterValueIssue(parameter, value, `values.${parameter.id}`)
232
+ if (valueIssue) errors.push(valueIssue)
233
+ else resolvedValues[parameter.id] = structuredClone(value)
234
+ }
235
+ if (errors.length) throw new RecipeError(errors[0].code, errors.map(error => error.message).join('; '), { errors })
236
+ const spec = structuredClone(checked.sceneSpec)
237
+ for (const parameter of manifest.parameters) for (const binding of parameter.bindings) {
238
+ const value = resolvedValues[parameter.id]
239
+ if (binding.kind === 'material') {
240
+ const material = spec.materials.find(item => item.id === binding.materialId)
241
+ material.parameters[binding.property] = parameter.type === 'color'
242
+ ? [...value, material.parameters.baseColor[3] ?? 1] : value
243
+ } else spec.renderProfiles[binding.profile].colorManagement.exposure = value
244
+ }
245
+ const validation = validateSceneSpec(spec)
246
+ if (!validation.ok) throw new RecipeError('RECIPE_SCENE_INVALID', validation.summary, { errors: validation.errors })
247
+ const compiled = compileSceneSpec(spec)
248
+ const finalCheck = validateSceneSpec(compiled.spec)
249
+ if (!finalCheck.ok) throw new RecipeError('RECIPE_SCENE_INVALID', finalCheck.summary, { errors: finalCheck.errors })
250
+ return { spec: compiled.spec, values: resolvedValues, notices: [...validation.notices, ...compiled.notices],
251
+ recipe: { id: manifest.id, version: manifest.version, manifestSha256: sha256Canonical(manifest),
252
+ inputSha256: manifest.input.sha256, previewSha256: manifest.preview.sha256, valuesSha256: sha256Canonical(resolvedValues) } }
253
+ }
@@ -0,0 +1,28 @@
1
+ /** Semantic checks shared by a SceneSpec and a project.brief.set operation.
2
+ * Shape validation runs first. With assets omitted, only local invariants apply.
3
+ */
4
+ export function referenceImageIssues(references, assets) {
5
+ const issues = []
6
+ const ids = new Set()
7
+ for (const [index, reference] of references.entries()) {
8
+ const add = (field, message, code = 'SCENE_SPEC_INVALID') =>
9
+ issues.push({ path: `[${index}].${field}`, message, code })
10
+ if (ids.has(reference.id)) add('id', `duplicate reference image id "${reference.id}"`, 'SCENE_ID_DUPLICATE')
11
+ ids.add(reference.id)
12
+ if (new Set(reference.purposes).size !== reference.purposes.length) {
13
+ add('purposes', 'reference image purposes must be unique')
14
+ }
15
+ if (assets === undefined) continue
16
+ const asset = assets.find(entry => entry.id === reference.assetId)
17
+ if (!asset) {
18
+ add('assetId', `reference image asset "${reference.assetId}" does not exist`, 'SCENE_REFERENCE_MISSING')
19
+ continue
20
+ }
21
+ if (!['png', 'jpg', 'jpeg'].includes(asset.type)) add('assetId', 'reference images require a PNG or JPEG asset')
22
+ if (asset.sha256 !== reference.sha256) add('sha256', 'reference image sha256 must match its declared asset sha256')
23
+ if (asset.path !== `assets/raw/${reference.sha256}.${asset.type}`) {
24
+ add('assetId', 'reference image assets require the immutable assets/raw/<sha256>.<type> path')
25
+ }
26
+ }
27
+ return issues
28
+ }