diff --git a/CHANGELOG.md b/CHANGELOG.md index 363569a..b5a15eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this package. Versions follow [Semantic Versioning](https ## Unreleased +### Data validation + +- Validate prefab hierarchy documents against their published transform and component schema. +- Describe room, spot, sound, video and CRI Lips documents, and accept null localization on native text records. + ### Site products - Load live variants from entry manifests and accept variant paths in read-set requests. @@ -179,4 +184,3 @@ All notable changes to this package. Versions follow [Semantic Versioning](https - install from GitHub until the package is on npm - add performance.md: the CPU cost of a frame - a file holds each controller and clip once; the validator checks every reference _(story)_ - diff --git a/docs/data-format.md b/docs/data-format.md index 18c5435..75b4be7 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -119,6 +119,9 @@ Rules: ### Objects +Prefab object documents have a [JSON Schema](../schema/prefab.schema.json) for hierarchy nodes, transforms, +components and RectTransform fields. Data validation also checks that parents precede their children. + Several products may retain different file roles of the same object. Loading their manifests combines these roles; overlapping roles and object metadata must agree. Product paths may include region directories, and are relative to the layout root. @@ -988,3 +991,11 @@ The song catalog example reads `music-data.json` with format `nnnotes.music-data A chart entry under `charts/` can be an `nnnotes.product/1` live manifest. Its optional `variants` lists immutable note-skin, note-effect and sound products relative to its declared root; the loader combines them with the required parts. `options.LiveQuality` lists the offered qualities. `scripts/read-set.mjs --variants=` supplies extra manifest paths for either plan or full mode. The live's `parts.ui` maps languages to UI products. An `audio: false` entry omits sound streams while retaining cue metadata. Story sessions use silent timing for such entries. Story listings select a playback language for which the entry includes resources. + +### Scene and media document schemas + +- [room](../schema/room.schema.json) +- [spot](../schema/spot.schema.json) +- [story-video](../schema/story-video.schema.json) +- [story-sound](../schema/story-sound.schema.json) +- [crilips](../schema/crilips.schema.json) diff --git a/schema/crilips.schema.json b/schema/crilips.schema.json new file mode 100644 index 0000000..badd45a --- /dev/null +++ b/schema/crilips.schema.json @@ -0,0 +1,57 @@ +{ + "$id": "https://raw.githubusercontent.com/empty-sekai/ournotes-player/main/schema/crilips.schema.json", + "title": "crilips document", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "dtype", + "order", + "total_floats", + "blocks", + "constants", + "frontend" + ], + "properties": { + "dtype": { + "const": "float32" + }, + "order": { + "const": "LE" + }, + "total_floats": { + "type": "integer", + "minimum": 0 + }, + "blocks": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "name", + "offset", + "count" + ], + "properties": { + "name": { + "type": "string" + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "count": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "constants": { + "type": "object" + }, + "frontend": { + "type": "object" + } + } +} diff --git a/schema/prefab.schema.json b/schema/prefab.schema.json new file mode 100644 index 0000000..5ca948d --- /dev/null +++ b/schema/prefab.schema.json @@ -0,0 +1,174 @@ +{ + "$id": "https://raw.githubusercontent.com/empty-sekai/ournotes-player/main/schema/prefab.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "nodes" + ], + "properties": { + "name": { + "type": "string" + }, + "scene": { + "type": "string" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/$defs/node" + } + } + }, + "$defs": { + "vec2": { + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "vec3": { + "type": "object", + "required": [ + "x", + "y", + "z" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + } + }, + "quat": { + "type": "object", + "required": [ + "x", + "y", + "z", + "w" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + } + }, + "component": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "pathId": { + "type": "string", + "pattern": "^-?[0-9]+$" + }, + "script": { + "type": [ + "string", + "null" + ] + }, + "fields": { + "type": "object" + } + } + }, + "node": { + "type": "object", + "required": [ + "path", + "localPosition", + "localRotation", + "localScale", + "components" + ], + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "pathId": { + "type": "string", + "pattern": "^-?[0-9]+$" + }, + "transformPathId": { + "type": "string", + "pattern": "^-?[0-9]+$" + }, + "active": { + "type": "boolean" + }, + "layer": { + "type": "integer" + }, + "localPosition": { + "$ref": "#/$defs/vec3" + }, + "localRotation": { + "$ref": "#/$defs/quat" + }, + "localScale": { + "$ref": "#/$defs/vec3" + }, + "components": { + "type": "array", + "items": { + "$ref": "#/$defs/component" + } + }, + "rect": { + "type": "object", + "properties": { + "m_AnchorMin": { + "$ref": "#/$defs/vec2" + }, + "m_AnchorMax": { + "$ref": "#/$defs/vec2" + }, + "m_AnchoredPosition": { + "$ref": "#/$defs/vec2" + }, + "m_SizeDelta": { + "$ref": "#/$defs/vec2" + }, + "m_Pivot": { + "$ref": "#/$defs/vec2" + } + } + } + } + } + }, + "title": "Prefab hierarchy" +} diff --git a/schema/room.schema.json b/schema/room.schema.json new file mode 100644 index 0000000..5c024a9 --- /dev/null +++ b/schema/room.schema.json @@ -0,0 +1,81 @@ +{ + "$id": "https://raw.githubusercontent.com/empty-sekai/ournotes-player/main/schema/room.schema.json", + "title": "room document", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "backgroundKey", + "glb", + "meshCount", + "inactiveMeshes", + "nodes", + "materialIds", + "materials", + "textures", + "samplers" + ], + "properties": { + "backgroundKey": { + "type": "string" + }, + "glb": { + "type": "string" + }, + "meshCount": { + "type": "integer", + "minimum": 0 + }, + "inactiveMeshes": { + "type": "integer", + "minimum": 0 + }, + "nullMaterialSubmeshes": { + "type": "array", + "items": { + "type": "object" + } + }, + "nullMeshFilters": { + "type": "array", + "items": { + "type": "string" + } + }, + "emptyMeshes": { + "type": "array", + "items": { + "type": "object" + } + }, + "nodes": { + "type": "array", + "items": { + "type": "object" + } + }, + "materialIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "materials": { + "type": "array", + "items": { + "type": "object" + } + }, + "textures": { + "type": "array", + "items": { + "type": "string" + } + }, + "samplers": { + "type": "array", + "items": { + "type": "object" + } + } + } +} diff --git a/schema/spot.schema.json b/schema/spot.schema.json new file mode 100644 index 0000000..4a3cacc --- /dev/null +++ b/schema/spot.schema.json @@ -0,0 +1,59 @@ +{ + "$id": "https://raw.githubusercontent.com/empty-sekai/ournotes-player/main/schema/spot.schema.json", + "title": "spot document", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "spotId", + "master", + "name", + "tapTalk", + "situationSettings", + "controller", + "characters", + "spineCharacters", + "skeletons", + "matrixConvention" + ], + "properties": { + "spotId": { + "type": "integer" + }, + "master": { + "type": "object" + }, + "name": { + "type": "object" + }, + "tapTalk": { + "type": "object" + }, + "situationSettings": { + "type": "object" + }, + "controller": { + "type": "object" + }, + "characters": { + "type": "array", + "items": { + "type": "object" + } + }, + "spineCharacters": { + "type": "array", + "items": { + "type": "object" + } + }, + "skeletons": { + "type": "array", + "items": { + "type": "object" + } + }, + "matrixConvention": { + "type": "string" + } + } +} diff --git a/schema/story-fonts.schema.json b/schema/story-fonts.schema.json index 938a32e..965acd3 100644 --- a/schema/story-fonts.schema.json +++ b/schema/story-fonts.schema.json @@ -995,7 +995,10 @@ } }, "localized": { - "type": "object", + "type": [ + "object", + "null" + ], "required": [ "fontAsset", "material", diff --git a/schema/story-simple-ui.schema.json b/schema/story-simple-ui.schema.json index 7ac2542..7e693fb 100644 --- a/schema/story-simple-ui.schema.json +++ b/schema/story-simple-ui.schema.json @@ -121,7 +121,10 @@ ], "properties": { "localized": { - "type": "object", + "type": [ + "object", + "null" + ], "required": [ "fontAsset", "material", diff --git a/schema/story-sound.schema.json b/schema/story-sound.schema.json new file mode 100644 index 0000000..54bbfc1 --- /dev/null +++ b/schema/story-sound.schema.json @@ -0,0 +1,56 @@ +{ + "$id": "https://raw.githubusercontent.com/empty-sekai/ournotes-player/main/schema/story-sound.schema.json", + "title": "story-sound document", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "file", + "sampleRate", + "samples" + ], + "properties": { + "file": { + "type": "string" + }, + "sampleRate": { + "type": "integer", + "minimum": 1 + }, + "samples": { + "type": "integer", + "minimum": 0 + }, + "channels": { + "type": "integer", + "minimum": 1 + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "encoderDelay": { + "type": "integer", + "minimum": 0 + }, + "loopStart": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "loopEnd": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + } +} diff --git a/schema/story-ui.schema.json b/schema/story-ui.schema.json index 4414810..66d8933 100644 --- a/schema/story-ui.schema.json +++ b/schema/story-ui.schema.json @@ -196,7 +196,10 @@ ], "properties": { "localized": { - "type": "object", + "type": [ + "object", + "null" + ], "required": [ "fontAsset", "material", diff --git a/schema/story-video.schema.json b/schema/story-video.schema.json new file mode 100644 index 0000000..effee2b --- /dev/null +++ b/schema/story-video.schema.json @@ -0,0 +1,85 @@ +{ + "$id": "https://raw.githubusercontent.com/empty-sekai/ournotes-player/main/schema/story-video.schema.json", + "title": "story-video document", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "format", + "file", + "codec", + "width", + "height", + "displayWidth", + "displayHeight", + "frameRate", + "frames", + "tracks" + ], + "properties": { + "format": { + "const": 1 + }, + "file": { + "type": "string" + }, + "codec": { + "type": "string" + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "displayWidth": { + "type": "integer", + "minimum": 1 + }, + "displayHeight": { + "type": "integer", + "minimum": 1 + }, + "frameRate": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "integer", + "minimum": 1 + } + }, + "frames": { + "type": "integer", + "minimum": 0 + }, + "tracks": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "kind", + "channel", + "codec" + ], + "properties": { + "kind": { + "type": "string" + }, + "channel": { + "type": "integer", + "minimum": 0 + }, + "codec": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + } + } +} diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs index c5d2264..1a26ab2 100644 --- a/scripts/validate-data.mjs +++ b/scripts/validate-data.mjs @@ -55,6 +55,8 @@ import { compile } from "./lib/json-schema.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const schema = (name) => compile(JSON.parse(fs.readFileSync(path.join(here, "..", "schema", `${name}.schema.json`), "utf8"))); const S = { + prefab: schema("prefab"), + room: schema("room"), spot: schema("spot"), video: schema("story-video"), sound: schema("story-sound"), crilips: schema("crilips"), charts: schema("charts"), manifest: schema("manifest"), live: schema("live"), score: schema("score"), scene: schema("live-scene"), notes: schema("live-notes"), noteskin: schema("live-noteskin"), noteeffect: schema("live-noteeffect"), sounds: schema("live-sounds"), music: schema("live-music"), models: schema("models"), product: schema("product"), modelJson: schema("model-json"), @@ -662,6 +664,7 @@ function nodeList(pf, where, errs) { for (const k of ["localPosition", "localRotation", "localScale"]) if (!isObj(n[k])) { errs.push(`${where}: ${n.path} has no ${k}`); return; } paths.add(n.path); } + errs.push(...S.prefab(pf).map((error) => `${where}: ${error}`)); } // ------------------------------------------------------------------------------------------------ one Live2D model @@ -1275,6 +1278,7 @@ class Story extends Product { if (!sheet || !d.parts.sounds[sheet]) this.err("episode.json", `sound ${id}: cue sheet not in parts.sounds`); else if (!d.cues(sheet)[row._cueName]) this.err("episode.json", `sound ${id}: cue ${row._cueName} not in ${sheet}`); } + for (const sheet of Object.keys(d.parts.sounds)) for (const error of S.sound(d.cues(sheet))) this.err(`cues.json ${sheet}`, error); for (const [sheet, path] of Object.entries(d.parts.sounds)) for (const [name, cue] of Object.entries(d.cues(sheet))) { const where = `${productDir(path)}/cues.json ${name}`; if (!Number.isInteger(cue.sampleRate) || !Number.isInteger(cue.samples) || !(cue.sampleRate > 0) || cue.samples < 0) { this.err(where, "invalid sampleRate/samples"); continue; } @@ -1287,6 +1291,7 @@ class Story extends Product { } if (d.parts.crilips) { const { descriptor: desc, data } = d.crilips, floats = data.byteLength / 4; + for (const error of S.crilips(desc)) this.err("crilips.json", error); if (desc.dtype !== "float32" || desc.order !== "LE") this.err("crilips.json", "expected float32 LE"); if (floats !== desc.total_floats) this.err("crilips.bin", `length ${floats} differs from total_floats ${desc.total_floats}`); for (const block of desc.blocks || []) if (!Number.isInteger(block.offset) || !Number.isInteger(block.count) || block.offset < 0 || block.count < 0 || block.offset + block.count > floats) @@ -1294,7 +1299,11 @@ class Story extends Product { if (!Array.isArray(desc.blocks) || !isObj(desc.constants) || !isObj(desc.frontend)) this.err("crilips.json", "blocks/constants/frontend missing"); } for (const [id, row] of Object.entries(e.videos)) if (row._assetName?.trim()) { - try { const v = d.videoFor(id); if (!this.store.has(v.file)) this.err("video.json", `${v.file} missing`); } catch (error) { this.err("episode.json", error.message); } + try { + const v = d.videoFor(id); + for (const error of S.video(v)) this.err("video.json", error); + if (!this.store.has(v.file)) this.err("video.json", `${v.file} missing`); + } catch (error) { this.err("episode.json", error.message); } } } @@ -1302,6 +1311,10 @@ class Story extends Product { materialKeywords() { const data = this.data, objects = this.objectsView, result = new Map(); if (data.host?.kind !== "home") return result; + for (const name of ["room", "spot"]) { + const doc = data.document(data.host.home.parts[name], `${name}.json`); + for (const error of S[name](doc)) this.err(`${name}.json`, error); + } const scene = new HomeScene(data, objects), home = data.host.home, graphics = data.scene.graphics; const hostManifest = this.partCache.get(Object.values(data.parts.host)[0]); // The host retains the prefab dependency graph. Only room renderers and selected UI subtrees are drawn. diff --git a/tests/unit/prefab-schema.test.mjs b/tests/unit/prefab-schema.test.mjs new file mode 100644 index 0000000..8d9f2ec --- /dev/null +++ b/tests/unit/prefab-schema.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { test } from "node:test"; +import { compile } from "../../scripts/lib/json-schema.mjs"; +import { node, mb } from "./live2d-objects.mjs"; + +const validate = compile(JSON.parse(fs.readFileSync(new URL("../../schema/prefab.schema.json", import.meta.url), "utf8"))); +const prefab = () => ({ name: "Root", nodes: [node("Root", [mb("Example", { enabled: true })])] }); + +test("prefab schemas describe numeric transforms and typed component fields", () => { + const doc = prefab(); + assert.deepEqual(validate(doc), []); + delete doc.nodes[0].localPosition.x; + assert.ok(validate(doc).some((e) => e.includes("x"))); + doc.nodes[0].localPosition.x = "zero"; + assert.ok(validate(doc).some((e) => e.includes("number"))); +}); + +test("prefab schemas check component identity and RectTransform vectors", () => { + const doc = prefab(); + doc.nodes[0].components[0].script = 3; + assert.ok(validate(doc).length); + doc.nodes[0].components[0].script = null; + doc.nodes[0].rect = { m_Pivot: { x: 0.5, y: 0.5 } }; + assert.deepEqual(validate(doc), []); + doc.nodes[0].rect.m_Pivot.y = null; + assert.ok(validate(doc).length); +}); diff --git a/tests/unit/story-schema.test.mjs b/tests/unit/story-schema.test.mjs index a7ec443..791a69c 100644 --- a/tests/unit/story-schema.test.mjs +++ b/tests/unit/story-schema.test.mjs @@ -97,6 +97,7 @@ test("fonts bind TMP component ids to the selected font and material", () => { fails(fontEdit((d, f) => { d.texts[f.textId].font.fontAsset = "Absent"; }), /font asset Absent not in fonts/); fails(fontEdit((d, f) => { d.texts[f.textId].font.material = "Absent"; }), /material Absent not in materials/); passes(fontEdit((d, f) => { d.texts[f.textId].localized = { fontAsset: "Source", material: "Source", lineSpacing: 0 }; })); + passes(fontEdit((d, f) => { d.texts[f.textId].localized = null; })); }); test("font glyphs fit their declared PNG pages", () => {