@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,354 @@
|
|
|
1
|
+
import { compileEditorDocument, EditorError, sequenceEnd, } from "../core/document.js";
|
|
2
|
+
import { ExportFailureError } from "alpha2/export-failure";
|
|
3
|
+
import { ExportJob, requireExportFrameRate } from "../browser/jobs.js";
|
|
4
|
+
/**
|
|
5
|
+
* Asks the service whether the file at `url` (an artifact's) can still be downloaded, without
|
|
6
|
+
* downloading it. Never rejects: a network error is `unreachable`, a 404 is `missing`, and a 410
|
|
7
|
+
* is `gone` with the reason the service gives (`expired` when it gives none).
|
|
8
|
+
*/
|
|
9
|
+
export async function nativeArtifactStatus(url, request = fetch) {
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await request(url, { method: "HEAD" });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { status: "unreachable" };
|
|
16
|
+
}
|
|
17
|
+
if (response.ok) {
|
|
18
|
+
const expires = Number(response.headers.get("x-alpha2-expires-at"));
|
|
19
|
+
return {
|
|
20
|
+
status: "available",
|
|
21
|
+
expiresAt: Number.isFinite(expires) && expires > 0 ? expires : null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (response.status === 410) {
|
|
25
|
+
// A HEAD answer has no body to say why; ask once more for it.
|
|
26
|
+
const detail = (await request(url)
|
|
27
|
+
.then((full) => full.json())
|
|
28
|
+
.catch(() => ({})));
|
|
29
|
+
return { status: "gone", reason: detail.reason ?? "expired" };
|
|
30
|
+
}
|
|
31
|
+
return { status: response.status === 404 ? "missing" : "unreachable" };
|
|
32
|
+
}
|
|
33
|
+
/** The error a failed or cancelled native job reports, carrying the host's failure. */
|
|
34
|
+
function nativeFailure(state, jobId) {
|
|
35
|
+
const message = state.error ?? "Native export cancelled";
|
|
36
|
+
if (state.phase === "cancelled")
|
|
37
|
+
return new DOMException(message, "AbortError");
|
|
38
|
+
const failure = state.failure ?? {
|
|
39
|
+
code: "UNKNOWN",
|
|
40
|
+
message,
|
|
41
|
+
origin: "node",
|
|
42
|
+
operation: "export",
|
|
43
|
+
backend: "native",
|
|
44
|
+
phase: "prepare",
|
|
45
|
+
resource: "unknown",
|
|
46
|
+
};
|
|
47
|
+
return new ExportFailureError({ ...failure, message, jobId });
|
|
48
|
+
}
|
|
49
|
+
/** How many times a job's state is asked for in a row before an attempt gives up on it. */
|
|
50
|
+
const STATUS_ATTEMPTS = 5;
|
|
51
|
+
/** The wait before asking for a job's state again, doubled each time (0.25 s to 2 s). */
|
|
52
|
+
const STATUS_RETRY_MS = 250;
|
|
53
|
+
function delay(ms, signal) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const stop = () => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
reject(new DOMException("Native export cancelled", "AbortError"));
|
|
58
|
+
};
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
signal.removeEventListener("abort", stop);
|
|
61
|
+
resolve();
|
|
62
|
+
}, ms);
|
|
63
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
64
|
+
if (signal.aborted)
|
|
65
|
+
stop();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A client for the native export service (`@alpha2-sh/node`'s HTTP host). Exports run on the service
|
|
70
|
+
* with the editor's retry policy; each job uploads its assets once and reuses them across attempts.
|
|
71
|
+
*/
|
|
72
|
+
export function createNativeClient(options) {
|
|
73
|
+
const request = options.fetch ?? fetch;
|
|
74
|
+
const base = options.baseUrl.replace(/\/$/, "");
|
|
75
|
+
const jobs = new Set();
|
|
76
|
+
let disposed = false;
|
|
77
|
+
// Set by `detach`: a job whose render was asked for is let go of, not removed.
|
|
78
|
+
let detached = false;
|
|
79
|
+
async function checked(path, init) {
|
|
80
|
+
const response = await request(`${base}${path}`, init);
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
throw Error(`Native host: ${await response.text()}`);
|
|
83
|
+
return response;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
/** The service's `/health` report. Rejects when the service answers with an error status. */
|
|
87
|
+
async capabilities() {
|
|
88
|
+
return (await checked("/health")).json();
|
|
89
|
+
},
|
|
90
|
+
/**
|
|
91
|
+
* Starts an export of `editor.document` on the service and returns its job. The document,
|
|
92
|
+
* `assets` and `settings` are captured now; later edits do not reach the job. `assets` may be
|
|
93
|
+
* a function, called once per upload with the document and the attempt's signal. The range is
|
|
94
|
+
* the document's export range, else the whole timeline. Throws when the client is disposed,
|
|
95
|
+
* and an `EditorError` `invalid_frame_rate` for a `frameRate` the engine does not read.
|
|
96
|
+
*/
|
|
97
|
+
startExport(editor, assets, settings = {}) {
|
|
98
|
+
if (disposed)
|
|
99
|
+
throw Error("Native client is disposed");
|
|
100
|
+
requireExportFrameRate(settings.frameRate);
|
|
101
|
+
const document = editor.document;
|
|
102
|
+
const capturedSettings = { ...settings };
|
|
103
|
+
const capturedAssets = typeof assets === "function" ? assets : [...assets];
|
|
104
|
+
const id = crypto.randomUUID();
|
|
105
|
+
const path = `/jobs/${id}`;
|
|
106
|
+
// Shared by attempts: an attempt resumes, and starts over only when the service lost the job.
|
|
107
|
+
let captured = null;
|
|
108
|
+
// Whether the service holds a job to clean up: from the acknowledged PUT on, uploads or not.
|
|
109
|
+
let allocated = false;
|
|
110
|
+
const acknowledged = new Set();
|
|
111
|
+
let uploaded = null;
|
|
112
|
+
// Whether a render was asked for: the service may be running one an earlier attempt started.
|
|
113
|
+
let posted = false;
|
|
114
|
+
const lost = () => {
|
|
115
|
+
allocated = false;
|
|
116
|
+
acknowledged.clear();
|
|
117
|
+
uploaded = null;
|
|
118
|
+
posted = false;
|
|
119
|
+
};
|
|
120
|
+
const allocate = async () => {
|
|
121
|
+
// Allocate without the cancellation signal: even a cancelled allocation is acknowledged
|
|
122
|
+
// before DELETE, so an orphan job cannot win that race.
|
|
123
|
+
try {
|
|
124
|
+
await checked(path, { method: "PUT" });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// An allocation whose answer was lost: the id is this job's alone, so a job there is it.
|
|
128
|
+
const existing = await request(`${base}${path}`).catch(() => null);
|
|
129
|
+
if (!existing?.ok)
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
allocated = true;
|
|
133
|
+
};
|
|
134
|
+
const upload = async (doc, signal) => {
|
|
135
|
+
if (!allocated)
|
|
136
|
+
await allocate();
|
|
137
|
+
captured ??=
|
|
138
|
+
typeof capturedAssets === "function"
|
|
139
|
+
? await capturedAssets(doc, signal)
|
|
140
|
+
: capturedAssets;
|
|
141
|
+
signal.throwIfAborted();
|
|
142
|
+
for (const asset of captured) {
|
|
143
|
+
const key = `${asset.kind}/${asset.id}`;
|
|
144
|
+
if (acknowledged.has(key))
|
|
145
|
+
continue;
|
|
146
|
+
await checked(`${path}/${asset.kind}/${encodeURIComponent(asset.id)}`, {
|
|
147
|
+
method: "PUT",
|
|
148
|
+
body: asset.blob,
|
|
149
|
+
signal,
|
|
150
|
+
headers: {
|
|
151
|
+
"x-alpha2-filename": encodeURIComponent(asset.name),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
acknowledged.add(key);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
assets: captured,
|
|
158
|
+
frames: new Map(captured.flatMap((asset) => asset.kind === "frames" && asset.locator !== undefined
|
|
159
|
+
? [[asset.locator, asset.id]]
|
|
160
|
+
: [])),
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* The job's state. A request that fails for a moment (the network, a proxy's 502) is asked
|
|
165
|
+
* again, backing off, so a long render is not given up for one lost answer; a 404 means the
|
|
166
|
+
* service lost the job, and the next attempt starts over.
|
|
167
|
+
*/
|
|
168
|
+
const status = async (signal, attempt) => {
|
|
169
|
+
for (let failures = 1;; failures += 1) {
|
|
170
|
+
signal.throwIfAborted();
|
|
171
|
+
let failure;
|
|
172
|
+
try {
|
|
173
|
+
const response = await request(`${base}${path}`, { signal });
|
|
174
|
+
if (response.status === 404) {
|
|
175
|
+
lost();
|
|
176
|
+
throw new ExportFailureError({
|
|
177
|
+
code: "HOST_EXITED",
|
|
178
|
+
message: "The native export service lost this job; it may have restarted",
|
|
179
|
+
origin: "browser",
|
|
180
|
+
operation: "export",
|
|
181
|
+
backend: "native",
|
|
182
|
+
phase: "render",
|
|
183
|
+
resource: "unknown",
|
|
184
|
+
jobId: id,
|
|
185
|
+
attemptId: attempt.id,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (response.ok)
|
|
189
|
+
return (await response.json());
|
|
190
|
+
failure = Error(`Native host: ${await response.text()}`);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (signal.aborted || error instanceof ExportFailureError)
|
|
194
|
+
throw error;
|
|
195
|
+
failure = error;
|
|
196
|
+
}
|
|
197
|
+
if (failures >= STATUS_ATTEMPTS)
|
|
198
|
+
throw failure;
|
|
199
|
+
await delay(STATUS_RETRY_MS * 2 ** (failures - 1), signal);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const render = async (doc, signal, progress, attempt) => {
|
|
203
|
+
uploaded ??= await upload(doc, signal);
|
|
204
|
+
const { assets: captured, frames } = uploaded;
|
|
205
|
+
const compiled = compileEditorDocument(doc);
|
|
206
|
+
const diagnostics = compiled.diagnostics;
|
|
207
|
+
// Sent whole: the host's conform decides which sources need media.
|
|
208
|
+
const project = compiled.project;
|
|
209
|
+
if (diagnostics.length)
|
|
210
|
+
throw Error(diagnostics.map((d) => d.message).join("; "));
|
|
211
|
+
signal.throwIfAborted();
|
|
212
|
+
// A render an earlier attempt asked for is followed, not asked again (the service refuses that
|
|
213
|
+
// while it runs). One that failed or never started is asked anew.
|
|
214
|
+
const running = posted
|
|
215
|
+
? !["preparing", "failed"].includes((await status(signal, attempt)).phase)
|
|
216
|
+
: false;
|
|
217
|
+
if (!running) {
|
|
218
|
+
posted = true;
|
|
219
|
+
await checked(path, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: { "content-type": "application/json" },
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
project,
|
|
224
|
+
...capturedSettings,
|
|
225
|
+
startTicks: doc.metadata.exportRange?.start ?? 0,
|
|
226
|
+
endTicks: doc.metadata.exportRange?.end ?? sequenceEnd(project),
|
|
227
|
+
fonts: captured
|
|
228
|
+
.filter((a) => a.kind === "caption-fonts")
|
|
229
|
+
.map((a) => a.id),
|
|
230
|
+
// Each image sequence's frame uploads, in project order.
|
|
231
|
+
frames: Object.fromEntries(project.sources.flatMap((source) => source.provider?.format === "image_sequence"
|
|
232
|
+
? [
|
|
233
|
+
[
|
|
234
|
+
source.id,
|
|
235
|
+
source.provider.frames.map((locator) => locator === null
|
|
236
|
+
? null
|
|
237
|
+
: (frames.get(locator) ?? null)),
|
|
238
|
+
],
|
|
239
|
+
]
|
|
240
|
+
: [])),
|
|
241
|
+
proxies: Object.fromEntries(captured
|
|
242
|
+
.filter((a) => a.kind === "proxies")
|
|
243
|
+
.map((a) => [a.id, { madeFrom: a.madeFrom }])),
|
|
244
|
+
media: capturedSettings.media ?? "original",
|
|
245
|
+
// A retry asks for less in flight: the host scales its own budgets.
|
|
246
|
+
...(attempt.profile.memoryScale < 1 || capturedSettings.memory
|
|
247
|
+
? {
|
|
248
|
+
memory: {
|
|
249
|
+
...capturedSettings.memory,
|
|
250
|
+
...(attempt.profile.memoryScale < 1
|
|
251
|
+
? { scale: attempt.profile.memoryScale }
|
|
252
|
+
: {}),
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
: {}),
|
|
256
|
+
attempt: attempt.id,
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
let published;
|
|
261
|
+
for (;;) {
|
|
262
|
+
const state = await status(signal, attempt);
|
|
263
|
+
if (state.phase === "failed" || state.phase === "cancelled")
|
|
264
|
+
throw nativeFailure(state, id);
|
|
265
|
+
if (state.phase === "completed") {
|
|
266
|
+
published = state.artifact;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
progress({
|
|
270
|
+
phase: state.phase === "finalizing" || state.phase === "publishing"
|
|
271
|
+
? state.phase
|
|
272
|
+
: "rendering",
|
|
273
|
+
fraction: state.fraction,
|
|
274
|
+
...(state.frames ? { frames: state.frames } : {}),
|
|
275
|
+
});
|
|
276
|
+
await delay(100, signal);
|
|
277
|
+
}
|
|
278
|
+
const artifact = published;
|
|
279
|
+
if (!artifact)
|
|
280
|
+
throw Error("The native service finished without a file to download");
|
|
281
|
+
// The file stays on the service until released, so a failed download retries without a rerender.
|
|
282
|
+
return {
|
|
283
|
+
kind: "url",
|
|
284
|
+
url: `${base}${path}/artifact`,
|
|
285
|
+
name: artifact.name,
|
|
286
|
+
bytes: artifact.bytes,
|
|
287
|
+
expiresAt: artifact.expiresAt,
|
|
288
|
+
release: async () => {
|
|
289
|
+
await checked(path, { method: "DELETE" });
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
const job = new ExportJob(editor.revision, document, {
|
|
294
|
+
run: render,
|
|
295
|
+
// Cleaned up once the job settles, never between attempts (a retry reuses the uploads). DELETE
|
|
296
|
+
// resolves only after the host exits and its files are removed.
|
|
297
|
+
async dispose({ ok }) {
|
|
298
|
+
if (ok || !allocated || (detached && posted))
|
|
299
|
+
return;
|
|
300
|
+
lost();
|
|
301
|
+
try {
|
|
302
|
+
await checked(path, { method: "DELETE" });
|
|
303
|
+
}
|
|
304
|
+
catch (cause) {
|
|
305
|
+
throw new EditorError("cancel_failed", "Native cleanup could not be confirmed. The host will expire an abandoned job.", "cancel", id, true, { cause });
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
}, () => jobs.delete(job), { id });
|
|
309
|
+
jobs.add(job);
|
|
310
|
+
return job;
|
|
311
|
+
},
|
|
312
|
+
/**
|
|
313
|
+
* What the service knows of job `id`, for a page that lost the job it started (it was
|
|
314
|
+
* reloaded): its state, and where its file can be downloaded once it is complete. Null when the
|
|
315
|
+
* service no longer has the job. Rejects on any other error status.
|
|
316
|
+
*/
|
|
317
|
+
async jobState(id) {
|
|
318
|
+
const response = await request(`${base}/jobs/${encodeURIComponent(id)}`);
|
|
319
|
+
if (response.status === 404)
|
|
320
|
+
return null;
|
|
321
|
+
if (!response.ok)
|
|
322
|
+
throw Error(`Native host: ${await response.text()}`);
|
|
323
|
+
const state = (await response.json());
|
|
324
|
+
return state.phase === "completed" && state.artifact
|
|
325
|
+
? { ...state, url: `${base}/jobs/${encodeURIComponent(id)}/artifact` }
|
|
326
|
+
: state;
|
|
327
|
+
},
|
|
328
|
+
/**
|
|
329
|
+
* Removes job `id` from the service: a render it runs stops, and its kept file is deleted. For
|
|
330
|
+
* a job this client did not start, such as one a page that is gone left (see
|
|
331
|
+
* {@link jobState}). Rejects when the service answers with an error status.
|
|
332
|
+
*/
|
|
333
|
+
async releaseJob(id) {
|
|
334
|
+
await checked(`/jobs/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
335
|
+
},
|
|
336
|
+
/** Cancels every running job and refuses new ones. Resolves once the cancellations settle. */
|
|
337
|
+
async dispose() {
|
|
338
|
+
disposed = true;
|
|
339
|
+
await Promise.all([...jobs].map((job) => job.cancel()));
|
|
340
|
+
},
|
|
341
|
+
/**
|
|
342
|
+
* Lets go of every running job and refuses new ones, for a page that is leaving, without
|
|
343
|
+
* cancelling what the service already renders: a job whose render was asked for goes on there
|
|
344
|
+
* and keeps its file, for a later page to find with {@link jobState}. A job still uploading is
|
|
345
|
+
* removed, since only this client could send the rest. The jobs here settle as cancelled.
|
|
346
|
+
* Resolves once they settle; a removal that fails is left to the service's lease.
|
|
347
|
+
*/
|
|
348
|
+
async detach() {
|
|
349
|
+
disposed = true;
|
|
350
|
+
detached = true;
|
|
351
|
+
await Promise.allSettled([...jobs].map((job) => job.cancel()));
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Selection } from "alpha2/assets";
|
|
2
|
+
import type { ExportFailure } from "alpha2/export-failure";
|
|
3
|
+
import type { RetryProfile } from "alpha2/export-retry";
|
|
4
|
+
import type { Project } from "alpha2/project";
|
|
5
|
+
import type { EditorDocument, AssetDescriptor } from "../core/document.js";
|
|
6
|
+
/** Shows a project in a preview surface. Implemented by the browser bridge and native previews. */
|
|
7
|
+
export interface PreviewBridge {
|
|
8
|
+
/**
|
|
9
|
+
* Replaces the shown project. `revision` identifies this version and increases with every call.
|
|
10
|
+
*/
|
|
11
|
+
replaceProject(project: Project, revision: number): Promise<void>;
|
|
12
|
+
/** Shows the frame at `tick` (timeline ticks). */
|
|
13
|
+
seek(tick: number): Promise<void>;
|
|
14
|
+
/** Releases the preview's resources. The bridge is unusable afterwards. */
|
|
15
|
+
dispose(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** Turns an imported input (a file, a URL) into a project source and its editor asset. */
|
|
18
|
+
export interface MediaAdapter<T = unknown> {
|
|
19
|
+
/**
|
|
20
|
+
* Makes `input` readable as a source. Rejects when the input cannot be read or `signal` aborts.
|
|
21
|
+
* The caller calls `dispose` once the source is no longer used.
|
|
22
|
+
*/
|
|
23
|
+
stage(input: T, signal: AbortSignal): Promise<{
|
|
24
|
+
source: Project["sources"][number];
|
|
25
|
+
asset: AssetDescriptor;
|
|
26
|
+
dispose(): void;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
/** Plays a project's audio and owns the playhead while it plays. */
|
|
30
|
+
export interface AudioTransport {
|
|
31
|
+
/** Moves the playhead to `tick` (timeline ticks), playing or not. */
|
|
32
|
+
seek(tick: number): void;
|
|
33
|
+
/** Starts playback from the playhead. Rejects when the output cannot start. */
|
|
34
|
+
play(): Promise<void>;
|
|
35
|
+
pause(): void;
|
|
36
|
+
/** The playhead, in timeline ticks. */
|
|
37
|
+
readonly tick: number;
|
|
38
|
+
dispose(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
/** An export job's progress, as its backends report it and `ExportJob.progress` holds it. */
|
|
41
|
+
export interface ExportProgress {
|
|
42
|
+
/**
|
|
43
|
+
* Where the job is. `rendering` counts frames; `finalizing` means every frame is encoded and the
|
|
44
|
+
* output is being flushed and closed; `publishing` that the file is being made available;
|
|
45
|
+
* `downloading` that a native result is crossing to this browser. Only `completed` means the
|
|
46
|
+
* result exists: 100 % of frames does not.
|
|
47
|
+
*/
|
|
48
|
+
readonly phase: "preparing" | "rendering" | "finalizing" | "publishing" | "downloading" | "retrying" | "completed" | "cancelling" | "cancelled" | "failed";
|
|
49
|
+
/** The whole job's share done, from 0 to 1. */
|
|
50
|
+
readonly fraction: number;
|
|
51
|
+
/** The attempt this progress belongs to, from 1; `retrying` names the one about to start. */
|
|
52
|
+
readonly attempt?: number;
|
|
53
|
+
/** While `retrying`: why, and how long until the next attempt starts. */
|
|
54
|
+
readonly retry?: {
|
|
55
|
+
readonly reason: string;
|
|
56
|
+
readonly delayMs: number;
|
|
57
|
+
};
|
|
58
|
+
/** Frames encoded, when the backend counts them. */
|
|
59
|
+
readonly frames?: {
|
|
60
|
+
readonly completed: number;
|
|
61
|
+
readonly total: number;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The file a completed export made: a `blob` in memory, a disk-backed `file` (temporary
|
|
66
|
+
* origin-private storage), a file the user picked (`saved`), or a `url` on an export service.
|
|
67
|
+
*/
|
|
68
|
+
export type ExportArtifact = ({
|
|
69
|
+
readonly kind: "blob";
|
|
70
|
+
readonly blob: Blob;
|
|
71
|
+
readonly name: string;
|
|
72
|
+
} | {
|
|
73
|
+
readonly kind: "file";
|
|
74
|
+
/** A disk-backed file: reading or downloading it does not load it into memory. */
|
|
75
|
+
readonly file: File;
|
|
76
|
+
readonly name: string;
|
|
77
|
+
/** Deletes the file. The artifact is unusable afterwards. */
|
|
78
|
+
release(): Promise<void>;
|
|
79
|
+
} | {
|
|
80
|
+
readonly kind: "saved";
|
|
81
|
+
/** The name of the file the user picked, which now holds the export. */
|
|
82
|
+
readonly name: string;
|
|
83
|
+
} | {
|
|
84
|
+
readonly kind: "url";
|
|
85
|
+
/** Where the file can be downloaded, any number of times, until it expires or is released. */
|
|
86
|
+
readonly url: string;
|
|
87
|
+
readonly name: string;
|
|
88
|
+
/** When the file stops being available, in milliseconds since the epoch. */
|
|
89
|
+
readonly expiresAt?: number;
|
|
90
|
+
/** Deletes the file behind `url`, when its holder allows it. */
|
|
91
|
+
release?(): Promise<void>;
|
|
92
|
+
}) & {
|
|
93
|
+
/** The media each source read, as the engine selected it, when the exporter reports it. */
|
|
94
|
+
readonly media?: Readonly<Record<string, Selection>>;
|
|
95
|
+
/** The file's length, when known. */
|
|
96
|
+
readonly bytes?: number;
|
|
97
|
+
/**
|
|
98
|
+
* What the exporter reported about the run: timings, `performance` (with `memory` and `output`),
|
|
99
|
+
* rates. A browser export adds `decodeFallbacks`: the sources whose frames were read by element
|
|
100
|
+
* seeks rather than decoded, with why, how many frames, and whether for good.
|
|
101
|
+
*/
|
|
102
|
+
readonly report?: Readonly<Record<string, unknown>>;
|
|
103
|
+
};
|
|
104
|
+
/** One run of an export job: a fresh device or host process, from the job's pinned document. */
|
|
105
|
+
export interface ExportAttempt {
|
|
106
|
+
/**
|
|
107
|
+
* `<job id>/<number>`: what a backend tags this run's work with, so a late answer from an earlier
|
|
108
|
+
* one is told apart.
|
|
109
|
+
*/
|
|
110
|
+
readonly id: string;
|
|
111
|
+
/** From 1. */
|
|
112
|
+
readonly number: number;
|
|
113
|
+
/**
|
|
114
|
+
* How much this run keeps in flight: a backend scales its byte budgets by `profile.memoryScale`.
|
|
115
|
+
*/
|
|
116
|
+
readonly profile: RetryProfile;
|
|
117
|
+
/** Why the previous attempt failed, when this one is a retry. */
|
|
118
|
+
readonly previous?: ExportFailure;
|
|
119
|
+
}
|
|
120
|
+
/** Runs export attempts for an export job, in the browser, a native host, or a service. */
|
|
121
|
+
export interface ExportBackend {
|
|
122
|
+
/** Runs one attempt. A retry is a new call, with the same document and a new `attempt`. */
|
|
123
|
+
run(document: EditorDocument, signal: AbortSignal, progress: (value: ExportProgress) => void, attempt: ExportAttempt): Promise<ExportArtifact>;
|
|
124
|
+
/**
|
|
125
|
+
* Called once, when the job has settled: after the attempt that succeeded, the last that failed,
|
|
126
|
+
* or the cancellation. What attempts share (uploads, a job on a service) is released here, not
|
|
127
|
+
* between attempts. Rejecting reports an unconfirmed cleanup.
|
|
128
|
+
*/
|
|
129
|
+
dispose?(outcome: {
|
|
130
|
+
readonly ok: boolean;
|
|
131
|
+
readonly cancelled: boolean;
|
|
132
|
+
}): Promise<void>;
|
|
133
|
+
}
|
|
134
|
+
/** Stores a serialized editor document. */
|
|
135
|
+
export interface PersistenceAdapter {
|
|
136
|
+
/** Returns the last saved JSON. Rejects when nothing can be read. */
|
|
137
|
+
load(): Promise<string>;
|
|
138
|
+
/** Stores `json`, replacing what was saved before. */
|
|
139
|
+
save(json: string): Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
/** Whether a feature works on this host. */
|
|
142
|
+
export interface Capability {
|
|
143
|
+
readonly available: boolean;
|
|
144
|
+
/** Why the feature is unavailable, for display; absent when it is available. */
|
|
145
|
+
readonly reason?: string;
|
|
146
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode, CSSProperties } from "react";
|
|
2
|
+
import type { EditorSession, EditorSnapshot } from "../core/session.js";
|
|
3
|
+
import type { BrowserBridge } from "../browser/types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Makes `editor` available to {@link useEditor} and {@link useEditorSelector} below it. The caller
|
|
6
|
+
* owns the session; mounting or unmounting a provider never disposes it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function EditorProvider({ editor, children, }: {
|
|
9
|
+
editor: EditorSession;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}): import("react").FunctionComponentElement<import("react").ProviderProps<EditorSession | null>>;
|
|
12
|
+
/** The session of the nearest {@link EditorProvider}. Throws when there is none. */
|
|
13
|
+
export declare function useEditor(): EditorSession;
|
|
14
|
+
/**
|
|
15
|
+
* Subscribes to one value derived from the session's snapshot; the component re-renders when it
|
|
16
|
+
* changes by `equal` (`Object.is` by default). Inline selectors are fine: the result is cached per
|
|
17
|
+
* snapshot and an `equal` result keeps the previous reference, even when the selector function
|
|
18
|
+
* itself is new on every render, so a selector that builds arrays or objects does not re-render
|
|
19
|
+
* in a loop. Throws without an {@link EditorProvider}.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useEditorSelector<T>(selector: (snapshot: EditorSnapshot) => T, equal?: (a: T, b: T) => boolean): T;
|
|
22
|
+
/**
|
|
23
|
+
* Shows `bridge`'s preview in a canvas filling a `div` (styled by `className` and `style`). The
|
|
24
|
+
* canvas is bound on mount and released on unmount; binds of one bridge run one at a time.
|
|
25
|
+
* `onReady` fires once the canvas is bound; `onError` receives a bind or release failure.
|
|
26
|
+
*/
|
|
27
|
+
export declare function EditorPreview({ bridge, className, style, onReady, onError, }: {
|
|
28
|
+
bridge: BrowserBridge;
|
|
29
|
+
className?: string;
|
|
30
|
+
style?: CSSProperties;
|
|
31
|
+
onReady?: () => void;
|
|
32
|
+
onError?: (error: unknown) => void;
|
|
33
|
+
}): import("react").DetailedReactHTMLElement<{
|
|
34
|
+
ref: import("react").RefObject<HTMLDivElement | null>;
|
|
35
|
+
className: string | undefined;
|
|
36
|
+
style: CSSProperties | undefined;
|
|
37
|
+
}, HTMLDivElement>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useEffect, useRef, useSyncExternalStore, } from "react";
|
|
2
|
+
const Context = createContext(null);
|
|
3
|
+
/**
|
|
4
|
+
* Makes `editor` available to {@link useEditor} and {@link useEditorSelector} below it. The caller
|
|
5
|
+
* owns the session; mounting or unmounting a provider never disposes it.
|
|
6
|
+
*/
|
|
7
|
+
export function EditorProvider({ editor, children, }) {
|
|
8
|
+
return createElement(Context.Provider, { value: editor }, children);
|
|
9
|
+
}
|
|
10
|
+
/** The session of the nearest {@link EditorProvider}. Throws when there is none. */
|
|
11
|
+
export function useEditor() {
|
|
12
|
+
const editor = useContext(Context);
|
|
13
|
+
if (!editor)
|
|
14
|
+
throw new Error("EditorProvider is missing");
|
|
15
|
+
return editor;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Subscribes to one value derived from the session's snapshot; the component re-renders when it
|
|
19
|
+
* changes by `equal` (`Object.is` by default). Inline selectors are fine: the result is cached per
|
|
20
|
+
* snapshot and an `equal` result keeps the previous reference, even when the selector function
|
|
21
|
+
* itself is new on every render, so a selector that builds arrays or objects does not re-render
|
|
22
|
+
* in a loop. Throws without an {@link EditorProvider}.
|
|
23
|
+
*/
|
|
24
|
+
export function useEditorSelector(selector, equal = Object.is) {
|
|
25
|
+
const editor = useEditor();
|
|
26
|
+
const cache = useRef(null);
|
|
27
|
+
const getSelection = () => {
|
|
28
|
+
const snapshot = editor.getSnapshot();
|
|
29
|
+
const previous = cache.current;
|
|
30
|
+
if (previous &&
|
|
31
|
+
previous.editor === editor &&
|
|
32
|
+
previous.snapshot === snapshot &&
|
|
33
|
+
previous.selector === selector)
|
|
34
|
+
return previous.value;
|
|
35
|
+
const next = selector(snapshot);
|
|
36
|
+
const value = previous && previous.editor === editor && equal(previous.value, next)
|
|
37
|
+
? previous.value
|
|
38
|
+
: next;
|
|
39
|
+
cache.current = { editor, snapshot, selector, value };
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
return useSyncExternalStore(editor.subscribe, getSelection, getSelection);
|
|
43
|
+
}
|
|
44
|
+
// Binds and unbinds are serialized per bridge, so a second surface can mount while the first is
|
|
45
|
+
// still initializing. Each effect run owns a fresh canvas, including StrictMode's double run.
|
|
46
|
+
const bindings = new WeakMap();
|
|
47
|
+
/**
|
|
48
|
+
* Shows `bridge`'s preview in a canvas filling a `div` (styled by `className` and `style`). The
|
|
49
|
+
* canvas is bound on mount and released on unmount; binds of one bridge run one at a time.
|
|
50
|
+
* `onReady` fires once the canvas is bound; `onError` receives a bind or release failure.
|
|
51
|
+
*/
|
|
52
|
+
export function EditorPreview({ bridge, className, style, onReady, onError, }) {
|
|
53
|
+
const host = useRef(null);
|
|
54
|
+
const callbacks = useRef({ onReady, onError });
|
|
55
|
+
callbacks.current = { onReady, onError };
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const container = host.current;
|
|
58
|
+
if (!container)
|
|
59
|
+
return;
|
|
60
|
+
const canvas = document.createElement("canvas");
|
|
61
|
+
canvas.setAttribute("aria-label", "Sequence preview");
|
|
62
|
+
canvas.style.cssText =
|
|
63
|
+
"display:block;width:100%;height:100%;object-fit:contain";
|
|
64
|
+
container.append(canvas);
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
let binding;
|
|
67
|
+
const setup = (bindings.get(bridge) ?? Promise.resolve())
|
|
68
|
+
.then(async () => {
|
|
69
|
+
if (cancelled)
|
|
70
|
+
return;
|
|
71
|
+
binding = await bridge.bindPreview(canvas);
|
|
72
|
+
if (cancelled) {
|
|
73
|
+
await binding.dispose();
|
|
74
|
+
binding = undefined;
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
callbacks.current.onReady?.();
|
|
78
|
+
})
|
|
79
|
+
.catch((error) => {
|
|
80
|
+
if (!cancelled)
|
|
81
|
+
callbacks.current.onError?.(error);
|
|
82
|
+
});
|
|
83
|
+
bindings.set(bridge, setup);
|
|
84
|
+
return () => {
|
|
85
|
+
cancelled = true;
|
|
86
|
+
canvas.remove();
|
|
87
|
+
const cleanup = setup.then(async () => {
|
|
88
|
+
await binding?.dispose();
|
|
89
|
+
});
|
|
90
|
+
bindings.set(bridge, cleanup.catch((error) => callbacks.current.onError?.(error)));
|
|
91
|
+
};
|
|
92
|
+
}, [bridge]);
|
|
93
|
+
return createElement("div", { ref: host, className, style });
|
|
94
|
+
}
|