@alpha2-sh/editor 0.0.0 → 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 (98) hide show
  1. package/README.md +107 -1
  2. package/dist/browser/bridge.d.ts +44 -0
  3. package/dist/browser/bridge.js +2176 -0
  4. package/dist/browser/extensions-worker-entry.js +3 -0
  5. package/dist/browser/extensions-worker.d.ts +46 -0
  6. package/dist/browser/extensions-worker.js +149 -0
  7. package/dist/browser/hash-worker-entry.d.ts +1 -0
  8. package/dist/browser/hash-worker-entry.js +3 -0
  9. package/dist/browser/hash-worker.d.ts +24 -0
  10. package/dist/browser/hash-worker.js +87 -0
  11. package/dist/browser/index.d.ts +11 -0
  12. package/dist/browser/index.js +9 -0
  13. package/dist/browser/jobs.d.ts +82 -0
  14. package/dist/browser/jobs.js +291 -0
  15. package/dist/browser/resources.d.ts +40 -0
  16. package/dist/browser/resources.js +83 -0
  17. package/dist/browser/runtime/assets.d.ts +233 -0
  18. package/dist/browser/runtime/assets.js +633 -0
  19. package/dist/browser/runtime/audio.d.ts +60 -0
  20. package/dist/browser/runtime/audio.js +549 -0
  21. package/dist/browser/runtime/budget.d.ts +11 -0
  22. package/dist/browser/runtime/budget.js +26 -0
  23. package/dist/browser/runtime/caption-fonts.d.ts +34 -0
  24. package/dist/browser/runtime/caption-fonts.js +69 -0
  25. package/dist/browser/runtime/color.d.ts +7 -0
  26. package/dist/browser/runtime/color.js +12 -0
  27. package/dist/browser/runtime/engine.d.ts +91 -0
  28. package/dist/browser/runtime/engine.js +352 -0
  29. package/dist/browser/runtime/export-audio.d.ts +23 -0
  30. package/dist/browser/runtime/export-audio.js +75 -0
  31. package/dist/browser/runtime/exporter.d.ts +140 -0
  32. package/dist/browser/runtime/exporter.js +751 -0
  33. package/dist/browser/runtime/extension-import.d.ts +196 -0
  34. package/dist/browser/runtime/extension-import.js +521 -0
  35. package/dist/browser/runtime/fingerprint.d.ts +57 -0
  36. package/dist/browser/runtime/fingerprint.js +157 -0
  37. package/dist/browser/runtime/generators.d.ts +188 -0
  38. package/dist/browser/runtime/generators.js +173 -0
  39. package/dist/browser/runtime/locks.d.ts +32 -0
  40. package/dist/browser/runtime/locks.js +86 -0
  41. package/dist/browser/runtime/output.d.ts +87 -0
  42. package/dist/browser/runtime/output.js +230 -0
  43. package/dist/browser/runtime/preview-utils.d.ts +3 -0
  44. package/dist/browser/runtime/preview-utils.js +49 -0
  45. package/dist/browser/runtime/proxies.d.ts +72 -0
  46. package/dist/browser/runtime/proxies.js +369 -0
  47. package/dist/browser/runtime/proxy-storage.d.ts +59 -0
  48. package/dist/browser/runtime/proxy-storage.js +119 -0
  49. package/dist/browser/runtime/sources.d.ts +82 -0
  50. package/dist/browser/runtime/sources.js +819 -0
  51. package/dist/browser/runtime/staging.d.ts +10 -0
  52. package/dist/browser/runtime/staging.js +32 -0
  53. package/dist/browser/runtime/still.d.ts +21 -0
  54. package/dist/browser/runtime/still.js +52 -0
  55. package/dist/browser/runtime/wasm.d.ts +9 -0
  56. package/dist/browser/runtime/wasm.js +20 -0
  57. package/dist/browser/types.d.ts +549 -0
  58. package/dist/browser/types.js +1 -0
  59. package/dist/browser/worker-entry.d.ts +1 -0
  60. package/dist/browser/worker-entry.js +2 -0
  61. package/dist/browser/worker.d.ts +24 -0
  62. package/dist/browser/worker.js +239 -0
  63. package/dist/core/captions.d.ts +34 -0
  64. package/dist/core/captions.js +108 -0
  65. package/dist/core/clip-time.d.ts +31 -0
  66. package/dist/core/clip-time.js +125 -0
  67. package/dist/core/commands.d.ts +413 -0
  68. package/dist/core/commands.js +1118 -0
  69. package/dist/core/compositions.d.ts +234 -0
  70. package/dist/core/compositions.js +851 -0
  71. package/dist/core/document.d.ts +245 -0
  72. package/dist/core/document.js +683 -0
  73. package/dist/core/keyframes.d.ts +82 -0
  74. package/dist/core/keyframes.js +153 -0
  75. package/dist/core/presets.d.ts +64 -0
  76. package/dist/core/presets.js +241 -0
  77. package/dist/core/session.d.ts +150 -0
  78. package/dist/core/session.js +541 -0
  79. package/dist/core/text.d.ts +17 -0
  80. package/dist/core/text.js +17 -0
  81. package/dist/core/time.d.ts +19 -0
  82. package/dist/core/time.js +39 -0
  83. package/dist/core/timeline-index.d.ts +44 -0
  84. package/dist/core/timeline-index.js +59 -0
  85. package/dist/core/transitions.d.ts +20 -0
  86. package/dist/core/transitions.js +118 -0
  87. package/dist/core/validated.d.ts +23 -0
  88. package/dist/core/validated.js +52 -0
  89. package/dist/index.d.ts +12 -0
  90. package/dist/index.js +11 -0
  91. package/dist/native-client/index.d.ts +131 -0
  92. package/dist/native-client/index.js +354 -0
  93. package/dist/ports/index.d.ts +146 -0
  94. package/dist/ports/index.js +1 -0
  95. package/dist/react/index.d.ts +37 -0
  96. package/dist/react/index.js +94 -0
  97. package/package.json +69 -8
  98. /package/{index.js → dist/browser/extensions-worker-entry.d.ts} +0 -0
@@ -0,0 +1,7 @@
1
+ /** A straight sRGB `#rrggbb` color with `alpha` as an `alpha2_model::Rgba`. */
2
+ export function rgba(hex: any, alpha?: number): {
3
+ red: number;
4
+ green: number;
5
+ blue: number;
6
+ alpha: number;
7
+ };
@@ -0,0 +1,12 @@
1
+ // Editor colors (`#rrggbb` strings) in the engine's form.
2
+ /** A straight sRGB `#rrggbb` color with `alpha` as an `alpha2_model::Rgba`. */
3
+ export function rgba(hex, alpha = 1) {
4
+ const value = /^#?([0-9a-f]{6})$/i.exec(hex ?? "")?.[1] ?? "ffffff";
5
+ const channel = (index) => Number.parseInt(value.slice(index * 2, index * 2 + 2), 16) / 255;
6
+ return {
7
+ red: channel(0),
8
+ green: channel(1),
9
+ blue: channel(2),
10
+ alpha: Math.min(1, Math.max(0, Number.isFinite(alpha) ? alpha : 1)),
11
+ };
12
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The wasm renderer of one preview or export: owns the engine instance, its frame supply, and a
3
+ * render queue that collapses to the newest request.
4
+ */
5
+ export class Engine {
6
+ constructor(canvas: any, sources: any, library: any, runtimeAssets?: {});
7
+ runtimeAssets: {};
8
+ canvas: any;
9
+ sources: any;
10
+ library: any;
11
+ kind: string;
12
+ traceUploads: boolean;
13
+ lastDiagnostics: any;
14
+ lastRenderMs: number;
15
+ onDiagnostics: any;
16
+ start(): Promise<{
17
+ registeredEffects: any;
18
+ failures: never[];
19
+ effectCatalog: any;
20
+ }>;
21
+ catalog: any;
22
+ /** The renderer's part of the capability report: GPU, color, and playback. */
23
+ capabilityReport(): any;
24
+ /** A clock for one run of playback; see the engine's `PlaybackPolicy`. */
25
+ playbackClock(policy: any, frameRate: any, rate: any, tick: any, now: any): PlaybackClock;
26
+ /** Whether sound plays under `policy` at `rate`. */
27
+ audible(policy: any, rate: any): boolean;
28
+ get ready(): boolean;
29
+ get deviceLost(): any;
30
+ /** Raw source video the frame supply copied through script memory, cumulative. */
31
+ get sourceTransfer(): any;
32
+ /** The wasm object, for callers that drive audio directly. */
33
+ get wasm(): null;
34
+ /** Engine instance used by the realtime browser audio transport. */
35
+ get audioEngine(): null;
36
+ /** The project document most recently handed to the engine. */
37
+ get project(): null;
38
+ /**
39
+ * Loads `project` into the engine and syncs its LUT resources with the library: those the project
40
+ * draws (`drawnEffectResources`), the only ones a render asks for; a resource nothing drawn names
41
+ * is neither required nor registered. Bumps the revision so in-flight renders for the old project
42
+ * are dropped.
43
+ *
44
+ * Throws `missing_asset` naming a drawn resource the library does not hold, and
45
+ * `invalid_effect_resource` naming one the engine cannot parse; resources registered before the
46
+ * failure stay known, so the next load neither leaks nor repeats them.
47
+ */
48
+ loadProject(project: any, operation?: string): void;
49
+ /**
50
+ * Registers the fonts caption tracks draw with, in fallback order. An SVG's text draws with the
51
+ * same fonts, as the native host draws it with its caption fonts. Returns the fonts the engine
52
+ * could not parse, as `{ font, error }`: they are left out, and the others draw.
53
+ */
54
+ setCaptionFonts(fonts: any): {
55
+ font: any;
56
+ error: unknown;
57
+ }[];
58
+ resize(width: any, height: any): void;
59
+ /**
60
+ * Uploads the sources the engine needs at a timestamp without rendering.
61
+ *
62
+ * Export uses this: the engine owns its frame schedule, so the host only satisfies `frameNeeds`.
63
+ * The uploads stay until `beginExportFrame` renders their frame; retired earlier, a frame whose
64
+ * uploads outgrow the retained budget would evict its own first ones and wait forever.
65
+ */
66
+ supply(seconds: any, { context }: {
67
+ context: any;
68
+ }): Promise<boolean>;
69
+ /**
70
+ * Renders the next export frame (see `beginExport` on the engine). Once a ticket is not
71
+ * `waiting`, the engine has consumed what `supply` uploaded for it, so those uploads are retired
72
+ * here.
73
+ */
74
+ beginExportFrame(): any;
75
+ /** Invalidates outstanding playback renders, for example on seek or pause. */
76
+ invalidate(): number;
77
+ get generation(): number;
78
+ /**
79
+ * Requests one frame. Concurrent requests collapse to the most recent one so that scrubbing never
80
+ * queues a backlog of stale frames.
81
+ */
82
+ render(seconds: any, { generation, context }?: {
83
+ generation?: null | undefined;
84
+ }): Promise<any>;
85
+ get fps(): number;
86
+ resetFpsWindow(): void;
87
+ dispose(): Promise<void>;
88
+ free(): void;
89
+ #private;
90
+ }
91
+ import { PlaybackClock } from "alpha2/wasm";
@@ -0,0 +1,352 @@
1
+ import { PlaybackClock, WebEffectRegistry } from "alpha2/wasm";
2
+ import { loadEngineWasm } from "./wasm.js";
3
+ import { FrameSupply } from "alpha2/frame-supply";
4
+ import { secondsToTicks } from "alpha2/project";
5
+ import { registerCaptionFonts } from "./caption-fonts.js";
6
+ import { drawnEffectResources } from "../../core/compositions.js";
7
+ import { EditorError } from "../../core/document.js";
8
+ /** Frames' worth of rendered results the preview keeps; see `setResultCacheBudget`. */
9
+ const RESULT_CACHE_FRAMES = 6;
10
+ /** The diagnostics panel refreshes at this rate, not at the render rate. */
11
+ const DIAGNOSTICS_INTERVAL_MS = 250;
12
+ /**
13
+ * The wasm renderer of one preview or export: owns the engine instance, its frame supply, and a
14
+ * render queue that collapses to the newest request.
15
+ */
16
+ export class Engine {
17
+ #registry = null;
18
+ #project = null;
19
+ #supply = null;
20
+ #revision = 0;
21
+ #generation = 0;
22
+ #pending = null;
23
+ #loop = null;
24
+ #lastFrameAt = 0;
25
+ #lastDiagnosticsAt = 0;
26
+ #fps = [];
27
+ #effectResources = new Map();
28
+ /** What `supply` uploaded for the export frame the engine has not rendered yet. */
29
+ #exportUploads = [];
30
+ constructor(canvas, sources, library, runtimeAssets = {}) {
31
+ this.runtimeAssets = runtimeAssets;
32
+ this.canvas = canvas;
33
+ this.sources = sources;
34
+ this.library = library;
35
+ this.kind = "wasm";
36
+ this.traceUploads = false;
37
+ this.lastDiagnostics = null;
38
+ this.lastRenderMs = 0;
39
+ this.onDiagnostics = null;
40
+ }
41
+ async start() {
42
+ if (!("VideoFrame" in globalThis) || !("OffscreenCanvas" in globalThis)) {
43
+ throw new Error("WebCodecs and OffscreenCanvas are required by this demo");
44
+ }
45
+ await loadEngineWasm(this.runtimeAssets.wasmUrl);
46
+ this.#registry =
47
+ this.canvas instanceof OffscreenCanvas
48
+ ? await WebEffectRegistry.createForCanvas(this.canvas)
49
+ : await WebEffectRegistry.createForHtmlCanvas(this.canvas);
50
+ this.#effectResources = new Map();
51
+ this.#sizeResultCache();
52
+ this.#supply = new FrameSupply(this.#registry);
53
+ // `engine.traceUploads = true` in the console logs every source upload, to tell a host decode
54
+ // bug from an engine one when a clip shows another clip's picture.
55
+ this.#supply.onUpload = (entry) => {
56
+ if (this.traceUploads)
57
+ console.debug("[alpha2 upload]", entry);
58
+ };
59
+ const effectCatalog = JSON.parse(this.#registry.builtinCatalog());
60
+ this.catalog = effectCatalog;
61
+ return {
62
+ registeredEffects: effectCatalog.effects.length + effectCatalog.transitions.length,
63
+ failures: [],
64
+ effectCatalog,
65
+ };
66
+ }
67
+ /**
68
+ * Rendered results kept for revisits (a still, a paused or scrubbed-back frame): about six frames
69
+ * at the canvas size in half float, sized again whenever the canvas is.
70
+ */
71
+ #sizeResultCache() {
72
+ this.#registry.setResultCacheBudget(RESULT_CACHE_FRAMES * this.canvas.width * this.canvas.height * 8);
73
+ }
74
+ /** The renderer's part of the capability report: GPU, color, and playback. */
75
+ capabilityReport() {
76
+ return this.#registry
77
+ ? JSON.parse(this.#registry.capabilityReport())
78
+ : null;
79
+ }
80
+ /** A clock for one run of playback; see the engine's `PlaybackPolicy`. */
81
+ playbackClock(policy, frameRate, rate, tick, now) {
82
+ return new PlaybackClock(policy, frameRate.num, frameRate.den, rate, tick, now);
83
+ }
84
+ /** Whether sound plays under `policy` at `rate`. */
85
+ audible(policy, rate) {
86
+ return PlaybackClock.audible(policy, rate);
87
+ }
88
+ get ready() {
89
+ return this.#registry !== null;
90
+ }
91
+ get deviceLost() {
92
+ return this.#registry?.deviceLost ?? false;
93
+ }
94
+ /** Raw source video the frame supply copied through script memory, cumulative. */
95
+ get sourceTransfer() {
96
+ return (this.#supply?.sourceTransfer ?? {
97
+ planarCopyFrames: 0,
98
+ planarCopyBytes: 0,
99
+ });
100
+ }
101
+ /** The wasm object, for callers that drive audio directly. */
102
+ get wasm() {
103
+ return this.#registry;
104
+ }
105
+ /** Engine instance used by the realtime browser audio transport. */
106
+ get audioEngine() {
107
+ return this.#registry;
108
+ }
109
+ /** The project document most recently handed to the engine. */
110
+ get project() {
111
+ return this.#project;
112
+ }
113
+ /**
114
+ * Loads `project` into the engine and syncs its LUT resources with the library: those the project
115
+ * draws (`drawnEffectResources`), the only ones a render asks for; a resource nothing drawn names
116
+ * is neither required nor registered. Bumps the revision so in-flight renders for the old project
117
+ * are dropped.
118
+ *
119
+ * Throws `missing_asset` naming a drawn resource the library does not hold, and
120
+ * `invalid_effect_resource` naming one the engine cannot parse; resources registered before the
121
+ * failure stay known, so the next load neither leaks nor repeats them.
122
+ */
123
+ loadProject(project, operation = "preview") {
124
+ if (!this.#registry)
125
+ throw new Error("the renderer is not initialized");
126
+ const drawn = project.effect_resources?.length
127
+ ? drawnEffectResources(project)
128
+ : new Set();
129
+ // Parsing a LUT is not free and edits rarely change one, so an unchanged resource stays.
130
+ for (const resource of project.effect_resources ?? []) {
131
+ if (!drawn.has(resource.id))
132
+ continue;
133
+ const asset = this.library.get(resource.id);
134
+ if (!asset || asset.kind !== "lut3d")
135
+ throw new EditorError("missing_asset", `Relink ${resource.id} before ${operation}`, operation, resource.id);
136
+ if (this.#effectResources.get(resource.id) === asset.text)
137
+ continue;
138
+ try {
139
+ this.#registry.registerLut3d(resource.id, asset.text);
140
+ }
141
+ catch (error) {
142
+ throw new EditorError("invalid_effect_resource", `${asset.name ?? resource.id} is not a 3D LUT the engine reads: ${error instanceof Error ? error.message : String(error)}`, operation, resource.id, true, { cause: error });
143
+ }
144
+ this.#effectResources.set(resource.id, asset.text);
145
+ }
146
+ for (const id of [...this.#effectResources.keys()]) {
147
+ if (drawn.has(id))
148
+ continue;
149
+ this.#registry.unregisterEffectResource(id);
150
+ this.#effectResources.delete(id);
151
+ }
152
+ this.#registry.loadProject(JSON.stringify(project));
153
+ this.#project = project;
154
+ this.#revision += 1;
155
+ this.#registry.resetPlayer();
156
+ }
157
+ /**
158
+ * Registers the fonts caption tracks draw with, in fallback order. An SVG's text draws with the
159
+ * same fonts, as the native host draws it with its caption fonts. Returns the fonts the engine
160
+ * could not parse, as `{ font, error }`: they are left out, and the others draw.
161
+ */
162
+ setCaptionFonts(fonts) {
163
+ if (!this.#registry)
164
+ throw new Error("the renderer is not initialized");
165
+ const refused = registerCaptionFonts(this.#registry, fonts);
166
+ const left = new Set(refused.map(({ font }) => font));
167
+ this.sources.setFonts?.(fonts.filter((font) => !left.has(font)));
168
+ this.#revision += 1;
169
+ return refused;
170
+ }
171
+ resize(width, height) {
172
+ if (!this.#registry)
173
+ return;
174
+ // Assigning `canvas.width` resets the drawing buffer and surface, so skip an unchanged size.
175
+ if (this.canvas.width === width && this.canvas.height === height)
176
+ return;
177
+ this.canvas.width = width;
178
+ this.canvas.height = height;
179
+ this.#registry.resizeSurface?.(width, height);
180
+ this.#sizeResultCache();
181
+ this.#revision += 1;
182
+ }
183
+ /**
184
+ * Uploads the sources the engine needs at a timestamp without rendering.
185
+ *
186
+ * Export uses this: the engine owns its frame schedule, so the host only satisfies `frameNeeds`.
187
+ * The uploads stay until `beginExportFrame` renders their frame; retired earlier, a frame whose
188
+ * uploads outgrow the retained budget would evict its own first ones and wait forever.
189
+ */
190
+ async supply(seconds, { context }) {
191
+ if (!this.#registry)
192
+ throw new Error("the renderer is not initialized");
193
+ await this.sources.release(context.liveSourceIds);
194
+ const supplied = await this.#supply.supply(BigInt(secondsToTicks(seconds)), {
195
+ width: context.width,
196
+ height: context.height,
197
+ contentOf: this.#contentOf,
198
+ liveSourceIds: context.liveSourceIds,
199
+ getLayer: context.getLayer,
200
+ pixelsFor: this.sources.pixelsFor,
201
+ retainVideoConsumers: this.sources.retainConsumers,
202
+ isCurrent: () => true,
203
+ });
204
+ if (supplied)
205
+ this.#exportUploads.push(...supplied.uploaded);
206
+ return Boolean(supplied);
207
+ }
208
+ /**
209
+ * Renders the next export frame (see `beginExport` on the engine). Once a ticket is not
210
+ * `waiting`, the engine has consumed what `supply` uploaded for it, so those uploads are retired
211
+ * here.
212
+ */
213
+ beginExportFrame() {
214
+ if (!this.#registry)
215
+ throw new Error("the renderer is not initialized");
216
+ const ticket = this.#registry.beginExportFrame();
217
+ if (ticket.kind !== "waiting") {
218
+ this.#supply.retire(this.#exportUploads);
219
+ this.#exportUploads = [];
220
+ }
221
+ return ticket;
222
+ }
223
+ /**
224
+ * A source's pixels are the runtime asset the library opens for it: a relink, a provider setting,
225
+ * or a proxy is another object, and only that source is uploaded again. An SVG's are also the
226
+ * fonts its text draws with (see `SourcePixels.contentOf`).
227
+ */
228
+ #contentOf = (id) => {
229
+ const asset = this.library.get(id) ?? null;
230
+ return this.sources.contentOf ? this.sources.contentOf(asset) : asset;
231
+ };
232
+ /** Invalidates outstanding playback renders, for example on seek or pause. */
233
+ invalidate() {
234
+ this.#generation += 1;
235
+ return this.#generation;
236
+ }
237
+ get generation() {
238
+ return this.#generation;
239
+ }
240
+ /**
241
+ * Requests one frame. Concurrent requests collapse to the most recent one so that scrubbing never
242
+ * queues a backlog of stale frames.
243
+ */
244
+ render(seconds, { generation = null, context } = {}) {
245
+ if (!this.#registry)
246
+ return Promise.reject(new Error("the renderer is not initialized"));
247
+ return new Promise((resolve, reject) => {
248
+ if (this.#pending) {
249
+ this.#pending.seconds = seconds;
250
+ this.#pending.generation = generation;
251
+ this.#pending.context = context;
252
+ this.#pending.waiters.push({ resolve, reject });
253
+ }
254
+ else {
255
+ this.#pending = {
256
+ seconds,
257
+ generation,
258
+ context,
259
+ waiters: [{ resolve, reject }],
260
+ };
261
+ }
262
+ if (!this.#loop)
263
+ this.#loop = this.#drain();
264
+ });
265
+ }
266
+ async #drain() {
267
+ try {
268
+ while (this.#pending) {
269
+ const request = this.#pending;
270
+ this.#pending = null;
271
+ try {
272
+ const rendered = await this.#renderOnce(request);
273
+ request.waiters.forEach(({ resolve }) => resolve(rendered));
274
+ }
275
+ catch (error) {
276
+ request.waiters.forEach(({ reject }) => reject(error));
277
+ }
278
+ }
279
+ }
280
+ finally {
281
+ this.#loop = null;
282
+ if (this.#pending)
283
+ this.#loop = this.#drain();
284
+ }
285
+ }
286
+ async #renderOnce({ seconds, generation, context }) {
287
+ const revision = this.#revision;
288
+ const isCurrent = () => revision === this.#revision &&
289
+ (generation === null || generation === this.#generation);
290
+ if (!isCurrent())
291
+ return false;
292
+ await this.sources.release(context.liveSourceIds);
293
+ const started = performance.now();
294
+ const playback = await this.#supply.render(BigInt(secondsToTicks(seconds)), {
295
+ width: context.width,
296
+ height: context.height,
297
+ contentOf: this.#contentOf,
298
+ liveSourceIds: context.liveSourceIds,
299
+ getLayer: context.getLayer,
300
+ pixelsFor: this.sources.pixelsFor,
301
+ retainVideoConsumers: this.sources.retainConsumers,
302
+ isCurrent,
303
+ });
304
+ if (!playback)
305
+ return false;
306
+ this.lastRenderMs = performance.now() - started;
307
+ this.#sampleDiagnostics();
308
+ return true;
309
+ }
310
+ #sampleDiagnostics() {
311
+ const now = performance.now();
312
+ if (this.#lastFrameAt)
313
+ this.#fps.push(1000 / Math.max(1e-3, now - this.#lastFrameAt));
314
+ this.#lastFrameAt = now;
315
+ if (this.#fps.length > 45)
316
+ this.#fps.shift();
317
+ if (!this.#registry?.diagnostics)
318
+ return;
319
+ // Serializing a snapshot every frame would only slow the critical path.
320
+ if (now - this.#lastDiagnosticsAt < DIAGNOSTICS_INTERVAL_MS)
321
+ return;
322
+ this.#lastDiagnosticsAt = now;
323
+ const diagnostics = JSON.parse(this.#registry.diagnostics());
324
+ diagnostics.preview = { surface: "html-canvas" };
325
+ diagnostics.host = {
326
+ fps: Number(this.fps.toFixed(1)),
327
+ renderMs: Number(this.lastRenderMs.toFixed(2)),
328
+ uploadedFrames: this.#registry.uploadedFrameCount,
329
+ };
330
+ this.lastDiagnostics = diagnostics;
331
+ this.onDiagnostics?.(diagnostics);
332
+ }
333
+ get fps() {
334
+ if (this.#fps.length === 0)
335
+ return 0;
336
+ return (this.#fps.reduce((total, value) => total + value, 0) / this.#fps.length);
337
+ }
338
+ resetFpsWindow() {
339
+ this.#fps = [];
340
+ this.#lastFrameAt = 0;
341
+ }
342
+ async dispose() {
343
+ this.invalidate();
344
+ this.#revision += 1;
345
+ await this.#loop;
346
+ this.free();
347
+ }
348
+ free() {
349
+ this.#registry?.free();
350
+ this.#registry = null;
351
+ }
352
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The project-rate samples the running export's audio covers, as the engine reports them
3
+ * (`exportProgress().audioWindow`): `startSample` is the mix position written at the output's zero,
4
+ * `frames` the sample count. Read after `beginExport`.
5
+ */
6
+ export function exportAudioWindow(engine: any): {
7
+ startSample: number;
8
+ frames: any;
9
+ };
10
+ /**
11
+ * Writes the running export's audio track into `sink` as the video advances:
12
+ * `writeThrough(seconds)` mixes and encodes every block up to `seconds` past the output's zero
13
+ * (`Infinity` for the rest). Create it after `beginExport`.
14
+ */
15
+ export function exportAudioWriter({ engine, audioSupplier, sink, sampleRate, signal, metrics, enter, }: {
16
+ engine: any;
17
+ audioSupplier: any;
18
+ sink: any;
19
+ sampleRate: any;
20
+ signal: any;
21
+ metrics: any;
22
+ enter: any;
23
+ }): (seconds: any) => Promise<void>;
@@ -0,0 +1,75 @@
1
+ // Export audio covers the export's effective range (widened to the frame grid) as the engine
2
+ // decides it on every host; its first sample sits at the output's zero with the first frame.
3
+ import { missingAudioSources, mixBlock } from "./audio.js";
4
+ /**
5
+ * Audio samples mixed and encoded per block, as the native host mixes them; at most the engine's
6
+ * `audioMaxBlock`.
7
+ */
8
+ const AUDIO_BLOCK_FRAMES = 4_096;
9
+ /**
10
+ * The project-rate samples the running export's audio covers, as the engine reports them
11
+ * (`exportProgress().audioWindow`): `startSample` is the mix position written at the output's zero,
12
+ * `frames` the sample count. Read after `beginExport`.
13
+ */
14
+ export function exportAudioWindow(engine) {
15
+ const { audioWindow } = JSON.parse(engine.exportProgress());
16
+ return {
17
+ startSample: Number(audioWindow.startSample),
18
+ frames: audioWindow.frames,
19
+ };
20
+ }
21
+ /**
22
+ * Writes the running export's audio track into `sink` as the video advances:
23
+ * `writeThrough(seconds)` mixes and encodes every block up to `seconds` past the output's zero
24
+ * (`Infinity` for the rest). Create it after `beginExport`.
25
+ */
26
+ export function exportAudioWriter({ engine, audioSupplier, sink, sampleRate, signal, metrics, enter, }) {
27
+ const window = exportAudioWindow(engine);
28
+ const blockFrames = Math.min(AUDIO_BLOCK_FRAMES, engine.audioMaxBlock);
29
+ let written = 0;
30
+ return async function writeThrough(seconds) {
31
+ const until = Math.min(window.frames, Math.ceil(seconds * sampleRate));
32
+ while (written < until) {
33
+ if (signal?.aborted)
34
+ throw (signal.reason ?? new DOMException("Export cancelled", "AbortError"));
35
+ const offset = written;
36
+ const frames = Math.min(blockFrames, window.frames - offset);
37
+ const startSample = window.startSample + offset;
38
+ const decodeStarted = performance.now();
39
+ enter("decode", undefined);
40
+ await audioSupplier.supply(startSample, frames, { signal });
41
+ metrics.audioDecodeMs += performance.now() - decodeStarted;
42
+ const mixStarted = performance.now();
43
+ let [left, right] = mixBlock(engine, startSample, frames, false);
44
+ metrics.audioMixMs += performance.now() - mixStarted;
45
+ let missing = missingAudioSources(engine);
46
+ if (missing.length > 0) {
47
+ const retryDecodeStarted = performance.now();
48
+ await audioSupplier.supply(startSample, frames, {
49
+ signal,
50
+ forceSources: missing,
51
+ });
52
+ metrics.audioDecodeMs += performance.now() - retryDecodeStarted;
53
+ const retryMixStarted = performance.now();
54
+ [left, right] = mixBlock(engine, startSample, frames, false);
55
+ metrics.audioMixMs += performance.now() - retryMixStarted;
56
+ missing = missingAudioSources(engine);
57
+ }
58
+ if (missing.length > 0) {
59
+ throw new Error(`export is missing audio for ${missing.join(", ")}`);
60
+ }
61
+ const audioEncodeStarted = performance.now();
62
+ enter("encode", undefined);
63
+ await sink.writeAudio({
64
+ sourceId: "alpha2-web-mix",
65
+ startSample: BigInt(offset),
66
+ generation: 0,
67
+ sampleRate,
68
+ channels: [left, right],
69
+ });
70
+ metrics.audioEncodeMs += performance.now() - audioEncodeStarted;
71
+ metrics.audioBlocksInterleaved += 1;
72
+ written += frames;
73
+ }
74
+ };
75
+ }