@alpha2-sh/editor 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +108 -2
  3. package/dist/browser/bridge.d.ts +44 -0
  4. package/dist/browser/bridge.js +2176 -0
  5. package/dist/browser/extensions-worker-entry.d.ts +1 -0
  6. package/dist/browser/extensions-worker-entry.js +3 -0
  7. package/dist/browser/extensions-worker.d.ts +46 -0
  8. package/dist/browser/extensions-worker.js +149 -0
  9. package/dist/browser/hash-worker-entry.d.ts +1 -0
  10. package/dist/browser/hash-worker-entry.js +3 -0
  11. package/dist/browser/hash-worker.d.ts +24 -0
  12. package/dist/browser/hash-worker.js +87 -0
  13. package/dist/browser/index.d.ts +11 -0
  14. package/dist/browser/index.js +9 -0
  15. package/dist/browser/jobs.d.ts +82 -0
  16. package/dist/browser/jobs.js +291 -0
  17. package/dist/browser/resources.d.ts +40 -0
  18. package/dist/browser/resources.js +83 -0
  19. package/dist/browser/runtime/assets.d.ts +233 -0
  20. package/dist/browser/runtime/assets.js +633 -0
  21. package/dist/browser/runtime/audio.d.ts +60 -0
  22. package/dist/browser/runtime/audio.js +549 -0
  23. package/dist/browser/runtime/budget.d.ts +11 -0
  24. package/dist/browser/runtime/budget.js +26 -0
  25. package/dist/browser/runtime/caption-fonts.d.ts +34 -0
  26. package/dist/browser/runtime/caption-fonts.js +69 -0
  27. package/dist/browser/runtime/color.d.ts +7 -0
  28. package/dist/browser/runtime/color.js +12 -0
  29. package/dist/browser/runtime/engine.d.ts +91 -0
  30. package/dist/browser/runtime/engine.js +352 -0
  31. package/dist/browser/runtime/export-audio.d.ts +23 -0
  32. package/dist/browser/runtime/export-audio.js +75 -0
  33. package/dist/browser/runtime/exporter.d.ts +140 -0
  34. package/dist/browser/runtime/exporter.js +751 -0
  35. package/dist/browser/runtime/extension-import.d.ts +196 -0
  36. package/dist/browser/runtime/extension-import.js +521 -0
  37. package/dist/browser/runtime/fingerprint.d.ts +57 -0
  38. package/dist/browser/runtime/fingerprint.js +157 -0
  39. package/dist/browser/runtime/generators.d.ts +188 -0
  40. package/dist/browser/runtime/generators.js +173 -0
  41. package/dist/browser/runtime/locks.d.ts +32 -0
  42. package/dist/browser/runtime/locks.js +86 -0
  43. package/dist/browser/runtime/output.d.ts +87 -0
  44. package/dist/browser/runtime/output.js +230 -0
  45. package/dist/browser/runtime/preview-utils.d.ts +3 -0
  46. package/dist/browser/runtime/preview-utils.js +49 -0
  47. package/dist/browser/runtime/proxies.d.ts +72 -0
  48. package/dist/browser/runtime/proxies.js +369 -0
  49. package/dist/browser/runtime/proxy-storage.d.ts +59 -0
  50. package/dist/browser/runtime/proxy-storage.js +119 -0
  51. package/dist/browser/runtime/sources.d.ts +82 -0
  52. package/dist/browser/runtime/sources.js +819 -0
  53. package/dist/browser/runtime/staging.d.ts +10 -0
  54. package/dist/browser/runtime/staging.js +32 -0
  55. package/dist/browser/runtime/still.d.ts +21 -0
  56. package/dist/browser/runtime/still.js +52 -0
  57. package/dist/browser/runtime/wasm.d.ts +9 -0
  58. package/dist/browser/runtime/wasm.js +20 -0
  59. package/dist/browser/types.d.ts +549 -0
  60. package/dist/browser/types.js +1 -0
  61. package/dist/browser/worker-entry.d.ts +1 -0
  62. package/dist/browser/worker-entry.js +2 -0
  63. package/dist/browser/worker.d.ts +24 -0
  64. package/dist/browser/worker.js +239 -0
  65. package/dist/core/captions.d.ts +34 -0
  66. package/dist/core/captions.js +108 -0
  67. package/dist/core/clip-time.d.ts +31 -0
  68. package/dist/core/clip-time.js +125 -0
  69. package/dist/core/commands.d.ts +413 -0
  70. package/dist/core/commands.js +1118 -0
  71. package/dist/core/compositions.d.ts +234 -0
  72. package/dist/core/compositions.js +851 -0
  73. package/dist/core/document.d.ts +245 -0
  74. package/dist/core/document.js +683 -0
  75. package/dist/core/keyframes.d.ts +82 -0
  76. package/dist/core/keyframes.js +153 -0
  77. package/dist/core/presets.d.ts +64 -0
  78. package/dist/core/presets.js +241 -0
  79. package/dist/core/session.d.ts +150 -0
  80. package/dist/core/session.js +541 -0
  81. package/dist/core/text.d.ts +17 -0
  82. package/dist/core/text.js +17 -0
  83. package/dist/core/time.d.ts +19 -0
  84. package/dist/core/time.js +39 -0
  85. package/dist/core/timeline-index.d.ts +44 -0
  86. package/dist/core/timeline-index.js +59 -0
  87. package/dist/core/transitions.d.ts +20 -0
  88. package/dist/core/transitions.js +118 -0
  89. package/dist/core/validated.d.ts +23 -0
  90. package/dist/core/validated.js +52 -0
  91. package/dist/index.d.ts +12 -0
  92. package/dist/index.js +11 -0
  93. package/dist/native-client/index.d.ts +131 -0
  94. package/dist/native-client/index.js +354 -0
  95. package/dist/ports/index.d.ts +146 -0
  96. package/dist/ports/index.js +1 -0
  97. package/dist/react/index.d.ts +37 -0
  98. package/dist/react/index.js +94 -0
  99. package/package.json +79 -4
@@ -0,0 +1,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
+ }