@deepblend/dsh-blender-contracts 0.2.19 → 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.
- package/lib/artistic-review.js +57 -0
- package/lib/asset-content.js +6 -3
- package/lib/handled-cup.js +55 -0
- package/lib/index.js +31 -14
- package/lib/recipe.js +253 -0
- package/lib/reference-images.js +28 -0
- package/lib/runtime-types.d.ts +208 -0
- package/lib/scene-patch.js +133 -1
- package/lib/scene-spec.js +249 -4
- package/lib/schema-types.d.ts +185 -0
- package/lib/schemas/job-result.schema.json +117 -0
- package/lib/schemas/recipe.schema.json +332 -0
- package/lib/schemas/scene-patch.schema.json +793 -57
- package/lib/schemas/scene-spec.schema.json +697 -72
- package/lib/sdk.d.ts +101 -0
- package/lib/sdk.js +41 -0
- package/lib/ui-api.js +68 -3
- package/lib/visual-composition.js +38 -24
- package/lib/visual-issue.js +26 -8
- package/lib/visual-loop.js +204 -26
- package/package.json +8 -1
|
@@ -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
|
+
}
|
package/lib/asset-content.js
CHANGED
|
@@ -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:
|
|
53
|
-
{ format:
|
|
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 =
|
|
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
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
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 {(
|
|
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 {
|
|
557
|
-
* @property {
|
|
558
|
-
* @property {string
|
|
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
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
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
|
+
}
|