@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,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
|
+
}
|