@kieransimkin/dancemoves 3.1.23 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/react.cjs CHANGED
@@ -115,7 +115,11 @@ function Rudiment({ runtime, options, children, ...attributes }) {
115
115
  }
116
116
  function PageMetadataEditor({ value = EMPTY, onSave }) {
117
117
  const [draft, setDraft] = (0, import_react.useState)(() => ({ ...value })), [message, setMessage] = (0, import_react.useState)(""), [busy, setBusy] = (0, import_react.useState)(false);
118
- (0, import_react.useEffect)(() => setDraft({ ...value }), [value]);
118
+ const [alignmentText, setAlignmentText] = (0, import_react.useState)(() => value.beatAlignment ? JSON.stringify(value.beatAlignment, null, 2) : "");
119
+ (0, import_react.useEffect)(() => {
120
+ setDraft({ ...value });
121
+ setAlignmentText(value.beatAlignment ? JSON.stringify(value.beatAlignment, null, 2) : "");
122
+ }, [value]);
119
123
  const input = (key, label, type = "text") => (0, import_react.createElement)("label", { key }, label, (0, import_react.createElement)("input", {
120
124
  type,
121
125
  value: draft[key] ?? "",
@@ -129,7 +133,7 @@ function PageMetadataEditor({ value = EMPTY, onSave }) {
129
133
  setBusy(true);
130
134
  setMessage("");
131
135
  try {
132
- await onSave((0, import_index.validatePageConfig)(draft));
136
+ await onSave((0, import_index.validatePageConfig)({ ...draft, beatAlignment: alignmentText.trim() ? JSON.parse(alignmentText) : null }));
133
137
  setMessage("Saved");
134
138
  } catch (error) {
135
139
  setMessage(error.message);
@@ -140,6 +144,7 @@ function PageMetadataEditor({ value = EMPTY, onSave }) {
140
144
  input("bpm", "BPM", "number"),
141
145
  input("lyricTimingUrl", "Lyric timing URL"),
142
146
  input("cueTimingUrl", "Cue timing URL"),
147
+ (0, import_react.createElement)("label", null, "Beat alignment reading (JSON)", (0, import_react.createElement)("textarea", { rows: 7, value: alignmentText, onChange: (e) => setAlignmentText(e.target.value) })),
143
148
  input("masterDurationMilliseconds", "Master duration (milliseconds)", "number"),
144
149
  (0, import_react.createElement)("label", null, (0, import_react.createElement)("input", { type: "checkbox", checked: !!draft.lyricPopupsEnabled, onChange: (e) => setDraft({ ...draft, lyricPopupsEnabled: e.target.checked }) }), "Timed lyric pop-ups"),
145
150
  (0, import_react.createElement)("label", null, "Ambient effect", (0, import_react.createElement)(
package/lib/react.mjs CHANGED
@@ -86,7 +86,11 @@ function Rudiment({ runtime, options, children, ...attributes }) {
86
86
  }
87
87
  function PageMetadataEditor({ value = EMPTY, onSave }) {
88
88
  const [draft, setDraft] = useState(() => ({ ...value })), [message, setMessage] = useState(""), [busy, setBusy] = useState(false);
89
- useEffect(() => setDraft({ ...value }), [value]);
89
+ const [alignmentText, setAlignmentText] = useState(() => value.beatAlignment ? JSON.stringify(value.beatAlignment, null, 2) : "");
90
+ useEffect(() => {
91
+ setDraft({ ...value });
92
+ setAlignmentText(value.beatAlignment ? JSON.stringify(value.beatAlignment, null, 2) : "");
93
+ }, [value]);
90
94
  const input = (key, label, type = "text") => h("label", { key }, label, h("input", {
91
95
  type,
92
96
  value: draft[key] ?? "",
@@ -100,7 +104,7 @@ function PageMetadataEditor({ value = EMPTY, onSave }) {
100
104
  setBusy(true);
101
105
  setMessage("");
102
106
  try {
103
- await onSave(validatePageConfig(draft));
107
+ await onSave(validatePageConfig({ ...draft, beatAlignment: alignmentText.trim() ? JSON.parse(alignmentText) : null }));
104
108
  setMessage("Saved");
105
109
  } catch (error) {
106
110
  setMessage(error.message);
@@ -111,6 +115,7 @@ function PageMetadataEditor({ value = EMPTY, onSave }) {
111
115
  input("bpm", "BPM", "number"),
112
116
  input("lyricTimingUrl", "Lyric timing URL"),
113
117
  input("cueTimingUrl", "Cue timing URL"),
118
+ h("label", null, "Beat alignment reading (JSON)", h("textarea", { rows: 7, value: alignmentText, onChange: (e) => setAlignmentText(e.target.value) })),
114
119
  input("masterDurationMilliseconds", "Master duration (milliseconds)", "number"),
115
120
  h("label", null, h("input", { type: "checkbox", checked: !!draft.lyricPopupsEnabled, onChange: (e) => setDraft({ ...draft, lyricPopupsEnabled: e.target.checked }) }), "Timed lyric pop-ups"),
116
121
  h("label", null, "Ambient effect", h(
package/lib/server.cjs CHANGED
@@ -48,6 +48,35 @@ var import_node_fs = require("node:fs");
48
48
  var import_node_path = __toESM(require("node:path"), 1);
49
49
  var import_node_stream = require("node:stream");
50
50
 
51
+ // .build/modules/beat-alignment.mjs
52
+ var hash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
53
+ function rational(value, label) {
54
+ if (typeof value !== "string" || !/^-?\d{1,80}(?:\/[1-9]\d{0,79})?$/.test(value)) throw new TypeError(`Invalid exact ${label}`);
55
+ const [n, d = "1"] = value.split("/");
56
+ return [BigInt(n), BigInt(d)];
57
+ }
58
+ function numeric(value, label, min, max) {
59
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw new RangeError(`Invalid ${label}`);
60
+ return value;
61
+ }
62
+ function validateBeatAlignment(input) {
63
+ if (input === void 0 || input === null || input === "") return null;
64
+ if (!input || typeof input !== "object" || Array.isArray(input) || input.schema !== "stemlab.beat-alignment.v1" || input.pips_per_beat !== 64 || input.canonical_first_beat_pip !== 0 || !hash(input.source_sha256) || !hash(input.evidence_sha256) || !hash(input.report_sha256)) throw new TypeError("Invalid source-bound StemLab beat alignment");
65
+ const bpm = numeric(input.bpm, "alignment BPM", 20, 400);
66
+ const first = numeric(input.first_beat_seconds, "first beat", 0, 86400);
67
+ const offset = numeric(input.offset_pips, "pip offset", -36864e3, 0);
68
+ const [bn, bd] = rational(input.exact_bpm, "BPM"), [fn, fd] = rational(input.first_beat_exact_seconds, "first beat"), [pn, pd] = rational(input.exact_offset_pips, "pip offset");
69
+ if (fn * bn * 64n * pd + pn * fd * bd * 60n !== 0n || bn <= 0n || fn < 0n || Math.abs(Number(bn) / Number(bd) - bpm) > 1e-9 || Math.abs(Number(fn) / Number(fd) - first) > 1e-9 || Math.abs(Number(pn) / Number(pd) - offset) > 1e-7) throw new RangeError("Exact beat alignment and numeric clock disagree");
70
+ if (!["canonical", "estimated"].includes(input.bpm_status) || !["estimated", "confirmed", "synthetic"].includes(input.phase_status)) throw new TypeError("Alignment authority is required");
71
+ for (const key of ["analysis_id", "bpm_basis"]) if (typeof input[key] !== "string" || !/^[a-zA-Z0-9_.-]{1,128}$/.test(input[key])) throw new TypeError(`Invalid ${key}`);
72
+ if (input.source_audio_verified !== true || !input.source_audio || !Number.isSafeInteger(input.source_audio.num_frames) || input.source_audio.num_frames < 1 || !Number.isSafeInteger(input.source_audio.sample_rate) || input.source_audio.sample_rate < 1 || first >= input.source_audio.num_frames / input.source_audio.sample_rate) throw new TypeError("Verified source audio frame bounds are required");
73
+ const sourceUrl = input.source_url;
74
+ if (typeof sourceUrl !== "string" || sourceUrl.length > 4096 || /[\u0000-\u001f]/.test(sourceUrl)) throw new TypeError("An exact verified master URL is required");
75
+ const parsed = new URL(sourceUrl);
76
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) throw new TypeError("Invalid verified master URL");
77
+ return Object.freeze({ ...input, bpm, first_beat_seconds: first, offset_pips: offset, source_audio: Object.freeze({ ...input.source_audio }) });
78
+ }
79
+
51
80
  // .build/modules/config.mjs
52
81
  var PAGE_META_KEYS = Object.freeze({
53
82
  bpm: "_dance_moves_bpm",
@@ -55,7 +84,9 @@ var PAGE_META_KEYS = Object.freeze({
55
84
  cue: "_dance_moves_cue_timing_id",
56
85
  popups: "_dance_moves_lyric_popups_enabled",
57
86
  duration: "_dance_moves_master_duration_ms",
58
- effect: "_dance_moves_effect"
87
+ effect: "_dance_moves_effect",
88
+ alignment: "_dance_moves_beat_alignment",
89
+ alignmentAudio: "_dance_moves_beat_alignment_audio_id"
59
90
  });
60
91
  var object = (value) => value && typeof value === "object" && !Array.isArray(value);
61
92
  function finite(value, name, min, max) {
@@ -74,7 +105,11 @@ function url(value, name) {
74
105
  function validatePageConfig(input = {}) {
75
106
  if (!object(input)) throw new TypeError("Page configuration must be an object");
76
107
  const explicit = input.bpm !== void 0 && input.bpm !== null && input.bpm !== "";
77
- const bpm = explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
108
+ const beatAlignment = validateBeatAlignment(input.beatAlignment);
109
+ const canonicalExplicit = explicit && !["fallback", "analysis"].includes(input.bpmSource);
110
+ if (canonicalExplicit && beatAlignment && Math.abs(input.bpm - beatAlignment.bpm) > 500001e-9) throw new RangeError("Stored BPM and selected beat alignment disagree; select a matching BPM basis");
111
+ const bpm = beatAlignment ? beatAlignment.bpm : explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
112
+ if (explicit) finite(input.bpm, "bpm", 20, 400);
78
113
  const effect = input.effect ?? "";
79
114
  if (!["", "paper-planes"].includes(effect)) throw new RangeError("effect must be empty or paper-planes");
80
115
  if (input.lyricPopupsEnabled !== void 0 && typeof input.lyricPopupsEnabled !== "boolean") throw new TypeError("lyricPopupsEnabled must be boolean");
@@ -86,8 +121,9 @@ function validatePageConfig(input = {}) {
86
121
  return Object.freeze({
87
122
  pageId,
88
123
  bpm,
89
- bpmSource: explicit && input.bpmSource !== "fallback" ? "explicit" : "fallback",
124
+ bpmSource: canonicalExplicit ? "explicit" : beatAlignment ? "analysis" : "fallback",
90
125
  fallbackBpm: 120,
126
+ beatAlignment,
91
127
  ticksPerBeat: 16,
92
128
  tickDefinition: "sixteenth-of-beat",
93
129
  longDurationQuantumTicks: 16,
@@ -103,9 +139,9 @@ function validatePageConfig(input = {}) {
103
139
  }
104
140
  async function fromWordPressMeta(meta, resolveAttachment) {
105
141
  if (!object(meta)) throw new TypeError("meta must be an object");
106
- const numeric2 = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
142
+ const numeric3 = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
107
143
  const b = meta[PAGE_META_KEYS.bpm];
108
- const bpm = numeric2(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
144
+ const bpm = numeric3(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
109
145
  const resolve = async (key) => {
110
146
  const value = Number(meta[key] || 0);
111
147
  if (!Number.isSafeInteger(value)) throw new RangeError("Attachment ID must be a safe integer");
@@ -115,8 +151,15 @@ async function fromWordPressMeta(meta, resolveAttachment) {
115
151
  return await resolveAttachment(id) || "";
116
152
  };
117
153
  const duration = Number(meta[PAGE_META_KEYS.duration]);
154
+ let beatAlignment = meta[PAGE_META_KEYS.alignment] || null;
155
+ if (typeof beatAlignment === "string") {
156
+ if (beatAlignment.length > 16384) throw new RangeError("Beat alignment exceeds 16 KiB");
157
+ beatAlignment = JSON.parse(beatAlignment);
158
+ }
159
+ if (beatAlignment) beatAlignment = { ...beatAlignment, source_url: await resolve(PAGE_META_KEYS.alignmentAudio) };
118
160
  return validatePageConfig({
119
161
  bpm,
162
+ beatAlignment,
120
163
  lyricTimingUrl: await resolve(PAGE_META_KEYS.lyric),
121
164
  cueTimingUrl: await resolve(PAGE_META_KEYS.cue),
122
165
  lyricPopupsEnabled: meta[PAGE_META_KEYS.popups] === true || String(meta[PAGE_META_KEYS.popups]) === "1",
@@ -286,7 +329,7 @@ function createDownloadService({ rootDirectory, secret, origin, route = "/api/do
286
329
  }
287
330
  return Object.freeze({ sign, handle });
288
331
  }
289
- var numeric = (v, min, max) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max;
332
+ var numeric2 = (v, min, max) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max;
290
333
  function boundedText(value, max = 500) {
291
334
  return String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max);
292
335
  }
@@ -294,13 +337,13 @@ function validateMotionCapture(payload) {
294
337
  if (!payload || payload.schema !== "ks-epk-motion-recording/v1") fail("ks_motion_schema", "Unsupported capture schema");
295
338
  if (!Array.isArray(payload.samples) || payload.samples.length < 1 || payload.samples.length > 500) fail("ks_motion_samples", "A capture must contain 1..500 samples");
296
339
  const samples = payload.samples.map((sample) => {
297
- if (!sample || !numeric(sample.milliseconds, 0, 12e4) || !numeric(sample.beta, -180, 180) || !numeric(sample.gamma, -90, 90)) fail("ks_motion_invalid_samples", "Invalid motion sample");
340
+ if (!sample || !numeric2(sample.milliseconds, 0, 12e4) || !numeric2(sample.beta, -180, 180) || !numeric2(sample.gamma, -90, 90)) fail("ks_motion_invalid_samples", "Invalid motion sample");
298
341
  return {
299
342
  milliseconds: sample.milliseconds,
300
343
  beta: sample.beta,
301
344
  gamma: sample.gamma,
302
- alpha: sample.alpha === null || sample.alpha === void 0 ? null : numeric(sample.alpha, 0, 360) ? sample.alpha : null,
303
- screenAngle: numeric(sample.screenAngle ?? 0, -360, 360) ? sample.screenAngle || 0 : null,
345
+ alpha: sample.alpha === null || sample.alpha === void 0 ? null : numeric2(sample.alpha, 0, 360) ? sample.alpha : null,
346
+ screenAngle: numeric2(sample.screenAngle ?? 0, -360, 360) ? sample.screenAngle || 0 : null,
304
347
  ...Object.fromEntries(["absolute", "nativeTrusted", "simulatedTrusted", "fieldParity", "dispatchParity", "mapperParity"].map((k) => [k, !!sample[k]]))
305
348
  };
306
349
  });
@@ -309,7 +352,7 @@ function validateMotionCapture(payload) {
309
352
  capturedAt: boundedText(payload.capturedAt, 100),
310
353
  userAgent: boundedText(payload.userAgent),
311
354
  samples,
312
- screenAngle: numeric(payload.screenAngle ?? 0, -360, 360) ? payload.screenAngle || 0 : null,
355
+ screenAngle: numeric2(payload.screenAngle ?? 0, -360, 360) ? payload.screenAngle || 0 : null,
313
356
  storedSampleCount: samples.length,
314
357
  sampleStrategy: boundedText(payload.sampleStrategy || "legacy", 80).toLowerCase().replace(/[^a-z0-9_-]/g, ""),
315
358
  fullWindowObserved: !!payload.fullWindowObserved,
@@ -321,7 +364,7 @@ function validateMotionCapture(payload) {
321
364
  validPairs: Number.isFinite(Number(v.validPairs)) ? Math.abs(Math.trunc(Number(v.validPairs))) : null
322
365
  }))
323
366
  };
324
- for (const key of ["targetDurationMilliseconds", "captureDurationMilliseconds", "sampledDurationMilliseconds", "storageIntervalMilliseconds"]) clean[key] = numeric(payload[key] ?? 0, 0, 12e4) ? payload[key] || 0 : null;
367
+ for (const key of ["targetDurationMilliseconds", "captureDurationMilliseconds", "sampledDurationMilliseconds", "storageIntervalMilliseconds"]) clean[key] = numeric2(payload[key] ?? 0, 0, 12e4) ? payload[key] || 0 : null;
325
368
  for (const key of ["processedPairCount", "failureCount"]) clean[key] = Math.min(1e5, Math.abs(Math.trunc(Number(payload[key]) || (key === "processedPairCount" ? samples.length : clean.failures.length))));
326
369
  if (Buffer.byteLength(JSON.stringify(clean)) > 262144) fail("ks_motion_size", "Capture too large", 413);
327
370
  return clean;
package/lib/server.mjs CHANGED
@@ -5,6 +5,35 @@ import { constants as fsConstants } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { Readable } from "node:stream";
7
7
 
8
+ // .build/modules/beat-alignment.mjs
9
+ var hash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
10
+ function rational(value, label) {
11
+ if (typeof value !== "string" || !/^-?\d{1,80}(?:\/[1-9]\d{0,79})?$/.test(value)) throw new TypeError(`Invalid exact ${label}`);
12
+ const [n, d = "1"] = value.split("/");
13
+ return [BigInt(n), BigInt(d)];
14
+ }
15
+ function numeric(value, label, min, max) {
16
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw new RangeError(`Invalid ${label}`);
17
+ return value;
18
+ }
19
+ function validateBeatAlignment(input) {
20
+ if (input === void 0 || input === null || input === "") return null;
21
+ if (!input || typeof input !== "object" || Array.isArray(input) || input.schema !== "stemlab.beat-alignment.v1" || input.pips_per_beat !== 64 || input.canonical_first_beat_pip !== 0 || !hash(input.source_sha256) || !hash(input.evidence_sha256) || !hash(input.report_sha256)) throw new TypeError("Invalid source-bound StemLab beat alignment");
22
+ const bpm = numeric(input.bpm, "alignment BPM", 20, 400);
23
+ const first = numeric(input.first_beat_seconds, "first beat", 0, 86400);
24
+ const offset = numeric(input.offset_pips, "pip offset", -36864e3, 0);
25
+ const [bn, bd] = rational(input.exact_bpm, "BPM"), [fn, fd] = rational(input.first_beat_exact_seconds, "first beat"), [pn, pd] = rational(input.exact_offset_pips, "pip offset");
26
+ if (fn * bn * 64n * pd + pn * fd * bd * 60n !== 0n || bn <= 0n || fn < 0n || Math.abs(Number(bn) / Number(bd) - bpm) > 1e-9 || Math.abs(Number(fn) / Number(fd) - first) > 1e-9 || Math.abs(Number(pn) / Number(pd) - offset) > 1e-7) throw new RangeError("Exact beat alignment and numeric clock disagree");
27
+ if (!["canonical", "estimated"].includes(input.bpm_status) || !["estimated", "confirmed", "synthetic"].includes(input.phase_status)) throw new TypeError("Alignment authority is required");
28
+ for (const key of ["analysis_id", "bpm_basis"]) if (typeof input[key] !== "string" || !/^[a-zA-Z0-9_.-]{1,128}$/.test(input[key])) throw new TypeError(`Invalid ${key}`);
29
+ if (input.source_audio_verified !== true || !input.source_audio || !Number.isSafeInteger(input.source_audio.num_frames) || input.source_audio.num_frames < 1 || !Number.isSafeInteger(input.source_audio.sample_rate) || input.source_audio.sample_rate < 1 || first >= input.source_audio.num_frames / input.source_audio.sample_rate) throw new TypeError("Verified source audio frame bounds are required");
30
+ const sourceUrl = input.source_url;
31
+ if (typeof sourceUrl !== "string" || sourceUrl.length > 4096 || /[\u0000-\u001f]/.test(sourceUrl)) throw new TypeError("An exact verified master URL is required");
32
+ const parsed = new URL(sourceUrl);
33
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) throw new TypeError("Invalid verified master URL");
34
+ return Object.freeze({ ...input, bpm, first_beat_seconds: first, offset_pips: offset, source_audio: Object.freeze({ ...input.source_audio }) });
35
+ }
36
+
8
37
  // .build/modules/config.mjs
9
38
  var PAGE_META_KEYS = Object.freeze({
10
39
  bpm: "_dance_moves_bpm",
@@ -12,7 +41,9 @@ var PAGE_META_KEYS = Object.freeze({
12
41
  cue: "_dance_moves_cue_timing_id",
13
42
  popups: "_dance_moves_lyric_popups_enabled",
14
43
  duration: "_dance_moves_master_duration_ms",
15
- effect: "_dance_moves_effect"
44
+ effect: "_dance_moves_effect",
45
+ alignment: "_dance_moves_beat_alignment",
46
+ alignmentAudio: "_dance_moves_beat_alignment_audio_id"
16
47
  });
17
48
  var object = (value) => value && typeof value === "object" && !Array.isArray(value);
18
49
  function finite(value, name, min, max) {
@@ -31,7 +62,11 @@ function url(value, name) {
31
62
  function validatePageConfig(input = {}) {
32
63
  if (!object(input)) throw new TypeError("Page configuration must be an object");
33
64
  const explicit = input.bpm !== void 0 && input.bpm !== null && input.bpm !== "";
34
- const bpm = explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
65
+ const beatAlignment = validateBeatAlignment(input.beatAlignment);
66
+ const canonicalExplicit = explicit && !["fallback", "analysis"].includes(input.bpmSource);
67
+ if (canonicalExplicit && beatAlignment && Math.abs(input.bpm - beatAlignment.bpm) > 500001e-9) throw new RangeError("Stored BPM and selected beat alignment disagree; select a matching BPM basis");
68
+ const bpm = beatAlignment ? beatAlignment.bpm : explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
69
+ if (explicit) finite(input.bpm, "bpm", 20, 400);
35
70
  const effect = input.effect ?? "";
36
71
  if (!["", "paper-planes"].includes(effect)) throw new RangeError("effect must be empty or paper-planes");
37
72
  if (input.lyricPopupsEnabled !== void 0 && typeof input.lyricPopupsEnabled !== "boolean") throw new TypeError("lyricPopupsEnabled must be boolean");
@@ -43,8 +78,9 @@ function validatePageConfig(input = {}) {
43
78
  return Object.freeze({
44
79
  pageId,
45
80
  bpm,
46
- bpmSource: explicit && input.bpmSource !== "fallback" ? "explicit" : "fallback",
81
+ bpmSource: canonicalExplicit ? "explicit" : beatAlignment ? "analysis" : "fallback",
47
82
  fallbackBpm: 120,
83
+ beatAlignment,
48
84
  ticksPerBeat: 16,
49
85
  tickDefinition: "sixteenth-of-beat",
50
86
  longDurationQuantumTicks: 16,
@@ -60,9 +96,9 @@ function validatePageConfig(input = {}) {
60
96
  }
61
97
  async function fromWordPressMeta(meta, resolveAttachment) {
62
98
  if (!object(meta)) throw new TypeError("meta must be an object");
63
- const numeric2 = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
99
+ const numeric3 = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
64
100
  const b = meta[PAGE_META_KEYS.bpm];
65
- const bpm = numeric2(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
101
+ const bpm = numeric3(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
66
102
  const resolve = async (key) => {
67
103
  const value = Number(meta[key] || 0);
68
104
  if (!Number.isSafeInteger(value)) throw new RangeError("Attachment ID must be a safe integer");
@@ -72,8 +108,15 @@ async function fromWordPressMeta(meta, resolveAttachment) {
72
108
  return await resolveAttachment(id) || "";
73
109
  };
74
110
  const duration = Number(meta[PAGE_META_KEYS.duration]);
111
+ let beatAlignment = meta[PAGE_META_KEYS.alignment] || null;
112
+ if (typeof beatAlignment === "string") {
113
+ if (beatAlignment.length > 16384) throw new RangeError("Beat alignment exceeds 16 KiB");
114
+ beatAlignment = JSON.parse(beatAlignment);
115
+ }
116
+ if (beatAlignment) beatAlignment = { ...beatAlignment, source_url: await resolve(PAGE_META_KEYS.alignmentAudio) };
75
117
  return validatePageConfig({
76
118
  bpm,
119
+ beatAlignment,
77
120
  lyricTimingUrl: await resolve(PAGE_META_KEYS.lyric),
78
121
  cueTimingUrl: await resolve(PAGE_META_KEYS.cue),
79
122
  lyricPopupsEnabled: meta[PAGE_META_KEYS.popups] === true || String(meta[PAGE_META_KEYS.popups]) === "1",
@@ -243,7 +286,7 @@ function createDownloadService({ rootDirectory, secret, origin, route = "/api/do
243
286
  }
244
287
  return Object.freeze({ sign, handle });
245
288
  }
246
- var numeric = (v, min, max) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max;
289
+ var numeric2 = (v, min, max) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max;
247
290
  function boundedText(value, max = 500) {
248
291
  return String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max);
249
292
  }
@@ -251,13 +294,13 @@ function validateMotionCapture(payload) {
251
294
  if (!payload || payload.schema !== "ks-epk-motion-recording/v1") fail("ks_motion_schema", "Unsupported capture schema");
252
295
  if (!Array.isArray(payload.samples) || payload.samples.length < 1 || payload.samples.length > 500) fail("ks_motion_samples", "A capture must contain 1..500 samples");
253
296
  const samples = payload.samples.map((sample) => {
254
- if (!sample || !numeric(sample.milliseconds, 0, 12e4) || !numeric(sample.beta, -180, 180) || !numeric(sample.gamma, -90, 90)) fail("ks_motion_invalid_samples", "Invalid motion sample");
297
+ if (!sample || !numeric2(sample.milliseconds, 0, 12e4) || !numeric2(sample.beta, -180, 180) || !numeric2(sample.gamma, -90, 90)) fail("ks_motion_invalid_samples", "Invalid motion sample");
255
298
  return {
256
299
  milliseconds: sample.milliseconds,
257
300
  beta: sample.beta,
258
301
  gamma: sample.gamma,
259
- alpha: sample.alpha === null || sample.alpha === void 0 ? null : numeric(sample.alpha, 0, 360) ? sample.alpha : null,
260
- screenAngle: numeric(sample.screenAngle ?? 0, -360, 360) ? sample.screenAngle || 0 : null,
302
+ alpha: sample.alpha === null || sample.alpha === void 0 ? null : numeric2(sample.alpha, 0, 360) ? sample.alpha : null,
303
+ screenAngle: numeric2(sample.screenAngle ?? 0, -360, 360) ? sample.screenAngle || 0 : null,
261
304
  ...Object.fromEntries(["absolute", "nativeTrusted", "simulatedTrusted", "fieldParity", "dispatchParity", "mapperParity"].map((k) => [k, !!sample[k]]))
262
305
  };
263
306
  });
@@ -266,7 +309,7 @@ function validateMotionCapture(payload) {
266
309
  capturedAt: boundedText(payload.capturedAt, 100),
267
310
  userAgent: boundedText(payload.userAgent),
268
311
  samples,
269
- screenAngle: numeric(payload.screenAngle ?? 0, -360, 360) ? payload.screenAngle || 0 : null,
312
+ screenAngle: numeric2(payload.screenAngle ?? 0, -360, 360) ? payload.screenAngle || 0 : null,
270
313
  storedSampleCount: samples.length,
271
314
  sampleStrategy: boundedText(payload.sampleStrategy || "legacy", 80).toLowerCase().replace(/[^a-z0-9_-]/g, ""),
272
315
  fullWindowObserved: !!payload.fullWindowObserved,
@@ -278,7 +321,7 @@ function validateMotionCapture(payload) {
278
321
  validPairs: Number.isFinite(Number(v.validPairs)) ? Math.abs(Math.trunc(Number(v.validPairs))) : null
279
322
  }))
280
323
  };
281
- for (const key of ["targetDurationMilliseconds", "captureDurationMilliseconds", "sampledDurationMilliseconds", "storageIntervalMilliseconds"]) clean[key] = numeric(payload[key] ?? 0, 0, 12e4) ? payload[key] || 0 : null;
324
+ for (const key of ["targetDurationMilliseconds", "captureDurationMilliseconds", "sampledDurationMilliseconds", "storageIntervalMilliseconds"]) clean[key] = numeric2(payload[key] ?? 0, 0, 12e4) ? payload[key] || 0 : null;
282
325
  for (const key of ["processedPairCount", "failureCount"]) clean[key] = Math.min(1e5, Math.abs(Math.trunc(Number(payload[key]) || (key === "processedPairCount" ? samples.length : clean.failures.length))));
283
326
  if (Buffer.byteLength(JSON.stringify(clean)) > 262144) fail("ks_motion_size", "Capture too large", 413);
284
327
  return clean;