@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
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getItemSortId,
|
|
13
13
|
cleanOrphanedItems,
|
|
14
14
|
} from "../presentation-utils.js"
|
|
15
|
+
import { AUDIO_PRODUCER_TYPES, VIDEO_PRODUCER_TYPES } from "../producer-types.js"
|
|
15
16
|
import type { GenericNode, GenericEdge } from "../types.js"
|
|
16
17
|
import type { PresentationItem } from "../presentation-types.js"
|
|
17
18
|
|
|
@@ -1135,3 +1136,99 @@ describe("presentation-utils — action-fx", () => {
|
|
|
1135
1136
|
expect(getInputFieldSchema("action-fx")).toEqual({ key: "actionFx", type: "select" })
|
|
1136
1137
|
})
|
|
1137
1138
|
})
|
|
1139
|
+
|
|
1140
|
+
// ---------------------------------------------------------------------------
|
|
1141
|
+
// Canonical output typing — the presentation classifier vs the producer sets
|
|
1142
|
+
// ---------------------------------------------------------------------------
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* `getOutputType` is what a published app renders from and what `/v1` app
|
|
1146
|
+
* schemas and run results declare (`routes/api-tokens.ts`). A node it calls
|
|
1147
|
+
* `"data"` is shown as a JSON blob instead of a player, is skipped by the
|
|
1148
|
+
* lightbox media list, and is announced to API consumers with the wrong type.
|
|
1149
|
+
*
|
|
1150
|
+
* The literal sets in the module are the classifier's own opinion and win; the
|
|
1151
|
+
* tail is derived from the producer vocabularies the canvas validators already
|
|
1152
|
+
* maintain, so a node cannot be a declared video producer and be typed `"data"`
|
|
1153
|
+
* at the same time.
|
|
1154
|
+
*/
|
|
1155
|
+
describe("getOutputType — derived from the producer vocabularies", () => {
|
|
1156
|
+
it("types 3D Render Pro as video (it settles with the standard videoUrl)", () => {
|
|
1157
|
+
expect(getOutputType("pro-3d-render")).toBe("video")
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
it("types every declared video producer as video", () => {
|
|
1161
|
+
for (const t of VIDEO_PRODUCER_TYPES) expect([t, getOutputType(t)]).toEqual([t, "video"])
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
it("types every declared audio producer as audio, except the dual-mode ones the literal sets claim", () => {
|
|
1165
|
+
for (const t of AUDIO_PRODUCER_TYPES) {
|
|
1166
|
+
// voice-changer / voice-changer-pro / dubbing can emit video; the
|
|
1167
|
+
// classifier's own list still calls them audio, which is their default.
|
|
1168
|
+
expect([t, getOutputType(t)]).toEqual([t, "audio"])
|
|
1169
|
+
}
|
|
1170
|
+
})
|
|
1171
|
+
|
|
1172
|
+
it("leaves a run-time-decided producer as data — there is no honest static answer", () => {
|
|
1173
|
+
expect(getOutputType("list")).toBe("data")
|
|
1174
|
+
expect(getOutputType("sub-workflow")).toBe("data")
|
|
1175
|
+
expect(getOutputType("split-media")).toBe("data")
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
it("keeps the literal sets authoritative where they and the producer sets disagree", () => {
|
|
1179
|
+
// adjust-volume is in AUDIO_PRODUCER_TYPES and DYNAMIC_PRODUCER_TYPES;
|
|
1180
|
+
// upload-video is a video producer AND an input node.
|
|
1181
|
+
expect(getOutputType("adjust-volume")).toBe("audio")
|
|
1182
|
+
expect(getOutputType("upload-video")).toBe("video")
|
|
1183
|
+
expect(getOutputType("text-prompt")).toBe("text")
|
|
1184
|
+
})
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
describe("published-app / API-token output mapping for 3D Render Pro", () => {
|
|
1188
|
+
const proNode = mkNode("pro", "pro-3d-render", { presentationOutput: true, label: "Render" })
|
|
1189
|
+
|
|
1190
|
+
it("declares the app-schema output type the SDK and MCP read (mirrors routes/api-tokens.ts)", () => {
|
|
1191
|
+
// The route builds `{ nodeId, label, type: getOutputType(node.type) }` for
|
|
1192
|
+
// both the app schema and the run result.
|
|
1193
|
+
const outputs = getOutputNodes([proNode], []).map((n) => ({
|
|
1194
|
+
nodeId: n.id,
|
|
1195
|
+
type: getOutputType(n.type),
|
|
1196
|
+
}))
|
|
1197
|
+
expect(outputs).toEqual([{ nodeId: "pro", type: "video" }])
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
it("is an OUTPUT, not an input — the picker must not offer it as an app input", () => {
|
|
1201
|
+
expect(INPUT_NODE_TYPES.has("pro-3d-render")).toBe(false)
|
|
1202
|
+
expect(getInputNodes([mkNode("pro", "pro-3d-render", { presentationVisible: true })])).toEqual([])
|
|
1203
|
+
})
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
describe("legacy presentationVisible rules for a media producer", () => {
|
|
1207
|
+
it("stays an output when its composition ALSO feeds something downstream", () => {
|
|
1208
|
+
// The Pro node's `composition` handle commonly feeds an editor or a
|
|
1209
|
+
// re-render; the legacy rule drops a node with an outgoing edge unless it
|
|
1210
|
+
// produces media of its own.
|
|
1211
|
+
const nodes = [
|
|
1212
|
+
mkNode("pro", "pro-3d-render", { presentationVisible: true }),
|
|
1213
|
+
mkNode("edit", "edit-3d-scene", {}),
|
|
1214
|
+
]
|
|
1215
|
+
const out = getOutputNodes(nodes, [mkEdge("pro", "edit")])
|
|
1216
|
+
expect(out.map((n) => n.id)).toEqual(["pro"])
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
it("applies the same rule to the other video producers the literal list forgot", () => {
|
|
1220
|
+
const nodes = [
|
|
1221
|
+
mkNode("gv", "generate-video", { presentationVisible: true }),
|
|
1222
|
+
mkNode("cap", "add-captions", {}),
|
|
1223
|
+
]
|
|
1224
|
+
expect(getOutputNodes(nodes, [mkEdge("gv", "cap")]).map((n) => n.id)).toEqual(["gv"])
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
it("still drops a non-media node that feeds something downstream", () => {
|
|
1228
|
+
const nodes = [
|
|
1229
|
+
mkNode("txt", "text-prompt", { presentationVisible: true }),
|
|
1230
|
+
mkNode("gv", "generate-video", {}),
|
|
1231
|
+
]
|
|
1232
|
+
expect(getOutputNodes(nodes, [mkEdge("txt", "gv")])).toEqual([])
|
|
1233
|
+
})
|
|
1234
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
SCENE3D_DEFAULT_ADVANCED_ENGINE,
|
|
4
|
+
SCENE3D_SUPPORTED_SCHEMA_VERSIONS,
|
|
5
|
+
resolveScene3DAuthoringEngine,
|
|
6
|
+
} from "../index.js"
|
|
7
|
+
|
|
8
|
+
/** Only the fields the resolver reads — it must not need a whole valid plan. */
|
|
9
|
+
const v1Plan = { planType: "3d-scene", schemaVersion: 1 }
|
|
10
|
+
const v2Plan = (engine = "blender-cloud") => ({
|
|
11
|
+
planType: "3d-scene",
|
|
12
|
+
schemaVersion: 2,
|
|
13
|
+
provenance: { engine },
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe("resolveScene3DAuthoringEngine", () => {
|
|
17
|
+
it("keeps a plain generate on Basic, with NO extra wire fields", () => {
|
|
18
|
+
const choice = resolveScene3DAuthoringEngine({})
|
|
19
|
+
expect(choice).toEqual({ ok: true, lane: "basic", engine: undefined, fields: {} })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("keeps a v1 edit on Basic", () => {
|
|
23
|
+
const choice = resolveScene3DAuthoringEngine({ plan: v1Plan })
|
|
24
|
+
expect(choice.ok && choice.lane).toBe("basic")
|
|
25
|
+
expect(choice.ok && choice.fields).toEqual({})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("sends an explicitly requested advanced engine plus the accepted versions", () => {
|
|
29
|
+
const choice = resolveScene3DAuthoringEngine({
|
|
30
|
+
requested: "blender-cloud",
|
|
31
|
+
availableEngines: ["blender-cloud"],
|
|
32
|
+
})
|
|
33
|
+
expect(choice.ok && choice.lane).toBe("advanced")
|
|
34
|
+
expect(choice.ok && choice.fields).toEqual({
|
|
35
|
+
engine: "blender-cloud",
|
|
36
|
+
acceptedSceneSchemaVersions: [...SCENE3D_SUPPORTED_SCHEMA_VERSIONS],
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("routes a v2 edit to the advanced lane with no engine chosen", () => {
|
|
41
|
+
const choice = resolveScene3DAuthoringEngine({ plan: v2Plan() })
|
|
42
|
+
expect(choice.ok && choice.lane).toBe("advanced")
|
|
43
|
+
expect(choice.ok && choice.engine).toBe(SCENE3D_DEFAULT_ADVANCED_ENGINE)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("keeps a v2 edit on the engine that authored it", () => {
|
|
47
|
+
const choice = resolveScene3DAuthoringEngine({
|
|
48
|
+
plan: v2Plan("blender-local"),
|
|
49
|
+
availableEngines: ["blender-cloud", "blender-local"],
|
|
50
|
+
})
|
|
51
|
+
expect(choice.ok && choice.engine).toBe("blender-local")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("does not send a v2 edit to an authoring engine this install dropped", () => {
|
|
55
|
+
const choice = resolveScene3DAuthoringEngine({
|
|
56
|
+
plan: v2Plan("blender-local"),
|
|
57
|
+
availableEngines: ["blender-cloud"],
|
|
58
|
+
})
|
|
59
|
+
expect(choice.ok && choice.engine).toBe("blender-cloud")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("REFUSES a v2 scene pointed at Basic instead of downgrading it", () => {
|
|
63
|
+
const choice = resolveScene3DAuthoringEngine({ requested: "basic", plan: v2Plan() })
|
|
64
|
+
expect(choice.ok).toBe(false)
|
|
65
|
+
expect(!choice.ok && choice.code).toBe("schema_requires_advanced")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("REFUSES a v2 edit when the install has no advanced engine at all", () => {
|
|
69
|
+
const choice = resolveScene3DAuthoringEngine({ plan: v2Plan(), availableEngines: [] })
|
|
70
|
+
expect(!choice.ok && choice.code).toBe("advanced_unavailable")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("REFUSES an explicit engine this install does not serve — never falls back to Basic", () => {
|
|
74
|
+
const choice = resolveScene3DAuthoringEngine({
|
|
75
|
+
requested: "blender-local",
|
|
76
|
+
availableEngines: ["blender-cloud"],
|
|
77
|
+
})
|
|
78
|
+
expect(!choice.ok && choice.code).toBe("engine_unavailable")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("REFUSES an explicit engine on a v2 scene the same way", () => {
|
|
82
|
+
const choice = resolveScene3DAuthoringEngine({
|
|
83
|
+
requested: "blender-local",
|
|
84
|
+
plan: v2Plan(),
|
|
85
|
+
availableEngines: ["blender-cloud"],
|
|
86
|
+
})
|
|
87
|
+
expect(!choice.ok && choice.code).toBe("engine_unavailable")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("REFUSES an engine name the contract does not know", () => {
|
|
91
|
+
const choice = resolveScene3DAuthoringEngine({ requested: "unknown-renderer" })
|
|
92
|
+
expect(!choice.ok && choice.code).toBe("unknown_engine")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("REFUSES a scene claiming a schema version nothing here can author against", () => {
|
|
96
|
+
const choice = resolveScene3DAuthoringEngine({ plan: { planType: "3d-scene", schemaVersion: 3 } })
|
|
97
|
+
expect(!choice.ok && choice.code).toBe("unsupported_schema_version")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("stays permissive when availability is UNKNOWN — the route refuses honestly", () => {
|
|
101
|
+
const choice = resolveScene3DAuthoringEngine({ requested: "blender-local" })
|
|
102
|
+
expect(choice.ok && choice.engine).toBe("blender-local")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
SCENE3D_CAMERA_TRACK_LIMITS,
|
|
4
|
+
isScene3DCameraTrack,
|
|
5
|
+
parseScene3DCameraTrackJson,
|
|
6
|
+
scene3DCameraTrackIssues,
|
|
7
|
+
scene3DCameraTrackPlanIssues,
|
|
8
|
+
scene3DCameraTrackSchema,
|
|
9
|
+
scene3DProjectionIssues,
|
|
10
|
+
scene3DSampleForFrame,
|
|
11
|
+
} from "../scene3d-camera-track.js"
|
|
12
|
+
import {
|
|
13
|
+
FIXTURE_FPS,
|
|
14
|
+
FIXTURE_FRAMES,
|
|
15
|
+
FIXTURE_HEIGHT,
|
|
16
|
+
FIXTURE_WIDTH,
|
|
17
|
+
cameraSample,
|
|
18
|
+
cameraTrack,
|
|
19
|
+
orthographicMatrix,
|
|
20
|
+
perspectiveMatrix,
|
|
21
|
+
planV2,
|
|
22
|
+
} from "./scene3d-v2-fixtures.js"
|
|
23
|
+
|
|
24
|
+
function joined(track: unknown): string {
|
|
25
|
+
const result = scene3DCameraTrackSchema.safeParse(track)
|
|
26
|
+
return result.success ? "" : result.error.issues.map((issue) => issue.message).join(" | ")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("scene3d camera track — structure", () => {
|
|
30
|
+
it("accepts a full 720-sample track", () => {
|
|
31
|
+
const track = cameraTrack()
|
|
32
|
+
expect(scene3DCameraTrackSchema.safeParse(track).success).toBe(true)
|
|
33
|
+
expect(isScene3DCameraTrack(track)).toBe(true)
|
|
34
|
+
expect(scene3DCameraTrackIssues(track)).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("accepts the maximum frame count and rejects one more", () => {
|
|
38
|
+
expect(SCENE3D_CAMERA_TRACK_LIMITS.maxFrameCount).toBe(3600)
|
|
39
|
+
expect(scene3DCameraTrackSchema.safeParse(cameraTrack(3600)).success).toBe(true)
|
|
40
|
+
expect(scene3DCameraTrackSchema.safeParse(cameraTrack(3601)).success).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("requires exactly one sample per frame", () => {
|
|
44
|
+
const short = cameraTrack(10)
|
|
45
|
+
short.samples = short.samples.slice(0, 9)
|
|
46
|
+
expect(joined(short)).toContain("exactly one sample per frame is required")
|
|
47
|
+
|
|
48
|
+
const long = cameraTrack(10)
|
|
49
|
+
long.samples.push(cameraSample())
|
|
50
|
+
expect(joined(long)).toContain("exactly one sample per frame is required")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("pins the format, version and zero-based frame start", () => {
|
|
54
|
+
expect(joined({ ...cameraTrack(2), format: "something-else" })).not.toBe("")
|
|
55
|
+
expect(joined({ ...cameraTrack(2), version: 2 })).not.toBe("")
|
|
56
|
+
expect(joined({ ...cameraTrack(2), frameStart: 1 })).not.toBe("")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("rejects unknown keys on the track and on a sample", () => {
|
|
60
|
+
expect(joined({ ...cameraTrack(2), extra: true })).not.toBe("")
|
|
61
|
+
const track = cameraTrack(2)
|
|
62
|
+
;(track.samples[0] as unknown as Record<string, unknown>).lookAtOverride = [0, 0, 0]
|
|
63
|
+
expect(joined(track)).not.toBe("")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("rejects a foreign value outright", () => {
|
|
67
|
+
for (const bad of [null, 7, "track", [], {}]) {
|
|
68
|
+
expect(isScene3DCameraTrack(bad)).toBe(false)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("scene3d camera track — quaternions", () => {
|
|
74
|
+
it("rejects an unnormalized quaternion", () => {
|
|
75
|
+
const track = cameraTrack(3)
|
|
76
|
+
track.samples[1] = cameraSample({ quaternion: [0, 0, 0, 2] })
|
|
77
|
+
expect(joined(track)).toContain("it must be normalized")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("accepts float noise inside the tolerance", () => {
|
|
81
|
+
const track = cameraTrack(3)
|
|
82
|
+
track.samples[1] = cameraSample({ quaternion: [0, 0, 0, 1 + 1e-6] })
|
|
83
|
+
expect(scene3DCameraTrackSchema.safeParse(track).success).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("rejects a non-finite quaternion component", () => {
|
|
87
|
+
const track = cameraTrack(2)
|
|
88
|
+
track.samples[0] = cameraSample({ quaternion: [0, 0, 0, Number.NaN] })
|
|
89
|
+
expect(scene3DCameraTrackSchema.safeParse(track).success).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe("scene3d camera track — projection", () => {
|
|
94
|
+
const aspect = FIXTURE_WIDTH / FIXTURE_HEIGHT
|
|
95
|
+
|
|
96
|
+
it("accepts a real perspective matrix", () => {
|
|
97
|
+
expect(scene3DProjectionIssues(perspectiveMatrix(35, aspect, 0.1, 200), 0.1, 200, ["m"])).toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("names orthographic instead of mis-reading it as a broken perspective", () => {
|
|
101
|
+
const issues = scene3DProjectionIssues(orthographicMatrix(0.1, 200), 0.1, 200, ["m"])
|
|
102
|
+
expect(issues).toHaveLength(1)
|
|
103
|
+
expect(issues[0].message).toContain("orthographic")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("rejects the wrong number of entries and non-finite entries", () => {
|
|
107
|
+
expect(scene3DProjectionIssues([1, 2, 3], 0.1, 200, ["m"])[0].message).toContain("exactly 16 entries")
|
|
108
|
+
const broken = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
109
|
+
broken[0] = Number.POSITIVE_INFINITY
|
|
110
|
+
expect(scene3DProjectionIssues(broken, 0.1, 200, ["m"])[0].message).toContain("non-finite")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("rejects a matrix whose fixed entries are not fixed", () => {
|
|
114
|
+
for (const index of [1, 2, 3, 4, 6, 7, 12, 13, 15]) {
|
|
115
|
+
const matrix = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
116
|
+
matrix[index] = 0.5
|
|
117
|
+
const issues = scene3DProjectionIssues(matrix, 0.1, 200, ["m"])
|
|
118
|
+
expect(issues.length).toBeGreaterThan(0)
|
|
119
|
+
}
|
|
120
|
+
const notPerspective = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
121
|
+
notPerspective[11] = -0.5
|
|
122
|
+
expect(scene3DProjectionIssues(notPerspective, 0.1, 200, ["m"])[0].message).toContain("must be -1")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("rejects a matrix that disagrees with its own declared near/far", () => {
|
|
126
|
+
const matrix = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
127
|
+
const nearIssues = scene3DProjectionIssues(matrix, 0.5, 200, ["m"])
|
|
128
|
+
expect(nearIssues.map((issue) => issue.message).join(" ")).toContain("implies near")
|
|
129
|
+
|
|
130
|
+
const farIssues = scene3DProjectionIssues(matrix, 0.1, 500, ["m"])
|
|
131
|
+
expect(farIssues.map((issue) => issue.message).join(" ")).toContain("implies far")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("rejects an infinite-far matrix that declares a finite far plane", () => {
|
|
135
|
+
const matrix = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
136
|
+
matrix[10] = -1
|
|
137
|
+
matrix[14] = -0.2
|
|
138
|
+
expect(scene3DProjectionIssues(matrix, 0.1, 200, ["m"]).map((i) => i.message).join(" ")).toContain(
|
|
139
|
+
"infinite far plane",
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("rejects near/far that are not strictly ordered", () => {
|
|
144
|
+
const track = cameraTrack(2)
|
|
145
|
+
track.samples[0] = cameraSample({ near: 200, far: 200 })
|
|
146
|
+
expect(joined(track)).toContain("must be greater than near")
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe("scene3d camera track — agreement with the manifest", () => {
|
|
151
|
+
const plan = planV2()
|
|
152
|
+
|
|
153
|
+
it("accepts a matching track", () => {
|
|
154
|
+
expect(scene3DCameraTrackPlanIssues(cameraTrack(), plan)).toEqual([])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("rejects a different fps — retiming needs a new revision", () => {
|
|
158
|
+
const track = cameraTrack(FIXTURE_FRAMES, { fps: 30 })
|
|
159
|
+
const messages = scene3DCameraTrackPlanIssues(track, plan).map((issue) => issue.message)
|
|
160
|
+
expect(messages.join(" ")).toContain("changing fps requires an explicit resample")
|
|
161
|
+
expect(FIXTURE_FPS).toBe(24)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("rejects a different length", () => {
|
|
165
|
+
const messages = scene3DCameraTrackPlanIssues(cameraTrack(600), plan).map((issue) => issue.message)
|
|
166
|
+
expect(messages.join(" ")).toContain("covers 600 frames but the scene is 720 frames")
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("rejects a projection baked for another aspect ratio", () => {
|
|
170
|
+
const track = cameraTrack(4)
|
|
171
|
+
track.samples[2] = cameraSample({ projectionMatrix: perspectiveMatrix(35, 16 / 9, 0.1, 200) })
|
|
172
|
+
const messages = scene3DCameraTrackPlanIssues(track, {
|
|
173
|
+
fps: FIXTURE_FPS,
|
|
174
|
+
durationInFrames: 4,
|
|
175
|
+
width: FIXTURE_WIDTH,
|
|
176
|
+
height: FIXTURE_HEIGHT,
|
|
177
|
+
}).map((issue) => issue.message)
|
|
178
|
+
expect(messages.join(" ")).toContain("reprojection requires a new revision")
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe("scene3d camera track — deterministic sampling", () => {
|
|
183
|
+
it("maps frame f to samples[f], and refuses to clamp", () => {
|
|
184
|
+
const track = cameraTrack(10)
|
|
185
|
+
for (const frame of [0, 4, 9]) {
|
|
186
|
+
expect(scene3DSampleForFrame(track, frame)).toBe(track.samples[frame])
|
|
187
|
+
}
|
|
188
|
+
expect(scene3DSampleForFrame(track, 10)).toBeUndefined()
|
|
189
|
+
expect(scene3DSampleForFrame(track, -1)).toBeUndefined()
|
|
190
|
+
expect(scene3DSampleForFrame(track, 2.5)).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("is order-independent: backwards and shuffled reads give identical samples", () => {
|
|
194
|
+
const track = cameraTrack(24)
|
|
195
|
+
const forward = Array.from({ length: 24 }, (_unused, frame) => scene3DSampleForFrame(track, frame))
|
|
196
|
+
const backward = Array.from({ length: 24 }, (_unused, index) => scene3DSampleForFrame(track, 23 - index)).reverse()
|
|
197
|
+
const shuffled = [7, 0, 23, 12, 3].map((frame) => scene3DSampleForFrame(track, frame))
|
|
198
|
+
expect(backward).toEqual(forward)
|
|
199
|
+
expect(shuffled).toEqual([7, 0, 23, 12, 3].map((frame) => forward[frame]))
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe("scene3d camera track — JSON admission", () => {
|
|
204
|
+
it("accepts a well-formed payload", () => {
|
|
205
|
+
const result = parseScene3DCameraTrackJson(JSON.stringify(cameraTrack(12)))
|
|
206
|
+
expect(result.ok).toBe(true)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("refuses an oversized payload before parsing it", () => {
|
|
210
|
+
const padding = "x".repeat(SCENE3D_CAMERA_TRACK_LIMITS.maxJsonBytes + 1)
|
|
211
|
+
const result = parseScene3DCameraTrackJson(padding)
|
|
212
|
+
expect(result.ok).toBe(false)
|
|
213
|
+
expect(result.ok ? "" : result.issues[0].message).toContain("the limit is")
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("reports malformed JSON as an issue rather than throwing", () => {
|
|
217
|
+
const result = parseScene3DCameraTrackJson("{ not json")
|
|
218
|
+
expect(result.ok).toBe(false)
|
|
219
|
+
expect(result.ok ? "" : result.issues[0].message).toContain("not valid JSON")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("reports schema failures with paths", () => {
|
|
223
|
+
const track = cameraTrack(3)
|
|
224
|
+
track.samples[1] = cameraSample({ quaternion: [1, 1, 1, 1] })
|
|
225
|
+
const result = parseScene3DCameraTrackJson(JSON.stringify(track))
|
|
226
|
+
expect(result.ok).toBe(false)
|
|
227
|
+
expect(result.ok ? [] : result.issues.map((issue) => issue.path.join("."))).toContain("samples.1.quaternion")
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("counts bytes, not characters", () => {
|
|
231
|
+
// A multi-byte name must not slip past a length check that counted chars.
|
|
232
|
+
const text = JSON.stringify({ note: "é".repeat(10) })
|
|
233
|
+
expect(text.length).toBeLessThan(new TextEncoder().encode(text).length)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { applyScene3DV2EditOperations } from "../scene3d-v2-edit.js"
|
|
3
|
+
import { computeScene3DPlanV2ContentHash, verifyScene3DPlanV2ContentHash } from "../scene3d-v2-resources.js"
|
|
4
|
+
import { planV2 } from "./scene3d-v2-fixtures.js"
|
|
5
|
+
|
|
6
|
+
async function fixture() {
|
|
7
|
+
const p = planV2()
|
|
8
|
+
return { ...p, provenance: { ...p.provenance, contentHash: await computeScene3DPlanV2ContentHash(p) } }
|
|
9
|
+
}
|
|
10
|
+
const move = { op: "set-override" as const, override: { kind: "entity-transform" as const, entityId: "e2", space: "world" as const, position: [4, 0, 0] as [number, number, number] } }
|
|
11
|
+
const revision = "c94b9f2f-4f10-48a6-a0bf-3ca4c2653b5a"
|
|
12
|
+
|
|
13
|
+
describe("baked scene edits", () => {
|
|
14
|
+
it("preserves unrelated state, stamps an immutable revision, and withdraws a stale native download", async () => {
|
|
15
|
+
const p = await fixture()
|
|
16
|
+
const before = JSON.stringify(p)
|
|
17
|
+
const result = await applyScene3DV2EditOperations(p, [move], { expectedRevisionId: p.revisionId, newRevisionId: revision })
|
|
18
|
+
expect(result.ok).toBe(true)
|
|
19
|
+
if (!result.ok) return
|
|
20
|
+
expect(result.plan.revisionId).toBe(revision)
|
|
21
|
+
expect(result.plan.parentRevisionId).toBe(p.revisionId)
|
|
22
|
+
expect(result.plan.objects).toEqual(p.objects)
|
|
23
|
+
expect(result.plan.assets).toEqual(p.assets.filter((a) => a.kind === "glb" || a.kind === "camera-track-json").map((a) => ({ ...a, originRevisionId: a.originRevisionId ?? p.revisionId })))
|
|
24
|
+
expect(result.plan.provenance.sourceArtifactId).toBeUndefined()
|
|
25
|
+
expect(result.plan.overrides?.find((o) => o.kind === "entity-color")).toEqual(p.overrides?.find((o) => o.kind === "entity-color"))
|
|
26
|
+
expect(result.plan.overrides?.find((o) => o.kind === "entity-transform")).toMatchObject({ position: [4, 0, 0], sourceRevisionId: p.revisionId, sourceContentHash: p.provenance.contentHash })
|
|
27
|
+
expect(await verifyScene3DPlanV2ContentHash(result.plan)).toBe(true)
|
|
28
|
+
expect(JSON.stringify(p)).toBe(before)
|
|
29
|
+
})
|
|
30
|
+
it("replays identically with an admission-allocated revision", async () => {
|
|
31
|
+
const p = await fixture()
|
|
32
|
+
const options = { expectedRevisionId: p.revisionId, newRevisionId: revision }
|
|
33
|
+
expect(await applyScene3DV2EditOperations(p, [move], options)).toEqual(await applyScene3DV2EditOperations(p, [move], options))
|
|
34
|
+
})
|
|
35
|
+
it("withdraws images and validation that describe the previous revision", async () => {
|
|
36
|
+
const p = await fixture()
|
|
37
|
+
p.assets.push(
|
|
38
|
+
{ assetId: "12b3a7d6-3aef-4e5f-b388-e6b96dcaa0f1", kind: "poster", role: "poster", sha256: "a".repeat(64), byteLength: 100 },
|
|
39
|
+
{ assetId: "12b3a7d6-3aef-4e5f-b388-e6b96dcaa0f2", kind: "validation-report", role: "validation-report", sha256: "b".repeat(64), byteLength: 100 },
|
|
40
|
+
)
|
|
41
|
+
p.provenance.contentHash = await computeScene3DPlanV2ContentHash(p)
|
|
42
|
+
const result = await applyScene3DV2EditOperations(p, [move], { expectedRevisionId: p.revisionId })
|
|
43
|
+
expect(result.ok).toBe(true)
|
|
44
|
+
if (result.ok) expect(result.plan.assets.every((a) => a.kind === "glb" || a.kind === "camera-track-json")).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
it("rejects stale identity and content, and tampered manifests", async () => {
|
|
47
|
+
const p = await fixture()
|
|
48
|
+
expect(await applyScene3DV2EditOperations(p, [move], { expectedRevisionId: revision })).toMatchObject({ ok: false, code: "stale_revision" })
|
|
49
|
+
expect(await applyScene3DV2EditOperations(p, [move], { expectedRevisionId: p.revisionId, expectedContentHash: "a".repeat(64) })).toMatchObject({ ok: false, code: "stale_revision" })
|
|
50
|
+
expect(await applyScene3DV2EditOperations({ ...p, backgroundColor: "#ffffff" }, [move], { expectedRevisionId: p.revisionId })).toMatchObject({ ok: false, code: "invalid_plan" })
|
|
51
|
+
})
|
|
52
|
+
it("blocks changes and removal of changes on locked entities", async () => {
|
|
53
|
+
const p = await fixture()
|
|
54
|
+
const options = { expectedRevisionId: p.revisionId, lockedObjectIds: ["e2"] }
|
|
55
|
+
expect(await applyScene3DV2EditOperations(p, [move], options)).toMatchObject({ ok: false, code: "locked" })
|
|
56
|
+
expect(await applyScene3DV2EditOperations(p, [{ op: "remove-override", overrideId: "ov-1" }], options)).toMatchObject({ ok: false, code: "locked" })
|
|
57
|
+
})
|
|
58
|
+
it("cannot evade a descendant lock by moving or hiding its parent", async () => {
|
|
59
|
+
const p = await fixture()
|
|
60
|
+
const options = { expectedRevisionId: p.revisionId, lockedObjectIds: ["e2"] }
|
|
61
|
+
expect(await applyScene3DV2EditOperations(p, [{ ...move, override: { ...move.override, entityId: "e1" } }], options)).toMatchObject({ ok: false, code: "locked" })
|
|
62
|
+
expect(await applyScene3DV2EditOperations(p, [{ op: "set-override", override: { kind: "entity-visibility", entityId: "e1", visible: false } }], options)).toMatchObject({ ok: false, code: "locked" })
|
|
63
|
+
})
|
|
64
|
+
it("preserves edited pose components when another component changes", async () => {
|
|
65
|
+
const p = await fixture()
|
|
66
|
+
const result = await applyScene3DV2EditOperations(p, [move, { ...move, override: { kind: "entity-transform", entityId: "e2", space: "world", rotation: [0, 1, 0] } }], { expectedRevisionId: p.revisionId })
|
|
67
|
+
expect(result.ok).toBe(true)
|
|
68
|
+
if (result.ok) expect(result.plan.overrides?.find((o) => o.kind === "entity-transform")).toMatchObject({ position: [4, 0, 0], rotation: [0, 1, 0] })
|
|
69
|
+
})
|
|
70
|
+
it("does not carry coordinates across a space change", async () => {
|
|
71
|
+
const p = await fixture()
|
|
72
|
+
const result = await applyScene3DV2EditOperations(p, [{ ...move, override: { kind: "entity-transform", entityId: "e2", space: "local", rotation: [0, 1, 0] } }], { expectedRevisionId: p.revisionId })
|
|
73
|
+
expect(result.ok).toBe(true)
|
|
74
|
+
if (result.ok) expect(result.plan.overrides?.find((o) => o.kind === "entity-transform")).not.toHaveProperty("position")
|
|
75
|
+
})
|
|
76
|
+
it("rejects missing targets, unknown material roles, and invalid later operations atomically", async () => {
|
|
77
|
+
const p = await fixture()
|
|
78
|
+
const before = JSON.stringify(p)
|
|
79
|
+
expect(await applyScene3DV2EditOperations(p, [move, { op: "set-override", override: { kind: "entity-color", entityId: "e2", materialRole: "chair", color: "#ffffff" } }], { expectedRevisionId: p.revisionId })).toMatchObject({ ok: false, code: "invalid_operations" })
|
|
80
|
+
expect(await applyScene3DV2EditOperations(p, [{ op: "remove-override", overrideId: "missing" }], { expectedRevisionId: p.revisionId })).toMatchObject({ ok: false, code: "invalid_operations" })
|
|
81
|
+
expect(JSON.stringify(p)).toBe(before)
|
|
82
|
+
})
|
|
83
|
+
})
|