@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.
Files changed (99) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +108 -2
  3. package/dist/browser/bridge.d.ts +44 -0
  4. package/dist/browser/bridge.js +2176 -0
  5. package/dist/browser/extensions-worker-entry.d.ts +1 -0
  6. package/dist/browser/extensions-worker-entry.js +3 -0
  7. package/dist/browser/extensions-worker.d.ts +46 -0
  8. package/dist/browser/extensions-worker.js +149 -0
  9. package/dist/browser/hash-worker-entry.d.ts +1 -0
  10. package/dist/browser/hash-worker-entry.js +3 -0
  11. package/dist/browser/hash-worker.d.ts +24 -0
  12. package/dist/browser/hash-worker.js +87 -0
  13. package/dist/browser/index.d.ts +11 -0
  14. package/dist/browser/index.js +9 -0
  15. package/dist/browser/jobs.d.ts +82 -0
  16. package/dist/browser/jobs.js +291 -0
  17. package/dist/browser/resources.d.ts +40 -0
  18. package/dist/browser/resources.js +83 -0
  19. package/dist/browser/runtime/assets.d.ts +233 -0
  20. package/dist/browser/runtime/assets.js +633 -0
  21. package/dist/browser/runtime/audio.d.ts +60 -0
  22. package/dist/browser/runtime/audio.js +549 -0
  23. package/dist/browser/runtime/budget.d.ts +11 -0
  24. package/dist/browser/runtime/budget.js +26 -0
  25. package/dist/browser/runtime/caption-fonts.d.ts +34 -0
  26. package/dist/browser/runtime/caption-fonts.js +69 -0
  27. package/dist/browser/runtime/color.d.ts +7 -0
  28. package/dist/browser/runtime/color.js +12 -0
  29. package/dist/browser/runtime/engine.d.ts +91 -0
  30. package/dist/browser/runtime/engine.js +352 -0
  31. package/dist/browser/runtime/export-audio.d.ts +23 -0
  32. package/dist/browser/runtime/export-audio.js +75 -0
  33. package/dist/browser/runtime/exporter.d.ts +140 -0
  34. package/dist/browser/runtime/exporter.js +751 -0
  35. package/dist/browser/runtime/extension-import.d.ts +196 -0
  36. package/dist/browser/runtime/extension-import.js +521 -0
  37. package/dist/browser/runtime/fingerprint.d.ts +57 -0
  38. package/dist/browser/runtime/fingerprint.js +157 -0
  39. package/dist/browser/runtime/generators.d.ts +188 -0
  40. package/dist/browser/runtime/generators.js +173 -0
  41. package/dist/browser/runtime/locks.d.ts +32 -0
  42. package/dist/browser/runtime/locks.js +86 -0
  43. package/dist/browser/runtime/output.d.ts +87 -0
  44. package/dist/browser/runtime/output.js +230 -0
  45. package/dist/browser/runtime/preview-utils.d.ts +3 -0
  46. package/dist/browser/runtime/preview-utils.js +49 -0
  47. package/dist/browser/runtime/proxies.d.ts +72 -0
  48. package/dist/browser/runtime/proxies.js +369 -0
  49. package/dist/browser/runtime/proxy-storage.d.ts +59 -0
  50. package/dist/browser/runtime/proxy-storage.js +119 -0
  51. package/dist/browser/runtime/sources.d.ts +82 -0
  52. package/dist/browser/runtime/sources.js +819 -0
  53. package/dist/browser/runtime/staging.d.ts +10 -0
  54. package/dist/browser/runtime/staging.js +32 -0
  55. package/dist/browser/runtime/still.d.ts +21 -0
  56. package/dist/browser/runtime/still.js +52 -0
  57. package/dist/browser/runtime/wasm.d.ts +9 -0
  58. package/dist/browser/runtime/wasm.js +20 -0
  59. package/dist/browser/types.d.ts +549 -0
  60. package/dist/browser/types.js +1 -0
  61. package/dist/browser/worker-entry.d.ts +1 -0
  62. package/dist/browser/worker-entry.js +2 -0
  63. package/dist/browser/worker.d.ts +24 -0
  64. package/dist/browser/worker.js +239 -0
  65. package/dist/core/captions.d.ts +34 -0
  66. package/dist/core/captions.js +108 -0
  67. package/dist/core/clip-time.d.ts +31 -0
  68. package/dist/core/clip-time.js +125 -0
  69. package/dist/core/commands.d.ts +413 -0
  70. package/dist/core/commands.js +1118 -0
  71. package/dist/core/compositions.d.ts +234 -0
  72. package/dist/core/compositions.js +851 -0
  73. package/dist/core/document.d.ts +245 -0
  74. package/dist/core/document.js +683 -0
  75. package/dist/core/keyframes.d.ts +82 -0
  76. package/dist/core/keyframes.js +153 -0
  77. package/dist/core/presets.d.ts +64 -0
  78. package/dist/core/presets.js +241 -0
  79. package/dist/core/session.d.ts +150 -0
  80. package/dist/core/session.js +541 -0
  81. package/dist/core/text.d.ts +17 -0
  82. package/dist/core/text.js +17 -0
  83. package/dist/core/time.d.ts +19 -0
  84. package/dist/core/time.js +39 -0
  85. package/dist/core/timeline-index.d.ts +44 -0
  86. package/dist/core/timeline-index.js +59 -0
  87. package/dist/core/transitions.d.ts +20 -0
  88. package/dist/core/transitions.js +118 -0
  89. package/dist/core/validated.d.ts +23 -0
  90. package/dist/core/validated.js +52 -0
  91. package/dist/index.d.ts +12 -0
  92. package/dist/index.js +11 -0
  93. package/dist/native-client/index.d.ts +131 -0
  94. package/dist/native-client/index.js +354 -0
  95. package/dist/ports/index.d.ts +146 -0
  96. package/dist/ports/index.js +1 -0
  97. package/dist/react/index.d.ts +37 -0
  98. package/dist/react/index.js +94 -0
  99. package/package.json +79 -4
@@ -0,0 +1,541 @@
1
+ import { compositionTree } from "./compositions.js";
2
+ import { createEmptyDocument, validateDocument, withInstanceIds, indexDocument, inheritDocumentIndex, EditorError, } from "./document.js";
3
+ import { executeCommand } from "./commands.js";
4
+ import { frozenValid, owned } from "./validated.js";
5
+ /**
6
+ * Holds an editor document with undo history, selection, clipboard and pointer gestures. Every
7
+ * document it holds is frozen; edits produce new documents through {@link Command}s.
8
+ *
9
+ * Methods other than `cancelGesture` and `dispose` throw an `EditorError` (`disposed`) after
10
+ * {@link EditorSession.dispose}.
11
+ */
12
+ export class EditorSession {
13
+ historyLimit;
14
+ historyByteLimit;
15
+ #document;
16
+ #selection = Object.freeze([]);
17
+ #undo = [];
18
+ #redo = [];
19
+ #revision = 0;
20
+ #generation = 0;
21
+ #disposed = false;
22
+ #listeners = new Set();
23
+ #snapshot;
24
+ #cancelGesture = null;
25
+ #draft = null;
26
+ /** Copied clips, each with its name and the compositions it drew when copied. */
27
+ #clipboard = [];
28
+ /**
29
+ * @param document The initial document, copied; an empty document by default. Throws an
30
+ * `EditorError` when it does not validate.
31
+ * @param historyLimit The most undo steps kept. Defaults to 120.
32
+ * @param historyByteLimit The most bytes the undo history keeps beyond the document: each step
33
+ * counts the serialized size of what it does not share with the document after it, by
34
+ * object identity, and the oldest steps are dropped first. Defaults to 64 MiB.
35
+ * @throws RangeError when a limit is not a non-negative safe integer.
36
+ */
37
+ constructor(document = createEmptyDocument(), historyLimit = 120, historyByteLimit = 64 * 1024 * 1024) {
38
+ this.historyLimit = historyLimit;
39
+ this.historyByteLimit = historyByteLimit;
40
+ if (!Number.isSafeInteger(historyLimit) ||
41
+ historyLimit < 0 ||
42
+ !Number.isSafeInteger(historyByteLimit) ||
43
+ historyByteLimit < 0)
44
+ throw new RangeError("Invalid history budget");
45
+ document = withInstanceIds(document);
46
+ validateDocument(document);
47
+ this.#document = frozenValid(owned(document));
48
+ this.#snapshot = this.#read();
49
+ }
50
+ get document() {
51
+ return this.#document;
52
+ }
53
+ get draft() {
54
+ return this.#draft ?? this.#document;
55
+ }
56
+ get revision() {
57
+ return this.#revision;
58
+ }
59
+ get generation() {
60
+ return this.#generation;
61
+ }
62
+ get disposed() {
63
+ return this.#disposed;
64
+ }
65
+ /**
66
+ * Every document the session can still show or return to: the undo history, the document, the
67
+ * active gesture's draft and the redo history. A host keeps what these refer to and may release
68
+ * the rest.
69
+ */
70
+ reachableDocuments() {
71
+ return [
72
+ ...this.#undo,
73
+ this.#document,
74
+ ...(this.#draft ? [this.#draft] : []),
75
+ ...this.#redo,
76
+ ];
77
+ }
78
+ /**
79
+ * The current snapshot; the same object until the next change, as `useSyncExternalStore` needs.
80
+ */
81
+ getSnapshot = () => this.#snapshot;
82
+ /** Calls `listener` after every change. Returns the unsubscribe function. */
83
+ subscribe = (listener) => {
84
+ this.#listeners.add(listener);
85
+ return () => this.#listeners.delete(listener);
86
+ };
87
+ /**
88
+ * Calls `listener` with `selector`'s result whenever it changes by `equal` (`Object.is` by
89
+ * default). Returns the unsubscribe function.
90
+ */
91
+ subscribeSelector(selector, listener, equal = Object.is) {
92
+ let previous = selector(this.#snapshot);
93
+ return this.subscribe(() => {
94
+ const next = selector(this.#snapshot);
95
+ if (!equal(previous, next)) {
96
+ previous = next;
97
+ listener(next);
98
+ }
99
+ });
100
+ }
101
+ #read() {
102
+ return Object.freeze({
103
+ document: this.#document,
104
+ draft: this.#draft ?? this.#document,
105
+ selection: this.#selection,
106
+ revision: this.#revision,
107
+ generation: this.#generation,
108
+ canUndo: this.#undo.length > 0,
109
+ canRedo: this.#redo.length > 0,
110
+ });
111
+ }
112
+ #emit() {
113
+ this.#snapshot = this.#read();
114
+ for (const listener of this.#listeners)
115
+ listener();
116
+ }
117
+ #active() {
118
+ if (this.#disposed)
119
+ throw new EditorError("disposed", "Session is disposed", "session");
120
+ }
121
+ /** Each undo step's {@link stepBytes}, with the document after it they were counted against. */
122
+ #stepSizes = new WeakMap();
123
+ /** Drops the oldest undo steps past the budget; `next` is the document after the newest step. */
124
+ #trimHistory(next) {
125
+ const size = (index) => {
126
+ const step = this.#undo[index];
127
+ const after = this.#undo[index + 1] ?? next;
128
+ let known = this.#stepSizes.get(step);
129
+ if (known?.next !== after) {
130
+ known = { next: after, bytes: stepBytes(step, after) };
131
+ this.#stepSizes.set(step, known);
132
+ }
133
+ return known.bytes;
134
+ };
135
+ let bytes = 0;
136
+ for (let index = 0; index < this.#undo.length; index++)
137
+ bytes += size(index);
138
+ while (this.#undo.length &&
139
+ (this.#undo.length > this.historyLimit || bytes > this.historyByteLimit)) {
140
+ bytes -= size(0);
141
+ this.#undo.shift();
142
+ }
143
+ }
144
+ #commit(document) {
145
+ inheritDocumentIndex(this.#document, document);
146
+ this.#undo.push(this.#document);
147
+ this.#trimHistory(document);
148
+ this.#redo = [];
149
+ // What it commits passed a dispatch's checks.
150
+ this.#document = frozenValid(document);
151
+ this.#revision++;
152
+ this.#prune();
153
+ this.#emit();
154
+ }
155
+ #prune() {
156
+ const index = indexDocument(this.#document);
157
+ const kept = this.#selection.filter((id) => index.clips.has(id));
158
+ if (kept.length !== this.#selection.length)
159
+ this.#selection = Object.freeze(kept);
160
+ }
161
+ /**
162
+ * The document `command` would produce from the committed one, or why it is refused. Changes
163
+ * nothing.
164
+ */
165
+ canExecute(command) {
166
+ this.#active();
167
+ return executeCommand(this.#document, command);
168
+ }
169
+ /**
170
+ * Runs `command` on the committed document and commits the result as one undo step, cancelling an
171
+ * active gesture first. A refusal is returned, not thrown, and leaves the session unchanged; so
172
+ * does a command that changes nothing (its result is the committed document itself), which keeps
173
+ * the redo history.
174
+ */
175
+ dispatch(command) {
176
+ this.#active();
177
+ this.#cancelGesture?.();
178
+ const result = this.canExecute(command);
179
+ if (result.ok && result.value !== this.#document)
180
+ this.#commit(result.value);
181
+ return result;
182
+ }
183
+ /**
184
+ * Replaces the document, clearing history, selection and clipboard and increasing `generation`.
185
+ * Throws an `EditorError` when `document` does not validate, leaving the session unchanged.
186
+ */
187
+ open(document) {
188
+ this.#active();
189
+ document = withInstanceIds(document);
190
+ validateDocument(document);
191
+ const next = frozenValid(owned(document));
192
+ this.#cancelGesture?.();
193
+ this.#document = next;
194
+ this.#undo = [];
195
+ this.#redo = [];
196
+ this.#selection = Object.freeze([]);
197
+ this.#clipboard = [];
198
+ this.#generation++;
199
+ this.#revision++;
200
+ this.#emit();
201
+ }
202
+ /** Steps back one committed change. Returns `false` when there is none. */
203
+ undo() {
204
+ this.#active();
205
+ this.#cancelGesture?.();
206
+ const previous = this.#undo.pop();
207
+ if (!previous)
208
+ return false;
209
+ this.#redo.push(this.#document);
210
+ this.#document = previous;
211
+ this.#revision++;
212
+ this.#prune();
213
+ this.#emit();
214
+ return true;
215
+ }
216
+ /** Reapplies the last undone change. Returns `false` when there is none. */
217
+ redo() {
218
+ this.#active();
219
+ this.#cancelGesture?.();
220
+ const next = this.#redo.pop();
221
+ if (!next)
222
+ return false;
223
+ this.#undo.push(this.#document);
224
+ this.#document = next;
225
+ this.#revision++;
226
+ this.#prune();
227
+ this.#emit();
228
+ return true;
229
+ }
230
+ /**
231
+ * Forgets the undo and redo history and keeps everything else: the document (so `revision` and
232
+ * `generation` stay), the selection, the clipboard and an active gesture. For a host that made
233
+ * changes the user should not step back through, such as relinking media it restored, without
234
+ * {@link open}'s new generation, which tells views and the bridge the document was replaced.
235
+ */
236
+ clearHistory() {
237
+ this.#active();
238
+ if (!this.#undo.length && !this.#redo.length)
239
+ return;
240
+ this.#undo = [];
241
+ this.#redo = [];
242
+ this.#emit();
243
+ }
244
+ /**
245
+ * Selects the clips `ids` names; unknown ids and duplicates are dropped. The selection it would
246
+ * set already holds (the same ids in the same order), it changes nothing and tells no one: a
247
+ * marquee calls it on every pointer move.
248
+ */
249
+ select(ids) {
250
+ this.#active();
251
+ const index = indexDocument(this.#document);
252
+ const next = [...new Set(ids)].filter((id) => index.clips.has(id));
253
+ if (next.length === this.#selection.length &&
254
+ next.every((id, i) => id === this.#selection[i]))
255
+ return;
256
+ this.#selection = Object.freeze(next);
257
+ this.#emit();
258
+ }
259
+ /** The committed document's tracks and clips by id. */
260
+ query() {
261
+ return indexDocument(this.#document);
262
+ }
263
+ /**
264
+ * Copies the clips `ids` names (the selection by default) to the session's clipboard, with their
265
+ * names and the compositions they draw. Unknown ids are skipped.
266
+ */
267
+ copy(ids = this.#selection) {
268
+ this.#active();
269
+ const { project, metadata } = this.#document;
270
+ this.#clipboard = ids
271
+ .map((id) => this.query().clips.get(id))
272
+ .filter((v) => !!v)
273
+ .map(({ clip, trackId }) => ({
274
+ clip,
275
+ trackId,
276
+ name: metadata.clips[clip.id]?.name,
277
+ compositions: clip.composition
278
+ ? compositionTree(project, clip.composition.id)
279
+ : [],
280
+ }));
281
+ }
282
+ /**
283
+ * Inserts the clipboard's clips on their original tracks, under their names, as one undo step,
284
+ * the earliest starting at tick `at` and the others keeping their offsets from it. New clip ids
285
+ * come from `idFactory`. With nothing copied it changes nothing and returns the document.
286
+ */
287
+ paste(at, idFactory = () => crypto.randomUUID()) {
288
+ this.#active();
289
+ if (!this.#clipboard.length)
290
+ return { ok: true, value: this.#document };
291
+ const anchor = Math.min(...this.#clipboard.map((c) => c.clip.timeline_range.start));
292
+ const commands = [];
293
+ const moves = [];
294
+ for (const { clip, trackId, name, compositions } of this.#clipboard) {
295
+ const id = idFactory();
296
+ commands.push({
297
+ type: "clip.insert",
298
+ trackId,
299
+ clip: { ...clip, id },
300
+ ...(name ? { name } : {}),
301
+ ...(compositions.length ? { compositions } : {}),
302
+ });
303
+ moves.push({ id, at: at + clip.timeline_range.start - anchor });
304
+ }
305
+ // Insert each copy where its original is, then move them all off together in one `clips.move`.
306
+ commands.push({ type: "clips.move", moves });
307
+ return this.dispatch({ type: "transaction", commands });
308
+ }
309
+ /**
310
+ * Cancels the active gesture, if any, discarding its draft: nothing it previewed is committed.
311
+ * Returns whether a gesture was active. A host calls it before it tears down the view a gesture
312
+ * belongs to.
313
+ */
314
+ cancelGesture() {
315
+ const cancel = this.#cancelGesture;
316
+ if (!cancel)
317
+ return false;
318
+ cancel();
319
+ return true;
320
+ }
321
+ /**
322
+ * Starts a pointer gesture, cancelling an active one. Each `update` runs a command on the
323
+ * document the gesture started from and, when valid, shows the result as the snapshot's `draft`
324
+ * without touching history. `commit` validates the last candidate fully and commits it as one
325
+ * undo step, returning whether anything was committed; `cancel` discards it. A gesture ends on
326
+ * commit, cancel, a dispatch, undo, redo, open or dispose, or the next `beginGesture`; `update`
327
+ * then returns a `stale_gesture` error.
328
+ */
329
+ beginGesture() {
330
+ this.#active();
331
+ this.#cancelGesture?.();
332
+ const base = this.#document;
333
+ let candidate = base;
334
+ let active = true;
335
+ const end = () => {
336
+ active = false;
337
+ candidate = base;
338
+ if (this.#cancelGesture === cancel)
339
+ this.#cancelGesture = null;
340
+ const hadDraft = this.#draft !== null;
341
+ this.#draft = null;
342
+ return hadDraft;
343
+ };
344
+ const cancel = () => {
345
+ if (active && end() && !this.#disposed)
346
+ this.#emit();
347
+ };
348
+ this.#cancelGesture = cancel;
349
+ return {
350
+ get candidate() {
351
+ return candidate;
352
+ },
353
+ update: (command) => {
354
+ if (!active)
355
+ return {
356
+ ok: false,
357
+ error: new EditorError("stale_gesture", "Gesture is no longer active", "gesture"),
358
+ };
359
+ const result = executeCommand(base, command, { gesture: true });
360
+ if (result.ok) {
361
+ candidate = frozenValid(result.value);
362
+ this.#draft = candidate;
363
+ this.#emit();
364
+ }
365
+ return result;
366
+ },
367
+ cancel,
368
+ commit: () => {
369
+ if (!active)
370
+ return false;
371
+ const value = candidate;
372
+ const hadDraft = end();
373
+ if (value === base) {
374
+ if (hadDraft)
375
+ this.#emit();
376
+ return false;
377
+ }
378
+ // Updates validated only the candidate's shape; the commit passes every dispatch check.
379
+ try {
380
+ validateDocument(value);
381
+ }
382
+ catch {
383
+ if (!this.#disposed)
384
+ this.#emit();
385
+ return false;
386
+ }
387
+ this.#commit(value);
388
+ return true;
389
+ },
390
+ };
391
+ }
392
+ #owners = new Set();
393
+ #disposal = null;
394
+ /**
395
+ * Disposes `resource` when the session is disposed. Returns a function that releases it from the
396
+ * session without disposing it.
397
+ */
398
+ own(resource) {
399
+ this.#active();
400
+ this.#owners.add(resource);
401
+ return () => {
402
+ this.#owners.delete(resource);
403
+ };
404
+ }
405
+ /**
406
+ * Ends the session: cancels a gesture, drops listeners, history and clipboard, and disposes every
407
+ * owned resource. Idempotent; every call returns the same promise, which rejects with an
408
+ * `AggregateError` when any resource's disposal failed.
409
+ */
410
+ dispose() {
411
+ if (this.#disposal)
412
+ return this.#disposal;
413
+ this.#cancelGesture?.();
414
+ this.#disposed = true;
415
+ this.#generation++;
416
+ this.#listeners.clear();
417
+ this.#undo = [];
418
+ this.#redo = [];
419
+ this.#clipboard = [];
420
+ const resources = [...this.#owners];
421
+ this.#owners.clear();
422
+ this.#disposal = Promise.allSettled(resources.map((resource) => Promise.resolve().then(() => resource.dispose()))).then((results) => {
423
+ const failures = results.filter((r) => r.status === "rejected");
424
+ if (failures.length)
425
+ throw new AggregateError(failures.map((r) => r.reason), "Session cleanup failed");
426
+ });
427
+ return this.#disposal;
428
+ }
429
+ }
430
+ /** Creates an {@link EditorSession}; see its constructor for the options and their defaults. */
431
+ export function createEditor(options = {}) {
432
+ return new EditorSession(options.document, options.historyLimit, options.historyByteLimit);
433
+ }
434
+ /** {@link jsonBytes} of frozen objects, by identity: nothing can change them. */
435
+ const frozenBytes = new WeakMap();
436
+ /** UTF-8 bytes of `text` as a JSON string, quotes included and escapes not. */
437
+ function stringBytes(text) {
438
+ let bytes = 2;
439
+ for (let i = 0; i < text.length; i++) {
440
+ const unit = text.charCodeAt(i);
441
+ // A surrogate is half of a 4-byte character.
442
+ bytes +=
443
+ unit < 0x80
444
+ ? 1
445
+ : unit < 0x800
446
+ ? 2
447
+ : unit >= 0xd800 && unit < 0xe000
448
+ ? 2
449
+ : 3;
450
+ }
451
+ return bytes;
452
+ }
453
+ /** The bytes a primitive serializes to. */
454
+ function primitiveBytes(value) {
455
+ if (typeof value === "string")
456
+ return stringBytes(value);
457
+ if (typeof value === "number" ||
458
+ typeof value === "boolean" ||
459
+ typeof value === "bigint")
460
+ return String(value).length;
461
+ // `null`; `undefined`, functions and symbols are left out.
462
+ return value === null ? 4 : 0;
463
+ }
464
+ /** The fields a plain object serializes, as `JSON.stringify` writes them. */
465
+ function serializedEntries(value) {
466
+ return Object.entries(value).filter(([, field]) => field !== undefined && typeof field !== "function");
467
+ }
468
+ /**
469
+ * About the bytes `JSON.stringify(value)` writes (strings unescaped), without building the string.
470
+ * A frozen part's size is kept, so a part every document shares is measured once.
471
+ */
472
+ function jsonBytes(value) {
473
+ if (value === null || typeof value !== "object")
474
+ return primitiveBytes(value);
475
+ const known = frozenBytes.get(value);
476
+ if (known !== undefined)
477
+ return known;
478
+ let bytes;
479
+ if (Array.isArray(value)) {
480
+ bytes = 1 + Math.max(value.length, 1);
481
+ for (const item of value)
482
+ bytes += jsonBytes(item ?? null);
483
+ }
484
+ else {
485
+ const entries = serializedEntries(value);
486
+ bytes = 1 + Math.max(entries.length, 1);
487
+ for (const [key, field] of entries)
488
+ bytes += stringBytes(key) + 1 + jsonBytes(field);
489
+ }
490
+ if (Object.isFrozen(value))
491
+ frozenBytes.set(value, bytes);
492
+ return bytes;
493
+ }
494
+ /**
495
+ * About the bytes an undo step holds that `after`, the document after it, does not: the serialized
496
+ * size of its parts that are not the same objects as `after`'s. Edits share what they leave
497
+ * unchanged, so a step that renames a marker in a project with a 100,000-frame image sequence holds
498
+ * a few hundred bytes, not the sequence again. An array's item is matched to `after`'s by identity
499
+ * anywhere in the array, then by its `id`, then by position; a part moved elsewhere is counted
500
+ * whole, so the estimate errs high.
501
+ */
502
+ function stepBytes(step, after) {
503
+ if (step === after)
504
+ return 0;
505
+ if (step === null || typeof step !== "object")
506
+ return primitiveBytes(step);
507
+ if (after === null ||
508
+ typeof after !== "object" ||
509
+ Array.isArray(step) !== Array.isArray(after))
510
+ return jsonBytes(step);
511
+ if (Array.isArray(step)) {
512
+ const later = after;
513
+ const kept = new Set(later);
514
+ let byId = null;
515
+ const idOf = (item) => item !== null && typeof item === "object"
516
+ ? item.id
517
+ : undefined;
518
+ let bytes = 1 + Math.max(step.length, 1);
519
+ step.forEach((item, index) => {
520
+ if (kept.has(item))
521
+ return;
522
+ const id = idOf(item);
523
+ let counterpart = later[index];
524
+ if (typeof id === "string") {
525
+ byId ??= new Map(later.map((other) => [idOf(other), other]));
526
+ if (byId.has(id))
527
+ counterpart = byId.get(id);
528
+ }
529
+ bytes += stepBytes(item ?? null, counterpart);
530
+ });
531
+ return bytes;
532
+ }
533
+ const entries = serializedEntries(step);
534
+ let bytes = 1 + Math.max(entries.length, 1);
535
+ for (const [key, field] of entries)
536
+ bytes +=
537
+ stringBytes(key) +
538
+ 1 +
539
+ stepBytes(field, after[key]);
540
+ return bytes;
541
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * What a title's authoring style (`asset.authoring.text`) means where it leaves a field out: the
3
+ * browser runtime draws the title with these. A UI that shows a title's style reads its fallbacks
4
+ * here, so what it shows is what is drawn.
5
+ */
6
+ export declare const TEXT_DEFAULTS: Readonly<{
7
+ readonly content: "alpha2";
8
+ /** In output pixels. */
9
+ readonly fontSize: 120;
10
+ readonly fontFamily: "Inter, Segoe UI, system-ui, sans-serif";
11
+ readonly color: "#ffffff";
12
+ readonly align: "center";
13
+ /** Zero leaves the text unwrapped. */
14
+ readonly maxWidth: 0;
15
+ /** Baseline-to-baseline distance in font sizes. */
16
+ readonly lineHeight: 1.15;
17
+ }>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * What a title's authoring style (`asset.authoring.text`) means where it leaves a field out: the
3
+ * browser runtime draws the title with these. A UI that shows a title's style reads its fallbacks
4
+ * here, so what it shows is what is drawn.
5
+ */
6
+ export const TEXT_DEFAULTS = Object.freeze({
7
+ content: "alpha2",
8
+ /** In output pixels. */
9
+ fontSize: 120,
10
+ fontFamily: "Inter, Segoe UI, system-ui, sans-serif",
11
+ color: "#ffffff",
12
+ align: "center",
13
+ /** Zero leaves the text unwrapped. */
14
+ maxWidth: 0,
15
+ /** Baseline-to-baseline distance in font sizes. */
16
+ lineHeight: 1.15,
17
+ });
@@ -0,0 +1,19 @@
1
+ import { TICKS_PER_SECOND } from "alpha2/project";
2
+ export { TICKS_PER_SECOND };
3
+ /** A frame or sample rate as `num / den` per second; both positive safe integers. */
4
+ export interface Rational {
5
+ readonly num: number;
6
+ readonly den: number;
7
+ }
8
+ /**
9
+ * The first tick of frame `frame` at `fps`, rounded down. Throws a `RangeError` for a negative or
10
+ * unsafe frame, a non-positive rate, or a tick past `Number.MAX_SAFE_INTEGER`.
11
+ */
12
+ export declare function frameStart(frame: number, fps: Rational): number;
13
+ /**
14
+ * The frame containing `tick` at `fps`: the inverse of {@link frameStart}, whose frames are
15
+ * half-open `[start, next start)`, including at fractional rates. Throws like `frameStart`.
16
+ */
17
+ export declare function frameAt(tick: number, fps: Rational): number;
18
+ /** The first tick of audio sample `sample` at `sampleRate` Hz, rounded down. */
19
+ export declare function sampleStart(sample: number, sampleRate: number): number;
@@ -0,0 +1,39 @@
1
+ import { TICKS_PER_SECOND } from "alpha2/project";
2
+ export { TICKS_PER_SECOND };
3
+ function integer(n) {
4
+ if (!Number.isSafeInteger(n) || n < 0)
5
+ throw new RangeError("Expected a non-negative safe integer");
6
+ return BigInt(n);
7
+ }
8
+ function rate(r) {
9
+ const n = integer(r.num), d = integer(r.den);
10
+ if (!n || !d)
11
+ throw new RangeError("Rate must be positive");
12
+ return [n, d];
13
+ }
14
+ function safe(n) {
15
+ const v = Number(n);
16
+ if (!Number.isSafeInteger(v))
17
+ throw new RangeError("Tick overflow");
18
+ return v;
19
+ }
20
+ /**
21
+ * The first tick of frame `frame` at `fps`, rounded down. Throws a `RangeError` for a negative or
22
+ * unsafe frame, a non-positive rate, or a tick past `Number.MAX_SAFE_INTEGER`.
23
+ */
24
+ export function frameStart(frame, fps) {
25
+ const [n, d] = rate(fps);
26
+ return safe((integer(frame) * BigInt(TICKS_PER_SECOND) * d) / n);
27
+ }
28
+ /**
29
+ * The frame containing `tick` at `fps`: the inverse of {@link frameStart}, whose frames are
30
+ * half-open `[start, next start)`, including at fractional rates. Throws like `frameStart`.
31
+ */
32
+ export function frameAt(tick, fps) {
33
+ const [n, d] = rate(fps);
34
+ return safe(((integer(tick) + 1n) * n - 1n) / (BigInt(TICKS_PER_SECOND) * d));
35
+ }
36
+ /** The first tick of audio sample `sample` at `sampleRate` Hz, rounded down. */
37
+ export function sampleStart(sample, sampleRate) {
38
+ return frameStart(sample, { num: sampleRate, den: 1 });
39
+ }
@@ -0,0 +1,44 @@
1
+ import type { EditorDocument, Marker } from "./document.js";
2
+ /** One row of a timeline index: something placed in time the user can find and go to. */
3
+ export type IndexEntry = {
4
+ readonly kind: "clip";
5
+ readonly id: string;
6
+ /** The clip's own name, else its source's. */
7
+ readonly name: string;
8
+ readonly trackId: string;
9
+ readonly start: number;
10
+ readonly end: number;
11
+ } | {
12
+ readonly kind: "transition";
13
+ /** `from->to`, the clips it joins. */
14
+ readonly id: string;
15
+ /** The transition effect's id. */
16
+ readonly name: string;
17
+ readonly trackId: string;
18
+ readonly start: number;
19
+ readonly end: number;
20
+ } | {
21
+ readonly kind: "caption";
22
+ /** `trackId#index`. */
23
+ readonly id: string;
24
+ /** The cue's text. */
25
+ readonly name: string;
26
+ readonly trackId: string;
27
+ readonly start: number;
28
+ readonly end: number;
29
+ } | {
30
+ readonly kind: "marker";
31
+ readonly id: string;
32
+ readonly name: string;
33
+ readonly trackId: null;
34
+ readonly start: number;
35
+ /** A range marker's end, or its start for an instant. */
36
+ readonly end: number;
37
+ readonly marker: Marker;
38
+ };
39
+ /**
40
+ * Every clip, transition, caption cue, and marker of a document, in timeline order (then by kind,
41
+ * then id). Derived from the document alone and kept per document, so a playing viewer never
42
+ * rebuilds it: only an edit does.
43
+ */
44
+ export declare function timelineIndex(document: EditorDocument): readonly IndexEntry[];