@aelionsdk/project-schema 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/admission.d.ts +24 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +287 -0
- package/dist/canonical.d.ts +9 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +61 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +415 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FoyonaCZY and AelionSDK contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @aelionsdk/project-schema
|
|
2
|
+
|
|
3
|
+
Project v1 types, validation and canonical serialization for AelionSDK
|
|
4
|
+
|
|
5
|
+
Install with `npm install @aelionsdk/project-schema@next`.
|
|
6
|
+
|
|
7
|
+
Version 0.1.0-beta.1 is a prerelease and its API may change before the first stable release. This package is part of [AelionSDK](https://github.com/FoyonaCZY/AelionSDK); see the repository README for supported browsers, examples and deployment requirements.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { JsonValue } from '@aelionsdk/core';
|
|
2
|
+
export declare const PROJECT_INPUT_MAX_DEPTH = 64;
|
|
3
|
+
export declare const PROJECT_INPUT_MAX_NODES = 262144;
|
|
4
|
+
export declare const PROJECT_INPUT_MAX_ARRAY_LENGTH = 16384;
|
|
5
|
+
export declare const PROJECT_INPUT_MAX_OBJECT_KEYS = 4096;
|
|
6
|
+
export declare const PROJECT_INPUT_MAX_PROPERTY_KEY_BYTES: number;
|
|
7
|
+
export declare const PROJECT_INPUT_MAX_STRING_BYTES: number;
|
|
8
|
+
export declare const PROJECT_INPUT_MAX_TOTAL_STRING_BYTES: number;
|
|
9
|
+
type ProjectAdmissionCode = 'PROJECT_INPUT_INVALID' | 'PROJECT_INPUT_LIMIT_EXCEEDED';
|
|
10
|
+
export declare class ProjectInputAdmissionError extends TypeError {
|
|
11
|
+
readonly code: ProjectAdmissionCode;
|
|
12
|
+
readonly path: readonly (string | number)[];
|
|
13
|
+
constructor(code: ProjectAdmissionCode, message: string, path: readonly (string | number)[]);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Captures a bounded, ownership-isolated JSON snapshot before Ajv or semantic
|
|
17
|
+
* validation sees caller-controlled objects. Reflection on a Proxy can still
|
|
18
|
+
* invoke its traps; failures are caught, and no accessor getter or iterator is
|
|
19
|
+
* intentionally called. After this function returns, the original value is no
|
|
20
|
+
* longer observed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function snapshotProjectInput(value: unknown): JsonValue;
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=admission.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admission.d.ts","sourceRoot":"","sources":["../src/admission.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAC1C,eAAO,MAAM,uBAAuB,SAAU,CAAC;AAC/C,eAAO,MAAM,8BAA8B,QAAS,CAAC;AACrD,eAAO,MAAM,6BAA6B,OAAQ,CAAC;AACnD,eAAO,MAAM,oCAAoC,QAAa,CAAC;AAC/D,eAAO,MAAM,8BAA8B,QAAoB,CAAC;AAChE,eAAO,MAAM,oCAAoC,QAAqB,CAAC;AAEvE,KAAK,oBAAoB,GAAG,uBAAuB,GAAG,8BAA8B,CAAC;AA2BrF,qBAAa,0BAA2B,SAAQ,SAAS;IACvD,SAAgB,IAAI,EAAE,oBAAoB,CAAC;IAC3C,SAAgB,IAAI,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;gBAGjD,IAAI,EAAE,oBAAoB,EAC1B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE;CAOrC;AAmKD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,CAuI9D"}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
export const PROJECT_INPUT_MAX_DEPTH = 64;
|
|
2
|
+
export const PROJECT_INPUT_MAX_NODES = 262_144;
|
|
3
|
+
export const PROJECT_INPUT_MAX_ARRAY_LENGTH = 16_384;
|
|
4
|
+
export const PROJECT_INPUT_MAX_OBJECT_KEYS = 4_096;
|
|
5
|
+
export const PROJECT_INPUT_MAX_PROPERTY_KEY_BYTES = 16 * 1_024;
|
|
6
|
+
export const PROJECT_INPUT_MAX_STRING_BYTES = 4 * 1_024 * 1_024;
|
|
7
|
+
export const PROJECT_INPUT_MAX_TOTAL_STRING_BYTES = 16 * 1_024 * 1_024;
|
|
8
|
+
const arrayIsArray = Array.isArray;
|
|
9
|
+
const defineProperty = Object.defineProperty;
|
|
10
|
+
const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
|
|
11
|
+
const getPrototypeOf = Object.getPrototypeOf;
|
|
12
|
+
const ownKeys = Reflect.ownKeys;
|
|
13
|
+
const reflectApply = Reflect.apply;
|
|
14
|
+
const functionToString = (value) =>
|
|
15
|
+
// Calling through Reflect.apply deliberately avoids observing value.toString.
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
17
|
+
reflectApply(Function.prototype.toString, value, []);
|
|
18
|
+
const textEncoder = new TextEncoder();
|
|
19
|
+
export class ProjectInputAdmissionError extends TypeError {
|
|
20
|
+
code;
|
|
21
|
+
path;
|
|
22
|
+
constructor(code, message, path) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'ProjectInputAdmissionError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.path = path;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function pathSegments(path) {
|
|
30
|
+
const segments = [];
|
|
31
|
+
for (let current = path; current !== null; current = current.parent) {
|
|
32
|
+
segments.push(current.segment);
|
|
33
|
+
}
|
|
34
|
+
return segments.reverse();
|
|
35
|
+
}
|
|
36
|
+
function invalid(path, message) {
|
|
37
|
+
throw new ProjectInputAdmissionError('PROJECT_INPUT_INVALID', message, pathSegments(path));
|
|
38
|
+
}
|
|
39
|
+
function limit(path, message) {
|
|
40
|
+
throw new ProjectInputAdmissionError('PROJECT_INPUT_LIMIT_EXCEEDED', message, pathSegments(path));
|
|
41
|
+
}
|
|
42
|
+
function descriptor(value, key, path) {
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
result = getOwnPropertyDescriptor(value, key);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
invalid(path, 'Project input contains an inaccessible property');
|
|
49
|
+
}
|
|
50
|
+
if (result === undefined || !('value' in result) || result.enumerable !== true) {
|
|
51
|
+
invalid(path, 'Project input properties must be enumerable own data properties');
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
function arrayLength(value, path) {
|
|
56
|
+
let result;
|
|
57
|
+
try {
|
|
58
|
+
result = getOwnPropertyDescriptor(value, 'length');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
invalid(path, 'Project input contains an inaccessible array length');
|
|
62
|
+
}
|
|
63
|
+
if (result === undefined || !('value' in result) || !Number.isSafeInteger(result.value)) {
|
|
64
|
+
invalid(path, 'Project input array length must be a safe own data property');
|
|
65
|
+
}
|
|
66
|
+
const length = Number(result.value);
|
|
67
|
+
if (length < 0)
|
|
68
|
+
invalid(path, 'Project input array length must be non-negative');
|
|
69
|
+
if (length > PROJECT_INPUT_MAX_ARRAY_LENGTH) {
|
|
70
|
+
limit(path, `Project input array exceeds ${PROJECT_INPUT_MAX_ARRAY_LENGTH.toString()} values`);
|
|
71
|
+
}
|
|
72
|
+
return length;
|
|
73
|
+
}
|
|
74
|
+
function inspectedPrototype(value, path) {
|
|
75
|
+
let prototype;
|
|
76
|
+
try {
|
|
77
|
+
prototype = getPrototypeOf(value);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
invalid(path, 'Project input contains an inaccessible prototype');
|
|
81
|
+
}
|
|
82
|
+
return prototype;
|
|
83
|
+
}
|
|
84
|
+
function inspectedDescriptor(value, key, path) {
|
|
85
|
+
try {
|
|
86
|
+
return getOwnPropertyDescriptor(value, key);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
invalid(path, 'Project input contains an inaccessible prototype property');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function isNativeConstructorFor(prototype, expectedName, path) {
|
|
93
|
+
const constructorDescriptor = inspectedDescriptor(prototype, 'constructor', path);
|
|
94
|
+
if (constructorDescriptor === undefined ||
|
|
95
|
+
!('value' in constructorDescriptor) ||
|
|
96
|
+
constructorDescriptor.enumerable !== false ||
|
|
97
|
+
typeof constructorDescriptor.value !== 'function') {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const constructor = constructorDescriptor.value;
|
|
101
|
+
const prototypeDescriptor = inspectedDescriptor(constructor, 'prototype', path);
|
|
102
|
+
if (prototypeDescriptor === undefined ||
|
|
103
|
+
!('value' in prototypeDescriptor) ||
|
|
104
|
+
prototypeDescriptor.value !== prototype) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
let source;
|
|
108
|
+
try {
|
|
109
|
+
source = functionToString(constructor);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return source.replaceAll(/\s+/gu, ' ') === `function ${expectedName}() { [native code] }`;
|
|
115
|
+
}
|
|
116
|
+
function isPlainObjectPrototype(prototype, path) {
|
|
117
|
+
return (inspectedPrototype(prototype, path) === null &&
|
|
118
|
+
isNativeConstructorFor(prototype, 'Object', path));
|
|
119
|
+
}
|
|
120
|
+
function recordPrototype(value, kind, path) {
|
|
121
|
+
const prototype = inspectedPrototype(value, path);
|
|
122
|
+
if (prototype === null)
|
|
123
|
+
return;
|
|
124
|
+
const accepted = kind === 'object'
|
|
125
|
+
? prototype === Object.prototype || isPlainObjectPrototype(prototype, path)
|
|
126
|
+
: prototype === Array.prototype ||
|
|
127
|
+
(arrayIsArray(prototype) &&
|
|
128
|
+
isNativeConstructorFor(prototype, 'Array', path) &&
|
|
129
|
+
(() => {
|
|
130
|
+
const parent = inspectedPrototype(prototype, path);
|
|
131
|
+
return parent !== null && isPlainObjectPrototype(parent, path);
|
|
132
|
+
})());
|
|
133
|
+
if (!accepted) {
|
|
134
|
+
invalid(path, 'Project input must contain only plain JSON objects and arrays');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function inspectedKeys(value, path) {
|
|
138
|
+
let keys;
|
|
139
|
+
try {
|
|
140
|
+
keys = ownKeys(value);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
invalid(path, 'Project input contains inaccessible properties');
|
|
144
|
+
}
|
|
145
|
+
return keys;
|
|
146
|
+
}
|
|
147
|
+
function byteLength(value, maximum, path, scope) {
|
|
148
|
+
// A UTF-16 code unit contributes at least one UTF-8 byte, so this cheap
|
|
149
|
+
// preflight avoids allocating a multi-megabyte encoded copy for obviously
|
|
150
|
+
// oversized attacker-controlled strings. JSON escaping can expand the
|
|
151
|
+
// canonical document further, but this admission budget is explicitly the
|
|
152
|
+
// decoded UTF-8 payload budget rather than a serialized-file-size limit.
|
|
153
|
+
if (value.length > maximum)
|
|
154
|
+
limit(path, `${scope} exceeds ${maximum.toString()} UTF-8 bytes`);
|
|
155
|
+
const bytes = textEncoder.encode(value).byteLength;
|
|
156
|
+
if (bytes > maximum)
|
|
157
|
+
limit(path, `${scope} exceeds ${maximum.toString()} UTF-8 bytes`);
|
|
158
|
+
return bytes;
|
|
159
|
+
}
|
|
160
|
+
function assign(target, key, value) {
|
|
161
|
+
defineProperty(target, key, {
|
|
162
|
+
configurable: true,
|
|
163
|
+
enumerable: true,
|
|
164
|
+
writable: true,
|
|
165
|
+
value,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Captures a bounded, ownership-isolated JSON snapshot before Ajv or semantic
|
|
170
|
+
* validation sees caller-controlled objects. Reflection on a Proxy can still
|
|
171
|
+
* invoke its traps; failures are caught, and no accessor getter or iterator is
|
|
172
|
+
* intentionally called. After this function returns, the original value is no
|
|
173
|
+
* longer observed.
|
|
174
|
+
*/
|
|
175
|
+
export function snapshotProjectInput(value) {
|
|
176
|
+
const holder = {};
|
|
177
|
+
const tasks = [{ value, target: holder, key: 'value', path: null, depth: 0 }];
|
|
178
|
+
const seen = new WeakSet();
|
|
179
|
+
let nodes = 0;
|
|
180
|
+
let totalStringBytes = 0;
|
|
181
|
+
while (tasks.length > 0) {
|
|
182
|
+
const task = tasks.pop();
|
|
183
|
+
if (task === undefined)
|
|
184
|
+
break;
|
|
185
|
+
nodes += 1;
|
|
186
|
+
if (nodes > PROJECT_INPUT_MAX_NODES) {
|
|
187
|
+
limit(task.path, `Project input exceeds ${PROJECT_INPUT_MAX_NODES.toString()} JSON values`);
|
|
188
|
+
}
|
|
189
|
+
if (task.depth > PROJECT_INPUT_MAX_DEPTH) {
|
|
190
|
+
limit(task.path, `Project input exceeds depth ${PROJECT_INPUT_MAX_DEPTH.toString()}`);
|
|
191
|
+
}
|
|
192
|
+
const current = task.value;
|
|
193
|
+
if (current === null || typeof current === 'boolean') {
|
|
194
|
+
assign(task.target, task.key, current);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (typeof current === 'number') {
|
|
198
|
+
if (!Number.isFinite(current) ||
|
|
199
|
+
Object.is(current, -0) ||
|
|
200
|
+
(Number.isInteger(current) && !Number.isSafeInteger(current))) {
|
|
201
|
+
invalid(task.path, 'Project input contains a non-canonical number');
|
|
202
|
+
}
|
|
203
|
+
assign(task.target, task.key, current);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (typeof current === 'string') {
|
|
207
|
+
totalStringBytes += byteLength(current, PROJECT_INPUT_MAX_STRING_BYTES, task.path, 'Project input string');
|
|
208
|
+
if (totalStringBytes > PROJECT_INPUT_MAX_TOTAL_STRING_BYTES) {
|
|
209
|
+
limit(task.path, `Project input strings exceed ${PROJECT_INPUT_MAX_TOTAL_STRING_BYTES.toString()} total UTF-8 bytes`);
|
|
210
|
+
}
|
|
211
|
+
assign(task.target, task.key, current);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (typeof current !== 'object') {
|
|
215
|
+
invalid(task.path, 'Project input contains a non-JSON value');
|
|
216
|
+
}
|
|
217
|
+
if (seen.has(current))
|
|
218
|
+
invalid(task.path, 'Project input contains a cycle or shared object');
|
|
219
|
+
seen.add(current);
|
|
220
|
+
if (arrayIsArray(current)) {
|
|
221
|
+
recordPrototype(current, 'array', task.path);
|
|
222
|
+
const length = arrayLength(current, task.path);
|
|
223
|
+
const keys = inspectedKeys(current, task.path);
|
|
224
|
+
if (keys.length !== length + 1) {
|
|
225
|
+
invalid(task.path, 'Project input arrays must be dense and contain no extra properties');
|
|
226
|
+
}
|
|
227
|
+
const remaining = new Set();
|
|
228
|
+
for (const key of keys) {
|
|
229
|
+
if (typeof key !== 'string') {
|
|
230
|
+
invalid(task.path, 'Project input arrays must contain only numeric index properties');
|
|
231
|
+
}
|
|
232
|
+
remaining.add(key);
|
|
233
|
+
}
|
|
234
|
+
if (!remaining.delete('length')) {
|
|
235
|
+
invalid(task.path, 'Project input arrays must be dense and contain no extra properties');
|
|
236
|
+
}
|
|
237
|
+
for (let index = 0; index < length; index += 1) {
|
|
238
|
+
if (!remaining.delete(index.toString())) {
|
|
239
|
+
invalid(task.path, 'Project input arrays must be dense and contain no extra properties');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (remaining.size !== 0) {
|
|
243
|
+
invalid(task.path, 'Project input arrays must be dense and contain no extra properties');
|
|
244
|
+
}
|
|
245
|
+
const output = [];
|
|
246
|
+
defineProperty(output, 'length', { value: length, writable: true });
|
|
247
|
+
assign(task.target, task.key, output);
|
|
248
|
+
for (let index = length - 1; index >= 0; index -= 1) {
|
|
249
|
+
const key = index.toString();
|
|
250
|
+
const path = { parent: task.path, segment: index };
|
|
251
|
+
tasks.push({
|
|
252
|
+
value: descriptor(current, key, path).value,
|
|
253
|
+
target: output,
|
|
254
|
+
key: index,
|
|
255
|
+
path,
|
|
256
|
+
depth: task.depth + 1,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
recordPrototype(current, 'object', task.path);
|
|
262
|
+
const keys = inspectedKeys(current, task.path);
|
|
263
|
+
if (keys.length > PROJECT_INPUT_MAX_OBJECT_KEYS) {
|
|
264
|
+
limit(task.path, `Project input object exceeds ${PROJECT_INPUT_MAX_OBJECT_KEYS.toString()} properties`);
|
|
265
|
+
}
|
|
266
|
+
const output = {};
|
|
267
|
+
assign(task.target, task.key, output);
|
|
268
|
+
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
|
269
|
+
const key = keys[index];
|
|
270
|
+
if (typeof key !== 'string')
|
|
271
|
+
invalid(task.path, 'Project input contains a symbol property');
|
|
272
|
+
const path = { parent: task.path, segment: key };
|
|
273
|
+
totalStringBytes += byteLength(key, PROJECT_INPUT_MAX_PROPERTY_KEY_BYTES, path, 'Project input property key');
|
|
274
|
+
if (totalStringBytes > PROJECT_INPUT_MAX_TOTAL_STRING_BYTES) {
|
|
275
|
+
limit(path, `Project input strings exceed ${PROJECT_INPUT_MAX_TOTAL_STRING_BYTES.toString()} total UTF-8 bytes`);
|
|
276
|
+
}
|
|
277
|
+
tasks.push({
|
|
278
|
+
value: descriptor(current, key, path).value,
|
|
279
|
+
target: output,
|
|
280
|
+
key,
|
|
281
|
+
path,
|
|
282
|
+
depth: task.depth + 1,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return holder.value;
|
|
287
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { JsonValue } from '@aelionsdk/core';
|
|
2
|
+
export declare class CanonicalizationError extends TypeError {
|
|
3
|
+
readonly code: 'CANONICAL_UNSUPPORTED_VALUE' | 'CANONICAL_NON_FINITE_NUMBER' | 'CANONICAL_NEGATIVE_ZERO' | 'CANONICAL_UNSAFE_INTEGER';
|
|
4
|
+
constructor(code: CanonicalizationError['code'], message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function canonicalStringify(value: JsonValue): string;
|
|
7
|
+
export declare function canonicalHash(value: JsonValue): Promise<string>;
|
|
8
|
+
export declare function canonicalClone<T extends JsonValue>(value: T): T;
|
|
9
|
+
//# sourceMappingURL=canonical.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canonical.d.ts","sourceRoot":"","sources":["../src/canonical.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,qBAAa,qBAAsB,SAAQ,SAAS;IAClD,SAAgB,IAAI,EAChB,6BAA6B,GAC7B,6BAA6B,GAC7B,yBAAyB,GACzB,0BAA0B,CAAC;gBAEZ,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM;CAKxE;AA8CD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAE3D;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAOrE;AAED,wBAAgB,cAAc,CAAC,CAAC,SAAS,SAAS,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAE/D"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export class CanonicalizationError extends TypeError {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'CanonicalizationError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function compareCodePoints(left, right) {
|
|
10
|
+
const leftPoints = Array.from(left, character => character.codePointAt(0) ?? 0);
|
|
11
|
+
const rightPoints = Array.from(right, character => character.codePointAt(0) ?? 0);
|
|
12
|
+
const length = Math.min(leftPoints.length, rightPoints.length);
|
|
13
|
+
for (let index = 0; index < length; index += 1) {
|
|
14
|
+
const difference = (leftPoints[index] ?? 0) - (rightPoints[index] ?? 0);
|
|
15
|
+
if (difference !== 0)
|
|
16
|
+
return difference;
|
|
17
|
+
}
|
|
18
|
+
return leftPoints.length - rightPoints.length;
|
|
19
|
+
}
|
|
20
|
+
function assertCanonicalNumber(value, path) {
|
|
21
|
+
if (!Number.isFinite(value)) {
|
|
22
|
+
throw new CanonicalizationError('CANONICAL_NON_FINITE_NUMBER', `Non-finite number at ${path}`);
|
|
23
|
+
}
|
|
24
|
+
if (Object.is(value, -0)) {
|
|
25
|
+
throw new CanonicalizationError('CANONICAL_NEGATIVE_ZERO', `Negative zero at ${path}`);
|
|
26
|
+
}
|
|
27
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
|
28
|
+
throw new CanonicalizationError('CANONICAL_UNSAFE_INTEGER', `Unsafe integer at ${path}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function encode(value, path) {
|
|
32
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
|
|
33
|
+
return JSON.stringify(value);
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === 'number') {
|
|
36
|
+
assertCanonicalNumber(value, path);
|
|
37
|
+
return JSON.stringify(value);
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
return `[${value.map((entry, index) => encode(entry, `${path}/${index}`)).join(',')}]`;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === 'object') {
|
|
43
|
+
const keys = Object.keys(value).sort(compareCodePoints);
|
|
44
|
+
return `{${keys
|
|
45
|
+
.map(key => `${JSON.stringify(key)}:${encode(value[key], `${path}/${key}`)}`)
|
|
46
|
+
.join(',')}}`;
|
|
47
|
+
}
|
|
48
|
+
throw new CanonicalizationError('CANONICAL_UNSUPPORTED_VALUE', `Unsupported value at ${path}`);
|
|
49
|
+
}
|
|
50
|
+
export function canonicalStringify(value) {
|
|
51
|
+
return encode(value, '$');
|
|
52
|
+
}
|
|
53
|
+
export async function canonicalHash(value) {
|
|
54
|
+
const bytes = new TextEncoder().encode(canonicalStringify(value));
|
|
55
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
56
|
+
const hex = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
|
|
57
|
+
return `sha256:${hex}`;
|
|
58
|
+
}
|
|
59
|
+
export function canonicalClone(value) {
|
|
60
|
+
return JSON.parse(canonicalStringify(value));
|
|
61
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC"}
|
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { JsonObject, JsonValue, Rational } from '@aelionsdk/core';
|
|
2
|
+
export type EntityId = string;
|
|
3
|
+
export type CollectionName = 'assets' | 'sequences' | 'tracks' | 'items' | 'materialInstances' | 'transitions' | 'markers' | 'linkGroups';
|
|
4
|
+
export interface TimeRange {
|
|
5
|
+
readonly startUs: number;
|
|
6
|
+
readonly durationUs: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ProjectSettings extends JsonObject {
|
|
9
|
+
defaultSequenceId: EntityId;
|
|
10
|
+
defaultStillDurationUs: number;
|
|
11
|
+
missingAssetPolicy: 'placeholder' | 'error';
|
|
12
|
+
missingMaterialPolicy: 'placeholder' | 'error';
|
|
13
|
+
missingPluginPolicy: 'placeholder' | 'error';
|
|
14
|
+
locale?: string;
|
|
15
|
+
timezone?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ProjectEntity extends JsonObject {
|
|
18
|
+
id: EntityId;
|
|
19
|
+
}
|
|
20
|
+
export interface SequenceEntity extends ProjectEntity {
|
|
21
|
+
trackIds: EntityId[];
|
|
22
|
+
transitionIds: EntityId[];
|
|
23
|
+
materialInstanceIds: EntityId[];
|
|
24
|
+
markerIds: EntityId[];
|
|
25
|
+
}
|
|
26
|
+
export interface TrackAudioProperties extends JsonObject {
|
|
27
|
+
gainDb: JsonValue;
|
|
28
|
+
pan: JsonValue;
|
|
29
|
+
muted: boolean;
|
|
30
|
+
/** Missing in older Project v1 documents means false. */
|
|
31
|
+
solo?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface TrackEntity extends ProjectEntity {
|
|
34
|
+
sequenceId: EntityId;
|
|
35
|
+
kind: 'visual' | 'audio' | 'caption';
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
locked: boolean;
|
|
38
|
+
itemIds: EntityId[];
|
|
39
|
+
materialInstanceIds: EntityId[];
|
|
40
|
+
audio?: TrackAudioProperties;
|
|
41
|
+
}
|
|
42
|
+
export interface ItemEntity extends ProjectEntity {
|
|
43
|
+
trackId: EntityId;
|
|
44
|
+
type: string;
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
range: TimeRange & JsonObject;
|
|
47
|
+
materialInstanceIds: EntityId[];
|
|
48
|
+
markerIds?: EntityId[];
|
|
49
|
+
linkGroupId?: EntityId;
|
|
50
|
+
}
|
|
51
|
+
/** Project v1 audio Items can use varispeed or deterministic pitch-preserving stretch. */
|
|
52
|
+
export interface ItemAudioProperties extends JsonObject {
|
|
53
|
+
gainDb: JsonValue;
|
|
54
|
+
pan: JsonValue;
|
|
55
|
+
fadeInUs?: number;
|
|
56
|
+
fadeOutUs?: number;
|
|
57
|
+
pitchPolicy?: 'varispeed' | 'preserve';
|
|
58
|
+
channelMap?: JsonValue[];
|
|
59
|
+
}
|
|
60
|
+
export interface TransitionEntity extends ProjectEntity {
|
|
61
|
+
sequenceId: EntityId;
|
|
62
|
+
trackId: EntityId;
|
|
63
|
+
fromItemId: EntityId;
|
|
64
|
+
toItemId: EntityId;
|
|
65
|
+
range: TimeRange & JsonObject;
|
|
66
|
+
materialInstanceId: EntityId;
|
|
67
|
+
}
|
|
68
|
+
export interface MaterialInstanceEntity extends ProjectEntity {
|
|
69
|
+
definition: JsonObject;
|
|
70
|
+
enabled: boolean;
|
|
71
|
+
parameters: JsonObject;
|
|
72
|
+
}
|
|
73
|
+
export interface LinkGroupEntity extends ProjectEntity {
|
|
74
|
+
kind: 'av-sync' | 'edit-group';
|
|
75
|
+
itemIds: EntityId[];
|
|
76
|
+
syncOffsetsUs?: Record<EntityId, number>;
|
|
77
|
+
}
|
|
78
|
+
export interface MarkerEntity extends ProjectEntity {
|
|
79
|
+
owner: {
|
|
80
|
+
type: 'sequence' | 'item';
|
|
81
|
+
id: EntityId;
|
|
82
|
+
} & JsonObject;
|
|
83
|
+
timeUs: number;
|
|
84
|
+
durationUs: number;
|
|
85
|
+
label?: string;
|
|
86
|
+
color?: string;
|
|
87
|
+
payload?: JsonValue;
|
|
88
|
+
}
|
|
89
|
+
export interface AelionProject extends JsonObject {
|
|
90
|
+
$schema: string;
|
|
91
|
+
schemaVersion: string;
|
|
92
|
+
projectId: EntityId;
|
|
93
|
+
metadata: JsonObject;
|
|
94
|
+
settings: ProjectSettings;
|
|
95
|
+
assets: Record<EntityId, ProjectEntity>;
|
|
96
|
+
sequences: Record<EntityId, SequenceEntity>;
|
|
97
|
+
tracks: Record<EntityId, TrackEntity>;
|
|
98
|
+
items: Record<EntityId, ItemEntity>;
|
|
99
|
+
materialInstances: Record<EntityId, MaterialInstanceEntity>;
|
|
100
|
+
transitions: Record<EntityId, TransitionEntity>;
|
|
101
|
+
markers: Record<EntityId, MarkerEntity>;
|
|
102
|
+
linkGroups: Record<EntityId, LinkGroupEntity>;
|
|
103
|
+
extensions: Record<string, JsonValue>;
|
|
104
|
+
}
|
|
105
|
+
export interface FrameFormat {
|
|
106
|
+
readonly width: number;
|
|
107
|
+
readonly height: number;
|
|
108
|
+
readonly pixelAspectRatio: Rational;
|
|
109
|
+
readonly frameRate: Rational;
|
|
110
|
+
readonly sampleRate: 44_100 | 48_000 | 96_000;
|
|
111
|
+
readonly channelLayout: 'mono' | 'stereo' | '5.1';
|
|
112
|
+
}
|
|
113
|
+
export declare const COLLECTION_NAMES: readonly CollectionName[];
|
|
114
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JsonObject, Result } from '@aelionsdk/core';
|
|
2
|
+
import { type AelionProject } from './types.js';
|
|
3
|
+
export interface ProjectValidatorOptions {
|
|
4
|
+
readonly projectSchema: JsonObject;
|
|
5
|
+
readonly materialInstanceSchema: JsonObject;
|
|
6
|
+
}
|
|
7
|
+
export interface ProjectValidationSuccess {
|
|
8
|
+
readonly project: AelionProject;
|
|
9
|
+
}
|
|
10
|
+
export declare class ProjectValidator {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(options: ProjectValidatorOptions);
|
|
13
|
+
validate(value: unknown): Result<ProjectValidationSuccess>;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAc,UAAU,EAAa,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAGjF,OAAO,EAAoB,KAAK,aAAa,EAAuB,MAAM,YAAY,CAAC;AAmCvF,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,aAAa,EAAE,UAAU,CAAC;IACnC,QAAQ,CAAC,sBAAsB,EAAE,UAAU,CAAC;CAC7C;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;CACjC;AAsrBD,qBAAa,gBAAgB;;gBAGR,OAAO,EAAE,uBAAuB;IAY5C,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC,wBAAwB,CAAC;CA+ClE"}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import Ajv2020, {} from 'ajv/dist/2020.js';
|
|
2
|
+
import addFormats from 'ajv-formats';
|
|
3
|
+
import { err, ok } from '@aelionsdk/core';
|
|
4
|
+
import { COLLECTION_NAMES } from './types.js';
|
|
5
|
+
import { ProjectInputAdmissionError, snapshotProjectInput } from './admission.js';
|
|
6
|
+
const MAX_PROJECT_DIAGNOSTICS = 64;
|
|
7
|
+
class BoundedDiagnosticCollector {
|
|
8
|
+
#diagnostics = [];
|
|
9
|
+
#truncated = false;
|
|
10
|
+
get diagnostics() {
|
|
11
|
+
return this.#diagnostics;
|
|
12
|
+
}
|
|
13
|
+
push(...diagnostics) {
|
|
14
|
+
for (const diagnostic of diagnostics) {
|
|
15
|
+
if (this.#diagnostics.length < MAX_PROJECT_DIAGNOSTICS - 1) {
|
|
16
|
+
this.#diagnostics.push(diagnostic);
|
|
17
|
+
}
|
|
18
|
+
else if (!this.#truncated) {
|
|
19
|
+
this.#diagnostics.push({
|
|
20
|
+
code: 'PROJECT_DIAGNOSTICS_TRUNCATED',
|
|
21
|
+
severity: 'error',
|
|
22
|
+
message: `Project validation produced more than ${MAX_PROJECT_DIAGNOSTICS.toString()} diagnostics`,
|
|
23
|
+
recoverable: false,
|
|
24
|
+
});
|
|
25
|
+
this.#truncated = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return this.#diagnostics.length;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function schemaDiagnostic(error) {
|
|
32
|
+
const path = error.instancePath
|
|
33
|
+
.split('/')
|
|
34
|
+
.slice(1)
|
|
35
|
+
.map(segment => segment.replaceAll('~1', '/').replaceAll('~0', '~'));
|
|
36
|
+
return {
|
|
37
|
+
code: 'PROJECT_SCHEMA_INVALID',
|
|
38
|
+
severity: 'error',
|
|
39
|
+
message: error.message ?? 'Project does not conform to its JSON Schema',
|
|
40
|
+
path,
|
|
41
|
+
recoverable: false,
|
|
42
|
+
details: {
|
|
43
|
+
keyword: error.keyword,
|
|
44
|
+
schemaPath: error.schemaPath,
|
|
45
|
+
params: error.params,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function semanticDiagnostic(code, message, path, entityId) {
|
|
50
|
+
return {
|
|
51
|
+
code,
|
|
52
|
+
severity: 'error',
|
|
53
|
+
message,
|
|
54
|
+
path,
|
|
55
|
+
...(entityId === undefined ? {} : { entityId }),
|
|
56
|
+
recoverable: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function validateEntityMap(project, collection, diagnostics) {
|
|
60
|
+
const entities = project[collection];
|
|
61
|
+
for (const [key, entity] of Object.entries(entities)) {
|
|
62
|
+
if (key !== entity.id) {
|
|
63
|
+
diagnostics.push(semanticDiagnostic('PROJECT_ENTITY_KEY_MISMATCH', `${collection} key ${key} does not match entity id ${entity.id}`, [collection, key, 'id'], entity.id));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function requireReference(values, id, expectedCollection, path, diagnostics) {
|
|
68
|
+
if (Object.hasOwn(values, id))
|
|
69
|
+
return true;
|
|
70
|
+
diagnostics.push(semanticDiagnostic('PROJECT_REFERENCE_MISSING', `Reference ${id} does not exist in ${expectedCollection}`, path, id));
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
function validateUniqueList(values, path, diagnostics) {
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
values.forEach((value, index) => {
|
|
76
|
+
if (seen.has(value)) {
|
|
77
|
+
diagnostics.push(semanticDiagnostic('PROJECT_DUPLICATE_REFERENCE', `Duplicate reference ${value}`, [...path, index], value));
|
|
78
|
+
}
|
|
79
|
+
seen.add(value);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function validateReferences(project, diagnostics) {
|
|
83
|
+
requireReference(project.sequences, project.settings.defaultSequenceId, 'sequences', ['settings', 'defaultSequenceId'], diagnostics);
|
|
84
|
+
for (const sequence of Object.values(project.sequences)) {
|
|
85
|
+
validateUniqueList(sequence.trackIds, ['sequences', sequence.id, 'trackIds'], diagnostics);
|
|
86
|
+
validateUniqueList(sequence.transitionIds, ['sequences', sequence.id, 'transitionIds'], diagnostics);
|
|
87
|
+
validateUniqueList(sequence.materialInstanceIds, ['sequences', sequence.id, 'materialInstanceIds'], diagnostics);
|
|
88
|
+
validateUniqueList(sequence.markerIds, ['sequences', sequence.id, 'markerIds'], diagnostics);
|
|
89
|
+
sequence.trackIds.forEach((id, index) => {
|
|
90
|
+
if (requireReference(project.tracks, id, 'tracks', ['sequences', sequence.id, 'trackIds', index], diagnostics) &&
|
|
91
|
+
project.tracks[id]?.sequenceId !== sequence.id) {
|
|
92
|
+
diagnostics.push(semanticDiagnostic('PROJECT_HOST_MISMATCH', `Track ${id} belongs to another sequence`, ['sequences', sequence.id, 'trackIds', index], id));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
sequence.transitionIds.forEach((id, index) => requireReference(project.transitions, id, 'transitions', ['sequences', sequence.id, 'transitionIds', index], diagnostics));
|
|
96
|
+
sequence.materialInstanceIds.forEach((id, index) => requireReference(project.materialInstances, id, 'materialInstances', ['sequences', sequence.id, 'materialInstanceIds', index], diagnostics));
|
|
97
|
+
sequence.markerIds.forEach((id, index) => requireReference(project.markers, id, 'markers', ['sequences', sequence.id, 'markerIds', index], diagnostics));
|
|
98
|
+
}
|
|
99
|
+
for (const track of Object.values(project.tracks)) {
|
|
100
|
+
requireReference(project.sequences, track.sequenceId, 'sequences', ['tracks', track.id, 'sequenceId'], diagnostics);
|
|
101
|
+
validateUniqueList(track.itemIds, ['tracks', track.id, 'itemIds'], diagnostics);
|
|
102
|
+
validateUniqueList(track.materialInstanceIds, ['tracks', track.id, 'materialInstanceIds'], diagnostics);
|
|
103
|
+
track.itemIds.forEach((id, index) => {
|
|
104
|
+
if (requireReference(project.items, id, 'items', ['tracks', track.id, 'itemIds', index], diagnostics) &&
|
|
105
|
+
project.items[id]?.trackId !== track.id) {
|
|
106
|
+
diagnostics.push(semanticDiagnostic('PROJECT_HOST_MISMATCH', `Item ${id} belongs to another track`, ['tracks', track.id, 'itemIds', index], id));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
track.materialInstanceIds.forEach((id, index) => requireReference(project.materialInstances, id, 'materialInstances', ['tracks', track.id, 'materialInstanceIds', index], diagnostics));
|
|
110
|
+
}
|
|
111
|
+
for (const item of Object.values(project.items)) {
|
|
112
|
+
requireReference(project.tracks, item.trackId, 'tracks', ['items', item.id, 'trackId'], diagnostics);
|
|
113
|
+
validateUniqueList(item.materialInstanceIds, ['items', item.id, 'materialInstanceIds'], diagnostics);
|
|
114
|
+
item.materialInstanceIds.forEach((id, index) => requireReference(project.materialInstances, id, 'materialInstances', ['items', item.id, 'materialInstanceIds', index], diagnostics));
|
|
115
|
+
if (item.type === 'adjustment' && item.materialInstanceIds.length === 0) {
|
|
116
|
+
diagnostics.push(semanticDiagnostic('PROJECT_ADJUSTMENT_EMPTY', `Adjustment Item ${item.id} must own at least one Material`, ['items', item.id, 'materialInstanceIds'], item.id));
|
|
117
|
+
}
|
|
118
|
+
if (item.type === 'nested-sequence') {
|
|
119
|
+
const source = item.source;
|
|
120
|
+
if (typeof source?.sequenceId === 'string') {
|
|
121
|
+
requireReference(project.sequences, source.sequenceId, 'sequences', ['items', item.id, 'source', 'sequenceId'], diagnostics);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (item.type === 'image') {
|
|
125
|
+
const source = item.source;
|
|
126
|
+
if (source?.stream?.type !== 'video') {
|
|
127
|
+
diagnostics.push(semanticDiagnostic('PROJECT_IMAGE_STREAM_INVALID', `Image Item ${item.id} must use the visual stream adapter`, ['items', item.id, 'source', 'stream', 'type'], item.id));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const visual = item.visual;
|
|
131
|
+
if (visual !== null && typeof visual === 'object' && !Array.isArray(visual)) {
|
|
132
|
+
const mask = Reflect.get(visual, 'mask');
|
|
133
|
+
if (mask !== null && typeof mask === 'object' && !Array.isArray(mask)) {
|
|
134
|
+
const sourceItemId = Reflect.get(mask, 'sourceItemId');
|
|
135
|
+
if (typeof sourceItemId === 'string') {
|
|
136
|
+
const exists = requireReference(project.items, sourceItemId, 'items', ['items', item.id, 'visual', 'mask', 'sourceItemId'], diagnostics);
|
|
137
|
+
const sourceTrackId = project.items[sourceItemId]?.trackId;
|
|
138
|
+
const ownerSequenceId = project.tracks[item.trackId]?.sequenceId;
|
|
139
|
+
const sourceSequenceId = sourceTrackId === undefined ? undefined : project.tracks[sourceTrackId]?.sequenceId;
|
|
140
|
+
if (exists && (sourceItemId === item.id || sourceSequenceId !== ownerSequenceId)) {
|
|
141
|
+
diagnostics.push(semanticDiagnostic('PROJECT_MASK_SOURCE_INVALID', `Mask source ${sourceItemId} must be another Item in the same Sequence`, ['items', item.id, 'visual', 'mask', 'sourceItemId'], item.id));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (item.linkGroupId !== undefined) {
|
|
147
|
+
if (requireReference(project.linkGroups, item.linkGroupId, 'linkGroups', ['items', item.id, 'linkGroupId'], diagnostics) &&
|
|
148
|
+
!project.linkGroups[item.linkGroupId]?.itemIds.includes(item.id)) {
|
|
149
|
+
diagnostics.push(semanticDiagnostic('PROJECT_LINK_GROUP_BACKREF_MISSING', `LinkGroup ${item.linkGroupId} does not contain Item ${item.id}`, ['items', item.id, 'linkGroupId'], item.id));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const group of Object.values(project.linkGroups)) {
|
|
154
|
+
validateUniqueList(group.itemIds, ['linkGroups', group.id, 'itemIds'], diagnostics);
|
|
155
|
+
if (group.itemIds.length < 2) {
|
|
156
|
+
diagnostics.push(semanticDiagnostic('PROJECT_LINK_GROUP_TOO_SMALL', `LinkGroup ${group.id} must contain at least two Items`, ['linkGroups', group.id, 'itemIds'], group.id));
|
|
157
|
+
}
|
|
158
|
+
let sequenceId;
|
|
159
|
+
group.itemIds.forEach((id, index) => {
|
|
160
|
+
if (!requireReference(project.items, id, 'items', ['linkGroups', group.id, 'itemIds', index], diagnostics)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const item = project.items[id];
|
|
164
|
+
if (item?.linkGroupId !== group.id) {
|
|
165
|
+
diagnostics.push(semanticDiagnostic('PROJECT_LINK_GROUP_BACKREF_MISSING', `Item ${id} does not reference LinkGroup ${group.id}`, ['linkGroups', group.id, 'itemIds', index], id));
|
|
166
|
+
}
|
|
167
|
+
const itemSequenceId = item === undefined ? undefined : project.tracks[item.trackId]?.sequenceId;
|
|
168
|
+
if (sequenceId === undefined)
|
|
169
|
+
sequenceId = itemSequenceId;
|
|
170
|
+
else if (itemSequenceId !== undefined && itemSequenceId !== sequenceId) {
|
|
171
|
+
diagnostics.push(semanticDiagnostic('PROJECT_LINK_GROUP_SEQUENCE_MISMATCH', `LinkGroup ${group.id} cannot span Sequences`, ['linkGroups', group.id, 'itemIds', index], group.id));
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
for (const id of Object.keys(group.syncOffsetsUs ?? {})) {
|
|
175
|
+
if (!group.itemIds.includes(id)) {
|
|
176
|
+
diagnostics.push(semanticDiagnostic('PROJECT_LINK_GROUP_OFFSET_ORPHAN', `LinkGroup ${group.id} has an offset for non-member ${id}`, ['linkGroups', group.id, 'syncOffsetsUs', id], group.id));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const transition of Object.values(project.transitions)) {
|
|
181
|
+
requireReference(project.sequences, transition.sequenceId, 'sequences', ['transitions', transition.id, 'sequenceId'], diagnostics);
|
|
182
|
+
requireReference(project.tracks, transition.trackId, 'tracks', ['transitions', transition.id, 'trackId'], diagnostics);
|
|
183
|
+
requireReference(project.items, transition.fromItemId, 'items', ['transitions', transition.id, 'fromItemId'], diagnostics);
|
|
184
|
+
requireReference(project.items, transition.toItemId, 'items', ['transitions', transition.id, 'toItemId'], diagnostics);
|
|
185
|
+
requireReference(project.materialInstances, transition.materialInstanceId, 'materialInstances', ['transitions', transition.id, 'materialInstanceId'], diagnostics);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function validateNestedSequenceCycles(project, diagnostics) {
|
|
189
|
+
const edges = new Map();
|
|
190
|
+
for (const item of Object.values(project.items)) {
|
|
191
|
+
if (item.type !== 'nested-sequence')
|
|
192
|
+
continue;
|
|
193
|
+
const owner = project.tracks[item.trackId]?.sequenceId;
|
|
194
|
+
const source = item.source;
|
|
195
|
+
if (owner === undefined || typeof source?.sequenceId !== 'string')
|
|
196
|
+
continue;
|
|
197
|
+
const values = edges.get(owner);
|
|
198
|
+
const edge = { itemId: item.id, target: source.sequenceId };
|
|
199
|
+
if (values === undefined)
|
|
200
|
+
edges.set(owner, [edge]);
|
|
201
|
+
else
|
|
202
|
+
values.push(edge);
|
|
203
|
+
}
|
|
204
|
+
const visiting = new Set();
|
|
205
|
+
const visited = new Set();
|
|
206
|
+
const stack = [];
|
|
207
|
+
const visit = (sequenceId) => {
|
|
208
|
+
if (visited.has(sequenceId))
|
|
209
|
+
return;
|
|
210
|
+
visiting.add(sequenceId);
|
|
211
|
+
stack.push(sequenceId);
|
|
212
|
+
for (const edge of edges.get(sequenceId) ?? []) {
|
|
213
|
+
if (visiting.has(edge.target)) {
|
|
214
|
+
const cycleStart = stack.indexOf(edge.target);
|
|
215
|
+
diagnostics.push(semanticDiagnostic('PROJECT_NESTED_SEQUENCE_CYCLE', `Nested Sequence cycle: ${[...stack.slice(cycleStart), edge.target].join(' -> ')}`, ['items', edge.itemId, 'source', 'sequenceId'], edge.itemId));
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
visit(edge.target);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
stack.pop();
|
|
222
|
+
visiting.delete(sequenceId);
|
|
223
|
+
visited.add(sequenceId);
|
|
224
|
+
};
|
|
225
|
+
for (const sequenceId of Object.keys(project.sequences))
|
|
226
|
+
visit(sequenceId);
|
|
227
|
+
}
|
|
228
|
+
function validateMaterialOwnership(project, diagnostics) {
|
|
229
|
+
const owners = new Map();
|
|
230
|
+
const claim = (instanceId, owner, path) => {
|
|
231
|
+
if (!Object.hasOwn(project.materialInstances, instanceId))
|
|
232
|
+
return;
|
|
233
|
+
const existing = owners.get(instanceId);
|
|
234
|
+
if (existing !== undefined) {
|
|
235
|
+
diagnostics.push(semanticDiagnostic('PROJECT_MATERIAL_MULTIPLE_OWNERS', `Material instance ${instanceId} is owned by both ${existing} and ${owner}`, path, instanceId));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
owners.set(instanceId, owner);
|
|
239
|
+
};
|
|
240
|
+
for (const sequence of Object.values(project.sequences)) {
|
|
241
|
+
sequence.materialInstanceIds.forEach((id, index) => claim(id, `sequence:${sequence.id}`, [
|
|
242
|
+
'sequences',
|
|
243
|
+
sequence.id,
|
|
244
|
+
'materialInstanceIds',
|
|
245
|
+
index,
|
|
246
|
+
]));
|
|
247
|
+
}
|
|
248
|
+
for (const track of Object.values(project.tracks)) {
|
|
249
|
+
track.materialInstanceIds.forEach((id, index) => claim(id, `track:${track.id}`, ['tracks', track.id, 'materialInstanceIds', index]));
|
|
250
|
+
}
|
|
251
|
+
for (const item of Object.values(project.items)) {
|
|
252
|
+
item.materialInstanceIds.forEach((id, index) => claim(id, `item:${item.id}`, ['items', item.id, 'materialInstanceIds', index]));
|
|
253
|
+
}
|
|
254
|
+
for (const transition of Object.values(project.transitions)) {
|
|
255
|
+
claim(transition.materialInstanceId, `transition:${transition.id}`, [
|
|
256
|
+
'transitions',
|
|
257
|
+
transition.id,
|
|
258
|
+
'materialInstanceId',
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
for (const id of Object.keys(project.materialInstances)) {
|
|
262
|
+
if (!owners.has(id)) {
|
|
263
|
+
diagnostics.push(semanticDiagnostic('PROJECT_MATERIAL_ORPHAN', `Material instance ${id} has no owner`, ['materialInstances', id], id));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function validateVisualTransitionOverlap(project, diagnostics) {
|
|
268
|
+
const bySequence = new Map();
|
|
269
|
+
for (const transition of Object.values(project.transitions)) {
|
|
270
|
+
if (transition.kind !== 'visual')
|
|
271
|
+
continue;
|
|
272
|
+
const startUs = BigInt(transition.range.startUs);
|
|
273
|
+
const interval = {
|
|
274
|
+
id: transition.id,
|
|
275
|
+
startUs,
|
|
276
|
+
endUs: startUs + BigInt(transition.range.durationUs),
|
|
277
|
+
};
|
|
278
|
+
const transitions = bySequence.get(transition.sequenceId);
|
|
279
|
+
if (transitions === undefined) {
|
|
280
|
+
bySequence.set(transition.sequenceId, [interval]);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
transitions.push(interval);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const transitions of bySequence.values()) {
|
|
287
|
+
transitions.sort((left, right) => {
|
|
288
|
+
if (left.startUs !== right.startUs)
|
|
289
|
+
return left.startUs < right.startUs ? -1 : 1;
|
|
290
|
+
if (left.endUs !== right.endUs)
|
|
291
|
+
return left.endUs < right.endUs ? -1 : 1;
|
|
292
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
293
|
+
});
|
|
294
|
+
let active = transitions[0];
|
|
295
|
+
for (const transition of transitions.slice(1)) {
|
|
296
|
+
if (active !== undefined && transition.startUs < active.endUs) {
|
|
297
|
+
diagnostics.push(semanticDiagnostic('PROJECT_VISUAL_TRANSITION_OVERLAP', `Visual transition ${transition.id} overlaps ${active.id} in the same sequence`, ['transitions', transition.id, 'range'], transition.id));
|
|
298
|
+
}
|
|
299
|
+
if (active === undefined || transition.startUs >= active.endUs) {
|
|
300
|
+
active = transition;
|
|
301
|
+
}
|
|
302
|
+
else if (transition.endUs > active.endUs) {
|
|
303
|
+
active = transition;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function validateTimeMappingSemantics(project, diagnostics) {
|
|
309
|
+
for (const item of Object.values(project.items)) {
|
|
310
|
+
if (item.type !== 'video' && item.type !== 'audio' && item.type !== 'nested-sequence')
|
|
311
|
+
continue;
|
|
312
|
+
const source = item.source;
|
|
313
|
+
const mapping = source.timeMapping;
|
|
314
|
+
if (mapping?.type !== 'curve' || !Array.isArray(mapping.points))
|
|
315
|
+
continue;
|
|
316
|
+
const points = mapping.points;
|
|
317
|
+
const pointTime = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
318
|
+
? Reflect.get(value, 'itemTimeUs')
|
|
319
|
+
: undefined;
|
|
320
|
+
const firstTime = pointTime(points[0]);
|
|
321
|
+
const lastTime = pointTime(points.at(-1));
|
|
322
|
+
if (firstTime !== 0 || lastTime !== item.range.durationUs) {
|
|
323
|
+
diagnostics.push(semanticDiagnostic('PROJECT_TIME_MAPPING_ENDPOINT_INVALID', `Curve TimeMap for ${item.id} must start at 0 and end at the Item duration`, ['items', item.id, 'source', 'timeMapping', 'points'], item.id));
|
|
324
|
+
}
|
|
325
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
326
|
+
const previous = pointTime(points[index - 1]);
|
|
327
|
+
const current = pointTime(points[index]);
|
|
328
|
+
if (typeof previous === 'number' && typeof current === 'number' && current <= previous) {
|
|
329
|
+
diagnostics.push(semanticDiagnostic('PROJECT_TIME_MAPPING_ORDER_INVALID', `Curve TimeMap Item times must strictly increase for ${item.id}`, ['items', item.id, 'source', 'timeMapping', 'points', index, 'itemTimeUs'], item.id));
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function validateAudioSemantics(project, diagnostics) {
|
|
336
|
+
for (const item of Object.values(project.items)) {
|
|
337
|
+
if (item.type !== 'audio')
|
|
338
|
+
continue;
|
|
339
|
+
const typedItem = item;
|
|
340
|
+
const audio = typedItem.audio;
|
|
341
|
+
for (const property of ['fadeInUs', 'fadeOutUs']) {
|
|
342
|
+
const durationUs = audio?.[property];
|
|
343
|
+
if (typeof durationUs !== 'number' || durationUs <= item.range.durationUs)
|
|
344
|
+
continue;
|
|
345
|
+
diagnostics.push(semanticDiagnostic('PROJECT_AUDIO_FADE_OUT_OF_RANGE', `${property} for audio item ${item.id} cannot exceed the Item duration`, ['items', item.id, 'audio', property], item.id));
|
|
346
|
+
}
|
|
347
|
+
if (audio?.pitchPolicy === 'preserve' && typedItem.source?.timeMapping?.type !== 'linear') {
|
|
348
|
+
diagnostics.push(semanticDiagnostic('PROJECT_AUDIO_PITCH_POLICY_UNSUPPORTED', `Pitch-preserving stretch for audio item ${item.id} requires a linear TimeMap`, ['items', item.id, 'audio', 'pitchPolicy'], item.id));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function validateColorSemantics(project, diagnostics) {
|
|
353
|
+
for (const sequence of Object.values(project.sequences)) {
|
|
354
|
+
const format = sequence.format;
|
|
355
|
+
if (format.transferFunction !== 'pq' && format.transferFunction !== 'hlg')
|
|
356
|
+
continue;
|
|
357
|
+
if (format.workingColorSpace !== 'rec2020-linear' || format.bitDepth !== 10) {
|
|
358
|
+
diagnostics.push(semanticDiagnostic('PROJECT_HDR_FORMAT_INVALID', `HDR Sequence ${sequence.id} requires rec2020-linear working space and 10-bit output`, ['sequences', sequence.id, 'format'], sequence.id));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
export class ProjectValidator {
|
|
363
|
+
#schemaValidator;
|
|
364
|
+
constructor(options) {
|
|
365
|
+
const ajv = new Ajv2020({
|
|
366
|
+
allErrors: false,
|
|
367
|
+
allowUnionTypes: true,
|
|
368
|
+
strict: true,
|
|
369
|
+
validateFormats: true,
|
|
370
|
+
});
|
|
371
|
+
addFormats(ajv);
|
|
372
|
+
ajv.addSchema(options.materialInstanceSchema);
|
|
373
|
+
this.#schemaValidator = ajv.compile(options.projectSchema);
|
|
374
|
+
}
|
|
375
|
+
validate(value) {
|
|
376
|
+
let admitted;
|
|
377
|
+
try {
|
|
378
|
+
admitted = snapshotProjectInput(value);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
const admission = error instanceof ProjectInputAdmissionError
|
|
382
|
+
? error
|
|
383
|
+
: new ProjectInputAdmissionError('PROJECT_INPUT_INVALID', 'Project input could not be safely inspected', []);
|
|
384
|
+
return err({
|
|
385
|
+
code: admission.code,
|
|
386
|
+
severity: 'error',
|
|
387
|
+
message: admission.message,
|
|
388
|
+
path: admission.path,
|
|
389
|
+
recoverable: false,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (!this.#schemaValidator(admitted)) {
|
|
393
|
+
const first = this.#schemaValidator.errors?.[0];
|
|
394
|
+
return err(first === undefined
|
|
395
|
+
? {
|
|
396
|
+
code: 'PROJECT_SCHEMA_INVALID',
|
|
397
|
+
severity: 'error',
|
|
398
|
+
message: 'Project does not conform to its JSON Schema',
|
|
399
|
+
recoverable: false,
|
|
400
|
+
}
|
|
401
|
+
: schemaDiagnostic(first));
|
|
402
|
+
}
|
|
403
|
+
const project = admitted;
|
|
404
|
+
const diagnostics = new BoundedDiagnosticCollector();
|
|
405
|
+
COLLECTION_NAMES.forEach(collection => validateEntityMap(project, collection, diagnostics));
|
|
406
|
+
validateReferences(project, diagnostics);
|
|
407
|
+
validateMaterialOwnership(project, diagnostics);
|
|
408
|
+
validateNestedSequenceCycles(project, diagnostics);
|
|
409
|
+
validateVisualTransitionOverlap(project, diagnostics);
|
|
410
|
+
validateTimeMappingSemantics(project, diagnostics);
|
|
411
|
+
validateAudioSemantics(project, diagnostics);
|
|
412
|
+
validateColorSemantics(project, diagnostics);
|
|
413
|
+
return diagnostics.diagnostics.length === 0 ? ok({ project }) : err(...diagnostics.diagnostics);
|
|
414
|
+
}
|
|
415
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aelionsdk/project-schema",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Project v1 types, validation and canonical serialization for AelionSDK",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/FoyonaCZY/AelionSDK.git",
|
|
9
|
+
"directory": "packages/project-schema"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"aelion",
|
|
13
|
+
"timeline",
|
|
14
|
+
"video",
|
|
15
|
+
"schema"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"!dist/.tsbuildinfo"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20.19"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"provenance": true
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@aelionsdk/core": "0.1.0-beta.1",
|
|
38
|
+
"ajv": "8.17.1",
|
|
39
|
+
"ajv-formats": "3.0.1"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -b",
|
|
43
|
+
"typecheck": "tsc -b --pretty false"
|
|
44
|
+
}
|
|
45
|
+
}
|