@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,369 @@
|
|
|
1
|
+
// Proxies: lighter copies of a source's video, kept in OPFS under the original's content hash so a
|
|
2
|
+
// reopened project previews before its files are relinked. Whether a proxy may stand in for its
|
|
3
|
+
// source is the engine's decision. A proxy is streamed to its file as it is encoded; only a browser
|
|
4
|
+
// without writable OPFS keeps it in memory, for this page and within a size limit.
|
|
5
|
+
import { ALL_FORMATS, BlobSource, BufferTarget, Conversion, Input, Mp4OutputFormat, Output, StreamTarget, canEncodeAudio, } from "mediabunny";
|
|
6
|
+
import { MEMORY_OUTPUT_COPIES, WritableFileDestination, } from "alpha2/export-destination";
|
|
7
|
+
import { EditorError } from "../../core/document.js";
|
|
8
|
+
import { displaySize, probeEvidence } from "./fingerprint.js";
|
|
9
|
+
import { DEFAULT_MEMORY_OUTPUT_LIMIT } from "./output.js";
|
|
10
|
+
import { holdProxy, keptProxies, proxyDirectory, removeKeptProxies, removeUnfinishedProxies, unfinishedProxyName, whileMakingProxy, } from "./proxy-storage.js";
|
|
11
|
+
/** A proxy's longer displayed edge, in pixels. */
|
|
12
|
+
export const PROXY_LONG_EDGE = 960;
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{
|
|
15
|
+
* made_from: import("alpha2/project").MediaFingerprint,
|
|
16
|
+
* width: number,
|
|
17
|
+
* height: number,
|
|
18
|
+
* frame_rate?: { num: number, den: number },
|
|
19
|
+
* duration_ticks?: number,
|
|
20
|
+
* audio_sample_rate?: number,
|
|
21
|
+
* }} ProxyRecord
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Keeps proxies by their original's SHA-256: in OPFS where the browser can write files there, and
|
|
25
|
+
* otherwise in this page's memory, within {@link DEFAULT_MEMORY_OUTPUT_LIMIT} unless asked
|
|
26
|
+
* otherwise.
|
|
27
|
+
*/
|
|
28
|
+
export class ProxyStore {
|
|
29
|
+
/**
|
|
30
|
+
* Proxies this page keeps in memory, where the browser cannot keep them on disk.
|
|
31
|
+
* @type {Map<string, { record: ProxyRecord, file: File }>}
|
|
32
|
+
*/
|
|
33
|
+
#memory = new Map();
|
|
34
|
+
#memoryLimit;
|
|
35
|
+
constructor({ memoryLimit = DEFAULT_MEMORY_OUTPUT_LIMIT } = {}) {
|
|
36
|
+
this.#memoryLimit = memoryLimit;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Holds the proxy of `hash` in use until `release()`, so no page removes it meanwhile (see
|
|
40
|
+
* `holdProxy`).
|
|
41
|
+
*/
|
|
42
|
+
hold(hash) {
|
|
43
|
+
return holdProxy(hash);
|
|
44
|
+
}
|
|
45
|
+
/** The proxy made from the media with `hash`, if one is kept. */
|
|
46
|
+
async find(hash) {
|
|
47
|
+
const kept = this.#memory.get(hash);
|
|
48
|
+
if (kept)
|
|
49
|
+
return kept;
|
|
50
|
+
const directory = await proxyDirectory();
|
|
51
|
+
if (!directory)
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const record = JSON.parse(await (await (await directory.getFileHandle(`${hash}.json`)).getFile()).text());
|
|
55
|
+
const file = await (await directory.getFileHandle(`${hash}.mp4`)).getFile();
|
|
56
|
+
// Read each time: another page may have removed it since.
|
|
57
|
+
return { record, file };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The proxies kept: each original's hash and the bytes its proxy takes, on disk or in this page's
|
|
65
|
+
* memory.
|
|
66
|
+
*/
|
|
67
|
+
async list() {
|
|
68
|
+
const kept = new Map((await keptProxies()).map(({ hash, bytes }) => [hash, bytes]));
|
|
69
|
+
for (const [hash, { file }] of this.#memory)
|
|
70
|
+
kept.set(hash, (kept.get(hash) ?? 0) + file.size);
|
|
71
|
+
return [...kept].map(([hash, bytes]) => ({ hash, bytes }));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Removes the proxies of `hashes`, except those a page holds in use. Resolves to the hashes
|
|
75
|
+
* removed and those left in use.
|
|
76
|
+
*/
|
|
77
|
+
async remove(hashes) {
|
|
78
|
+
const wanted = [...new Set(hashes)];
|
|
79
|
+
for (const hash of wanted)
|
|
80
|
+
this.#memory.delete(hash);
|
|
81
|
+
return removeKeptProxies(wanted);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Makes a proxy of `file`, whose fingerprint is `fingerprint`, and keeps it. The video is scaled
|
|
85
|
+
* to fit {@link PROXY_LONG_EDGE} with a key frame every second; timestamps and the audio sample
|
|
86
|
+
* rate are the original's. A proxy already kept for the same bytes is returned instead. Pages of
|
|
87
|
+
* the origin make one hash's proxy in turn (`whileMakingProxy`).
|
|
88
|
+
*
|
|
89
|
+
* Refuses before encoding when the conversion would drop a track the fingerprint records. Rejects
|
|
90
|
+
* with `signal.reason` if `signal` aborts before the proxy is kept.
|
|
91
|
+
*/
|
|
92
|
+
async generate(file, fingerprint, { signal, onProgress } = {}) {
|
|
93
|
+
signal?.throwIfAborted();
|
|
94
|
+
const hash = fingerprint.content_sha256;
|
|
95
|
+
if (!hash)
|
|
96
|
+
throw new EditorError("unsupported_media", "A proxy needs the original's hash", "proxy");
|
|
97
|
+
const kept = await this.find(hash);
|
|
98
|
+
if (kept)
|
|
99
|
+
return kept;
|
|
100
|
+
return whileMakingProxy(hash, { signal }, async (held) => {
|
|
101
|
+
const made = await this.find(hash);
|
|
102
|
+
if (made)
|
|
103
|
+
return made;
|
|
104
|
+
const directory = held ? await proxyDirectory() : null;
|
|
105
|
+
if (directory)
|
|
106
|
+
await removeUnfinishedProxies(directory, hash);
|
|
107
|
+
// Held while it is made, so no page removes it before it is returned.
|
|
108
|
+
const hold = await holdProxy(hash);
|
|
109
|
+
try {
|
|
110
|
+
return await this.#make(file, fingerprint, hash, {
|
|
111
|
+
signal,
|
|
112
|
+
onProgress,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
void hold.release();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Makes the proxy of `file`, as {@link generate} says, holding the turn to make it. */
|
|
121
|
+
async #make(file, fingerprint, hash, { signal, onProgress }) {
|
|
122
|
+
signal?.throwIfAborted();
|
|
123
|
+
const input = new Input({
|
|
124
|
+
source: new BlobSource(file),
|
|
125
|
+
formats: ALL_FORMATS,
|
|
126
|
+
});
|
|
127
|
+
// Installed before the setup's awaits, so an abort that comes first cancels the conversion once
|
|
128
|
+
// it exists.
|
|
129
|
+
let cancel = () => { };
|
|
130
|
+
const onAbort = () => cancel();
|
|
131
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
132
|
+
try {
|
|
133
|
+
const video = await input.getPrimaryVideoTrack();
|
|
134
|
+
if (!video)
|
|
135
|
+
throw new EditorError("unsupported_media", `${file.name} has no video to proxy`, "proxy");
|
|
136
|
+
const space = await video.getColorSpace().catch(() => null);
|
|
137
|
+
// The browser's scaler tone-maps HDR its own way, so the proxy would not match the original.
|
|
138
|
+
if (space?.transfer === "pq" || space?.transfer === "hlg")
|
|
139
|
+
throw new EditorError("unsupported_media", `${file.name} is HDR; the browser cannot make a proxy that keeps its color`, "proxy");
|
|
140
|
+
// A proxy has square pixels, so it takes the original's displayed size.
|
|
141
|
+
const shown = await displaySize(video);
|
|
142
|
+
const scale = Math.min(1, PROXY_LONG_EDGE / Math.max(shown.width, shown.height));
|
|
143
|
+
const even = (value) => Math.max(2, 2 * Math.round((value * scale) / 2));
|
|
144
|
+
const audio = await input.getPrimaryAudioTrack();
|
|
145
|
+
// AAC keeps any sample rate, else PCM; Opus would resample and the engine
|
|
146
|
+
// would refuse the proxy.
|
|
147
|
+
const audioCodec = audio &&
|
|
148
|
+
(await canEncodeAudio("aac", {
|
|
149
|
+
numberOfChannels: audio.numberOfChannels,
|
|
150
|
+
sampleRate: audio.sampleRate,
|
|
151
|
+
}).catch(() => false))
|
|
152
|
+
? "aac"
|
|
153
|
+
: "pcm-s16";
|
|
154
|
+
signal?.throwIfAborted();
|
|
155
|
+
const sink = await this.#open(hash);
|
|
156
|
+
try {
|
|
157
|
+
const output = new Output({
|
|
158
|
+
// No fast start: samples stream out as they are muxed and nothing rewrites the payload.
|
|
159
|
+
format: new Mp4OutputFormat({ fastStart: false }),
|
|
160
|
+
target: sink.target,
|
|
161
|
+
});
|
|
162
|
+
const conversion = await Conversion.init({
|
|
163
|
+
input,
|
|
164
|
+
output,
|
|
165
|
+
tracks: "primary",
|
|
166
|
+
// Without it a conversion starts at the first frame, so an original with a late one
|
|
167
|
+
// would show that much early.
|
|
168
|
+
trim: { start: 0 },
|
|
169
|
+
video: {
|
|
170
|
+
width: even(shown.width),
|
|
171
|
+
height: even(shown.height),
|
|
172
|
+
fit: "fill",
|
|
173
|
+
keyFrameInterval: 1,
|
|
174
|
+
},
|
|
175
|
+
audio: { codec: audioCodec },
|
|
176
|
+
showWarnings: false,
|
|
177
|
+
});
|
|
178
|
+
if (!conversion.isValid)
|
|
179
|
+
throw new EditorError("unsupported_media", `Cannot make a proxy of ${file.name}: ${conversion.discardedTracks
|
|
180
|
+
.map((track) => track.reason)
|
|
181
|
+
.join(", ")}`, "proxy");
|
|
182
|
+
// The engine reads a proxy only with every stream its original's fingerprint records.
|
|
183
|
+
const left = conversion.discardedTracks.find(({ track }) => track === video || (track === audio && fingerprint.streams?.audio));
|
|
184
|
+
if (left)
|
|
185
|
+
throw new EditorError("unsupported_media", `Cannot make a proxy of ${file.name}: this browser cannot convert its ${left.track === video ? "video" : "audio"} (${left.reason})`, "proxy");
|
|
186
|
+
// Discarded before the output closes it, so a cancelled proxy is never committed.
|
|
187
|
+
cancel = () => {
|
|
188
|
+
void sink.discard();
|
|
189
|
+
void conversion.cancel();
|
|
190
|
+
};
|
|
191
|
+
sink.onOverflow = cancel;
|
|
192
|
+
signal?.throwIfAborted();
|
|
193
|
+
if (onProgress)
|
|
194
|
+
conversion.onProgress = (fraction) => onProgress(fraction);
|
|
195
|
+
try {
|
|
196
|
+
await conversion.execute();
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
signal?.throwIfAborted();
|
|
200
|
+
throw sink.failure ?? error;
|
|
201
|
+
}
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
if (sink.failure)
|
|
204
|
+
throw sink.failure;
|
|
205
|
+
// From here a failure discards the file; an abort after the proxy is kept leaves it kept.
|
|
206
|
+
cancel = () => { };
|
|
207
|
+
const proxy = await sink.file();
|
|
208
|
+
const evidence = await probeEvidence(proxy);
|
|
209
|
+
const shape = evidence.streams?.video;
|
|
210
|
+
/** @type {ProxyRecord} */
|
|
211
|
+
const record = {
|
|
212
|
+
made_from: fingerprint,
|
|
213
|
+
width: shape?.width ?? 0,
|
|
214
|
+
height: shape?.height ?? 0,
|
|
215
|
+
...(shape?.frame_rate ? { frame_rate: shape.frame_rate } : {}),
|
|
216
|
+
...(evidence.duration_ticks === undefined
|
|
217
|
+
? {}
|
|
218
|
+
: { duration_ticks: evidence.duration_ticks }),
|
|
219
|
+
...(evidence.streams?.audio
|
|
220
|
+
? { audio_sample_rate: evidence.streams.audio.sample_rate }
|
|
221
|
+
: {}),
|
|
222
|
+
};
|
|
223
|
+
if (!record.width ||
|
|
224
|
+
!record.height ||
|
|
225
|
+
(fingerprint.streams?.audio && record.audio_sample_rate === undefined))
|
|
226
|
+
throw new EditorError("unsupported_media", `Cannot make a proxy of ${file.name}: the converted file lacks its ${record.width && record.height ? "audio" : "video"}`, "proxy");
|
|
227
|
+
return { record, file: await sink.keep(record) };
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
await sink.discard().catch(() => { });
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
signal?.removeEventListener("abort", onAbort);
|
|
236
|
+
input.dispose();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Where the proxy of `hash` is written: `target` takes the muxer's writes, `keep(record)` keeps
|
|
241
|
+
* it, `discard()` leaves nothing behind. The file is written under its own name and moved to
|
|
242
|
+
* `<hash>.mp4` by `keep`, so `discard` removes only what this sink wrote.
|
|
243
|
+
*/
|
|
244
|
+
async #open(hash) {
|
|
245
|
+
const directory = await proxyDirectory();
|
|
246
|
+
const name = unfinishedProxyName(hash, uniqueId());
|
|
247
|
+
const kept = `${hash}.mp4`;
|
|
248
|
+
/** @type {FileSystemFileHandle | null} */
|
|
249
|
+
let handle = null;
|
|
250
|
+
let writable = null;
|
|
251
|
+
if (directory)
|
|
252
|
+
try {
|
|
253
|
+
handle = await directory.getFileHandle(name, { create: true });
|
|
254
|
+
if (typeof handle.createWritable === "function")
|
|
255
|
+
writable = await handle.createWritable();
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
writable = null;
|
|
259
|
+
}
|
|
260
|
+
if (writable && handle && directory) {
|
|
261
|
+
const destination = new WritableFileDestination(writable);
|
|
262
|
+
/** @type {Promise<void> | null} */
|
|
263
|
+
let discarding = null;
|
|
264
|
+
let moved = false;
|
|
265
|
+
let recorded = false;
|
|
266
|
+
return {
|
|
267
|
+
target: new StreamTarget(destination.writable, { chunked: false }),
|
|
268
|
+
get failure() {
|
|
269
|
+
const failure = destination.failure;
|
|
270
|
+
return failure
|
|
271
|
+
? new EditorError(failure.code === "STORAGE_QUOTA_EXCEEDED"
|
|
272
|
+
? "storage_quota_exceeded"
|
|
273
|
+
: "proxy_write_failed", `The proxy could not be kept: ${failure.message}`, "proxy")
|
|
274
|
+
: null;
|
|
275
|
+
},
|
|
276
|
+
onOverflow: () => { },
|
|
277
|
+
file: () => handle.getFile(),
|
|
278
|
+
keep: async (record) => {
|
|
279
|
+
// A video without a record is a stopped page's leftover and this page holds the turn.
|
|
280
|
+
await directory.removeEntry(kept).catch(() => { });
|
|
281
|
+
moved = true;
|
|
282
|
+
await moveFile(directory, handle, name, kept);
|
|
283
|
+
const json = await (await directory.getFileHandle(`${hash}.json`, { create: true })).createWritable();
|
|
284
|
+
recorded = true;
|
|
285
|
+
await json.write(JSON.stringify(record));
|
|
286
|
+
await json.close();
|
|
287
|
+
return (await directory.getFileHandle(kept)).getFile();
|
|
288
|
+
},
|
|
289
|
+
discard: () => (discarding ??= (async () => {
|
|
290
|
+
await destination.abort();
|
|
291
|
+
// Nothing finds an unrecorded file, and it is this sink's own: no other page's proxy is
|
|
292
|
+
// in place while this page holds the turn.
|
|
293
|
+
if (recorded)
|
|
294
|
+
await directory.removeEntry(`${hash}.json`).catch(() => { });
|
|
295
|
+
if (moved)
|
|
296
|
+
await directory.removeEntry(kept).catch(() => { });
|
|
297
|
+
await directory.removeEntry(name).catch(() => { });
|
|
298
|
+
})()),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// An empty file a browser without `createWritable` could not fill.
|
|
302
|
+
if (handle)
|
|
303
|
+
await directory?.removeEntry(name).catch(() => { });
|
|
304
|
+
const maxBytes = Math.floor(this.#memoryLimit / MEMORY_OUTPUT_COPIES);
|
|
305
|
+
const target = new BufferTarget();
|
|
306
|
+
/** @type {File | null} */
|
|
307
|
+
let finished = null;
|
|
308
|
+
const sink = {
|
|
309
|
+
target,
|
|
310
|
+
failure: null,
|
|
311
|
+
onOverflow: () => { },
|
|
312
|
+
file: async () => (finished ??= new File([target.buffer], kept, { type: "video/mp4" })),
|
|
313
|
+
keep: async (record) => {
|
|
314
|
+
const file = await sink.file();
|
|
315
|
+
this.#memory.set(hash, { record, file });
|
|
316
|
+
return file;
|
|
317
|
+
},
|
|
318
|
+
discard: async () => { },
|
|
319
|
+
};
|
|
320
|
+
target.onwrite = (_start, end) => {
|
|
321
|
+
if (end <= maxBytes || sink.failure)
|
|
322
|
+
return;
|
|
323
|
+
sink.failure = new EditorError("resource_limit", `This browser cannot keep proxies on disk, and a proxy kept in memory may take at most ${Math.round(maxBytes / 1e6)} MB. Preview from the original instead.`, "proxy");
|
|
324
|
+
sink.onOverflow();
|
|
325
|
+
};
|
|
326
|
+
return sink;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** An id no other page's unfinished proxy has. */
|
|
330
|
+
function uniqueId() {
|
|
331
|
+
return (globalThis.crypto?.randomUUID?.() ??
|
|
332
|
+
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Moves the finished file `handle` (named `from`) to `to` in `directory`: with `move` where the
|
|
336
|
+
* browser has it, else by copying it and removing the original.
|
|
337
|
+
* @param {FileSystemDirectoryHandle} directory
|
|
338
|
+
* @param {FileSystemFileHandle} handle
|
|
339
|
+
* @param {string} from
|
|
340
|
+
* @param {string} to
|
|
341
|
+
*/
|
|
342
|
+
async function moveFile(directory, handle, from, to) {
|
|
343
|
+
const move =
|
|
344
|
+
/** @type {{ move?: (into: FileSystemDirectoryHandle, name: string) => Promise<void> }} */ (handle).move;
|
|
345
|
+
if (typeof move === "function")
|
|
346
|
+
try {
|
|
347
|
+
await move.call(handle, directory, to);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// A browser whose move refuses here: copied instead.
|
|
352
|
+
}
|
|
353
|
+
const writable = await (await directory.getFileHandle(to, { create: true })).createWritable();
|
|
354
|
+
try {
|
|
355
|
+
const reader = (await handle.getFile()).stream().getReader();
|
|
356
|
+
for (;;) {
|
|
357
|
+
const { done, value } = await reader.read();
|
|
358
|
+
if (done)
|
|
359
|
+
break;
|
|
360
|
+
await writable.write(value);
|
|
361
|
+
}
|
|
362
|
+
await writable.close();
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
await writable.abort().catch(() => { });
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
await directory.removeEntry(from).catch(() => { });
|
|
369
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proxy directory, or `null` where the browser has no origin-private file system (or, with
|
|
3
|
+
* `create: false`, where no proxy was ever kept).
|
|
4
|
+
* @returns {Promise<FileSystemDirectoryHandle | null>}
|
|
5
|
+
*/
|
|
6
|
+
export function proxyDirectory({ create }?: {
|
|
7
|
+
create?: boolean | undefined;
|
|
8
|
+
}): Promise<FileSystemDirectoryHandle | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Holds the proxy of `hash` in use, for this page, until `release()`: a removal from any page of
|
|
11
|
+
* the origin leaves it alone meanwhile. Resolves once held; where the browser has no Web Locks, at
|
|
12
|
+
* once and holding nothing.
|
|
13
|
+
*
|
|
14
|
+
* `release()` returns a promise that settles when the lock is let go.
|
|
15
|
+
* @param {string} hash
|
|
16
|
+
* @returns {Promise<{ release(): Promise<void> }>}
|
|
17
|
+
*/
|
|
18
|
+
export function holdProxy(hash: string): Promise<{
|
|
19
|
+
release(): Promise<void>;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* Runs `make` as the only page of the origin making the proxy of `hash`: it waits for a page that
|
|
23
|
+
* is making it to finish, so it can find that page's proxy instead of making it again. `make` is
|
|
24
|
+
* told whether it holds the turn; where the browser has no Web Locks it runs at once, holding
|
|
25
|
+
* nothing. Rejects with `signal.reason` once `signal` aborts while it waits.
|
|
26
|
+
* @template T
|
|
27
|
+
* @param {string} hash
|
|
28
|
+
* @param {{ signal?: AbortSignal }} options
|
|
29
|
+
* @param {(held: boolean) => Promise<T>} make
|
|
30
|
+
* @returns {Promise<T>}
|
|
31
|
+
*/
|
|
32
|
+
export function whileMakingProxy<T>(hash: string, options: {
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}, make: (held: boolean) => Promise<T>): Promise<T>;
|
|
35
|
+
/**
|
|
36
|
+
* Removes what a page that stopped while making the proxy of `hash` left: the unfinished videos.
|
|
37
|
+
* Only for the page holding the turn to make it ({@link whileMakingProxy}), when no other page can
|
|
38
|
+
* be writing one.
|
|
39
|
+
* @param {FileSystemDirectoryHandle} directory
|
|
40
|
+
* @param {string} hash
|
|
41
|
+
*/
|
|
42
|
+
export function removeUnfinishedProxies(directory: FileSystemDirectoryHandle, hash: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The proxies kept in this origin's storage: each original's hash and the bytes its proxy takes.
|
|
45
|
+
* @returns {Promise<import("../types.js").KeptProxy[]>}
|
|
46
|
+
*/
|
|
47
|
+
export function keptProxies(): Promise<import("../types.js").KeptProxy[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Removes the kept proxies of `hashes`, except those a page of this origin holds in use ({@link
|
|
50
|
+
* holdProxy}). Resolves to the hashes removed and those left because they are in use; a hash with
|
|
51
|
+
* nothing kept counts as removed. A string that is not a lowercase hex SHA-256 is ignored and
|
|
52
|
+
* listed in neither.
|
|
53
|
+
* @param {Iterable<string>} hashes
|
|
54
|
+
* @returns {Promise<import("../types.js").ProxyRemoval>}
|
|
55
|
+
*/
|
|
56
|
+
export function removeKeptProxies(hashes: Iterable<string>): Promise<import("../types.js").ProxyRemoval>;
|
|
57
|
+
/** The origin-private directory proxies are kept in. */
|
|
58
|
+
export const PROXY_DIRECTORY: "alpha2-proxies";
|
|
59
|
+
export function unfinishedProxyName(hash: any, id: any): string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Where kept proxies live: OPFS {@link PROXY_DIRECTORY}, `<hash>.mp4` plus `<hash>.json` per
|
|
2
|
+
// original SHA-256. A proxy is shared by every project of the origin, so it lives while something
|
|
3
|
+
// refers to it. Readers hold a shared Web Lock on the hash and removal takes it exclusively; makers
|
|
4
|
+
// also take turns on a second lock ({@link whileMakingProxy}) and write `<hash>.<id>.part` until
|
|
5
|
+
// finished. No media libraries here, so a storage-only page can load this module alone.
|
|
6
|
+
import { holdShared, whenUnheld, withExclusive } from "./locks.js";
|
|
7
|
+
/** The origin-private directory proxies are kept in. */
|
|
8
|
+
export const PROXY_DIRECTORY = "alpha2-proxies";
|
|
9
|
+
const NAME = /^([0-9a-f]{64})\.(mp4|json)$/;
|
|
10
|
+
const lockName = (hash) => `alpha2-proxy:${hash}`;
|
|
11
|
+
const makeLockName = (hash) => `alpha2-proxy-make:${hash}`;
|
|
12
|
+
/** The name an unfinished proxy video of `hash` is written under. */
|
|
13
|
+
export const unfinishedProxyName = (hash, id) => `${hash}.${id}.part`;
|
|
14
|
+
const UNFINISHED = /^([0-9a-f]{64})\.[0-9A-Za-z-]+\.part$/;
|
|
15
|
+
/**
|
|
16
|
+
* The proxy directory, or `null` where the browser has no origin-private file system (or, with
|
|
17
|
+
* `create: false`, where no proxy was ever kept).
|
|
18
|
+
* @returns {Promise<FileSystemDirectoryHandle | null>}
|
|
19
|
+
*/
|
|
20
|
+
export async function proxyDirectory({ create = true } = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const root = await globalThis.navigator.storage.getDirectory();
|
|
23
|
+
return await root.getDirectoryHandle(PROXY_DIRECTORY, { create });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Holds the proxy of `hash` in use, for this page, until `release()`: a removal from any page of
|
|
31
|
+
* the origin leaves it alone meanwhile. Resolves once held; where the browser has no Web Locks, at
|
|
32
|
+
* once and holding nothing.
|
|
33
|
+
*
|
|
34
|
+
* `release()` returns a promise that settles when the lock is let go.
|
|
35
|
+
* @param {string} hash
|
|
36
|
+
* @returns {Promise<{ release(): Promise<void> }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function holdProxy(hash) {
|
|
39
|
+
return holdShared(lockName(hash));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Runs `make` as the only page of the origin making the proxy of `hash`: it waits for a page that
|
|
43
|
+
* is making it to finish, so it can find that page's proxy instead of making it again. `make` is
|
|
44
|
+
* told whether it holds the turn; where the browser has no Web Locks it runs at once, holding
|
|
45
|
+
* nothing. Rejects with `signal.reason` once `signal` aborts while it waits.
|
|
46
|
+
* @template T
|
|
47
|
+
* @param {string} hash
|
|
48
|
+
* @param {{ signal?: AbortSignal }} options
|
|
49
|
+
* @param {(held: boolean) => Promise<T>} make
|
|
50
|
+
* @returns {Promise<T>}
|
|
51
|
+
*/
|
|
52
|
+
export function whileMakingProxy(hash, options, make) {
|
|
53
|
+
return withExclusive(makeLockName(hash), options, make);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Removes what a page that stopped while making the proxy of `hash` left: the unfinished videos.
|
|
57
|
+
* Only for the page holding the turn to make it ({@link whileMakingProxy}), when no other page can
|
|
58
|
+
* be writing one.
|
|
59
|
+
* @param {FileSystemDirectoryHandle} directory
|
|
60
|
+
* @param {string} hash
|
|
61
|
+
*/
|
|
62
|
+
export async function removeUnfinishedProxies(directory, hash) {
|
|
63
|
+
const names = [];
|
|
64
|
+
for await (const [name] of directory.entries())
|
|
65
|
+
if (UNFINISHED.exec(name)?.[1] === hash)
|
|
66
|
+
names.push(name);
|
|
67
|
+
for (const name of names)
|
|
68
|
+
await directory.removeEntry(name).catch(() => { });
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The proxies kept in this origin's storage: each original's hash and the bytes its proxy takes.
|
|
72
|
+
* @returns {Promise<import("../types.js").KeptProxy[]>}
|
|
73
|
+
*/
|
|
74
|
+
export async function keptProxies() {
|
|
75
|
+
const directory = await proxyDirectory({ create: false });
|
|
76
|
+
if (!directory)
|
|
77
|
+
return [];
|
|
78
|
+
/** @type {Map<string, number>} */
|
|
79
|
+
const bytes = new Map();
|
|
80
|
+
for await (const [name, handle] of directory.entries()) {
|
|
81
|
+
const match = NAME.exec(name);
|
|
82
|
+
if (!match || handle.kind !== "file")
|
|
83
|
+
continue;
|
|
84
|
+
const size = await handle
|
|
85
|
+
.getFile()
|
|
86
|
+
.then((file) => file.size)
|
|
87
|
+
.catch(() => 0);
|
|
88
|
+
bytes.set(match[1], (bytes.get(match[1]) ?? 0) + size);
|
|
89
|
+
}
|
|
90
|
+
return [...bytes].map(([hash, size]) => ({ hash, bytes: size }));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Removes the kept proxies of `hashes`, except those a page of this origin holds in use ({@link
|
|
94
|
+
* holdProxy}). Resolves to the hashes removed and those left because they are in use; a hash with
|
|
95
|
+
* nothing kept counts as removed. A string that is not a lowercase hex SHA-256 is ignored and
|
|
96
|
+
* listed in neither.
|
|
97
|
+
* @param {Iterable<string>} hashes
|
|
98
|
+
* @returns {Promise<import("../types.js").ProxyRemoval>}
|
|
99
|
+
*/
|
|
100
|
+
export async function removeKeptProxies(hashes) {
|
|
101
|
+
const removed = [];
|
|
102
|
+
const inUse = [];
|
|
103
|
+
const directory = await proxyDirectory({ create: false });
|
|
104
|
+
for (const hash of new Set(hashes)) {
|
|
105
|
+
if (!/^[0-9a-f]{64}$/.test(hash))
|
|
106
|
+
continue;
|
|
107
|
+
const erase = async () => {
|
|
108
|
+
// The record first: without it the proxy is not found, whatever happens to the video.
|
|
109
|
+
for (const name of [`${hash}.json`, `${hash}.mp4`])
|
|
110
|
+
await directory?.removeEntry(name).catch((error) => {
|
|
111
|
+
if (error?.name !== "NotFoundError")
|
|
112
|
+
throw error;
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
const done = await whenUnheld(lockName(hash), erase);
|
|
116
|
+
(done ? removed : inUse).push(hash);
|
|
117
|
+
}
|
|
118
|
+
return { removed, inUse };
|
|
119
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `provider`'s settings as one comparable string, serialized once per provider object: an image
|
|
3
|
+
* sequence's holds every frame's locator.
|
|
4
|
+
*/
|
|
5
|
+
export function providerSettings(provider: any): any;
|
|
6
|
+
/**
|
|
7
|
+
* Whether two providers have the same settings: the same object, or equal ones. Two objects are
|
|
8
|
+
* compared once, so a pair asked about for every frame costs no serialization or string compare.
|
|
9
|
+
*/
|
|
10
|
+
export function sameProviderSettings(a: any, b: any): any;
|
|
11
|
+
/** Default byte budget of still and text rasters, matching the frame supply's image cache. */
|
|
12
|
+
export const DEFAULT_RASTER_BUDGET_BYTES: number;
|
|
13
|
+
/**
|
|
14
|
+
* The default byte budget of what open extension sources hold (their decoded frames in the provider
|
|
15
|
+
* module and the frame each showed last): the native host's `providerSourceBytes` default.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_PROVIDER_SOURCE_BYTES: number;
|
|
18
|
+
/** The host side of the engine's per-frame source requests for one renderer (preview or export). */
|
|
19
|
+
export class SourcePixels {
|
|
20
|
+
constructor(library: any, { decoderLimit, rasterBudgetBytes, providerSourceBytes, extensionsWasmUrl, }?: {
|
|
21
|
+
decoderLimit?: number | undefined;
|
|
22
|
+
rasterBudgetBytes?: number | undefined;
|
|
23
|
+
providerSourceBytes?: number | undefined;
|
|
24
|
+
});
|
|
25
|
+
decoderLimit: number;
|
|
26
|
+
rasterBudgetBytes: number;
|
|
27
|
+
providerSourceBytes: number;
|
|
28
|
+
library: any;
|
|
29
|
+
extensionsWasmUrl: any;
|
|
30
|
+
get diagnostics(): {
|
|
31
|
+
liveDecoders: number;
|
|
32
|
+
retainedFrameBytes: any;
|
|
33
|
+
windowedFrames: any;
|
|
34
|
+
reverseWindows: any;
|
|
35
|
+
cachedRasters: number;
|
|
36
|
+
cachedRasterBytes: number;
|
|
37
|
+
fallbackVideos: number;
|
|
38
|
+
decodeFallbackFrames: any;
|
|
39
|
+
openProviderSources: number;
|
|
40
|
+
providerSourceBytes: number;
|
|
41
|
+
providerReopens: number;
|
|
42
|
+
};
|
|
43
|
+
setOutputSize(width: any, height: any): void;
|
|
44
|
+
/**
|
|
45
|
+
* The fonts an SVG's text draws with, in fallback order: the stack the engine draws captions with
|
|
46
|
+
* (`{ bytes }` each), as the native host draws an SVG's text with its caption fonts. An SVG
|
|
47
|
+
* opened with other fonts opens again on its next frame; other sources are not touched.
|
|
48
|
+
*/
|
|
49
|
+
setFonts(fonts: any): void;
|
|
50
|
+
/**
|
|
51
|
+
* What a source's pixels are, for `FrameSupply`'s `contentOf`: its runtime asset, and for an SVG
|
|
52
|
+
* also the fonts its text draws with, so a change of fonts draws it again. The same object while
|
|
53
|
+
* neither changes.
|
|
54
|
+
*/
|
|
55
|
+
contentOf(asset: any): any;
|
|
56
|
+
/** Drops the cached raster of `assetId`, so its next read makes it again. */
|
|
57
|
+
invalidate(assetId: any): void;
|
|
58
|
+
/**
|
|
59
|
+
* Bound method handed to `FrameSupply` as its `pixelsFor` callback. `sourceId` is the source the
|
|
60
|
+
* engine asked for, which the layer may stand in for (a proxy has an ID of its own): decoders are
|
|
61
|
+
* kept per source, as `release` names them.
|
|
62
|
+
*/
|
|
63
|
+
pixelsFor: (layer: any, seconds: any, { preferVideoFrame, consumerId, sourceId }?: {
|
|
64
|
+
preferVideoFrame?: boolean | undefined;
|
|
65
|
+
consumerId?: any;
|
|
66
|
+
}) => Promise<any>;
|
|
67
|
+
/**
|
|
68
|
+
* The sources whose frames fell back from WebCodecs decoding, one entry each: the last reason,
|
|
69
|
+
* how many frames, and whether its decoding stopped for good (it could not open, or failed
|
|
70
|
+
* {@link MAX_DECODE_FAILURES} frames running). A browser export reports it.
|
|
71
|
+
*/
|
|
72
|
+
get decodeFallbacks(): any[];
|
|
73
|
+
/** Keeps decoder sessions bounded to consumers needed by the current frame. */
|
|
74
|
+
retainConsumers: (liveConsumerIds: any) => Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Drops decoders, extension sources, and rasters for source IDs not in `liveIds`. The engine
|
|
77
|
+
* calls it before each frame reads its sources, so the previous frame's reads may be evicted.
|
|
78
|
+
*/
|
|
79
|
+
release(liveIds: any): Promise<void>;
|
|
80
|
+
dispose(): Promise<void>;
|
|
81
|
+
#private;
|
|
82
|
+
}
|