@alpha2-sh/editor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +108 -2
- package/dist/browser/bridge.d.ts +44 -0
- package/dist/browser/bridge.js +2176 -0
- package/dist/browser/extensions-worker-entry.d.ts +1 -0
- package/dist/browser/extensions-worker-entry.js +3 -0
- package/dist/browser/extensions-worker.d.ts +46 -0
- package/dist/browser/extensions-worker.js +149 -0
- package/dist/browser/hash-worker-entry.d.ts +1 -0
- package/dist/browser/hash-worker-entry.js +3 -0
- package/dist/browser/hash-worker.d.ts +24 -0
- package/dist/browser/hash-worker.js +87 -0
- package/dist/browser/index.d.ts +11 -0
- package/dist/browser/index.js +9 -0
- package/dist/browser/jobs.d.ts +82 -0
- package/dist/browser/jobs.js +291 -0
- package/dist/browser/resources.d.ts +40 -0
- package/dist/browser/resources.js +83 -0
- package/dist/browser/runtime/assets.d.ts +233 -0
- package/dist/browser/runtime/assets.js +633 -0
- package/dist/browser/runtime/audio.d.ts +60 -0
- package/dist/browser/runtime/audio.js +549 -0
- package/dist/browser/runtime/budget.d.ts +11 -0
- package/dist/browser/runtime/budget.js +26 -0
- package/dist/browser/runtime/caption-fonts.d.ts +34 -0
- package/dist/browser/runtime/caption-fonts.js +69 -0
- package/dist/browser/runtime/color.d.ts +7 -0
- package/dist/browser/runtime/color.js +12 -0
- package/dist/browser/runtime/engine.d.ts +91 -0
- package/dist/browser/runtime/engine.js +352 -0
- package/dist/browser/runtime/export-audio.d.ts +23 -0
- package/dist/browser/runtime/export-audio.js +75 -0
- package/dist/browser/runtime/exporter.d.ts +140 -0
- package/dist/browser/runtime/exporter.js +751 -0
- package/dist/browser/runtime/extension-import.d.ts +196 -0
- package/dist/browser/runtime/extension-import.js +521 -0
- package/dist/browser/runtime/fingerprint.d.ts +57 -0
- package/dist/browser/runtime/fingerprint.js +157 -0
- package/dist/browser/runtime/generators.d.ts +188 -0
- package/dist/browser/runtime/generators.js +173 -0
- package/dist/browser/runtime/locks.d.ts +32 -0
- package/dist/browser/runtime/locks.js +86 -0
- package/dist/browser/runtime/output.d.ts +87 -0
- package/dist/browser/runtime/output.js +230 -0
- package/dist/browser/runtime/preview-utils.d.ts +3 -0
- package/dist/browser/runtime/preview-utils.js +49 -0
- package/dist/browser/runtime/proxies.d.ts +72 -0
- package/dist/browser/runtime/proxies.js +369 -0
- package/dist/browser/runtime/proxy-storage.d.ts +59 -0
- package/dist/browser/runtime/proxy-storage.js +119 -0
- package/dist/browser/runtime/sources.d.ts +82 -0
- package/dist/browser/runtime/sources.js +819 -0
- package/dist/browser/runtime/staging.d.ts +10 -0
- package/dist/browser/runtime/staging.js +32 -0
- package/dist/browser/runtime/still.d.ts +21 -0
- package/dist/browser/runtime/still.js +52 -0
- package/dist/browser/runtime/wasm.d.ts +9 -0
- package/dist/browser/runtime/wasm.js +20 -0
- package/dist/browser/types.d.ts +549 -0
- package/dist/browser/types.js +1 -0
- package/dist/browser/worker-entry.d.ts +1 -0
- package/dist/browser/worker-entry.js +2 -0
- package/dist/browser/worker.d.ts +24 -0
- package/dist/browser/worker.js +239 -0
- package/dist/core/captions.d.ts +34 -0
- package/dist/core/captions.js +108 -0
- package/dist/core/clip-time.d.ts +31 -0
- package/dist/core/clip-time.js +125 -0
- package/dist/core/commands.d.ts +413 -0
- package/dist/core/commands.js +1118 -0
- package/dist/core/compositions.d.ts +234 -0
- package/dist/core/compositions.js +851 -0
- package/dist/core/document.d.ts +245 -0
- package/dist/core/document.js +683 -0
- package/dist/core/keyframes.d.ts +82 -0
- package/dist/core/keyframes.js +153 -0
- package/dist/core/presets.d.ts +64 -0
- package/dist/core/presets.js +241 -0
- package/dist/core/session.d.ts +150 -0
- package/dist/core/session.js +541 -0
- package/dist/core/text.d.ts +17 -0
- package/dist/core/text.js +17 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +39 -0
- package/dist/core/timeline-index.d.ts +44 -0
- package/dist/core/timeline-index.js +59 -0
- package/dist/core/transitions.d.ts +20 -0
- package/dist/core/transitions.js +118 -0
- package/dist/core/validated.d.ts +23 -0
- package/dist/core/validated.js +52 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/native-client/index.d.ts +131 -0
- package/dist/native-client/index.js +354 -0
- package/dist/ports/index.d.ts +146 -0
- package/dist/ports/index.js +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.js +94 -0
- package/package.json +79 -4
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { frozenValid, knownValid, rememberValid, withValidated, } from "./validated.js";
|
|
2
|
+
import { validateProject, MAX_JSON_DEPTH, MAX_PROJECT_JSON_BYTES, } from "alpha2/project";
|
|
3
|
+
import { CAPTIONS_ASSET_KIND, validCaptionsAuthoring } from "./captions.js";
|
|
4
|
+
/**
|
|
5
|
+
* The export failure class each editor error code is evidence of (`alpha2/export-failure`): a
|
|
6
|
+
* refusal of the document or its media is `INVALID_INPUT`, which a retry meets again; a budget or
|
|
7
|
+
* storage refusal says so; a state that may pass (a busy export, a pending conform) is `UNKNOWN`.
|
|
8
|
+
* `tests/cases/failure-classes.json` holds the same table, and a test checks every code the editor makes
|
|
9
|
+
* is in it.
|
|
10
|
+
*/
|
|
11
|
+
const EDITOR_FAILURE_CODES = Object.freeze({
|
|
12
|
+
audio_unavailable: "UNKNOWN",
|
|
13
|
+
cancel_failed: "UNKNOWN",
|
|
14
|
+
cancelled: "CANCELLED",
|
|
15
|
+
conform_failed: "INVALID_INPUT",
|
|
16
|
+
conform_pending: "UNKNOWN",
|
|
17
|
+
disposed: "UNKNOWN",
|
|
18
|
+
duplicate_resource: "INVALID_INPUT",
|
|
19
|
+
empty_export: "INVALID_INPUT",
|
|
20
|
+
export_busy: "UNKNOWN",
|
|
21
|
+
invalid_asset: "INVALID_INPUT",
|
|
22
|
+
invalid_captions: "INVALID_INPUT",
|
|
23
|
+
invalid_command: "INVALID_INPUT",
|
|
24
|
+
invalid_document: "INVALID_INPUT",
|
|
25
|
+
invalid_effect_resource: "INVALID_INPUT",
|
|
26
|
+
invalid_font: "INVALID_INPUT",
|
|
27
|
+
invalid_frame_rate: "INVALID_INPUT",
|
|
28
|
+
invalid_metadata: "INVALID_INPUT",
|
|
29
|
+
invalid_policy: "INVALID_INPUT",
|
|
30
|
+
invalid_project: "INVALID_INPUT",
|
|
31
|
+
invalid_projection: "INVALID_INPUT",
|
|
32
|
+
invalid_rate: "INVALID_INPUT",
|
|
33
|
+
invalid_time: "INVALID_INPUT",
|
|
34
|
+
invalid_transition: "INVALID_INPUT",
|
|
35
|
+
locked: "INVALID_INPUT",
|
|
36
|
+
missing_asset: "INVALID_INPUT",
|
|
37
|
+
preview_worker_failed: "UNKNOWN",
|
|
38
|
+
provider_stalled: "RESOURCE_LIMIT",
|
|
39
|
+
proxy_busy: "UNKNOWN",
|
|
40
|
+
relink_conflict: "INVALID_INPUT",
|
|
41
|
+
resource_budget: "RESOURCE_LIMIT",
|
|
42
|
+
resource_limit: "RESOURCE_LIMIT",
|
|
43
|
+
sequence_gap: "INVALID_INPUT",
|
|
44
|
+
sequence_size: "INVALID_INPUT",
|
|
45
|
+
stale_gesture: "UNKNOWN",
|
|
46
|
+
stale_import: "UNKNOWN",
|
|
47
|
+
storage_quota_exceeded: "STORAGE_QUOTA_EXCEEDED",
|
|
48
|
+
sync_conflict: "INVALID_INPUT",
|
|
49
|
+
text_unrenderable: "INVALID_INPUT",
|
|
50
|
+
too_large: "INVALID_INPUT",
|
|
51
|
+
unreadable_media: "INVALID_INPUT",
|
|
52
|
+
unsupported_media: "INVALID_INPUT",
|
|
53
|
+
unsupported_version: "INVALID_INPUT",
|
|
54
|
+
});
|
|
55
|
+
/** An editor refusal or failure, with a machine-readable `code` (a snake_case identifier). */
|
|
56
|
+
export class EditorError extends Error {
|
|
57
|
+
code;
|
|
58
|
+
operation;
|
|
59
|
+
entityId;
|
|
60
|
+
recoverable;
|
|
61
|
+
/**
|
|
62
|
+
* @param code What went wrong, such as `invalid_command` or `locked`.
|
|
63
|
+
* @param message A human-readable description.
|
|
64
|
+
* @param operation The operation that failed, such as `validate`, `open` or a command name.
|
|
65
|
+
* @param entityId The track, clip, asset or other entity the error concerns, when there is one.
|
|
66
|
+
* @param recoverable Whether the editor stays usable after the error. Defaults to `true`.
|
|
67
|
+
*/
|
|
68
|
+
constructor(code, message, operation, entityId, recoverable = true, options) {
|
|
69
|
+
super(message, options);
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.operation = operation;
|
|
72
|
+
this.entityId = entityId;
|
|
73
|
+
this.recoverable = recoverable;
|
|
74
|
+
this.name = "EditorError";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The export failure class this error is evidence of, which `classifyBrowserFailure` reads: the
|
|
78
|
+
* editor knows what its own refusals mean. `UNKNOWN` for a code without a class.
|
|
79
|
+
*/
|
|
80
|
+
get failureCode() {
|
|
81
|
+
return Object.hasOwn(EDITOR_FAILURE_CODES, this.code)
|
|
82
|
+
? EDITOR_FAILURE_CODES[this.code]
|
|
83
|
+
: "UNKNOWN";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Deep-freezes `value` in place and returns it. Already frozen objects are not descended into. */
|
|
87
|
+
export function freeze(value) {
|
|
88
|
+
// Without recursion: a value nested deeper than the stack is frozen too.
|
|
89
|
+
const pending = [value];
|
|
90
|
+
const sealing = [];
|
|
91
|
+
while (pending.length) {
|
|
92
|
+
const next = pending.pop();
|
|
93
|
+
if (!next || typeof next !== "object" || Object.isFrozen(next))
|
|
94
|
+
continue;
|
|
95
|
+
sealing.push(next);
|
|
96
|
+
// A frozen child is not looked into: most of an edit's result is frozen parts it kept.
|
|
97
|
+
for (const child of Object.values(next))
|
|
98
|
+
if (child && typeof child === "object" && !Object.isFrozen(child))
|
|
99
|
+
pending.push(child);
|
|
100
|
+
}
|
|
101
|
+
for (const object of sealing)
|
|
102
|
+
Object.freeze(object);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The deepest nesting of arrays and objects an editor document may have: the engine's for its
|
|
107
|
+
* project ({@link MAX_JSON_DEPTH}), and one more for the document that holds it.
|
|
108
|
+
*/
|
|
109
|
+
export const MAX_DOCUMENT_JSON_DEPTH = MAX_JSON_DEPTH + 1;
|
|
110
|
+
/**
|
|
111
|
+
* Throws an `EditorError` (`invalid_document`) unless `value` is plain JSON: finite numbers, and
|
|
112
|
+
* arrays and plain objects of JSON values only, nested at most `maxDepth` arrays and objects deep
|
|
113
|
+
* ({@link MAX_DOCUMENT_JSON_DEPTH} by default).
|
|
114
|
+
*/
|
|
115
|
+
export function assertJson(value, maxDepth = MAX_DOCUMENT_JSON_DEPTH) {
|
|
116
|
+
const pending = [[value, 0]];
|
|
117
|
+
while (pending.length) {
|
|
118
|
+
const [next, depth] = pending.pop();
|
|
119
|
+
if (next === null || typeof next === "string" || typeof next === "boolean")
|
|
120
|
+
continue;
|
|
121
|
+
if (typeof next === "number" && Number.isFinite(next))
|
|
122
|
+
continue;
|
|
123
|
+
const container = Array.isArray(next) ||
|
|
124
|
+
(typeof next === "object" &&
|
|
125
|
+
Object.getPrototypeOf(next) === Object.prototype);
|
|
126
|
+
if (!container)
|
|
127
|
+
throw new EditorError("invalid_document", "Document must contain finite JSON values", "validate");
|
|
128
|
+
if (depth >= maxDepth)
|
|
129
|
+
throw new EditorError("invalid_document", `Document nests deeper than ${maxDepth} levels`, "validate");
|
|
130
|
+
for (const child of Object.values(next))
|
|
131
|
+
pending.push([child, depth + 1]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Throws an `EditorError` unless `document` is a valid editor document: finite JSON within the
|
|
136
|
+
* engine and editor file size limits, a supported format and version, a project the engine
|
|
137
|
+
* accepts, and well-formed editor metadata and assets.
|
|
138
|
+
*/
|
|
139
|
+
export function validateDocument(document) {
|
|
140
|
+
assertJson(document);
|
|
141
|
+
if (!isRecord(document))
|
|
142
|
+
throw new EditorError("invalid_document", "An editor document is an object", "validate");
|
|
143
|
+
// Size first: the engine reads no project over its budget.
|
|
144
|
+
assertDocumentSize(document);
|
|
145
|
+
validateDocumentShape(document);
|
|
146
|
+
assertEditorMetadata(document);
|
|
147
|
+
assertProjection(document);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Throws the first `invalid_projection` error of `document`'s projection (the project without what
|
|
151
|
+
* disabled tracks draw and play), so a document that opens is one the engine renders.
|
|
152
|
+
*/
|
|
153
|
+
export function assertProjection(document) {
|
|
154
|
+
if (!document.project.timeline.tracks.some((t) => document.metadata.tracks[t.id]?.enabled === false))
|
|
155
|
+
return;
|
|
156
|
+
const [error] = compileEditorDocument(document).diagnostics;
|
|
157
|
+
if (error)
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The checks a gesture's candidate needs on every pointer move: the format and the project's
|
|
162
|
+
* rules. What `validateDocument` adds (every value is finite JSON, the engine JSON and the editor
|
|
163
|
+
* file fit their size limits) walks or serializes the whole document, so a gesture runs it once,
|
|
164
|
+
* when it commits. The project's rules cost a candidate the parts its command changed:
|
|
165
|
+
* `validateProject` reuses what it found of the frozen parts the candidate shares with the
|
|
166
|
+
* gesture's start. Throws an `EditorError` (`unsupported_version` or `invalid_project`).
|
|
167
|
+
*/
|
|
168
|
+
export function validateDocumentShape(document) {
|
|
169
|
+
if (document.format !== "alpha2-editor-project" || document.version !== 1)
|
|
170
|
+
throw new EditorError("unsupported_version", "Unsupported editor format/version", "validate");
|
|
171
|
+
// A project the editor already validated is not walked again.
|
|
172
|
+
if (knownValid(document.project))
|
|
173
|
+
return;
|
|
174
|
+
const errors = validateProject(document.project);
|
|
175
|
+
if (errors.length)
|
|
176
|
+
throw new EditorError("invalid_project", errors.join("\n"), "validate");
|
|
177
|
+
rememberValid(document.project);
|
|
178
|
+
}
|
|
179
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
180
|
+
const optionalString = (value) => value === undefined || typeof value === "string";
|
|
181
|
+
const optionalBoolean = (value) => value === undefined || typeof value === "boolean";
|
|
182
|
+
/**
|
|
183
|
+
* The largest editor file {@link parseDocument} opens. Every document that validates fits it, as
|
|
184
|
+
* {@link serializeDocument} writes it.
|
|
185
|
+
*/
|
|
186
|
+
export const MAX_EDITOR_FILE_BYTES = 128 * 1024 * 1024;
|
|
187
|
+
const utf8Length = (text) => new TextEncoder().encode(text).length;
|
|
188
|
+
function assertDocumentSize(document) {
|
|
189
|
+
if (utf8Length(JSON.stringify(document.project)) > MAX_PROJECT_JSON_BYTES)
|
|
190
|
+
throw new EditorError("too_large", "Engine JSON exceeds its size limit", "validate");
|
|
191
|
+
if (utf8Length(JSON.stringify(document)) > MAX_EDITOR_FILE_BYTES)
|
|
192
|
+
throw new EditorError("too_large", "Editor document exceeds the 128 MiB file limit", "validate");
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* A marker's or track's color, as the editor writes it and a view paints it: a CSS value of any
|
|
196
|
+
* other form may load a resource.
|
|
197
|
+
*/
|
|
198
|
+
const COLOR = /^#[0-9a-f]{6}$/i;
|
|
199
|
+
const isColor = (value) => typeof value === "string" && COLOR.test(value);
|
|
200
|
+
function assertEditorMetadata(document) {
|
|
201
|
+
if (!document.metadata?.tracks ||
|
|
202
|
+
!document.metadata.clips ||
|
|
203
|
+
!document.metadata.instanceIds ||
|
|
204
|
+
!Array.isArray(document.metadata.markers) ||
|
|
205
|
+
!isRecord(document.assets))
|
|
206
|
+
throw new EditorError("invalid_metadata", "Missing editor metadata", "validate");
|
|
207
|
+
// A non-string here takes down the whole page that renders it.
|
|
208
|
+
const { tracks, clips, instanceIds } = document.metadata;
|
|
209
|
+
if (!isRecord(tracks) || !isRecord(clips) || !isRecord(instanceIds))
|
|
210
|
+
throw new EditorError("invalid_metadata", "Editor metadata maps must be objects", "validate");
|
|
211
|
+
for (const [id, track] of Object.entries(tracks))
|
|
212
|
+
if (!isRecord(track) ||
|
|
213
|
+
!optionalBoolean(track.locked) ||
|
|
214
|
+
!optionalBoolean(track.enabled) ||
|
|
215
|
+
!optionalBoolean(track.syncLock) ||
|
|
216
|
+
!(track.color === undefined || isColor(track.color)))
|
|
217
|
+
throw new EditorError("invalid_metadata", "Invalid track metadata", "validate", id);
|
|
218
|
+
for (const [id, clip] of Object.entries(clips))
|
|
219
|
+
if (!isRecord(clip) || !optionalString(clip.name))
|
|
220
|
+
throw new EditorError("invalid_metadata", "Invalid clip metadata", "validate", id);
|
|
221
|
+
for (const [key, ids] of Object.entries(instanceIds))
|
|
222
|
+
if (!Array.isArray(ids) ||
|
|
223
|
+
!ids.every((id) => typeof id === "string") ||
|
|
224
|
+
new Set(ids).size !== ids.length)
|
|
225
|
+
throw new EditorError("invalid_metadata", "Invalid instance ids", "validate", key);
|
|
226
|
+
const { graphLayout, graphNames } = document.metadata;
|
|
227
|
+
if (graphLayout !== undefined &&
|
|
228
|
+
!(isRecord(graphLayout) &&
|
|
229
|
+
Object.values(graphLayout).every((positions) => isRecord(positions) &&
|
|
230
|
+
Object.values(positions).every((p) => isRecord(p) && Number.isFinite(p.x) && Number.isFinite(p.y)))))
|
|
231
|
+
throw new EditorError("invalid_metadata", "Node positions must be finite x and y by composition and node", "validate");
|
|
232
|
+
if (graphNames !== undefined &&
|
|
233
|
+
!(isRecord(graphNames) &&
|
|
234
|
+
Object.values(graphNames).every((name) => typeof name === "string")))
|
|
235
|
+
throw new EditorError("invalid_metadata", "Group names must be text by composition", "validate");
|
|
236
|
+
// A transition's id is the edge it joins, so a command names the transition it means.
|
|
237
|
+
for (const track of document.project.timeline.tracks) {
|
|
238
|
+
const ids = instanceIds[`${track.id}/transitions`] ?? [];
|
|
239
|
+
const transitions = track.transitions ?? [];
|
|
240
|
+
if (ids.length !== transitions.length ||
|
|
241
|
+
transitions.some((t, i) => ids[i] !== transitionInstanceId(track.id, t)))
|
|
242
|
+
throw new EditorError("invalid_metadata", "Transition ids must name the clips each joins", "validate", track.id);
|
|
243
|
+
}
|
|
244
|
+
for (const [id, asset] of Object.entries(document.assets)) {
|
|
245
|
+
if (!isRecord(asset) ||
|
|
246
|
+
asset.id !== id ||
|
|
247
|
+
!Number.isSafeInteger(asset.contentRevision) ||
|
|
248
|
+
asset.contentRevision < 0)
|
|
249
|
+
throw new EditorError("invalid_asset", "Invalid asset identity/revision", "validate", id);
|
|
250
|
+
if (asset.kind === CAPTIONS_ASSET_KIND &&
|
|
251
|
+
!validCaptionsAuthoring(asset.authoring))
|
|
252
|
+
throw new EditorError("invalid_asset", "Invalid captions: each row must be a list of cues", "validate", id);
|
|
253
|
+
// `authoring.text` is a text asset's style; other assets keep no text (`null`) or a file's
|
|
254
|
+
// contents (a string) there.
|
|
255
|
+
const text = isRecord(asset.authoring) ? asset.authoring.text : undefined;
|
|
256
|
+
if (typeof asset.name !== "string" ||
|
|
257
|
+
typeof asset.kind !== "string" ||
|
|
258
|
+
!optionalString(asset.locator) ||
|
|
259
|
+
!optionalBoolean(asset.missing) ||
|
|
260
|
+
(isRecord(text) &&
|
|
261
|
+
(!optionalString(text.content) || !optionalString(text.fontFamily))))
|
|
262
|
+
throw new EditorError("invalid_asset", "Invalid asset name, kind, locator, offline flag or text", "validate", id);
|
|
263
|
+
}
|
|
264
|
+
const markerIds = new Set();
|
|
265
|
+
for (const marker of document.metadata.markers) {
|
|
266
|
+
if (!isRecord(marker))
|
|
267
|
+
throw new EditorError("invalid_metadata", "Invalid marker", "validate");
|
|
268
|
+
if (!Number.isSafeInteger(marker.time) ||
|
|
269
|
+
marker.time < 0 ||
|
|
270
|
+
(marker.end !== undefined &&
|
|
271
|
+
(!Number.isSafeInteger(marker.end) || marker.end <= marker.time)))
|
|
272
|
+
throw new EditorError("invalid_time", "Invalid marker time", "validate", marker.id);
|
|
273
|
+
if (typeof marker.id !== "string" ||
|
|
274
|
+
markerIds.has(marker.id) ||
|
|
275
|
+
typeof marker.name !== "string" ||
|
|
276
|
+
(marker.note !== undefined && typeof marker.note !== "string") ||
|
|
277
|
+
(marker.color !== undefined && !isColor(marker.color)) ||
|
|
278
|
+
(marker.done !== undefined && typeof marker.done !== "boolean"))
|
|
279
|
+
throw new EditorError("invalid_metadata", "Invalid marker", "validate", marker.id);
|
|
280
|
+
markerIds.add(marker.id);
|
|
281
|
+
}
|
|
282
|
+
const range = document.metadata.exportRange;
|
|
283
|
+
if (range &&
|
|
284
|
+
(!Number.isSafeInteger(range.start) ||
|
|
285
|
+
!Number.isSafeInteger(range.end) ||
|
|
286
|
+
range.start < 0 ||
|
|
287
|
+
range.end <= range.start))
|
|
288
|
+
throw new EditorError("invalid_time", "Invalid export range", "validate");
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* A new frozen document holding a copy of `project`, with an asset for each source and effect
|
|
292
|
+
* resource (offline unless generated or a composition) overridden by `assets`. Throws an
|
|
293
|
+
* `EditorError` when the result does not validate.
|
|
294
|
+
*/
|
|
295
|
+
export function createDocument(project, assets = {}) {
|
|
296
|
+
return withValidated(() => {
|
|
297
|
+
assertJson(project, MAX_JSON_DEPTH);
|
|
298
|
+
// Frozen before validation, so validated parts are reused when edits check them.
|
|
299
|
+
const copy = freeze(structuredClone(project));
|
|
300
|
+
if (!knownValid(project)) {
|
|
301
|
+
const errors = validateProject(copy);
|
|
302
|
+
if (errors.length)
|
|
303
|
+
throw new EditorError("invalid_project", errors.join("\n"), "validate");
|
|
304
|
+
}
|
|
305
|
+
rememberValid(copy);
|
|
306
|
+
return documentOf(copy, assets);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function documentOf(project, assets) {
|
|
310
|
+
const document = {
|
|
311
|
+
format: "alpha2-editor-project",
|
|
312
|
+
version: 1,
|
|
313
|
+
project,
|
|
314
|
+
assets: {
|
|
315
|
+
...Object.fromEntries([...project.sources, ...(project.effect_resources ?? [])].map((source) => [
|
|
316
|
+
source.id,
|
|
317
|
+
{
|
|
318
|
+
id: source.id,
|
|
319
|
+
kind: source.kind,
|
|
320
|
+
name: source.id,
|
|
321
|
+
contentRevision: 0,
|
|
322
|
+
missing: source.kind !== "generated" && source.kind !== "composition",
|
|
323
|
+
},
|
|
324
|
+
])),
|
|
325
|
+
...structuredClone(assets),
|
|
326
|
+
},
|
|
327
|
+
metadata: {
|
|
328
|
+
tracks: {},
|
|
329
|
+
clips: {},
|
|
330
|
+
instanceIds: {},
|
|
331
|
+
markers: [],
|
|
332
|
+
exportRange: null,
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
const named = withInstanceIds(document);
|
|
336
|
+
validateDocument(named);
|
|
337
|
+
return frozenValid(named);
|
|
338
|
+
}
|
|
339
|
+
/** A new document with no media: 1920x1080 at 30 fps and 48 kHz unless `settings` say otherwise. */
|
|
340
|
+
export function createEmptyDocument(settings = {}) {
|
|
341
|
+
return createDocument({
|
|
342
|
+
schema_version: 1,
|
|
343
|
+
id: "project",
|
|
344
|
+
name: "Untitled project",
|
|
345
|
+
settings: {
|
|
346
|
+
width: 1920,
|
|
347
|
+
height: 1080,
|
|
348
|
+
frame_rate: { num: 30, den: 1 },
|
|
349
|
+
sample_rate: 48000,
|
|
350
|
+
...settings,
|
|
351
|
+
},
|
|
352
|
+
sources: [],
|
|
353
|
+
timeline: { tracks: [] },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The editor file of `document`: compact JSON, so a document that validates is a file
|
|
358
|
+
* {@link parseDocument} opens. Throws an `EditorError` when the document does not validate.
|
|
359
|
+
*/
|
|
360
|
+
export function serializeDocument(document) {
|
|
361
|
+
validateDocument(document);
|
|
362
|
+
return JSON.stringify(document);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Opens an editor file, or a bare engine project (JSON with `schema_version`) as a new document.
|
|
366
|
+
* Returns a frozen document. Every refusal is an `EditorError`: `too_large` for a file over
|
|
367
|
+
* {@link MAX_EDITOR_FILE_BYTES}, `invalid_document` for text that is not an editor document's JSON,
|
|
368
|
+
* and the code of the check a document (or project) fails otherwise.
|
|
369
|
+
*/
|
|
370
|
+
export function parseDocument(text) {
|
|
371
|
+
if (utf8Length(text) > MAX_EDITOR_FILE_BYTES)
|
|
372
|
+
throw new EditorError("too_large", "Editor file exceeds 128 MiB", "open");
|
|
373
|
+
let data;
|
|
374
|
+
try {
|
|
375
|
+
data = JSON.parse(text);
|
|
376
|
+
}
|
|
377
|
+
catch (cause) {
|
|
378
|
+
throw new EditorError("invalid_document", `Editor file is not JSON: ${cause instanceof Error ? cause.message : String(cause)}`, "open", undefined, true, { cause });
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
if (isRecord(data) && "schema_version" in data)
|
|
382
|
+
return createDocument(data);
|
|
383
|
+
const document = freeze(withInstanceIds(data));
|
|
384
|
+
validateDocument(document);
|
|
385
|
+
return frozenValid(document);
|
|
386
|
+
}
|
|
387
|
+
catch (cause) {
|
|
388
|
+
if (cause instanceof EditorError)
|
|
389
|
+
throw cause;
|
|
390
|
+
// A shape the checks above did not foresee is still the file's, not the editor's.
|
|
391
|
+
throw new EditorError("invalid_document", `Editor file could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, "open", undefined, true, { cause });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The id of `transition` on track `trackId`: the edge it joins, which a track has one transition
|
|
396
|
+
* on. It stays the transition's own while other transitions come and go, and changes when its clips
|
|
397
|
+
* do (a split hands an outgoing transition to the right part).
|
|
398
|
+
*/
|
|
399
|
+
export function transitionInstanceId(trackId, transition) {
|
|
400
|
+
return `${trackId}/transitions/${encodeURIComponent(transition.from_clip)}/${encodeURIComponent(transition.to_clip)}`;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Frozen tracks and clips {@link withInstanceIds} found named as they are in a frozen `instanceIds`
|
|
404
|
+
* record.
|
|
405
|
+
*/
|
|
406
|
+
const namedIn = new WeakMap();
|
|
407
|
+
/**
|
|
408
|
+
* `document` with an instance id for every caption cue, transition and clip mask: each track's
|
|
409
|
+
* transitions named by {@link transitionInstanceId}; cues and masks keep the ids they have, and
|
|
410
|
+
* those without one get a new one no other in the list has. Returns `document` itself when nothing
|
|
411
|
+
* changes, and leaves a document of the wrong shape for validation to refuse.
|
|
412
|
+
*/
|
|
413
|
+
export function withInstanceIds(document) {
|
|
414
|
+
const tracks = document?.project?.timeline?.tracks;
|
|
415
|
+
const original = document?.metadata?.instanceIds;
|
|
416
|
+
if (!Array.isArray(tracks) || !isRecord(original))
|
|
417
|
+
return document;
|
|
418
|
+
// Asked once: of a record of many keys, the answer takes a look at each.
|
|
419
|
+
const lasting = Object.isFrozen(original);
|
|
420
|
+
// Copied on the first change: a copy per changed key would be O(n^2) over n clips.
|
|
421
|
+
let changed;
|
|
422
|
+
const current = (key) => (changed ?? original)[key];
|
|
423
|
+
const set = (key, values) => {
|
|
424
|
+
const previous = current(key);
|
|
425
|
+
if (Array.isArray(previous) &&
|
|
426
|
+
previous.length === values.length &&
|
|
427
|
+
previous.every((id, i) => id === values[i]))
|
|
428
|
+
return;
|
|
429
|
+
(changed ??= { ...original })[key] = values;
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* The ids a list of `count` keeps, `<key>/<index>` (numbered past one taken) for the new ones.
|
|
433
|
+
*/
|
|
434
|
+
const kept = (key, count) => {
|
|
435
|
+
const previous = current(key);
|
|
436
|
+
const ids = (Array.isArray(previous) ? previous : [])
|
|
437
|
+
.slice(0, count)
|
|
438
|
+
.filter((id) => typeof id === "string");
|
|
439
|
+
const taken = new Set();
|
|
440
|
+
const unique = ids.filter((id) => !taken.has(id) && !!taken.add(id));
|
|
441
|
+
for (let i = unique.length; i < count; i += 1) {
|
|
442
|
+
let id = `${key}/${i}`;
|
|
443
|
+
for (let n = 2; taken.has(id); n += 1)
|
|
444
|
+
id = `${key}/${i}~${n}`;
|
|
445
|
+
taken.add(id);
|
|
446
|
+
unique.push(id);
|
|
447
|
+
}
|
|
448
|
+
return unique;
|
|
449
|
+
};
|
|
450
|
+
for (const track of tracks) {
|
|
451
|
+
if (!isRecord(track) || typeof track.id !== "string")
|
|
452
|
+
return document;
|
|
453
|
+
// A track whose ids are unchanged in this frozen record needs nothing.
|
|
454
|
+
if (changed === undefined && namedIn.get(track) === original)
|
|
455
|
+
continue;
|
|
456
|
+
const transitions = track.transitions ?? [];
|
|
457
|
+
const clips = track.clips ?? [];
|
|
458
|
+
const cues = track.captions?.cues ?? [];
|
|
459
|
+
if (!Array.isArray(transitions) ||
|
|
460
|
+
!transitions.every(isRecord) ||
|
|
461
|
+
!Array.isArray(clips) ||
|
|
462
|
+
!clips.every(isRecord) ||
|
|
463
|
+
!Array.isArray(cues))
|
|
464
|
+
return document;
|
|
465
|
+
let frozen = lasting &&
|
|
466
|
+
[
|
|
467
|
+
track,
|
|
468
|
+
track.transitions,
|
|
469
|
+
track.clips,
|
|
470
|
+
track.captions,
|
|
471
|
+
track.captions?.cues,
|
|
472
|
+
].every((part) => part === undefined || Object.isFrozen(part)) &&
|
|
473
|
+
transitions.every(Object.isFrozen);
|
|
474
|
+
const unchanged = changed === undefined;
|
|
475
|
+
set(`${track.id}/cues`, kept(`${track.id}/cues`, cues.length));
|
|
476
|
+
set(`${track.id}/transitions`, transitions.map((t) => transitionInstanceId(track.id, t)));
|
|
477
|
+
for (const clip of clips) {
|
|
478
|
+
const masks = clip.masks ?? [];
|
|
479
|
+
frozen &&=
|
|
480
|
+
Object.isFrozen(clip) &&
|
|
481
|
+
(clip.masks === undefined || Object.isFrozen(clip.masks));
|
|
482
|
+
if (!Array.isArray(masks))
|
|
483
|
+
continue;
|
|
484
|
+
if (changed === undefined && namedIn.get(clip) === original)
|
|
485
|
+
continue;
|
|
486
|
+
const key = `${clip.id}/masks`;
|
|
487
|
+
const previous = current(key);
|
|
488
|
+
if (masks.length > 0 || !Array.isArray(previous) || previous.length)
|
|
489
|
+
set(key, kept(key, masks.length));
|
|
490
|
+
if (changed === undefined &&
|
|
491
|
+
lasting &&
|
|
492
|
+
Object.isFrozen(clip) &&
|
|
493
|
+
(clip.masks === undefined || Object.isFrozen(clip.masks)))
|
|
494
|
+
namedIn.set(clip, original);
|
|
495
|
+
}
|
|
496
|
+
if (frozen && unchanged && changed === undefined)
|
|
497
|
+
namedIn.set(track, original);
|
|
498
|
+
}
|
|
499
|
+
return changed === undefined
|
|
500
|
+
? document
|
|
501
|
+
: {
|
|
502
|
+
...document,
|
|
503
|
+
metadata: {
|
|
504
|
+
...document.metadata,
|
|
505
|
+
instanceIds: changed,
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
const indexes = new WeakMap();
|
|
510
|
+
/** The {@link EntityIndex} of `document`, built once per document object and cached. */
|
|
511
|
+
export function indexDocument(document) {
|
|
512
|
+
let index = indexes.get(document);
|
|
513
|
+
if (index)
|
|
514
|
+
return index;
|
|
515
|
+
const tracks = new Map();
|
|
516
|
+
const clips = new Map();
|
|
517
|
+
for (const track of document.project.timeline.tracks) {
|
|
518
|
+
tracks.set(track.id, track);
|
|
519
|
+
for (const clip of track.clips ?? [])
|
|
520
|
+
clips.set(clip.id, { clip, trackId: track.id });
|
|
521
|
+
}
|
|
522
|
+
index = { tracks, clips };
|
|
523
|
+
indexes.set(document, index);
|
|
524
|
+
return index;
|
|
525
|
+
}
|
|
526
|
+
// A frozen document never changes, so its projection is computed once.
|
|
527
|
+
const compiled = new WeakMap();
|
|
528
|
+
/**
|
|
529
|
+
* The project the engine renders for `document`. A disabled track draws and plays nothing: it is
|
|
530
|
+
* left out, but for the clips enabled clips name as mattes (never drawn themselves), kept on it
|
|
531
|
+
* muted; a track that ducks under a disabled track does not duck; a clip only disabled clips name
|
|
532
|
+
* as their matte draws nothing but still plays. Never throws for an invalid projection; the errors
|
|
533
|
+
* are in `diagnostics`. Cached for frozen documents.
|
|
534
|
+
*/
|
|
535
|
+
export function compileEditorDocument(document) {
|
|
536
|
+
const cached = compiled.get(document);
|
|
537
|
+
if (cached)
|
|
538
|
+
return cached;
|
|
539
|
+
const result = compileProjection(document);
|
|
540
|
+
if (Object.isFrozen(document))
|
|
541
|
+
compiled.set(document, result);
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Where `project`'s timeline ends, in ticks: the end of its last clip or caption cue, 0 when it has
|
|
546
|
+
* neither.
|
|
547
|
+
*/
|
|
548
|
+
export function sequenceEnd(project) {
|
|
549
|
+
let end = 0;
|
|
550
|
+
for (const track of project.timeline.tracks) {
|
|
551
|
+
for (const clip of track.clips ?? [])
|
|
552
|
+
end = Math.max(end, clip.timeline_range.end);
|
|
553
|
+
for (const cue of track.captions?.cues ?? [])
|
|
554
|
+
end = Math.max(end, cue.timeline_range.end);
|
|
555
|
+
}
|
|
556
|
+
return end;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Where `document`'s program ends, in ticks: the {@link sequenceEnd} of what the engine renders of
|
|
560
|
+
* it ({@link compileEditorDocument}), so a disabled track adds no length. Playback without an
|
|
561
|
+
* export range stops there, an export without one ends there, and an export range with only its
|
|
562
|
+
* start set runs to it.
|
|
563
|
+
*/
|
|
564
|
+
export function programEnd(document) {
|
|
565
|
+
return sequenceEnd(compileEditorDocument(document).project);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The tracks the engine renders of `document`. A disabled track draws and plays nothing, but what
|
|
569
|
+
* enabled tracks read from it stays: the clips they name as mattes, on the track muted and with
|
|
570
|
+
* nothing else. A matte clip only sets another clip's coverage and is never composited itself, so
|
|
571
|
+
* keeping it draws nothing of the disabled track. A track that ducks under a disabled track ducks
|
|
572
|
+
* under silence, so it does not duck. A clip that only disabled clips name as their matte is still
|
|
573
|
+
* a matte: it is not drawn (no opacity), and it plays as before.
|
|
574
|
+
*/
|
|
575
|
+
function projectedTracks(document) {
|
|
576
|
+
const tracks = document.project.timeline.tracks;
|
|
577
|
+
const disabled = new Set(tracks
|
|
578
|
+
.filter((t) => document.metadata.tracks[t.id]?.enabled === false)
|
|
579
|
+
.map((t) => t.id));
|
|
580
|
+
if (!disabled.size)
|
|
581
|
+
return tracks;
|
|
582
|
+
const named = (enabled) => new Set(tracks
|
|
583
|
+
.filter((t) => disabled.has(t.id) !== enabled)
|
|
584
|
+
.flatMap((t) => (t.clips ?? []).flatMap((c) => c.matte?.source ?? [])));
|
|
585
|
+
const mattes = named(true);
|
|
586
|
+
// Named by hidden clips alone: the projection leaves its naming clips out, so the engine would
|
|
587
|
+
// draw it as an ordinary clip.
|
|
588
|
+
const orphaned = new Set([...named(false)].filter((id) => !mattes.has(id)));
|
|
589
|
+
return tracks.flatMap((track) => {
|
|
590
|
+
if (disabled.has(track.id)) {
|
|
591
|
+
const clips = (track.clips ?? []).filter((c) => mattes.has(c.id));
|
|
592
|
+
return clips.length
|
|
593
|
+
? [
|
|
594
|
+
{
|
|
595
|
+
id: track.id,
|
|
596
|
+
name: track.name,
|
|
597
|
+
kind: track.kind,
|
|
598
|
+
clips,
|
|
599
|
+
audio: { muted: true },
|
|
600
|
+
},
|
|
601
|
+
]
|
|
602
|
+
: [];
|
|
603
|
+
}
|
|
604
|
+
let projected = track;
|
|
605
|
+
if (orphaned.size && track.clips?.some((c) => orphaned.has(c.id)))
|
|
606
|
+
projected = {
|
|
607
|
+
...projected,
|
|
608
|
+
clips: track.clips.map((c) => orphaned.has(c.id)
|
|
609
|
+
? {
|
|
610
|
+
...c,
|
|
611
|
+
visual: {
|
|
612
|
+
...(c.visual ?? DEFAULT_VISUAL),
|
|
613
|
+
opacity: { mode: "constant", value: 0 },
|
|
614
|
+
},
|
|
615
|
+
}
|
|
616
|
+
: c),
|
|
617
|
+
};
|
|
618
|
+
const sidechain = track.audio?.ducking?.sidechain_track;
|
|
619
|
+
if (sidechain != null && disabled.has(sidechain))
|
|
620
|
+
projected = {
|
|
621
|
+
...projected,
|
|
622
|
+
audio: { ...projected.audio, ducking: null },
|
|
623
|
+
};
|
|
624
|
+
return [projected];
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/** A clip's visual properties when it has none (the engine's `VisualProperties::default`). */
|
|
628
|
+
const DEFAULT_VISUAL = {
|
|
629
|
+
position: { mode: "constant", value: { x: 0.5, y: 0.5 } },
|
|
630
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
631
|
+
scale: { mode: "constant", value: { x: 1, y: 1 } },
|
|
632
|
+
rotation_degrees: { mode: "constant", value: 0 },
|
|
633
|
+
opacity: { mode: "constant", value: 1 },
|
|
634
|
+
};
|
|
635
|
+
function compileProjection(document) {
|
|
636
|
+
const tracks = projectedTracks(document);
|
|
637
|
+
const project = tracks === document.project.timeline.tracks
|
|
638
|
+
? document.project
|
|
639
|
+
: {
|
|
640
|
+
...document.project,
|
|
641
|
+
timeline: { ...document.project.timeline, tracks },
|
|
642
|
+
};
|
|
643
|
+
const diagnostics = (knownValid(project) ? [] : validateProject(project)).map((message) => new EditorError("invalid_projection", message, "compile"));
|
|
644
|
+
// Missing media is the engine's conform check, not a document problem.
|
|
645
|
+
let revisionKey;
|
|
646
|
+
return {
|
|
647
|
+
project,
|
|
648
|
+
diagnostics,
|
|
649
|
+
get revisionKey() {
|
|
650
|
+
return (revisionKey ??= JSON.stringify(project));
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Seeds the {@link indexDocument} cache of `next` from that of `previous`, rebuilding entries only
|
|
656
|
+
* for tracks that changed. Does nothing when `previous` has no index.
|
|
657
|
+
*/
|
|
658
|
+
export function inheritDocumentIndex(previous, next) {
|
|
659
|
+
const prior = indexes.get(previous);
|
|
660
|
+
if (!prior)
|
|
661
|
+
return;
|
|
662
|
+
if (previous.project.timeline === next.project.timeline) {
|
|
663
|
+
indexes.set(next, prior);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const tracks = new Map(prior.tracks), clips = new Map(prior.clips);
|
|
667
|
+
const incoming = new Map(next.project.timeline.tracks.map((t) => [t.id, t]));
|
|
668
|
+
for (const [id, old] of prior.tracks) {
|
|
669
|
+
if (incoming.get(id) === old)
|
|
670
|
+
continue;
|
|
671
|
+
tracks.delete(id);
|
|
672
|
+
for (const clip of old.clips ?? [])
|
|
673
|
+
clips.delete(clip.id);
|
|
674
|
+
}
|
|
675
|
+
for (const [id, track] of incoming) {
|
|
676
|
+
if (prior.tracks.get(id) === track)
|
|
677
|
+
continue;
|
|
678
|
+
tracks.set(id, track);
|
|
679
|
+
for (const clip of track.clips ?? [])
|
|
680
|
+
clips.set(clip.id, { clip, trackId: id });
|
|
681
|
+
}
|
|
682
|
+
indexes.set(next, { tracks, clips });
|
|
683
|
+
}
|