@nodaro/shared 2.27.0 → 3.1.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/dist/index.cjs +1776 -159
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2921 -73
- package/dist/index.d.ts +2921 -73
- package/dist/index.js +1665 -160
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/presentation-utils.test.ts +97 -0
- package/src/__tests__/scene3d-authoring-engine.test.ts +104 -0
- package/src/__tests__/scene3d-camera-track.test.ts +235 -0
- package/src/__tests__/scene3d-v2-edit.test.ts +83 -0
- package/src/__tests__/scene3d-v2-fixtures.ts +232 -0
- package/src/__tests__/scene3d-v2-resources.test.ts +239 -0
- package/src/__tests__/scene3d-v2.test.ts +742 -0
- package/src/__tests__/scene3d.test.ts +6 -6
- package/src/index.ts +9 -0
- package/src/model-constants.ts +10 -0
- package/src/node-mappable-fields.ts +1 -0
- package/src/presentation-utils.ts +54 -2
- package/src/pro-3d-render.ts +466 -0
- package/src/producer-types.ts +5 -0
- package/src/scene3d-authoring-engine.ts +215 -0
- package/src/scene3d-camera-track.ts +369 -0
- package/src/scene3d-edit.ts +10 -10
- package/src/scene3d-v2-edit.ts +156 -0
- package/src/scene3d-v2-plan.ts +694 -0
- package/src/scene3d-v2-resources.ts +382 -0
- package/src/scene3d-v2.ts +666 -0
- package/src/scene3d.ts +39 -13
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH authoring lane a Generate/Edit 3D Scene run goes down.
|
|
3
|
+
*
|
|
4
|
+
* There are two, and they are not interchangeable. Basic is the v1 LLM
|
|
5
|
+
* authoring path the platform has always had; Advanced is the installed
|
|
6
|
+
* private engine (`blender-cloud` / `blender-local`) that authors and edits
|
|
7
|
+
* schema-v2 scenes. The wire difference is one field — an `engine` on the body
|
|
8
|
+
* makes `POST /v1/3d-scene/{generate,edit}` hand the request to the private
|
|
9
|
+
* engine instead of the Basic guard.
|
|
10
|
+
*
|
|
11
|
+
* Deciding that is NOT a per-surface choice. The canvas executor and the
|
|
12
|
+
* headless orchestrator run the same nodes, and a node that means "Advanced"
|
|
13
|
+
* in the browser and "Basic" in a scheduled run is a scene authored by a
|
|
14
|
+
* different engine depending on who pressed Run. So the decision lives here,
|
|
15
|
+
* once, and both callers spread the SAME `fields` onto their request body.
|
|
16
|
+
*
|
|
17
|
+
* Three refusals, each of which used to be a silent wrong answer:
|
|
18
|
+
*
|
|
19
|
+
* - a **v2 plan on the Basic lane** is refused, never downgraded. Basic parses
|
|
20
|
+
* `scene3DPlanV1Schema`, so a v2 scene reaches it as a wall of Zod issues
|
|
21
|
+
* (the "Pro composition → Edit 3D Scene 400s" report) — and if it ever did
|
|
22
|
+
* parse, it would author from a scene it cannot represent.
|
|
23
|
+
* - an **explicitly requested engine this install does not have** is refused
|
|
24
|
+
* rather than quietly becoming Basic. Falling back would charge the user for
|
|
25
|
+
* a different pipeline than the one they picked.
|
|
26
|
+
* - an **unknown engine name** is refused before anything is spent.
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
SCENE3D_SCHEMA_VERSION,
|
|
30
|
+
} from "./scene3d.js"
|
|
31
|
+
import {
|
|
32
|
+
SCENE3D_SCHEMA_VERSION_V2,
|
|
33
|
+
SCENE3D_SUPPORTED_SCHEMA_VERSIONS,
|
|
34
|
+
SCENE3D_V2_ENGINES,
|
|
35
|
+
type Scene3DKnownEngine,
|
|
36
|
+
} from "./scene3d-v2.js"
|
|
37
|
+
import { isKnownScene3DEngine, scene3DPlanSchemaVersion } from "./scene3d-v2-plan.js"
|
|
38
|
+
|
|
39
|
+
/** The value that names the Basic lane explicitly. Absent means the same. */
|
|
40
|
+
export const SCENE3D_BASIC_ENGINE = "basic"
|
|
41
|
+
|
|
42
|
+
/** Everything a caller may put in `engine` on a Generate/Edit request. */
|
|
43
|
+
export const SCENE3D_AUTHORING_ENGINES = [SCENE3D_BASIC_ENGINE, ...SCENE3D_V2_ENGINES] as const
|
|
44
|
+
export type Scene3DAuthoringEngine = (typeof SCENE3D_AUTHORING_ENGINES)[number]
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The engine an Advanced run picks when nothing else names one.
|
|
48
|
+
*
|
|
49
|
+
* Hosted cloud, because that is the contract's default lane; `blender-local`
|
|
50
|
+
* is never inferred — it needs a paired desktop and its own deployment flag,
|
|
51
|
+
* so it is only ever used when it was explicitly asked for or when the scene
|
|
52
|
+
* under edit was authored by it and this install still offers it.
|
|
53
|
+
*/
|
|
54
|
+
export const SCENE3D_DEFAULT_ADVANCED_ENGINE: Scene3DKnownEngine = "blender-cloud"
|
|
55
|
+
|
|
56
|
+
export function isScene3DAuthoringEngine(value: unknown): value is Scene3DAuthoringEngine {
|
|
57
|
+
return typeof value === "string" && (SCENE3D_AUTHORING_ENGINES as readonly string[]).includes(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Scene3DEngineChoiceInput {
|
|
61
|
+
/** The node's/caller's explicit selection. `undefined` = "not chosen". */
|
|
62
|
+
requested?: string | null
|
|
63
|
+
/**
|
|
64
|
+
* The plan the run edits, for an edit. Omit for generate.
|
|
65
|
+
*
|
|
66
|
+
* The raw plan rather than a version number on purpose: the caller already
|
|
67
|
+
* holds it, and reading the version here is the ONE place the "v2 never goes
|
|
68
|
+
* to Basic" rule can be enforced for every surface at once.
|
|
69
|
+
*/
|
|
70
|
+
plan?: unknown
|
|
71
|
+
/**
|
|
72
|
+
* Advanced engines this install can actually serve, from
|
|
73
|
+
* `GET /v1/3d-scene/capabilities`.
|
|
74
|
+
*
|
|
75
|
+
* `undefined` means NOT KNOWN (the headless orchestrator never asks, and the
|
|
76
|
+
* browser has not had the answer back yet) — which is different from "none".
|
|
77
|
+
* Unknown proceeds and lets the route refuse honestly with
|
|
78
|
+
* `SCENE_CAPABILITY_UNAVAILABLE`; a known-empty list refuses here, before a
|
|
79
|
+
* request that cannot succeed is sent.
|
|
80
|
+
*/
|
|
81
|
+
availableEngines?: readonly string[] | undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The extra body fields an Advanced request carries. Empty on Basic, so the
|
|
85
|
+
* Basic request stays byte-identical to what it has always been. */
|
|
86
|
+
export interface Scene3DEngineRequestFields {
|
|
87
|
+
engine?: Scene3DKnownEngine
|
|
88
|
+
/**
|
|
89
|
+
* Which scene schema versions the CALLER can read back.
|
|
90
|
+
*
|
|
91
|
+
* Contract §5: an advanced authoring request declares this so the engine
|
|
92
|
+
* never answers with a revision the caller cannot render. Both of our
|
|
93
|
+
* surfaces read v1 and v2, so both send the same list.
|
|
94
|
+
*/
|
|
95
|
+
acceptedSceneSchemaVersions?: number[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type Scene3DEngineChoiceRefusalCode =
|
|
99
|
+
/** The name is not an engine this contract knows. */
|
|
100
|
+
| "unknown_engine"
|
|
101
|
+
/** Explicitly asked for an engine this install does not serve. */
|
|
102
|
+
| "engine_unavailable"
|
|
103
|
+
/** A v2 scene was pointed at the Basic lane. */
|
|
104
|
+
| "schema_requires_advanced"
|
|
105
|
+
/** The scene claims a version nothing here can author against. */
|
|
106
|
+
| "unsupported_schema_version"
|
|
107
|
+
/** v2 scene, and no Advanced engine installed at all. */
|
|
108
|
+
| "advanced_unavailable"
|
|
109
|
+
|
|
110
|
+
export type Scene3DEngineChoice =
|
|
111
|
+
| { ok: true; lane: "basic"; engine: undefined; fields: Scene3DEngineRequestFields }
|
|
112
|
+
| { ok: true; lane: "advanced"; engine: Scene3DKnownEngine; fields: Scene3DEngineRequestFields }
|
|
113
|
+
| { ok: false; code: Scene3DEngineChoiceRefusalCode; message: string }
|
|
114
|
+
|
|
115
|
+
function advancedFields(engine: Scene3DKnownEngine): Scene3DEngineRequestFields {
|
|
116
|
+
return { engine, acceptedSceneSchemaVersions: [...SCENE3D_SUPPORTED_SCHEMA_VERSIONS] }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `undefined` (unknown) is permissive; a known list is authoritative. */
|
|
120
|
+
function serves(available: readonly string[] | undefined, engine: string): boolean {
|
|
121
|
+
return available === undefined || available.includes(engine)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The engine recorded on a v2 plan's provenance, when it is one we know. */
|
|
125
|
+
function planAuthoringEngine(plan: unknown): Scene3DKnownEngine | undefined {
|
|
126
|
+
const provenance = (plan as { provenance?: { engine?: unknown } } | null | undefined)?.provenance
|
|
127
|
+
const engine = provenance?.engine
|
|
128
|
+
return typeof engine === "string" && isKnownScene3DEngine(engine) ? engine : undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the lane, or refuse with a sentence the user can act on.
|
|
133
|
+
*
|
|
134
|
+
* Pure and synchronous: every caller already holds the three inputs, and the
|
|
135
|
+
* answer must be identical on the canvas and in the orchestrator.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveScene3DAuthoringEngine(input: Scene3DEngineChoiceInput): Scene3DEngineChoice {
|
|
138
|
+
const requested = typeof input.requested === "string" && input.requested.trim() !== ""
|
|
139
|
+
? input.requested.trim()
|
|
140
|
+
: undefined
|
|
141
|
+
if (requested !== undefined && !isScene3DAuthoringEngine(requested)) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
code: "unknown_engine",
|
|
145
|
+
message: `"${requested}" is not a 3D authoring engine — choose Basic, or an advanced engine this install offers.`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// `plan` absent = generate. `null` version = not a Scene3D plan at all, which
|
|
150
|
+
// is the caller's own error to report (they need the plan for other reasons
|
|
151
|
+
// too); this resolver only speaks to versions it can read.
|
|
152
|
+
const version = input.plan === undefined ? undefined : scene3DPlanSchemaVersion(input.plan)
|
|
153
|
+
if (version !== undefined && version !== null && !(SCENE3D_SUPPORTED_SCHEMA_VERSIONS as readonly number[]).includes(version)) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
code: "unsupported_schema_version",
|
|
157
|
+
message: `This scene uses schema version ${version}, which this version of Nodaro cannot edit.`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const isV2 = version === SCENE3D_SCHEMA_VERSION_V2
|
|
161
|
+
|
|
162
|
+
if (isV2) {
|
|
163
|
+
if (requested === SCENE3D_BASIC_ENGINE) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
code: "schema_requires_advanced",
|
|
167
|
+
message:
|
|
168
|
+
"This scene was authored by an advanced engine (schema v2) and cannot be edited on the Basic engine — switch this node's engine to the advanced one.",
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// An EXPLICIT choice is never silently swapped for another engine — it is
|
|
172
|
+
// refused, exactly like an explicit unavailable engine on a v1 scene.
|
|
173
|
+
if (requested !== undefined) {
|
|
174
|
+
return serves(input.availableEngines, requested)
|
|
175
|
+
? { ok: true, lane: "advanced", engine: requested, fields: advancedFields(requested) }
|
|
176
|
+
: unavailable(requested)
|
|
177
|
+
}
|
|
178
|
+
// Nothing was picked. Preference order: the engine that AUTHORED the scene
|
|
179
|
+
// (an edit stays on its own engine unless told otherwise), then the hosted
|
|
180
|
+
// default.
|
|
181
|
+
const preferred: Scene3DKnownEngine[] = []
|
|
182
|
+
const authored = planAuthoringEngine(input.plan)
|
|
183
|
+
if (authored) preferred.push(authored)
|
|
184
|
+
if (!preferred.includes(SCENE3D_DEFAULT_ADVANCED_ENGINE)) preferred.push(SCENE3D_DEFAULT_ADVANCED_ENGINE)
|
|
185
|
+
const engine = preferred.find((candidate) => serves(input.availableEngines, candidate))
|
|
186
|
+
if (!engine) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
code: "advanced_unavailable",
|
|
190
|
+
message:
|
|
191
|
+
"This scene needs an advanced 3D engine to edit, and this install does not have one available.",
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, lane: "advanced", engine, fields: advancedFields(engine) }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// v1, or a generate with no plan at all.
|
|
198
|
+
if (requested === undefined || requested === SCENE3D_BASIC_ENGINE) {
|
|
199
|
+
return { ok: true, lane: "basic", engine: undefined, fields: {} }
|
|
200
|
+
}
|
|
201
|
+
const engine = requested as Scene3DKnownEngine
|
|
202
|
+
if (!serves(input.availableEngines, engine)) return unavailable(engine)
|
|
203
|
+
return { ok: true, lane: "advanced", engine, fields: advancedFields(engine) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function unavailable(engine: string): Scene3DEngineChoice {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
code: "engine_unavailable",
|
|
210
|
+
message: `The "${engine}" 3D authoring engine is not available on this install.`,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** v1's version constant, re-exported for callers narrowing a plan by hand. */
|
|
215
|
+
export const SCENE3D_BASIC_SCHEMA_VERSION = SCENE3D_SCHEMA_VERSION
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Scene3D v2 camera sidecar — one baked sample per frame.
|
|
3
|
+
*
|
|
4
|
+
* A 30-second scene is 720 camera samples. V1's 240-keyframe interpolated track
|
|
5
|
+
* cannot carry that, and interpolating a sparse track across a hard cut blends
|
|
6
|
+
* two shots into a frame that belongs to neither. So v2 moves the camera out of
|
|
7
|
+
* the manifest into this dense JSON asset, and the rule becomes trivial:
|
|
8
|
+
*
|
|
9
|
+
* **at integer frame `f`, use `samples[f]`.**
|
|
10
|
+
*
|
|
11
|
+
* No interpolation, no easing, no "nearest key". Pausing, scrubbing backwards
|
|
12
|
+
* and rendering frames out of order therefore produce identical state, which is
|
|
13
|
+
* the whole reason preview and export can be trusted to agree.
|
|
14
|
+
*
|
|
15
|
+
* Two things this format refuses to guess at:
|
|
16
|
+
*
|
|
17
|
+
* - **Orientation is a quaternion, not a look-at.** A renderer that replaces the
|
|
18
|
+
* exported quaternion with `lookAt(target)` throws away the authored roll and
|
|
19
|
+
* the handheld component. `target` is carried for inspection and intent only.
|
|
20
|
+
* - **Projection is a matrix, not a lens number.** A focal length cannot express
|
|
21
|
+
* sensor fit or lens shift, and re-deriving a projection at a different aspect
|
|
22
|
+
* silently reframes every shot. `focalLengthMm` is metadata; the 16-element
|
|
23
|
+
* column-major matrix is authoritative.
|
|
24
|
+
*
|
|
25
|
+
* Changing fps or aspect ratio is an explicit resample/reprojection producing a
|
|
26
|
+
* NEW revision — never a render-time override. `scene3DCameraTrackPlanIssues`
|
|
27
|
+
* is what makes that non-negotiable.
|
|
28
|
+
*/
|
|
29
|
+
import { z } from "zod"
|
|
30
|
+
import {
|
|
31
|
+
SCENE3D_V2_LIMITS,
|
|
32
|
+
scene3DJsonByteLength,
|
|
33
|
+
scene3DZodIssues,
|
|
34
|
+
type Scene3DParseResult,
|
|
35
|
+
type Scene3DPlanV2,
|
|
36
|
+
} from "./scene3d-v2.js"
|
|
37
|
+
import { SCENE3D_LIMITS, type Scene3DSemanticIssue, type Vec3 } from "./scene3d.js"
|
|
38
|
+
|
|
39
|
+
export const SCENE3D_CAMERA_TRACK_FORMAT = "scene3d-camera-track"
|
|
40
|
+
export const SCENE3D_CAMERA_TRACK_VERSION = 1
|
|
41
|
+
|
|
42
|
+
export const SCENE3D_CAMERA_TRACK_LIMITS = {
|
|
43
|
+
maxJsonBytes: SCENE3D_V2_LIMITS.maxCameraTrackBytes,
|
|
44
|
+
maxFrameCount: SCENE3D_V2_LIMITS.maxDurationInFrames,
|
|
45
|
+
minFps: SCENE3D_V2_LIMITS.minFps,
|
|
46
|
+
maxFps: SCENE3D_V2_LIMITS.maxFps,
|
|
47
|
+
/** A unit quaternion off by more than this is a bug, not float noise. */
|
|
48
|
+
quaternionTolerance: 1e-4,
|
|
49
|
+
/** Absolute tolerance on the projection entries that must be exactly zero
|
|
50
|
+
* (or exactly ∓1) in a perspective matrix. */
|
|
51
|
+
projectionEpsilon: 1e-6,
|
|
52
|
+
/** Relative tolerance when comparing declared near/far against the values the
|
|
53
|
+
* projection matrix implies. */
|
|
54
|
+
nearFarRelativeTolerance: 1e-3,
|
|
55
|
+
/** Relative tolerance on `m[0]/m[5]` vs the manifest's `height/width`. */
|
|
56
|
+
aspectRelativeTolerance: 1e-3,
|
|
57
|
+
minNear: 1e-4,
|
|
58
|
+
maxFar: 1e7,
|
|
59
|
+
} as const
|
|
60
|
+
|
|
61
|
+
export interface Scene3DCameraSample {
|
|
62
|
+
position: Vec3
|
|
63
|
+
/** `[x, y, z, w]` — that order, normalized. */
|
|
64
|
+
quaternion: [number, number, number, number]
|
|
65
|
+
/** Exactly 16 entries, COLUMN-MAJOR (Three.js `Matrix4.elements` order). */
|
|
66
|
+
projectionMatrix: number[]
|
|
67
|
+
near: number
|
|
68
|
+
far: number
|
|
69
|
+
/** Authoring intent, for inspection and validation reporting. A renderer must
|
|
70
|
+
* never feed this back through `lookAt()`. */
|
|
71
|
+
target?: Vec3
|
|
72
|
+
/** Metadata only; the projection matrix wins. */
|
|
73
|
+
focalLengthMm?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Scene3DCameraTrackV1 {
|
|
77
|
+
format: typeof SCENE3D_CAMERA_TRACK_FORMAT
|
|
78
|
+
version: typeof SCENE3D_CAMERA_TRACK_VERSION
|
|
79
|
+
/** Always 0: public frames are zero-based, and the exporter has already
|
|
80
|
+
* subtracted the authoring package's start frame. */
|
|
81
|
+
frameStart: 0
|
|
82
|
+
frameCount: number
|
|
83
|
+
fps: number
|
|
84
|
+
samples: Scene3DCameraSample[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const coordinate = z.number().min(-SCENE3D_LIMITS.maxCoordinate).max(SCENE3D_LIMITS.maxCoordinate)
|
|
88
|
+
const positionSchema = z.tuple([coordinate, coordinate, coordinate])
|
|
89
|
+
|
|
90
|
+
export const scene3DCameraSampleSchema = z
|
|
91
|
+
.object({
|
|
92
|
+
position: positionSchema,
|
|
93
|
+
quaternion: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
|
94
|
+
projectionMatrix: z.array(z.number()).length(16),
|
|
95
|
+
near: z.number().min(SCENE3D_CAMERA_TRACK_LIMITS.minNear).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFar),
|
|
96
|
+
far: z.number().min(SCENE3D_CAMERA_TRACK_LIMITS.minNear).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFar),
|
|
97
|
+
target: positionSchema.optional(),
|
|
98
|
+
focalLengthMm: z
|
|
99
|
+
.number()
|
|
100
|
+
.min(SCENE3D_LIMITS.minFocalLengthMm)
|
|
101
|
+
.max(SCENE3D_LIMITS.maxFocalLengthMm)
|
|
102
|
+
.optional(),
|
|
103
|
+
})
|
|
104
|
+
.strict()
|
|
105
|
+
|
|
106
|
+
/** Structure only; `scene3DCameraTrackIssues` carries the numeric rules. */
|
|
107
|
+
export const scene3DCameraTrackObjectSchema = z
|
|
108
|
+
.object({
|
|
109
|
+
format: z.literal(SCENE3D_CAMERA_TRACK_FORMAT),
|
|
110
|
+
version: z.literal(SCENE3D_CAMERA_TRACK_VERSION),
|
|
111
|
+
frameStart: z.literal(0),
|
|
112
|
+
frameCount: z.number().int().min(1).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFrameCount),
|
|
113
|
+
fps: z.number().int().min(SCENE3D_CAMERA_TRACK_LIMITS.minFps).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFps),
|
|
114
|
+
samples: z.array(scene3DCameraSampleSchema).min(1).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFrameCount),
|
|
115
|
+
})
|
|
116
|
+
.strict()
|
|
117
|
+
|
|
118
|
+
type Issue = Scene3DSemanticIssue
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Is this a real PERSPECTIVE projection, and does it agree with the declared
|
|
122
|
+
* near/far?
|
|
123
|
+
*
|
|
124
|
+
* Column-major layout produced by every Three.js/glTF perspective camera:
|
|
125
|
+
*
|
|
126
|
+
* ```text
|
|
127
|
+
* m0 0 m8 0
|
|
128
|
+
* 0 m5 m9 0
|
|
129
|
+
* 0 0 m10 m14
|
|
130
|
+
* 0 0 -1 0
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* `m8`/`m9` carry lens shift and are free. Everything else is pinned. Inverting
|
|
134
|
+
* the two depth terms recovers `near = m14 / (m10 - 1)` and
|
|
135
|
+
* `far = m14 / (m10 + 1)`, which is how a matrix that quietly disagrees with its
|
|
136
|
+
* own declared clip planes gets caught.
|
|
137
|
+
*
|
|
138
|
+
* Exported because the builder validates its export with the same function the
|
|
139
|
+
* renderer admits it with.
|
|
140
|
+
*/
|
|
141
|
+
export function scene3DProjectionIssues(
|
|
142
|
+
matrix: readonly number[],
|
|
143
|
+
near: number,
|
|
144
|
+
far: number,
|
|
145
|
+
path: (string | number)[],
|
|
146
|
+
): Issue[] {
|
|
147
|
+
const issues: Issue[] = []
|
|
148
|
+
const eps = SCENE3D_CAMERA_TRACK_LIMITS.projectionEpsilon
|
|
149
|
+
|
|
150
|
+
if (matrix.length !== 16) {
|
|
151
|
+
issues.push({ path, message: `projection matrix must have exactly 16 entries (got ${matrix.length})` })
|
|
152
|
+
return issues
|
|
153
|
+
}
|
|
154
|
+
if (matrix.some((value) => !Number.isFinite(value))) {
|
|
155
|
+
issues.push({ path, message: "projection matrix contains a non-finite entry" })
|
|
156
|
+
return issues
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Orthographic is a future explicit capability. Name it, so it is never
|
|
160
|
+
// mis-read as a broken perspective matrix.
|
|
161
|
+
if (Math.abs(matrix[11]) < eps && Math.abs(matrix[15] - 1) < eps) {
|
|
162
|
+
issues.push({
|
|
163
|
+
path,
|
|
164
|
+
message: "projection matrix is orthographic; only perspective cameras are supported by this schema version",
|
|
165
|
+
})
|
|
166
|
+
return issues
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const index of [1, 2, 3, 4, 6, 7, 12, 13, 15]) {
|
|
170
|
+
if (Math.abs(matrix[index]) > eps) {
|
|
171
|
+
issues.push({ path: [...path, index], message: `projection matrix entry ${index} must be 0 (got ${matrix[index]})` })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (Math.abs(matrix[11] + 1) > eps) {
|
|
175
|
+
issues.push({ path: [...path, 11], message: `projection matrix entry 11 must be -1 for a perspective camera (got ${matrix[11]})` })
|
|
176
|
+
}
|
|
177
|
+
if (!(matrix[0] > 0)) {
|
|
178
|
+
issues.push({ path: [...path, 0], message: `projection matrix entry 0 must be positive (got ${matrix[0]})` })
|
|
179
|
+
}
|
|
180
|
+
if (!(matrix[5] > 0)) {
|
|
181
|
+
issues.push({ path: [...path, 5], message: `projection matrix entry 5 must be positive (got ${matrix[5]})` })
|
|
182
|
+
}
|
|
183
|
+
if (!(matrix[10] < 0)) {
|
|
184
|
+
issues.push({ path: [...path, 10], message: `projection matrix entry 10 must be negative (got ${matrix[10]})` })
|
|
185
|
+
}
|
|
186
|
+
if (!(matrix[14] < 0)) {
|
|
187
|
+
issues.push({ path: [...path, 14], message: `projection matrix entry 14 must be negative (got ${matrix[14]})` })
|
|
188
|
+
}
|
|
189
|
+
if (issues.length > 0) return issues
|
|
190
|
+
|
|
191
|
+
if (!(near > 0) || !(far > near)) {
|
|
192
|
+
issues.push({ path, message: `near/far must satisfy 0 < near < far (got near ${near}, far ${far})` })
|
|
193
|
+
return issues
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const tolerance = SCENE3D_CAMERA_TRACK_LIMITS.nearFarRelativeTolerance
|
|
197
|
+
const impliedNear = matrix[14] / (matrix[10] - 1)
|
|
198
|
+
if (Math.abs(impliedNear - near) > Math.abs(near) * tolerance) {
|
|
199
|
+
issues.push({
|
|
200
|
+
path,
|
|
201
|
+
message: `projection matrix implies near ${impliedNear.toPrecision(6)}, but the sample declares ${near}`,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
const farDenominator = matrix[10] + 1
|
|
205
|
+
if (Math.abs(farDenominator) < eps) {
|
|
206
|
+
issues.push({
|
|
207
|
+
path,
|
|
208
|
+
message: `projection matrix implies an infinite far plane, but the sample declares ${far}`,
|
|
209
|
+
})
|
|
210
|
+
} else {
|
|
211
|
+
const impliedFar = matrix[14] / farDenominator
|
|
212
|
+
if (Math.abs(impliedFar - far) > Math.abs(far) * tolerance) {
|
|
213
|
+
issues.push({
|
|
214
|
+
path,
|
|
215
|
+
message: `projection matrix implies far ${impliedFar.toPrecision(6)}, but the sample declares ${far}`,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return issues
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The numeric rules the schema cannot express: exact sample count, normalized
|
|
225
|
+
* quaternions, and a real perspective projection on every frame.
|
|
226
|
+
*
|
|
227
|
+
* Split out of the schema (as v1 does) so a caller holding a parsed track can
|
|
228
|
+
* re-check it, and so the per-sample walk stays one readable loop over up to
|
|
229
|
+
* 3,600 samples.
|
|
230
|
+
*/
|
|
231
|
+
export function scene3DCameraTrackIssues(track: Scene3DCameraTrackV1): Issue[] {
|
|
232
|
+
const issues: Issue[] = []
|
|
233
|
+
|
|
234
|
+
if (track.samples.length !== track.frameCount) {
|
|
235
|
+
issues.push({
|
|
236
|
+
path: ["samples"],
|
|
237
|
+
message: `track declares ${track.frameCount} frames but carries ${track.samples.length} samples; exactly one sample per frame is required`,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const quaternionTolerance = SCENE3D_CAMERA_TRACK_LIMITS.quaternionTolerance
|
|
242
|
+
track.samples.forEach((sample, index) => {
|
|
243
|
+
const [x, y, z, w] = sample.quaternion
|
|
244
|
+
const norm = Math.sqrt(x * x + y * y + z * z + w * w)
|
|
245
|
+
if (Math.abs(norm - 1) > quaternionTolerance) {
|
|
246
|
+
issues.push({
|
|
247
|
+
path: ["samples", index, "quaternion"],
|
|
248
|
+
message: `quaternion at frame ${index} has length ${norm.toPrecision(6)}; it must be normalized`,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
if (!(sample.far > sample.near)) {
|
|
252
|
+
issues.push({
|
|
253
|
+
path: ["samples", index, "far"],
|
|
254
|
+
message: `frame ${index}: far (${sample.far}) must be greater than near (${sample.near})`,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
for (const issue of scene3DProjectionIssues(
|
|
258
|
+
sample.projectionMatrix,
|
|
259
|
+
sample.near,
|
|
260
|
+
sample.far,
|
|
261
|
+
["samples", index, "projectionMatrix"],
|
|
262
|
+
)) {
|
|
263
|
+
issues.push(issue)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
return issues
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** THE camera-track validator: structure, then the numeric rules. */
|
|
271
|
+
export const scene3DCameraTrackSchema = scene3DCameraTrackObjectSchema.superRefine((track, ctx) => {
|
|
272
|
+
for (const issue of scene3DCameraTrackIssues(track as Scene3DCameraTrackV1)) {
|
|
273
|
+
ctx.addIssue({ code: "custom", path: issue.path, message: issue.message })
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
export function isScene3DCameraTrack(value: unknown): value is Scene3DCameraTrackV1 {
|
|
278
|
+
return scene3DCameraTrackSchema.safeParse(value).success
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Track ↔ manifest agreement. A track that is valid on its own can still be the
|
|
283
|
+
* WRONG track for this scene: a different fps, a different length, or a
|
|
284
|
+
* projection baked for another aspect ratio. Each of those silently reframes or
|
|
285
|
+
* retimes every shot, so each is an error here rather than a render-time
|
|
286
|
+
* surprise.
|
|
287
|
+
*/
|
|
288
|
+
export function scene3DCameraTrackPlanIssues(
|
|
289
|
+
track: Scene3DCameraTrackV1,
|
|
290
|
+
plan: Pick<Scene3DPlanV2, "fps" | "durationInFrames" | "width" | "height">,
|
|
291
|
+
): Issue[] {
|
|
292
|
+
const issues: Issue[] = []
|
|
293
|
+
|
|
294
|
+
if (track.fps !== plan.fps) {
|
|
295
|
+
issues.push({
|
|
296
|
+
path: ["fps"],
|
|
297
|
+
message: `camera track is ${track.fps} fps but the scene is ${plan.fps} fps; changing fps requires an explicit resample and a new revision`,
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
if (track.frameCount !== plan.durationInFrames) {
|
|
301
|
+
issues.push({
|
|
302
|
+
path: ["frameCount"],
|
|
303
|
+
message: `camera track covers ${track.frameCount} frames but the scene is ${plan.durationInFrames} frames`,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// m[0]/m[5] === (t-b)/(r-l) === height/width for any perspective matrix,
|
|
308
|
+
// including a shifted one (shift moves m[8]/m[9], not the frustum extents).
|
|
309
|
+
const expected = plan.height / plan.width
|
|
310
|
+
const tolerance = SCENE3D_CAMERA_TRACK_LIMITS.aspectRelativeTolerance
|
|
311
|
+
track.samples.forEach((sample, index) => {
|
|
312
|
+
const m0 = sample.projectionMatrix[0]
|
|
313
|
+
const m5 = sample.projectionMatrix[5]
|
|
314
|
+
if (!Number.isFinite(m0) || !Number.isFinite(m5) || m5 === 0) return
|
|
315
|
+
const actual = m0 / m5
|
|
316
|
+
if (Math.abs(actual - expected) > expected * tolerance) {
|
|
317
|
+
issues.push({
|
|
318
|
+
path: ["samples", index, "projectionMatrix"],
|
|
319
|
+
message: `frame ${index}: projection is baked for aspect ${(1 / actual).toPrecision(6)} but the scene renders ${plan.width}×${plan.height}; reprojection requires a new revision`,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
return issues
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The sample for an integer frame. `undefined` outside `[0, frameCount)` — a
|
|
329
|
+
* caller must fail rather than clamp, because a clamped frame is a wrong frame
|
|
330
|
+
* that looks plausible.
|
|
331
|
+
*/
|
|
332
|
+
export function scene3DSampleForFrame(
|
|
333
|
+
track: Scene3DCameraTrackV1,
|
|
334
|
+
frame: number,
|
|
335
|
+
): Scene3DCameraSample | undefined {
|
|
336
|
+
if (!Number.isInteger(frame) || frame < 0 || frame >= track.frameCount) return undefined
|
|
337
|
+
return track.samples[frame]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Size-gate, then parse, then validate. This is the admission path for a
|
|
342
|
+
* downloaded camera track: an 80 MiB "8 MiB" track is refused before
|
|
343
|
+
* `JSON.parse` gets a chance to allocate it.
|
|
344
|
+
*/
|
|
345
|
+
export function parseScene3DCameraTrackJson(text: string): Scene3DParseResult<Scene3DCameraTrackV1> {
|
|
346
|
+
const bytes = scene3DJsonByteLength(text)
|
|
347
|
+
if (bytes > SCENE3D_CAMERA_TRACK_LIMITS.maxJsonBytes) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
issues: [
|
|
351
|
+
{
|
|
352
|
+
path: [],
|
|
353
|
+
message: `camera track is ${bytes} bytes; the limit is ${SCENE3D_CAMERA_TRACK_LIMITS.maxJsonBytes}`,
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
let decoded: unknown
|
|
359
|
+
try {
|
|
360
|
+
decoded = JSON.parse(text)
|
|
361
|
+
} catch {
|
|
362
|
+
return { ok: false, issues: [{ path: [], message: "camera track is not valid JSON" }] }
|
|
363
|
+
}
|
|
364
|
+
const parsed = scene3DCameraTrackSchema.safeParse(decoded)
|
|
365
|
+
if (!parsed.success) {
|
|
366
|
+
return { ok: false, issues: scene3DZodIssues(parsed.error) }
|
|
367
|
+
}
|
|
368
|
+
return { ok: true, value: parsed.data as Scene3DCameraTrackV1 }
|
|
369
|
+
}
|
package/src/scene3d-edit.ts
CHANGED
|
@@ -22,12 +22,12 @@ import {
|
|
|
22
22
|
scene3DIdSchema,
|
|
23
23
|
scene3DObjectKeyframeSchema,
|
|
24
24
|
scene3DObjectSchema,
|
|
25
|
-
|
|
25
|
+
scene3DPlanV1Schema,
|
|
26
26
|
scene3DPrimitiveSchema,
|
|
27
27
|
sizeVec3Schema,
|
|
28
28
|
vec3Schema,
|
|
29
29
|
type Scene3DObject,
|
|
30
|
-
type
|
|
30
|
+
type Scene3DPlanV1,
|
|
31
31
|
} from "./scene3d.js"
|
|
32
32
|
|
|
33
33
|
|
|
@@ -119,14 +119,14 @@ export interface Scene3DEditOptions {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
export type Scene3DEditResult =
|
|
122
|
-
| { ok: true; plan:
|
|
122
|
+
| { ok: true; plan: Scene3DPlanV1; changedObjectIds: string[]; changeSummary: string }
|
|
123
123
|
| { ok: false; code: Scene3DEditErrorCode; message: string; operationIndex?: number }
|
|
124
124
|
|
|
125
125
|
/** Structural clone that cannot share a reference with its input. `structured-
|
|
126
126
|
* Clone` is not available in every consumer runtime we ship to, and a plan is
|
|
127
127
|
* pure JSON by construction. */
|
|
128
|
-
function clonePlan(plan:
|
|
129
|
-
return JSON.parse(JSON.stringify(plan)) as
|
|
128
|
+
function clonePlan(plan: Scene3DPlanV1): Scene3DPlanV1 {
|
|
129
|
+
return JSON.parse(JSON.stringify(plan)) as Scene3DPlanV1
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/** One human sentence per operation — the deterministic lane's answer to the
|
|
@@ -183,15 +183,15 @@ function firstIssueMessage(error: z.ZodError): string {
|
|
|
183
183
|
* at.
|
|
184
184
|
*/
|
|
185
185
|
export function applyScene3DEditOperations(
|
|
186
|
-
plan:
|
|
186
|
+
plan: Scene3DPlanV1,
|
|
187
187
|
operations: readonly Scene3DEditOperation[] | unknown,
|
|
188
188
|
options: Scene3DEditOptions = {},
|
|
189
189
|
): Scene3DEditResult {
|
|
190
|
-
const parsedPlan =
|
|
190
|
+
const parsedPlan = scene3DPlanV1Schema.safeParse(plan)
|
|
191
191
|
if (!parsedPlan.success) {
|
|
192
192
|
return { ok: false, code: "invalid_plan", message: `scenePlan is invalid — ${firstIssueMessage(parsedPlan.error)}` }
|
|
193
193
|
}
|
|
194
|
-
const source = parsedPlan.data as
|
|
194
|
+
const source = parsedPlan.data as Scene3DPlanV1
|
|
195
195
|
|
|
196
196
|
if (options.expectedRevisionId !== undefined && options.expectedRevisionId !== source.revisionId) {
|
|
197
197
|
return {
|
|
@@ -293,7 +293,7 @@ export function applyScene3DEditOperations(
|
|
|
293
293
|
next.parentRevisionId = source.revisionId
|
|
294
294
|
next.revisionId = options.revisionId ?? newScene3DRevisionId()
|
|
295
295
|
|
|
296
|
-
const validated =
|
|
296
|
+
const validated = scene3DPlanV1Schema.safeParse(next)
|
|
297
297
|
if (!validated.success) {
|
|
298
298
|
return {
|
|
299
299
|
ok: false,
|
|
@@ -304,7 +304,7 @@ export function applyScene3DEditOperations(
|
|
|
304
304
|
|
|
305
305
|
return {
|
|
306
306
|
ok: true,
|
|
307
|
-
plan: validated.data as
|
|
307
|
+
plan: validated.data as Scene3DPlanV1,
|
|
308
308
|
changedObjectIds: [...changed],
|
|
309
309
|
changeSummary: summarizeScene3DOperations(ops),
|
|
310
310
|
}
|