Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this package. Versions follow [Semantic Versioning](https

## Unreleased

### Site products

- Load live variants from entry manifests and accept variant paths in read-set requests.
- Validate product chart aliases, language-indexed UI references and offered variants.
- Honor audio-disabled story products and select an available playback language from story listings.

### Story products

- Story loading, playback and authoring use `nnnotes.product/1` manifests with shared object resources and language-specific UI and fonts.
Expand Down
6 changes: 6 additions & 0 deletions docs/data-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -982,3 +982,9 @@ further and run lives and charts through the player in Node (see [CONTRIBUTING.m
The song catalog example reads `music-data.json` with format `nnnotes.music-data/1`. Replay presets read
`nnnotes.deck-data/1` for the caller-provided deck capabilities, and the replay worker accepts
`nnnotes.replay-manifest/1` references. These are separate inputs from the live, model and story product manifests.

## Site entry products

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=<JSON array>` 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.
11 changes: 5 additions & 6 deletions examples/chart-list/chart-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,10 @@ const loadIndex = async () => {

const play = async (music, difficulty) => {
if (!/^\d+$/.test(music) || !DIFFICULTIES.includes(difficulty)) throw new Error("unknown chart");
let manifest = `charts/${music}_${difficulty}.json`;
if (q.has("region")) { // a region's own manifest, when it has one
const index = await loadIndex().catch(() => null);
if (index) manifest = manifestFor(index, music, difficulty, pickRegion(index, q.get("region")));
}
const index = await loadIndex().catch(() => null);
const region = index ? pickRegion(index, q.get("region")) : null;
const manifest = index ? manifestFor(index, music, difficulty, region) : `charts/${music}_${difficulty}.json`;
const entry = index && chartsFor(index, region).find(c => c.id === `${music}_${difficulty}`);
document.body.className = "play";
const el = document.createElement("ournotes-player");
el.controls = true;
Expand All @@ -42,7 +41,7 @@ const play = async (music, difficulty) => {
if (musicOff) el.music = false;
if (off(q.get("se"))) el.se = false;
el.addEventListener("ready", () => {
const c = el.chart;
const c = entry || el.chart;
const title = c && chartText(c, q.get("lang")).title;
if (title) document.title = `${title} ${difficulty.toUpperCase()}`;
el.focus(); // keyboard controls without a first click
Expand Down
7 changes: 7 additions & 0 deletions examples/story-list/listing.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export const nameIn = (names, lang, fallback) => {
// The title of an entry in `lang` (else its manifest's default language, else any), or "#<advId>".
export const storyTitle = (s, lang) => nameIn(s.titles, lang, s.language) || `#${s.advId}`;

// A language with playback resources for this entry, including when its listing has more title languages.
export const playbackLanguage = (entry, wanted) => {
if (!Array.isArray(entry?.languages)) return wanted;
if (entry.languages.includes(wanted)) return wanted;
return entry.languages.includes(entry.language) ? entry.language : entry.languages[0];
};

// The kind of the entry's first group ("chapter", "friendship", "spotTalk", "liveResult", "spot"), or "other".
export const storyKind = (s) => (Array.isArray(s.groups) && s.groups.length ? s.groups[0].kind : "other");

Expand Down
7 changes: 5 additions & 2 deletions examples/story-list/story-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// story's. ?kind=<kind> lists the stories of one group kind.
// Served from the repository. On a site, import dist/ournotes-player.story.element.min.js instead.
import "../../src/story/define.js";
import { firstLoad, groupLabel, languagesFor, manifestFor, pickLanguage, pickRegion, queryString, storiesFor, storyKind,
import { firstLoad, groupLabel, languagesFor, manifestFor, pickLanguage, pickRegion, playbackLanguage, queryString, storiesFor, storyKind,
storyTitle } from "./listing.js";

const CORE = "https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js";
Expand Down Expand Up @@ -84,7 +84,10 @@ const play = async (advId) => {
await loadScript(q.get("core") || CORE, "Live2DCubismCore");
if (q.get("motionsync")) await loadScript(q.get("motionsync"), "Live2DCubismMotionSyncCore");
if (q.get("spine")) await loadScript(q.get("spine"), "spine");
for (const a of ["lang", "ui-lang", "line", "speed", "quality", "film-grain"]) if (q.get(a)) story.setAttribute(a, q.get(a));
const entry = index?.stories.find(s => s.manifest === manifest);
const language = playbackLanguage(entry, q.get("lang"));
if (language) story.lang = language;
for (const a of ["ui-lang", "line", "speed", "quality", "film-grain"]) if (q.get(a)) story.setAttribute(a, q.get(a));
if (q.get("auto")) story.setAttribute("auto", q.get("auto"));
story.src = new URL(manifest, site).href;
};
Expand Down
18 changes: 18 additions & 0 deletions schema/live.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@
"additionalProperties": {
"type": "string",
"pattern": "^[^/\\\\]+/[^/\\\\]+\\.json$"
},
"properties": {
"ui": {
"type": "object",
"propertyNames": {
"enum": [
"ja",
"en",
"zh-Hant",
"zh-Hans",
"ko"
]
},
"additionalProperties": {
"type": "string",
"pattern": "^[^/\\\\]+/[^/\\\\]+\\.json$"
}
}
}
}
},
Expand Down
32 changes: 32 additions & 0 deletions schema/product.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,38 @@
"zh-Hans",
"ko"
]
},
"options": {
"type": "object",
"description": "Optional: Live option name -> the values the files support (options that select files).",
"properties": {
"LiveQuality": {
"type": "array",
"items": {
"enum": [
0,
1,
2
]
},
"minItems": 1
}
},
"additionalProperties": {
"type": "array",
"minItems": 1
}
},
"variants": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"audio": {
"type": "boolean",
"description": "Whether audio streams are included."
}
},
"$defs": {
Expand Down
4 changes: 4 additions & 0 deletions schema/story-manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
"type": "string",
"minLength": 1
},
"audio": {
"type": "boolean",
"description": "Whether audio streams are included."
},
"language": {
"enum": [
"ja",
Expand Down
26 changes: 20 additions & 6 deletions scripts/read-set.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
// loads.
//
// node scripts/read-set.mjs <chart> [--plan] [--files=<json list of paths>] [--keys] [--frames=<n>] [--quality=<0..2>]
// [--settings=<json object>]
// [--settings=<json object>] [--variants=<json manifest paths>]
// node scripts/read-set.mjs --serve
// node scripts/read-set.mjs --features
// <chart> a live manifest (lives/<musicId>_<difficulty>.json of a product layout), a chart manifest
Expand All @@ -38,12 +38,13 @@
// ("<path>#<key>", "<path>#<key>.<subkey>"); in plan mode the keys read by the load and the plan
// --frames full mode: stop after n frames instead of at the end of the chart (not with --plan)
// --settings the Live options of the session, a JSON object ({"MirrorChart": true, "NoteSpeed": 9})
// --variants additional product manifest paths for note skins, effects or sounds, relative to the site root
// --serve requests on stdin, one JSON object per line: {"chart": <chart>, "plan"?, "files"?, "keys"?,
// "frames"?, "quality"?, "settings"?} (the options above; "settings" as an object); one answer per
// "frames"?, "quality"?, "settings"?, "variants"?} (the options above; "settings" as an object); one answer per
// request on stdout, one JSON object per line: {"ok": true, "result": <the object below>, "cpuSeconds"}
// or {"ok": false, "error", "cpuSeconds"} (cpuSeconds: CPU time of the process for the request); exits at
// the end of stdin
// --features prints {"features": [...]}: the modes beyond the full simulation ("plan", "serve", "settings")
// --features prints {"features": [...]}: the modes beyond the full simulation ("plan", "serve", "settings", "variants")
// Prints one JSON object: {chart, mode, frames, state, readAfterLoad, read, seconds, settings?, files | listed +
// missing + extra, keys?}; mode "full" (state "ended" at the end of the chart) or "plan" (state "planned", frames 0);
// settings: the options asked for, when given. Exit 2 for a usage error or settings the chart does not accept.
Expand All @@ -53,9 +54,9 @@ import { ChartSession } from "../src/live/session.js";
import { LiveSettingsError } from "../src/live/settings.js";
import { HeadlessAudioContext, headlessGL, openChart } from "./lib/headless.mjs";

const FEATURES = ["plan", "serve", "settings"];
const FEATURES = ["plan", "serve", "settings", "variants"];
const USAGE = "usage: node scripts/read-set.mjs <live manifest | chart manifest | live dir> [--plan] [--files=<json>] [--keys] [--frames=<n>] " +
"[--quality=<n>] [--settings=<json object>] | --serve | --features";
"[--quality=<n>] [--settings=<json object>] [--variants=<json manifest paths>] | --serve | --features";

class UsageError extends Error {}
class NoPlan extends Error {}
Expand All @@ -71,12 +72,25 @@ const settingsOf = (v) => {
return o;
};

const variantsOf = (value) => {
if (value === undefined || value === null) return [];
let paths = value;
if (typeof paths === "string") {
try { paths = JSON.parse(paths); } catch (e) { throw new UsageError(`--variants: ${e.message}`); }
}
if (!Array.isArray(paths) || paths.some(p => typeof p !== "string" || !p.endsWith(".json") || /[:?#\\]/.test(p)
|| p.split("/").length < 2 || p.split("/").some(s => !s || s === "." || s === "..")))
throw new UsageError("--variants must be an array of relative product manifest paths");
return paths;
};

// the read set of one chart: {out, bad}; bad: a --files comparison found a difference
const readSet = async (where, opt) => {
if (opt.plan && opt.frames !== undefined) throw new UsageError("--frames has no meaning with --plan");
const settings = settingsOf(opt.settings);
const variants = variantsOf(opt.variants);
const t0 = Date.now();
const assets = await openChart(where);
const assets = await openChart(where, { variants });
if (!assets.info) assets.info = { options: { LiveQuality: [0, 1, 2] } }; // a live directory (see the header)
const reads = new Set(), keyReads = new Set();
const recorded = (name) => { const f = assets[name].bind(assets); assets[name] = (p, ...a) => { reads.add(p); return f(p, ...a); }; };
Expand Down
22 changes: 18 additions & 4 deletions scripts/validate-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { AssetStore, TEXT_ASSET, TEXT_FILE } from "../src/data/assets.js";
import { LiveData } from "../src/live/data.js";
import { legacyLive } from "../src/live/legacy.js";
import { LiveOptionContext } from "../src/live/settings.js";
import { LIVE_PARTS, LIVE_PRODUCT, liveManifest, productDir } from "../src/live/store.js";
import { LIVE_PARTS, LIVE_PRODUCT, LIVE_VARIANTS, liveManifest, productDir } from "../src/live/store.js";
import { storyPartRequests } from "../src/story/store.js";
import { StoryData } from "../src/story/data.js";
import { StoryPlayerCore } from "../src/story/player-core.js";
Expand Down Expand Up @@ -753,7 +753,10 @@ class Product extends Chart {
// the manifest's name (its path without the directory and .json) is its subject with "/" as "_"
checkName() {
if (this.immutable) return;
const name = path.posix.basename(this.where, ".json"), want = String(this.man.subject).replaceAll("/", "_");
const name = path.posix.basename(this.where, ".json");
const subject = this.man.product === LIVE_PRODUCT && this.where.startsWith("charts/")
? String(this.man.subject).split("/").slice(0, 2).join("/") : String(this.man.subject);
const want = subject.replaceAll("/", "_");
if (name !== want) this.err("manifest", `subject ${this.man.subject}: the manifest's name is ${name}, not ${want}`);
}

Expand Down Expand Up @@ -979,7 +982,7 @@ class Part extends Product {
if (this.errs.length) return this.errs;
const dir = productDir(this.where);
const files = Object.fromEntries(Object.entries(m.files).map(([p, f]) => [`${dir}/${p}`, f]));
const C = new LiveChecks(this.site, new SiteStore(this.site, files, m.objects), (w, msg) => this.err(w, msg));
const C = new LiveChecks(this.site, new SiteStore(this.site, files, m.objects, { audio: m.audio }), (w, msg) => this.err(w, msg));
C.part(product, dir);
C.objects();
return this.errs;
Expand Down Expand Up @@ -1040,6 +1043,15 @@ class Live extends Product {
r.shown = true;
} else loaded.push([productDir(p), r.man]);
}
for (const p of m.variants || []) {
try {
const variant = JSON.parse(fs.readFileSync(this.site.file(p), "utf8"));
if (!LIVE_VARIANTS.includes(variant.product)) { this.err(p, "not a live variant product"); continue; }
const r = this.site.part(p, variant.product);
for (const e of r.errs) this.err(p, e);
if (!r.errs.length) loaded.push([productDir(p), r.man]);
} catch (e) { this.err(p, e.code === "ENOENT" ? "missing" : e.message); }
}
if (this.errs.length) return this.errs;
let merged;
try { merged = liveManifest(loaded); } catch (e) { this.err("live", e.message); return this.errs; }
Expand Down Expand Up @@ -1269,6 +1281,7 @@ class Story extends Product {
if (!Array.isArray(cue.categories) || cue.categories.some((c) => typeof c !== "string")) this.err(where, "categories must be an array of names");
if ((cue.loopStart == null) !== (cue.loopEnd == null) || (cue.loopStart != null && !(cue.loopStart >= 0 && cue.loopStart < cue.loopEnd && cue.loopEnd <= cue.samples))) this.err(where, "loop points outside the waveform");
if (cue.encoderDelay !== undefined && (extOf(cue.file) !== "m4a" || !Number.isInteger(cue.encoderDelay) || cue.encoderDelay < 0)) this.err(where, "invalid encoderDelay");
if (!d.hasSounds) continue;
if (!this.store.has(cue.file)) this.err(where, `${cue.file} missing`);
else { const error = C.ofFile(`story-wave|${cue.sampleRate}|${cue.samples}|${cue.channels}`, cue.file, (b) => waveCheck(b, cue)); if (error) this.err(where, error); }
}
Expand Down Expand Up @@ -1501,7 +1514,8 @@ function runAll(site, list, Kind) {
} else {
try {
const man = JSON.parse(fs.readFileSync(site.file(manifest), "utf8"));
errs = new Kind(site, id, man, manifest).run(entry);
const Reader = Kind === Chart && man.schema === "nnnotes.product/1" ? Live : Kind;
errs = new Reader(site, id, man, manifest).run(entry);
} catch (e) { errs = [`${manifest}: ${e.message}`]; }
}
if (errs.length) {
Expand Down
2 changes: 1 addition & 1 deletion src/live/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const loadLiveStore = async (url, { variants = [], fetch = globalThis.fet
if (typeof live.parts[part] !== "string") throw new Error(`${href}: live.json names no ${part} part`);
wanted.push([live.parts[part], [product]]);
}
for (const p of variants) wanted.push([String(p), LIVE_VARIANTS]);
for (const p of new Set([...(man.variants || []), ...variants])) wanted.push([String(p), LIVE_VARIANTS]);
const loaded = await Promise.all(wanted.map(async ([p, products]) => {
const u = new URL(p, root).href, m = await getJson(u);
if (!products.some((x) => isProduct(m, x)))
Expand Down
2 changes: 2 additions & 0 deletions src/story/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export class StoryData {
this._docs = new Map();
}

get hasSounds() { return this.assets.info.audio !== false && Object.keys(this.parts.sounds || {}).length > 0; }

document(manifest, file, format = null) {
const path = `${productDir(manifest)}/${file}`;
if (!this._docs.has(path)) {
Expand Down
2 changes: 1 addition & 1 deletion src/story/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export class StorySession {
// empty) plays no sound, with the voices off as in the game without voice data (Session.WithVoice false: the
// lines advance by their length, the speakers get the timed pseudo lip sync)
const resolve = Audio.episodeResolver(episode, data);
const hasSounds = Object.keys(data.parts.sounds || {}).length > 0;
const hasSounds = data.hasSounds;
const webAudio = hasSounds && opts.sound !== false && typeof globalThis.AudioContext === "function";
audio = this.audio = opts.audio ? opts.audio(resolve, loop, { assets: store })
: webAudio ? new Audio(resolve, loop, { context: opts.audioContext || null, assets: store })
Expand Down
2 changes: 1 addition & 1 deletion src/story/simple/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export class SimpleStorySession {
const resolve = Audio.episodeResolver(episode, data);
// a story without its sound files (story.json `audio` empty) plays no sound, with the voices off as in the game
// without voice data (as StorySession)
const hasSounds = Object.keys(data.parts.sounds || {}).length > 0;
const hasSounds = data.hasSounds;
this.hasSounds = hasSounds;
const webAudio = hasSounds && opts.sound !== false && typeof globalThis.AudioContext === "function";
const audioOptions = { assets: store };
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/live-store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ test("a regional live manifest resolves products against its declared root", asy
assert.deepEqual(new LiveData(store).audio().sounds[10].layers[0].file, "sounds/default/audio/se1/a.flac");
});

test("a live manifest loads its offered variant products", async () => {
const L = live();
L.files.get("lives/7_expert.json").variants = ["noteskins/skin002.json"];
const store = await loadLiveStore(`${ROOT}lives/7_expert.json`, { fetch: L.fetch });
assert.ok(store.info.products["live.noteskin"].skin002);
});

test("productDir: a product manifest path without .json", () => {
assert.equal(productDir("scenes/default.json"), "scenes/default");
assert.equal(productDir("sounds/NoteSePatternId=1,3.json"), "sounds/NoteSePatternId=1,3");
Expand Down
12 changes: 9 additions & 3 deletions tests/unit/read-set.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,23 @@ test("plan: a planned material makes the shader library requests of its draw", (
// ---- command line
const run = (...args) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8" });

test("read-set --features names the plan, serve and settings modes; option errors exit 2", () => {
test("read-set --features names the supported modes; option errors exit 2", () => {
const f = run("--features");
assert.equal(f.status, 0);
assert.deepEqual(JSON.parse(f.stdout), { features: ["plan", "serve", "settings"] });
assert.equal(f.stdout.trim(), '{"features":["plan","serve","settings"]}');
assert.deepEqual(JSON.parse(f.stdout), { features: ["plan", "serve", "settings", "variants"] });
assert.equal(run().status, 2);
const r = run("some/chart", "--plan", "--frames=10");
assert.equal(r.status, 2);
assert.match(r.stderr, /--frames has no meaning with --plan/);
});

test("read-set validates variant manifest paths before opening the chart", () => {
for (const variants of ['{}', '["../outside.json"]', '["https://example.org/part.json"]']) {
const r = run("missing", "--variants=" + variants);
assert.equal(r.status, 2); assert.match(r.stderr, /relative product manifest paths/);
}
});

test("read-set --serve answers each request line in order, one JSON line each, until the end of stdin", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ournotes-serve-"));
try {
Expand Down
Loading
Loading