@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,239 @@
|
|
|
1
|
+
import { SourcePixels } from "./runtime/sources.js";
|
|
2
|
+
import { AudioWindowSupplier, mixBlock } from "./runtime/audio.js";
|
|
3
|
+
/**
|
|
4
|
+
* Serves the editor's preview protocol (`createWorkerPreview`) on `scope`, a dedicated worker's
|
|
5
|
+
* global. Importing this module has no effect until this is called. Requests run one at a
|
|
6
|
+
* time in arrival order. A `cancel` or `replace-project` stops the render in flight and drops the
|
|
7
|
+
* seeks that arrived before it, not those sent after it. Requests from another session or an
|
|
8
|
+
* older revision are answered with an error.
|
|
9
|
+
* @param {import("./types.js").EditorWorkerScope} scope
|
|
10
|
+
* @returns {{ dispose(): Promise<void> }} Waits for queued requests, releases the engine and
|
|
11
|
+
* decoders, and detaches the message handler.
|
|
12
|
+
*/
|
|
13
|
+
export function installEditorWorker(scope) {
|
|
14
|
+
let engine = null, pixels = null, audio = null, sessionId = null, bitmapOutput = false, currentProject = null, revision = -1, tail = Promise.resolve();
|
|
15
|
+
const assets = new Map(), library = { get: (id) => assets.get(id) };
|
|
16
|
+
const context = () => ({
|
|
17
|
+
width: engine.project.settings.width,
|
|
18
|
+
height: engine.project.settings.height,
|
|
19
|
+
liveSourceIds: new Set(engine.project.sources.map((s) => s.id)),
|
|
20
|
+
getLayer: (id) => {
|
|
21
|
+
const asset = library.get(id);
|
|
22
|
+
return asset?.kind === "text" ? { id, kind: "image", asset } : asset;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
// Counts cancels and project replacements. A request remembers the count on arrival, so work
|
|
26
|
+
// queued before a cancel or replacement is dropped however long it waited.
|
|
27
|
+
let epoch = 0;
|
|
28
|
+
scope.onmessage = ({ data: request }) => {
|
|
29
|
+
if (request.type === "cancel" || request.type === "replace-project") {
|
|
30
|
+
epoch += 1;
|
|
31
|
+
// The render in flight is for what this replaces: it stops.
|
|
32
|
+
engine?.invalidate();
|
|
33
|
+
}
|
|
34
|
+
const arrived = epoch;
|
|
35
|
+
tail = tail.then(async () => {
|
|
36
|
+
const { requestId, type } = request;
|
|
37
|
+
const response = {
|
|
38
|
+
sessionId: request.sessionId,
|
|
39
|
+
revision: request.revision,
|
|
40
|
+
requestId,
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
if (type === "init") {
|
|
44
|
+
if (engine)
|
|
45
|
+
throw Error("Worker already initialized");
|
|
46
|
+
sessionId = request.sessionId;
|
|
47
|
+
bitmapOutput = request.bitmapOutput === true;
|
|
48
|
+
pixels = new SourcePixels(library, {
|
|
49
|
+
extensionsWasmUrl: request.runtimeAssets?.extensionsWasmUrl,
|
|
50
|
+
});
|
|
51
|
+
const { Engine } = await import("./runtime/engine.js");
|
|
52
|
+
engine = new Engine(request.canvas ??
|
|
53
|
+
new OffscreenCanvas(request.width ?? 16, request.height ?? 16), pixels, library, request.runtimeAssets);
|
|
54
|
+
audio = new AudioWindowSupplier(library);
|
|
55
|
+
await engine.start();
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
if (!engine || request.sessionId !== sessionId)
|
|
59
|
+
throw Error("Stale worker session");
|
|
60
|
+
if (request.revision < revision)
|
|
61
|
+
throw Error("Stale worker revision");
|
|
62
|
+
if (type === "replace-project") {
|
|
63
|
+
// What the previous project sent and still uses stays, decoders included.
|
|
64
|
+
const keep = new Set(request.keep ?? []);
|
|
65
|
+
for (const id of [...assets.keys()])
|
|
66
|
+
if (!keep.has(id))
|
|
67
|
+
assets.delete(id);
|
|
68
|
+
await pixels.release(keep);
|
|
69
|
+
for (const asset of request.assets ?? [])
|
|
70
|
+
assets.set(asset.id, asset);
|
|
71
|
+
if (request.fonts)
|
|
72
|
+
engine.setCaptionFonts(request.fonts);
|
|
73
|
+
currentProject = request.project;
|
|
74
|
+
if (currentProject.timeline.tracks.some((track) => (track.clips?.length ?? 0) +
|
|
75
|
+
(track.captions?.cues.length ?? 0)))
|
|
76
|
+
engine.loadProject(request.project);
|
|
77
|
+
engine.resize(request.project.settings.width, request.project.settings.height);
|
|
78
|
+
pixels.setOutputSize(request.project.settings.width, request.project.settings.height);
|
|
79
|
+
audio.configure(engine.audioEngine, request.project);
|
|
80
|
+
revision = request.revision;
|
|
81
|
+
}
|
|
82
|
+
else if (type === "seek") {
|
|
83
|
+
const generation = engine.generation;
|
|
84
|
+
const rendered = arrived !== epoch ||
|
|
85
|
+
!currentProject?.timeline.tracks.some((track) => (track.clips?.length ?? 0) +
|
|
86
|
+
(track.captions?.cues.length ?? 0))
|
|
87
|
+
? false
|
|
88
|
+
: await engine.render(request.tick / 120000, {
|
|
89
|
+
generation,
|
|
90
|
+
context: context(),
|
|
91
|
+
});
|
|
92
|
+
const bitmap = rendered && bitmapOutput
|
|
93
|
+
? engine.canvas.transferToImageBitmap()
|
|
94
|
+
: null;
|
|
95
|
+
scope.postMessage({
|
|
96
|
+
...response,
|
|
97
|
+
ok: true,
|
|
98
|
+
value: {
|
|
99
|
+
rendered,
|
|
100
|
+
diagnostics: pixels.diagnostics,
|
|
101
|
+
...(bitmap ? { bitmap } : {}),
|
|
102
|
+
},
|
|
103
|
+
}, bitmap ? [bitmap] : []);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
else if (type === "audio-mix") {
|
|
107
|
+
await audio.supply(request.startSample, request.frames);
|
|
108
|
+
const planes = mixBlock(engine.audioEngine, request.startSample, request.frames, false);
|
|
109
|
+
const owned = planes.map((p) => p.slice());
|
|
110
|
+
scope.postMessage({ ...response, ok: true, value: owned }, owned.map((p) => p.buffer));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
else if (type === "resize")
|
|
114
|
+
engine.resize(request.width, request.height);
|
|
115
|
+
else if (type === "register-fonts")
|
|
116
|
+
engine.setCaptionFonts(request.fonts);
|
|
117
|
+
else if (type === "dispose") {
|
|
118
|
+
await audio.close();
|
|
119
|
+
await engine.dispose();
|
|
120
|
+
await pixels.dispose();
|
|
121
|
+
assets.clear();
|
|
122
|
+
engine = null;
|
|
123
|
+
}
|
|
124
|
+
else if (type !== "cancel")
|
|
125
|
+
throw Error(`Unknown editor worker request ${type}`);
|
|
126
|
+
}
|
|
127
|
+
scope.postMessage({ ...response, ok: true });
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
scope.postMessage({
|
|
131
|
+
...response,
|
|
132
|
+
ok: false,
|
|
133
|
+
error: {
|
|
134
|
+
code: error.code ?? "worker_error",
|
|
135
|
+
message: error.message ?? String(error),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
async dispose() {
|
|
143
|
+
await tail;
|
|
144
|
+
await audio?.close();
|
|
145
|
+
await engine?.dispose();
|
|
146
|
+
await pixels?.dispose();
|
|
147
|
+
assets.clear();
|
|
148
|
+
scope.onmessage = null;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Drives a preview renderer in `worker`, which must run `installEditorWorker`. The caller owns
|
|
154
|
+
* the worker; `dispose()` terminates it. This port renders picture and mixes audio blocks only:
|
|
155
|
+
* it is separate from a bridge's main-thread audio output. Every call waits for `ready`, which
|
|
156
|
+
* rejects if the worker cannot start its engine; a disposed preview rejects new calls, and a
|
|
157
|
+
* worker error rejects every pending one.
|
|
158
|
+
* @param {Worker} worker
|
|
159
|
+
* @param {import("./types.js").WorkerPreviewOptions} [options]
|
|
160
|
+
* @returns {import("./types.js").WorkerPreview}
|
|
161
|
+
*/
|
|
162
|
+
export function createWorkerPreview(worker, { sessionId = crypto.randomUUID(), runtimeAssets = {}, canvas, bitmapOutput = false, width = 16, height = 16, } = {}) {
|
|
163
|
+
let nextId = 0, revision = 0, disposed = false;
|
|
164
|
+
const pending = new Map();
|
|
165
|
+
const rejectAll = (error) => {
|
|
166
|
+
for (const waiter of pending.values())
|
|
167
|
+
waiter.reject(error);
|
|
168
|
+
pending.clear();
|
|
169
|
+
};
|
|
170
|
+
worker.onmessage = ({ data }) => {
|
|
171
|
+
if (data.sessionId !== sessionId) {
|
|
172
|
+
data.value?.bitmap?.close();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const waiter = pending.get(data.requestId);
|
|
176
|
+
if (!waiter) {
|
|
177
|
+
data.value?.bitmap?.close();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
pending.delete(data.requestId);
|
|
181
|
+
if (data.ok)
|
|
182
|
+
waiter.resolve(data.value);
|
|
183
|
+
else
|
|
184
|
+
waiter.reject(Object.assign(new Error(data.error.message), { code: data.error.code }));
|
|
185
|
+
};
|
|
186
|
+
worker.onerror = (event) => rejectAll(new Error(event.message));
|
|
187
|
+
const call = (type, body = {}, transfer = []) => {
|
|
188
|
+
if (disposed)
|
|
189
|
+
return Promise.reject(new Error("Worker preview disposed"));
|
|
190
|
+
const requestId = ++nextId;
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
pending.set(requestId, { resolve, reject });
|
|
193
|
+
try {
|
|
194
|
+
worker.postMessage({ type, sessionId, revision, requestId, ...body }, transfer);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
pending.delete(requestId);
|
|
198
|
+
reject(error);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
const ready = call("init", {
|
|
203
|
+
runtimeAssets,
|
|
204
|
+
bitmapOutput,
|
|
205
|
+
width,
|
|
206
|
+
height,
|
|
207
|
+
...(canvas ? { canvas } : {}),
|
|
208
|
+
}, canvas ? [canvas] : []);
|
|
209
|
+
return {
|
|
210
|
+
ready,
|
|
211
|
+
async replaceProject(project, nextRevision, assets = [], fonts = [], keep = []) {
|
|
212
|
+
await ready;
|
|
213
|
+
revision = nextRevision;
|
|
214
|
+
return call("replace-project", { project, assets, fonts, keep });
|
|
215
|
+
},
|
|
216
|
+
async seek(tick) {
|
|
217
|
+
await ready;
|
|
218
|
+
return call("seek", { tick });
|
|
219
|
+
},
|
|
220
|
+
async mix(startSample, frames) {
|
|
221
|
+
await ready;
|
|
222
|
+
return call("audio-mix", { startSample, frames });
|
|
223
|
+
},
|
|
224
|
+
cancel: () => call("cancel"),
|
|
225
|
+
async dispose() {
|
|
226
|
+
if (disposed)
|
|
227
|
+
return;
|
|
228
|
+
try {
|
|
229
|
+
await ready;
|
|
230
|
+
await call("dispose");
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
disposed = true;
|
|
234
|
+
worker.terminate();
|
|
235
|
+
rejectAll(new Error("Worker disposed"));
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CaptionCue, TextStyle } from "alpha2/project";
|
|
2
|
+
import type { AssetDescriptor, Json } from "./document.js";
|
|
3
|
+
import type { Command } from "./commands.js";
|
|
4
|
+
/** The bin asset kind a subtitle file (SRT or WebVTT) imports as. */
|
|
5
|
+
export declare const CAPTIONS_ASSET_KIND = "captions";
|
|
6
|
+
/**
|
|
7
|
+
* A subtitle file in the bin: its cues split into rows, none overlapping another in its row, as
|
|
8
|
+
* `parseCaptions` reads them. The cues travel in the asset's authoring data, so the asset needs no
|
|
9
|
+
* file to stay online and saves with the document. Inserting it adds one caption track per row.
|
|
10
|
+
*/
|
|
11
|
+
export declare function captionsAsset(id: string, name: string, rows: readonly (readonly CaptionCue[])[]): AssetDescriptor;
|
|
12
|
+
/**
|
|
13
|
+
* Whether a captions asset's authoring data is the cue rows {@link captionsAsset} writes;
|
|
14
|
+
* `validateDocument` refuses a captions asset with anything else.
|
|
15
|
+
*/
|
|
16
|
+
export declare function validCaptionsAuthoring(authoring: Json | undefined): boolean;
|
|
17
|
+
/** A captions asset's cue rows; empty for any other asset or malformed authoring data. */
|
|
18
|
+
export declare function captionRows(asset: AssetDescriptor | undefined): readonly (readonly CaptionCue[])[];
|
|
19
|
+
/** Where a captions asset's last cue ends, in ticks from its start. */
|
|
20
|
+
export declare function captionsDuration(asset: AssetDescriptor | undefined): number;
|
|
21
|
+
/**
|
|
22
|
+
* The commands that put a captions asset on the timeline: a new caption track for each of its rows,
|
|
23
|
+
* with every cue moved `at` ticks later, in `style` when given. Track IDs come from `newId`,
|
|
24
|
+
* `crypto.randomUUID` by default.
|
|
25
|
+
*
|
|
26
|
+
* Throws when the asset has no cues.
|
|
27
|
+
*/
|
|
28
|
+
export declare function captionTrackCommands(asset: AssetDescriptor, at: number, options?: {
|
|
29
|
+
newId?: () => string;
|
|
30
|
+
style?: TextStyle;
|
|
31
|
+
}): {
|
|
32
|
+
trackIds: string[];
|
|
33
|
+
commands: Command[];
|
|
34
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** The bin asset kind a subtitle file (SRT or WebVTT) imports as. */
|
|
2
|
+
export const CAPTIONS_ASSET_KIND = "captions";
|
|
3
|
+
/**
|
|
4
|
+
* A subtitle file in the bin: its cues split into rows, none overlapping another in its row, as
|
|
5
|
+
* `parseCaptions` reads them. The cues travel in the asset's authoring data, so the asset needs no
|
|
6
|
+
* file to stay online and saves with the document. Inserting it adds one caption track per row.
|
|
7
|
+
*/
|
|
8
|
+
export function captionsAsset(id, name, rows) {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
kind: CAPTIONS_ASSET_KIND,
|
|
12
|
+
name,
|
|
13
|
+
contentRevision: 0,
|
|
14
|
+
authoring: { captions: { rows } },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether `value` is a cue as a captions asset keeps it: text, and a non-empty range of safe
|
|
19
|
+
* integer ticks from the asset's start.
|
|
20
|
+
*/
|
|
21
|
+
function isCaptionCue(value) {
|
|
22
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
23
|
+
return false;
|
|
24
|
+
const { text, timeline_range: range } = value;
|
|
25
|
+
if (typeof range !== "object" || range === null)
|
|
26
|
+
return false;
|
|
27
|
+
const { start, end } = range;
|
|
28
|
+
return (typeof text === "string" &&
|
|
29
|
+
Number.isSafeInteger(start) &&
|
|
30
|
+
Number.isSafeInteger(end) &&
|
|
31
|
+
start >= 0 &&
|
|
32
|
+
start < end);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The cue rows a captions asset's authoring data holds, or `undefined` when it holds anything else:
|
|
36
|
+
* `{ captions: { rows } }`, each row a list of cues.
|
|
37
|
+
*/
|
|
38
|
+
function rowsOf(authoring) {
|
|
39
|
+
const captions = typeof authoring === "object" &&
|
|
40
|
+
authoring !== null &&
|
|
41
|
+
!Array.isArray(authoring)
|
|
42
|
+
? authoring.captions
|
|
43
|
+
: undefined;
|
|
44
|
+
const rows = typeof captions === "object" && captions !== null
|
|
45
|
+
? captions.rows
|
|
46
|
+
: undefined;
|
|
47
|
+
if (!Array.isArray(rows) ||
|
|
48
|
+
!rows.every((row) => Array.isArray(row) && row.every(isCaptionCue)))
|
|
49
|
+
return undefined;
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether a captions asset's authoring data is the cue rows {@link captionsAsset} writes;
|
|
54
|
+
* `validateDocument` refuses a captions asset with anything else.
|
|
55
|
+
*/
|
|
56
|
+
export function validCaptionsAuthoring(authoring) {
|
|
57
|
+
return rowsOf(authoring) !== undefined;
|
|
58
|
+
}
|
|
59
|
+
/** A captions asset's cue rows; empty for any other asset or malformed authoring data. */
|
|
60
|
+
export function captionRows(asset) {
|
|
61
|
+
if (asset?.kind !== CAPTIONS_ASSET_KIND)
|
|
62
|
+
return [];
|
|
63
|
+
return rowsOf(asset.authoring) ?? [];
|
|
64
|
+
}
|
|
65
|
+
/** Where a captions asset's last cue ends, in ticks from its start. */
|
|
66
|
+
export function captionsDuration(asset) {
|
|
67
|
+
let end = 0;
|
|
68
|
+
for (const row of captionRows(asset))
|
|
69
|
+
for (const cue of row)
|
|
70
|
+
end = Math.max(end, cue.timeline_range.end);
|
|
71
|
+
return end;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The commands that put a captions asset on the timeline: a new caption track for each of its rows,
|
|
75
|
+
* with every cue moved `at` ticks later, in `style` when given. Track IDs come from `newId`,
|
|
76
|
+
* `crypto.randomUUID` by default.
|
|
77
|
+
*
|
|
78
|
+
* Throws when the asset has no cues.
|
|
79
|
+
*/
|
|
80
|
+
export function captionTrackCommands(asset, at, options = {}) {
|
|
81
|
+
const rows = captionRows(asset).filter((row) => row.length > 0);
|
|
82
|
+
if (!rows.length)
|
|
83
|
+
throw Error(`${asset.name} has no captions to insert.`);
|
|
84
|
+
const newId = options.newId ?? (() => crypto.randomUUID());
|
|
85
|
+
const base = asset.name.replace(/\.(srt|vtt)$/i, "");
|
|
86
|
+
const trackIds = [];
|
|
87
|
+
const commands = rows.map((row, i) => {
|
|
88
|
+
const track = {
|
|
89
|
+
id: newId(),
|
|
90
|
+
name: rows.length > 1 ? `${base} ${i + 1}` : base,
|
|
91
|
+
kind: "caption",
|
|
92
|
+
clips: [],
|
|
93
|
+
captions: {
|
|
94
|
+
...(options.style ? { style: options.style } : {}),
|
|
95
|
+
cues: row.map((cue) => ({
|
|
96
|
+
...cue,
|
|
97
|
+
timeline_range: {
|
|
98
|
+
start: cue.timeline_range.start + at,
|
|
99
|
+
end: cue.timeline_range.end + at,
|
|
100
|
+
},
|
|
101
|
+
})),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
trackIds.push(track.id);
|
|
105
|
+
return { type: "track.add", track };
|
|
106
|
+
});
|
|
107
|
+
return { trackIds, commands };
|
|
108
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Clip } from "alpha2/project";
|
|
2
|
+
/** A frame rate as the document holds it. */
|
|
3
|
+
export interface FrameRateValue {
|
|
4
|
+
readonly num: number;
|
|
5
|
+
readonly den: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The source instant `clip`'s picture shows at timeline tick `tick`, as the engine draws it
|
|
9
|
+
* (`picture_source_time` in alpha2-eval). With `e` the ticks since the clip starts, `T` its
|
|
10
|
+
* timeline length, `S` its source length, and `F` one frame at `frameRate`:
|
|
11
|
+
*
|
|
12
|
+
* - forward: `floor(e*S/T)` past the source start;
|
|
13
|
+
* - reverse: the forward instant at the start of the mirrored project frame, less the time into the
|
|
14
|
+
* frame, so a reversed clip shows the forward clip's frames in the opposite order, each at the
|
|
15
|
+
* instant the forward clip shows it, at any rate and wherever the clip starts and ends (the
|
|
16
|
+
* source start where that falls before it);
|
|
17
|
+
* - freeze: its instant;
|
|
18
|
+
* - a repeating loop: `e mod S` past the source start;
|
|
19
|
+
* - a ping-pong loop: the same on even passes, and on odd ones the pass mirrored by frame in the
|
|
20
|
+
* same way.
|
|
21
|
+
*
|
|
22
|
+
* A clip with an empty range reads its source start.
|
|
23
|
+
*/
|
|
24
|
+
export declare function clipSourceTime(clip: Clip, tick: number, frameRate: FrameRateValue): number;
|
|
25
|
+
/**
|
|
26
|
+
* The first timeline tick at which `clip`'s picture shows source time `time` (rounded up to a
|
|
27
|
+
* tick), or where it never shows exactly that, the first that shows it passed in the direction of
|
|
28
|
+
* play: the inverse of {@link clipSourceTime} on a clip's first pass. A freeze shows its instant
|
|
29
|
+
* from its start.
|
|
30
|
+
*/
|
|
31
|
+
export declare function clipTimelineTime(clip: Clip, time: number, frameRate: FrameRateValue): number;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { TICKS_PER_SECOND } from "alpha2/project";
|
|
2
|
+
/**
|
|
3
|
+
* The source instant `clip`'s picture shows at timeline tick `tick`, as the engine draws it
|
|
4
|
+
* (`picture_source_time` in alpha2-eval). With `e` the ticks since the clip starts, `T` its
|
|
5
|
+
* timeline length, `S` its source length, and `F` one frame at `frameRate`:
|
|
6
|
+
*
|
|
7
|
+
* - forward: `floor(e*S/T)` past the source start;
|
|
8
|
+
* - reverse: the forward instant at the start of the mirrored project frame, less the time into the
|
|
9
|
+
* frame, so a reversed clip shows the forward clip's frames in the opposite order, each at the
|
|
10
|
+
* instant the forward clip shows it, at any rate and wherever the clip starts and ends (the
|
|
11
|
+
* source start where that falls before it);
|
|
12
|
+
* - freeze: its instant;
|
|
13
|
+
* - a repeating loop: `e mod S` past the source start;
|
|
14
|
+
* - a ping-pong loop: the same on even passes, and on odd ones the pass mirrored by frame in the
|
|
15
|
+
* same way.
|
|
16
|
+
*
|
|
17
|
+
* A clip with an empty range reads its source start.
|
|
18
|
+
*/
|
|
19
|
+
export function clipSourceTime(clip, tick, frameRate) {
|
|
20
|
+
const { timeline, source, span, length, per, frame } = parts(clip, frameRate);
|
|
21
|
+
const map = clip.time_map ?? { type: "forward" };
|
|
22
|
+
if (map.type === "freeze")
|
|
23
|
+
return map.source_time;
|
|
24
|
+
if (span <= 0n || length <= 0n)
|
|
25
|
+
return Number(source);
|
|
26
|
+
const elapsed = BigInt(Math.floor(tick)) - timeline;
|
|
27
|
+
let offset;
|
|
28
|
+
if (map.type === "reverse")
|
|
29
|
+
offset = mirrored(timeline, length, elapsed, span, frame, per);
|
|
30
|
+
else if (map.type === "loop") {
|
|
31
|
+
const within = euclid(elapsed, span);
|
|
32
|
+
const pass = floorDiv(elapsed, span);
|
|
33
|
+
offset =
|
|
34
|
+
map.mode === "ping_pong" && euclid(pass, 2n) === 1n
|
|
35
|
+
? mirrored(timeline + pass * span, span, within, span, frame, per)
|
|
36
|
+
: within;
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
offset = floorDiv(elapsed * span, length);
|
|
40
|
+
return Number(source + offset);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The first timeline tick at which `clip`'s picture shows source time `time` (rounded up to a
|
|
44
|
+
* tick), or where it never shows exactly that, the first that shows it passed in the direction of
|
|
45
|
+
* play: the inverse of {@link clipSourceTime} on a clip's first pass. A freeze shows its instant
|
|
46
|
+
* from its start.
|
|
47
|
+
*/
|
|
48
|
+
export function clipTimelineTime(clip, time, frameRate) {
|
|
49
|
+
const { timeline, source, span, length, per, frame } = parts(clip, frameRate);
|
|
50
|
+
const map = clip.time_map ?? { type: "forward" };
|
|
51
|
+
if (map.type === "freeze" || span <= 0n || length <= 0n)
|
|
52
|
+
return Number(timeline);
|
|
53
|
+
const k = BigInt(Math.ceil(time)) - source;
|
|
54
|
+
let elapsed;
|
|
55
|
+
if (map.type === "loop")
|
|
56
|
+
elapsed = k;
|
|
57
|
+
else if (map.type === "reverse") {
|
|
58
|
+
// The mirrored instant never rises with `e`: binary-search the first `e` showing `k` or earlier.
|
|
59
|
+
let low = 0n;
|
|
60
|
+
let high = length;
|
|
61
|
+
while (low < high) {
|
|
62
|
+
const middle = (low + high) / 2n;
|
|
63
|
+
if (mirrored(timeline, length, middle, span, frame, per) <= k)
|
|
64
|
+
high = middle;
|
|
65
|
+
else
|
|
66
|
+
low = middle + 1n;
|
|
67
|
+
}
|
|
68
|
+
elapsed = low;
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
elapsed = ceilDiv(k * length, span);
|
|
72
|
+
return Number(timeline + elapsed);
|
|
73
|
+
}
|
|
74
|
+
function parts(clip, frameRate) {
|
|
75
|
+
const timeline = BigInt(clip.timeline_range.start);
|
|
76
|
+
const source = BigInt(clip.source_range.start);
|
|
77
|
+
return {
|
|
78
|
+
timeline,
|
|
79
|
+
source,
|
|
80
|
+
length: BigInt(clip.timeline_range.end) - timeline,
|
|
81
|
+
span: BigInt(clip.source_range.end) - source,
|
|
82
|
+
frame: BigInt(TICKS_PER_SECOND) * BigInt(frameRate.den),
|
|
83
|
+
per: BigInt(frameRate.num),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The forward offset `floor(e' * scale / length)` the mirror of `elapsed` shows within `length`
|
|
88
|
+
* ticks from timeline tick `start`, by project frames of `frame / per` ticks (alpha2-eval's
|
|
89
|
+
* `Mirror::offset`): with `k0` and `k1` the first and last frames starting inside and `k` the one
|
|
90
|
+
* containing the instant (`k0` before it, with no time into it), `e'` is the start of `k0 + k1 - k`
|
|
91
|
+
* less the time into `k`, from `start`, or zero where that falls before it. A window no frame
|
|
92
|
+
* starts in shows its start.
|
|
93
|
+
*/
|
|
94
|
+
function mirrored(start, length, elapsed, scale, frame, per) {
|
|
95
|
+
const startOf = (k) => floorDiv(k * frame, per);
|
|
96
|
+
const frameOf = (tick) => floorDiv((tick + 1n) * per - 1n, frame);
|
|
97
|
+
const first = frameOf(start - 1n) + 1n;
|
|
98
|
+
const last = frameOf(start + length - 1n);
|
|
99
|
+
if (first > last)
|
|
100
|
+
return 0n;
|
|
101
|
+
const tick = start + elapsed;
|
|
102
|
+
let k = frameOf(tick);
|
|
103
|
+
let into = 0n;
|
|
104
|
+
if (k < first)
|
|
105
|
+
k = first;
|
|
106
|
+
else
|
|
107
|
+
into = tick - startOf(k);
|
|
108
|
+
const position = startOf(first + last - k) - into - start;
|
|
109
|
+
if (position <= 0n)
|
|
110
|
+
return 0n;
|
|
111
|
+
return floorDiv(position * scale, length);
|
|
112
|
+
}
|
|
113
|
+
function floorDiv(value, divisor) {
|
|
114
|
+
const quotient = value / divisor;
|
|
115
|
+
return value % divisor !== 0n && value < 0n !== divisor < 0n
|
|
116
|
+
? quotient - 1n
|
|
117
|
+
: quotient;
|
|
118
|
+
}
|
|
119
|
+
function ceilDiv(value, divisor) {
|
|
120
|
+
return -floorDiv(-value, divisor);
|
|
121
|
+
}
|
|
122
|
+
function euclid(value, divisor) {
|
|
123
|
+
const remainder = value % divisor;
|
|
124
|
+
return remainder < 0n ? remainder + divisor : remainder;
|
|
125
|
+
}
|