@driftengine/media 3.61.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 +202 -0
- package/NOTICE +9 -0
- package/README.md +14 -0
- package/dist/clipEncoder.d.ts +106 -0
- package/dist/clipEncoder.js +283 -0
- package/dist/clipSupport.d.ts +73 -0
- package/dist/clipSupport.js +139 -0
- package/dist/frameDelivery.d.ts +24 -0
- package/dist/frameDelivery.js +186 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/package.json +60 -0
- package/src/clipEncoder.ts +328 -0
- package/src/clipSupport.ts +156 -0
- package/src/frameDelivery.ts +191 -0
- package/src/index.ts +14 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encoding frames and samples into a file, on a timeline we choose.
|
|
3
|
+
*
|
|
4
|
+
* The difference between this and a screen recorder is one word: **timestamps.** A
|
|
5
|
+
* recorder stamps a frame with the instant it happened to arrive, so the file's cadence
|
|
6
|
+
* is a measurement of how busy the machine was. Here every frame is stamped with where
|
|
7
|
+
* it *belongs*, so a slow machine produces a slower export rather than a worse file.
|
|
8
|
+
*
|
|
9
|
+
* That was not a preference. Measured with `ffprobe` on real `MediaRecorder` output: a
|
|
10
|
+
* clip's audio led its picture by 51 ms in one run and 85 ms in the next, and the
|
|
11
|
+
* `outputLatency` it would have to be corrected by reported 0.048 then 0.024 — so the
|
|
12
|
+
* error is real, varies, and cannot be calibrated out from inside the page. Nothing
|
|
13
|
+
* below asks the browser what time it is.
|
|
14
|
+
*
|
|
15
|
+
* Game-agnostic on purpose (AGENTS.md): frames, samples, sizes and rates. A second game
|
|
16
|
+
* uses it unchanged.
|
|
17
|
+
*/
|
|
18
|
+
import type { FrameOverlay } from '@driftengine/core';
|
|
19
|
+
import { clipCodecs } from './clipSupport.ts';
|
|
20
|
+
import type { ClipEncodeRequest } from './clipSupport.ts';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Everything an encode needs: the frame, the rate, the bitrate, and the score's sample rate.
|
|
24
|
+
*
|
|
25
|
+
* The first four are `ClipEncodeRequest`, which is what `clipEncodingSupported` asks about, so a
|
|
26
|
+
* caller can hand the same object to the gate and to `open` and know they asked one question.
|
|
27
|
+
*/
|
|
28
|
+
export interface ClipEncoderOptions extends ClipEncodeRequest {
|
|
29
|
+
/** Audio sample rate, so the score and the encoder agree. */
|
|
30
|
+
readonly sampleRate?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Microseconds for frame `index` at `fps`.
|
|
35
|
+
*
|
|
36
|
+
* **The product is rounded, never a per-frame constant accumulated.** A 60 fps frame is
|
|
37
|
+
* 16666.67 µs and the nearest integer is 16667, which is a third of a microsecond long
|
|
38
|
+
* — trivial once and 20 ms over a minute, which is more than a frame of drift by the
|
|
39
|
+
* end of a clip. Deriving each stamp from its own index cannot drift at all.
|
|
40
|
+
*/
|
|
41
|
+
export function frameTimestampUs(index: number, fps: number): number {
|
|
42
|
+
return Math.round((index * 1_000_000) / Math.max(fps, 1));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How many frames may be in flight before the loop waits for the encoder. */
|
|
46
|
+
const QUEUE_LIMIT = 8;
|
|
47
|
+
/** Samples per encoded audio chunk. A power of two the encoders all accept. */
|
|
48
|
+
const AUDIO_CHUNK = 1024;
|
|
49
|
+
/** Seconds between key frames. Two is what every platform's transcoder expects. */
|
|
50
|
+
const KEYFRAME_SEC = 2;
|
|
51
|
+
|
|
52
|
+
interface MuxerLike {
|
|
53
|
+
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void;
|
|
54
|
+
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
|
|
55
|
+
finalize(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Which audio codec a file ended up carrying. See `ClipEncoder.audioCodec`. */
|
|
59
|
+
export type ClipAudioCodec = 'aac' | 'opus';
|
|
60
|
+
|
|
61
|
+
export class ClipEncoder {
|
|
62
|
+
private video: VideoEncoder | null = null;
|
|
63
|
+
private audio: AudioEncoder | null = null;
|
|
64
|
+
private muxer: MuxerLike | null = null;
|
|
65
|
+
private target: { buffer: ArrayBuffer | null } | null = null;
|
|
66
|
+
/**
|
|
67
|
+
* The audio codec this file carries, once configured.
|
|
68
|
+
*
|
|
69
|
+
* Reported rather than kept private because the two answers are not equivalent to
|
|
70
|
+
* whoever receives the file, and the difference is invisible in it: an MP4 is an
|
|
71
|
+
* MP4 either way. AAC is what every share target ingests. Opus in MP4 is legal ISO
|
|
72
|
+
* and almost nothing consumer-side reads it — Android's MediaCodec decodes Opus
|
|
73
|
+
* only in Matroska, WebM and Ogg, so the TikTok app cannot open the file at all.
|
|
74
|
+
*
|
|
75
|
+
* A caller that knows which one it got can say so. One that cannot know hands over
|
|
76
|
+
* a file that fails somewhere it will never see, which is what happened.
|
|
77
|
+
*/
|
|
78
|
+
private chosenAudio: ClipAudioCodec | null = null;
|
|
79
|
+
/** The first error either encoder reported. Read by the caller; never thrown at it. */
|
|
80
|
+
private failure: string | null = null;
|
|
81
|
+
private closed = false;
|
|
82
|
+
|
|
83
|
+
private constructor(private readonly options: ClipEncoderOptions) {}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Configure the encoders and the container.
|
|
87
|
+
*
|
|
88
|
+
* The muxer is imported here rather than at the top of the module, so a player who
|
|
89
|
+
* never exports never downloads it. Rejects when the browser cannot encode, which the
|
|
90
|
+
* caller answers by recording instead.
|
|
91
|
+
*/
|
|
92
|
+
static async open(options: ClipEncoderOptions): Promise<ClipEncoder> {
|
|
93
|
+
const encoder = new ClipEncoder(options);
|
|
94
|
+
await encoder.configure();
|
|
95
|
+
return encoder;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The error that ended the encode, or null while it is going well. */
|
|
99
|
+
get error(): string | null {
|
|
100
|
+
return this.failure;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The audio codec the file carries, or null before `open` has configured one.
|
|
105
|
+
*
|
|
106
|
+
* Survives `finish`, deliberately: the caller asks *after* it has a blob.
|
|
107
|
+
*/
|
|
108
|
+
get audioCodec(): ClipAudioCodec | null {
|
|
109
|
+
return this.chosenAudio;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async configure(): Promise<void> {
|
|
113
|
+
const { width, height, fps, bitrate, sampleRate = 48000 } = this.options;
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* The audio codec is *asked about* before anything is built, and that omission is
|
|
117
|
+
* what broke the first export. AAC was configured without checking, and Chrome on
|
|
118
|
+
* Linux has no AAC encoder — so `configure` accepted it, the codec errored
|
|
119
|
+
* internally on the first frame, closed itself, and the loop's next call reported
|
|
120
|
+
* "Cannot call 'encode' on a closed codec". Which is a true statement about a
|
|
121
|
+
* consequence and says nothing about the cause.
|
|
122
|
+
*
|
|
123
|
+
* AAC first because it is what every share target ingests without transcoding.
|
|
124
|
+
* Opus second because Chrome muxes Opus into MP4 happily — its own `MediaRecorder`
|
|
125
|
+
* does, as `ffprobe` showed on a probe file earlier — so it is a real fallback
|
|
126
|
+
* rather than a theoretical one.
|
|
127
|
+
*/
|
|
128
|
+
const audioCandidates = [
|
|
129
|
+
{ codec: 'mp4a.40.2', muxed: 'aac' as const },
|
|
130
|
+
{ codec: 'opus', muxed: 'opus' as const },
|
|
131
|
+
];
|
|
132
|
+
let audioChoice: { codec: string; muxed: ClipAudioCodec } | null = null;
|
|
133
|
+
for (const candidate of audioCandidates) {
|
|
134
|
+
const config = {
|
|
135
|
+
codec: candidate.codec,
|
|
136
|
+
sampleRate,
|
|
137
|
+
numberOfChannels: 2,
|
|
138
|
+
bitrate: 192_000,
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
const support = await AudioEncoder.isConfigSupported(config);
|
|
142
|
+
if (support.supported === true) {
|
|
143
|
+
audioChoice = candidate;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// A codec string this build does not recognise. Try the next.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (audioChoice === null) throw new Error('This browser cannot encode clip audio.');
|
|
151
|
+
this.chosenAudio = audioChoice.muxed;
|
|
152
|
+
|
|
153
|
+
const { Muxer, ArrayBufferTarget } = await import('mp4-muxer');
|
|
154
|
+
const target = new ArrayBufferTarget();
|
|
155
|
+
const muxer = new Muxer({
|
|
156
|
+
target,
|
|
157
|
+
fastStart: 'in-memory',
|
|
158
|
+
video: { codec: 'avc', width, height },
|
|
159
|
+
audio: { codec: audioChoice.muxed, numberOfChannels: 2, sampleRate },
|
|
160
|
+
});
|
|
161
|
+
this.target = target;
|
|
162
|
+
this.muxer = muxer as unknown as MuxerLike;
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
* The ladder is derived from the frame rather than written down here — see `clipCodecs`,
|
|
166
|
+
* which is also what `clipEncodingSupported` walks, so a gate and an export cannot answer
|
|
167
|
+
* differently.
|
|
168
|
+
*
|
|
169
|
+
* **Both rungs used to be level 4.0 spelled out as literals, and that is what stopped 4K.**
|
|
170
|
+
* A level is a promise about frame size: 4.0's 8192 macroblocks holds 1920x1080 and nothing
|
|
171
|
+
* larger, so `isConfigSupported` said no at 2560x1440 and above and `configure` below was
|
|
172
|
+
* never reached at those sizes. The comment that stood here reasoned that the level is a
|
|
173
|
+
* floor and that Chrome raises it, which is true of every file this produces and is not a
|
|
174
|
+
* thing that can happen to a config the browser refused first.
|
|
175
|
+
*/
|
|
176
|
+
const video = new VideoEncoder({
|
|
177
|
+
output: (chunk, meta) => this.muxer?.addVideoChunk(chunk, meta),
|
|
178
|
+
error: (error) => this.fail(error.message),
|
|
179
|
+
});
|
|
180
|
+
const codecs = clipCodecs(width, height);
|
|
181
|
+
let configured = false;
|
|
182
|
+
for (const codec of codecs) {
|
|
183
|
+
const config = { codec, width, height, bitrate, framerate: fps };
|
|
184
|
+
const support = await VideoEncoder.isConfigSupported(config);
|
|
185
|
+
if (support.supported !== true) continue;
|
|
186
|
+
video.configure(config);
|
|
187
|
+
configured = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
if (!configured) throw new Error('This browser cannot encode video at that size.');
|
|
191
|
+
this.video = video;
|
|
192
|
+
|
|
193
|
+
const audio = new AudioEncoder({
|
|
194
|
+
output: (chunk, meta) => this.muxer?.addAudioChunk(chunk, meta),
|
|
195
|
+
error: (error) => this.fail(error.message),
|
|
196
|
+
});
|
|
197
|
+
audio.configure({
|
|
198
|
+
codec: audioChoice.codec,
|
|
199
|
+
sampleRate,
|
|
200
|
+
numberOfChannels: 2,
|
|
201
|
+
bitrate: 192_000,
|
|
202
|
+
});
|
|
203
|
+
this.audio = audio;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* One frame, at its own index.
|
|
208
|
+
*
|
|
209
|
+
* Awaits while the encoder is behind, which is the only backpressure in the pipeline:
|
|
210
|
+
* a render loop with nothing to wait for will queue a thousand 1080x1920 frames and
|
|
211
|
+
* run the tab out of memory long before the encoder catches up.
|
|
212
|
+
*/
|
|
213
|
+
async addFrame(source: CanvasImageSource, index: number): Promise<void> {
|
|
214
|
+
const video = this.video;
|
|
215
|
+
// `state`, not just the failure flag: a codec that hit an internal error closes
|
|
216
|
+
// itself, and the error callback that would set the flag may not have run yet.
|
|
217
|
+
// Encoding into it then throws a message about the closure and hides the cause.
|
|
218
|
+
if (video === null || video.state !== 'configured' || this.failure !== null) return;
|
|
219
|
+
const { fps } = this.options;
|
|
220
|
+
const frame = new VideoFrame(source, {
|
|
221
|
+
timestamp: frameTimestampUs(index, fps),
|
|
222
|
+
duration: Math.round(1_000_000 / fps),
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
video.encode(frame, { keyFrame: index % Math.round(fps * KEYFRAME_SEC) === 0 });
|
|
226
|
+
} finally {
|
|
227
|
+
// Closed whatever happened: a `VideoFrame` holds a GPU surface, and leaking one a
|
|
228
|
+
// frame is how a long export becomes an out-of-memory crash.
|
|
229
|
+
frame.close();
|
|
230
|
+
}
|
|
231
|
+
while (
|
|
232
|
+
video.encodeQueueSize > QUEUE_LIMIT &&
|
|
233
|
+
this.failure === null &&
|
|
234
|
+
video.state === 'configured'
|
|
235
|
+
) {
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The whole score, in order.
|
|
242
|
+
*
|
|
243
|
+
* Interleaved into the encoder's f32 planar layout by hand rather than handed the
|
|
244
|
+
* `AudioBuffer`, because `AudioData` takes a flat buffer and the two channels of an
|
|
245
|
+
* `AudioBuffer` are separate arrays.
|
|
246
|
+
*/
|
|
247
|
+
async addAudio(buffer: AudioBuffer): Promise<void> {
|
|
248
|
+
const audio = this.audio;
|
|
249
|
+
if (audio === null || audio.state !== 'configured' || this.failure !== null) return;
|
|
250
|
+
const channels = Math.min(buffer.numberOfChannels, 2);
|
|
251
|
+
const left = buffer.getChannelData(0);
|
|
252
|
+
const right = channels > 1 ? buffer.getChannelData(1) : left;
|
|
253
|
+
const rate = buffer.sampleRate;
|
|
254
|
+
const scratch = new Float32Array(AUDIO_CHUNK * 2);
|
|
255
|
+
|
|
256
|
+
for (let start = 0; start < buffer.length; start += AUDIO_CHUNK) {
|
|
257
|
+
const count = Math.min(AUDIO_CHUNK, buffer.length - start);
|
|
258
|
+
// Planar: all of the left channel, then all of the right.
|
|
259
|
+
for (let i = 0; i < count; i++) {
|
|
260
|
+
scratch[i] = left[start + i] ?? 0;
|
|
261
|
+
scratch[count + i] = right[start + i] ?? 0;
|
|
262
|
+
}
|
|
263
|
+
const data = new AudioData({
|
|
264
|
+
format: 'f32-planar',
|
|
265
|
+
sampleRate: rate,
|
|
266
|
+
numberOfFrames: count,
|
|
267
|
+
numberOfChannels: 2,
|
|
268
|
+
timestamp: Math.round((start / rate) * 1_000_000),
|
|
269
|
+
data: scratch.subarray(0, count * 2),
|
|
270
|
+
});
|
|
271
|
+
if (audio.state !== 'configured' || this.failure !== null) {
|
|
272
|
+
data.close();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
audio.encode(data);
|
|
277
|
+
} finally {
|
|
278
|
+
data.close();
|
|
279
|
+
}
|
|
280
|
+
if (this.failure !== null) return;
|
|
281
|
+
while (audio.encodeQueueSize > QUEUE_LIMIT * 4) {
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Flush both encoders, close the container, and hand back the file. */
|
|
288
|
+
async finish(): Promise<Blob | null> {
|
|
289
|
+
if (this.closed) return null;
|
|
290
|
+
this.closed = true;
|
|
291
|
+
try {
|
|
292
|
+
await this.video?.flush();
|
|
293
|
+
await this.audio?.flush();
|
|
294
|
+
this.muxer?.finalize();
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.fail(error instanceof Error ? error.message : 'the encoder gave up');
|
|
297
|
+
}
|
|
298
|
+
this.release();
|
|
299
|
+
const buffer = this.target?.buffer ?? null;
|
|
300
|
+
if (this.failure !== null || buffer === null) return null;
|
|
301
|
+
return new Blob([buffer], { type: 'video/mp4' });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Give everything back without producing a file. Safe at any point. */
|
|
305
|
+
abort(): void {
|
|
306
|
+
this.closed = true;
|
|
307
|
+
this.release();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private release(): void {
|
|
311
|
+
try {
|
|
312
|
+
if (this.video?.state === 'configured') this.video.close();
|
|
313
|
+
if (this.audio?.state === 'configured') this.audio.close();
|
|
314
|
+
} catch {
|
|
315
|
+
// Already closed. Nothing to do about an encoder we were discarding.
|
|
316
|
+
}
|
|
317
|
+
this.video = null;
|
|
318
|
+
this.audio = null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** First failure wins: the later ones are consequences of it. */
|
|
322
|
+
private fail(message: string): void {
|
|
323
|
+
this.failure ??= message;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Re-exported so a caller can composite a mark without importing two modules. */
|
|
328
|
+
export type { FrameOverlay };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this browser will encode, asked before anything is built.
|
|
3
|
+
*
|
|
4
|
+
* One responsibility: turn a frame size into the codec strings an encode is negotiated with, and
|
|
5
|
+
* answer whether this browser accepts them. `ClipEncoder.open` configures from the same ladder,
|
|
6
|
+
* so a caller greying out a control and a caller pressing Export are asking one question rather
|
|
7
|
+
* than two copies of it.
|
|
8
|
+
*
|
|
9
|
+
* **That was not the arrangement, and the gap shipped.** Both rungs used to be level 4.0 written
|
|
10
|
+
* into the string by hand, and a level is a promise about frame *size*: 4.0 holds 8192
|
|
11
|
+
* macroblocks, which is 1920x1080 with 32 to spare and nothing above it. So `isConfigSupported`
|
|
12
|
+
* refused 2560x1440, 2160x2160 and 3840x2160 outright, `VideoEncoder.configure` was never reached
|
|
13
|
+
* at those sizes, and a 4K export ended in "this browser cannot encode video at that size" on a
|
|
14
|
+
* card that encodes 4K. A second consumer found it by copying the two strings into its own
|
|
15
|
+
* repository to grey out a panel with, which is the shape of a decision that has no home.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether this browser can render a clip offline rather than record one.
|
|
20
|
+
*
|
|
21
|
+
* Both halves are needed: frames without a score is a silent clip and a score without
|
|
22
|
+
* frames is nothing. A browser missing either falls back to the recorder.
|
|
23
|
+
*
|
|
24
|
+
* Says nothing about a *size*, deliberately: it is about the API being present at all, and it
|
|
25
|
+
* answers the same for a 64x64 frame and a 4K one. `clipEncodingSupported` is the size question.
|
|
26
|
+
*/
|
|
27
|
+
export function offlineEncodingSupported(): boolean {
|
|
28
|
+
return (
|
|
29
|
+
typeof VideoEncoder === 'function' &&
|
|
30
|
+
typeof AudioEncoder === 'function' &&
|
|
31
|
+
typeof VideoFrame === 'function' &&
|
|
32
|
+
typeof AudioData === 'function' &&
|
|
33
|
+
typeof OfflineAudioContext === 'function'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The four numbers an encode is negotiated with, before there is anything to encode.
|
|
39
|
+
*
|
|
40
|
+
* `ClipEncoderOptions` extends it, so the question a gate asks is the options the export opens
|
|
41
|
+
* with rather than a paraphrase of them.
|
|
42
|
+
*/
|
|
43
|
+
export interface ClipEncodeRequest {
|
|
44
|
+
readonly width: number;
|
|
45
|
+
readonly height: number;
|
|
46
|
+
readonly fps: number;
|
|
47
|
+
/** Bits per second for video. See `clipBitrate` for a figure worth passing. */
|
|
48
|
+
readonly bitrate: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* H.264 levels, smallest first, with the largest frame each can represent.
|
|
53
|
+
*
|
|
54
|
+
* `maxMacroblocks` is the spec's MaxFS, and on Chrome 149 it is exactly what
|
|
55
|
+
* `isConfigSupported` enforces: measured over 9 sizes, 2 rates and 7 levels on an RX 9070 XT,
|
|
56
|
+
* every level at or above the one whose MaxFS holds the frame answered yes and every level below
|
|
57
|
+
* it answered no, in all 252 cells. Profile never moved an answer and neither did frame rate.
|
|
58
|
+
*
|
|
59
|
+
* **The rate bound is deliberately not part of the choice**, and that is the compromise here.
|
|
60
|
+
* The spec's MaxMBPS says 1920x1080 at 60 needs level 4.2 and at 120 needs 5.1; this asks 4.0 for
|
|
61
|
+
* both. It is defensible because the level in a codec string is a floor for the negotiation
|
|
62
|
+
* rather than a description of the file: asked for 4.0 at 1920x1080, Chrome wrote **level 4.2**
|
|
63
|
+
* into the file, and asked for 5.1 at 3840x2160 it wrote 5.2. What would make it wrong is a
|
|
64
|
+
* browser that honours the floor literally and encodes a stream its own level cannot carry, which
|
|
65
|
+
* nothing here has met. The rungs are worth having anyway for the size wall, which is real.
|
|
66
|
+
*
|
|
67
|
+
* Levels that hold no more than the one below are left out rather than listed: 4.1 has 4.0's
|
|
68
|
+
* 8192, 5.2 has 5.1's 36864, 6.1 and 6.2 have 6.0's 139264. Asking for one of them buys a bigger
|
|
69
|
+
* number in the string and no frame. Measured, at 3840x2160: a 5.1 request and a 5.2 request
|
|
70
|
+
* produced the same byte count and the same level in the file.
|
|
71
|
+
*
|
|
72
|
+
* The table starts at 4.0 rather than at 1.0 because nothing below it changes an answer. A 720p
|
|
73
|
+
* fallback frame is 3600 macroblocks and level 4.0 carries it, the encoder writes its own level
|
|
74
|
+
* regardless, and lowering the floor would only put a smaller number in front of a browser that
|
|
75
|
+
* has never been measured caring.
|
|
76
|
+
*/
|
|
77
|
+
const AVC_LEVELS: readonly { readonly id: number; readonly maxMacroblocks: number }[] = [
|
|
78
|
+
{ id: 0x28, maxMacroblocks: 8192 }, // 4.0
|
|
79
|
+
{ id: 0x2a, maxMacroblocks: 8704 }, // 4.2
|
|
80
|
+
{ id: 0x32, maxMacroblocks: 22_080 }, // 5.0
|
|
81
|
+
{ id: 0x33, maxMacroblocks: 36_864 }, // 5.1
|
|
82
|
+
{ id: 0x3c, maxMacroblocks: 139_264 }, // 6.0
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The codec strings to try for a frame this size, best first.
|
|
87
|
+
*
|
|
88
|
+
* High profile first, constrained baseline as the fallback, both at the smallest level that can
|
|
89
|
+
* represent the frame. High is what every phone decodes in hardware and what a transcoder
|
|
90
|
+
* prefers; baseline exists because a machine without hardware AVC will refuse the first and
|
|
91
|
+
* encode the second in software rather than refuse to export. **Both rungs move together**: a
|
|
92
|
+
* baseline rung pinned to 4.0 is no fallback at 4K, it is a second refusal.
|
|
93
|
+
*
|
|
94
|
+
* Empty when no level can hold the frame, which is a frame past 139,264 macroblocks. The caller
|
|
95
|
+
* turns that into the same refusal it gives when the browser says no, one round trip earlier.
|
|
96
|
+
*
|
|
97
|
+
* Not on the barrel on purpose. The ladder is the engine's decision, and a consumer that can read
|
|
98
|
+
* it is a consumer that can pin a codec string in its own repository, which is how one decision
|
|
99
|
+
* became two copies the last time.
|
|
100
|
+
*/
|
|
101
|
+
export function clipCodecs(width: number, height: number): readonly string[] {
|
|
102
|
+
/* Whole macroblocks: 1080 is 67.5 columns of 16 and costs 68 of them. */
|
|
103
|
+
const macroblocks = Math.ceil(width / 16) * Math.ceil(height / 16);
|
|
104
|
+
const level = AVC_LEVELS.find((rung) => macroblocks <= rung.maxMacroblocks);
|
|
105
|
+
if (level === undefined) return [];
|
|
106
|
+
const hex = level.id.toString(16).toUpperCase().padStart(2, '0');
|
|
107
|
+
return [`avc1.6400${hex}`, `avc1.42E0${hex}`];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One answer per distinct request, kept for the session. See `clipEncodingSupported`. */
|
|
111
|
+
const answers = new Map<string, Promise<boolean>>();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether this browser will encode a clip of this size, at this rate, at this bitrate.
|
|
115
|
+
*
|
|
116
|
+
* The question `ClipEncoder.open` is about to ask, asked early enough to grey out a control
|
|
117
|
+
* instead of failing an export somebody already chose. It walks the same ladder `open` walks, so
|
|
118
|
+
* the two cannot drift; a gate built from a copied codec string offered ten sizes the export
|
|
119
|
+
* would refuse, which is the fault this exists to make unrepeatable.
|
|
120
|
+
*
|
|
121
|
+
* Memoised on the whole request, because the answer is a property of the machine rather than of
|
|
122
|
+
* the moment: a panel asking about nine sizes at two rates costs eighteen probes at boot and
|
|
123
|
+
* nothing afterwards. The promise is stored rather than the result, so eighteen concurrent asks
|
|
124
|
+
* about one size are one probe.
|
|
125
|
+
*
|
|
126
|
+
* False when WebCodecs is absent, which is the same answer from the caller's point of view. What
|
|
127
|
+
* it does **not** answer: whether the frames arrive once encoding starts. See `framesReachEncoder`.
|
|
128
|
+
*/
|
|
129
|
+
export async function clipEncodingSupported(request: ClipEncodeRequest): Promise<boolean> {
|
|
130
|
+
const { width, height, fps, bitrate } = request;
|
|
131
|
+
const key = `${width}x${height}@${fps}/${bitrate}`;
|
|
132
|
+
const known = answers.get(key);
|
|
133
|
+
if (known !== undefined) return known;
|
|
134
|
+
const asking = ask(request);
|
|
135
|
+
answers.set(key, asking);
|
|
136
|
+
return asking;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function ask({ width, height, fps, bitrate }: ClipEncodeRequest): Promise<boolean> {
|
|
140
|
+
if (typeof VideoEncoder !== 'function') return false;
|
|
141
|
+
for (const codec of clipCodecs(width, height)) {
|
|
142
|
+
try {
|
|
143
|
+
const support = await VideoEncoder.isConfigSupported({
|
|
144
|
+
codec,
|
|
145
|
+
width,
|
|
146
|
+
height,
|
|
147
|
+
bitrate,
|
|
148
|
+
framerate: fps,
|
|
149
|
+
});
|
|
150
|
+
if (support.supported === true) return true;
|
|
151
|
+
} catch {
|
|
152
|
+
// A codec string this build will not parse is one it cannot encode. Try the next rung.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the pictures this browser encodes are the pictures it was given.
|
|
3
|
+
*
|
|
4
|
+
* Every other capability call asks whether the browser *says* it can do something. This one asks
|
|
5
|
+
* whether it did, and it is the only one that has caught a file going out wrong.
|
|
6
|
+
*
|
|
7
|
+
* **The fault it exists for.** Chrome 149 with `chrome://flags#enable-vulkan` on, under Wayland,
|
|
8
|
+
* composites through Vulkan while ANGLE stays on GL, and in that arrangement a `VideoFrame` built
|
|
9
|
+
* from a canvas reaches the encoder empty. Nothing reports it: `VideoEncoder.error` never fires,
|
|
10
|
+
* `encodeQueueSize` drains, `flush` resolves, the muxer finalises, and the app hands somebody a
|
|
11
|
+
* clip that is solid green from end to end with its audio intact. Four went out that way from a
|
|
12
|
+
* consuming app on 2026-08-14 before anybody opened one.
|
|
13
|
+
*
|
|
14
|
+
* Reproduced here from this repository's own harness, on an RX 9070 XT on RADV, with the flags as
|
|
15
|
+
* the only variable: twelve frames of 1920x1080 encoded to 114,157 bytes with them off and 8,747
|
|
16
|
+
* bytes with them on, and `ffprobe` read every frame of the second file back as YAVG 0, UAVG 0,
|
|
17
|
+
* VAVG 0. All-zero YUV is green rather than black once a decoder applies the limited range.
|
|
18
|
+
* `--headless=new` does not reproduce it at all, with or without Vulkan, which is consistent with
|
|
19
|
+
* it taking the compositor and the ozone platform together.
|
|
20
|
+
*
|
|
21
|
+
* **So the check is a round trip rather than an inspection**, and three things that look like
|
|
22
|
+
* witnesses are not: `getImageData` on the same canvas returns the right pixels in every run,
|
|
23
|
+
* `VideoFrame.copyTo` reported full buffers for a case that went on to encode green, and every
|
|
24
|
+
* capability call answers yes. What would make this module wrong is a browser that fails delivery
|
|
25
|
+
* for some frames and not others, in which case one probe at the start is not enough; the fault
|
|
26
|
+
* measured here is a property of the browser's configuration and does not vary frame to frame.
|
|
27
|
+
*/
|
|
28
|
+
import { clipCodecs } from './clipSupport.ts';
|
|
29
|
+
|
|
30
|
+
/** What the probe found. `unknown` is the answer that changes nothing. */
|
|
31
|
+
export type FrameDelivery = 'carries' | 'empty' | 'unknown';
|
|
32
|
+
|
|
33
|
+
/** Side of the probe frame. The smallest thing worth asking; it encodes to 39 bytes. */
|
|
34
|
+
const PROBE_SIZE = 64;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The colour the probe paints: full red and full blue, no green.
|
|
38
|
+
*
|
|
39
|
+
* Chosen to be the opposite of the failure. An all-zero frame decodes to green with no red and no
|
|
40
|
+
* blue in either colour space a browser might tag it with, BT.709 putting it at `0,77,0` and
|
|
41
|
+
* BT.601 at `0,135,0`, so a picture that survived and a picture that did not cannot be confused
|
|
42
|
+
* by a decoder's choice of matrix. A grey probe would have to tell them apart by brightness,
|
|
43
|
+
* which is a threshold somebody has to guess at.
|
|
44
|
+
*/
|
|
45
|
+
const PROBE_COLOR = 'rgb(255 0 255)';
|
|
46
|
+
|
|
47
|
+
/** How long the round trip gets before it is abandoned. Far longer than it has ever taken. */
|
|
48
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a decoded probe colour means.
|
|
52
|
+
*
|
|
53
|
+
* **Only a positive reading of an empty frame refuses anything.** A probe that could not run, a
|
|
54
|
+
* decoder that returned nothing, a colour this does not recognise: all `unknown`, and a caller
|
|
55
|
+
* lets the export through on all of them. The two mistakes are not equal. A false negative costs
|
|
56
|
+
* somebody a file they can already see is wrong; a false positive costs them every clip they were
|
|
57
|
+
* going to make, on a machine where nothing was ever broken.
|
|
58
|
+
*/
|
|
59
|
+
export function deliveryVerdict(decoded: readonly [number, number, number] | null): FrameDelivery {
|
|
60
|
+
if (decoded === null) return 'unknown';
|
|
61
|
+
const [red, , blue] = decoded;
|
|
62
|
+
/* Both channels the probe painted came back. Generous, because the round trip goes through a
|
|
63
|
+
4:2:0 chroma subsample and a colour space conversion and neither is lossless. */
|
|
64
|
+
if (red > 128 && blue > 128) return 'carries';
|
|
65
|
+
/* Neither did, which no encode of this colour can produce. */
|
|
66
|
+
if (red < 32 && blue < 32) return 'empty';
|
|
67
|
+
return 'unknown';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One answer per session. See `framesReachEncoder`. */
|
|
71
|
+
let asked: Promise<FrameDelivery> | null = null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Paint a known colour, encode it, decode it back, and report what came out.
|
|
75
|
+
*
|
|
76
|
+
* Memoised for the session: the fault is a property of how the browser was started, so a second
|
|
77
|
+
* answer cannot differ from the first, and an export path that asks before every attempt should
|
|
78
|
+
* pay for one encode rather than one per press.
|
|
79
|
+
*
|
|
80
|
+
* Every failure path answers `unknown` rather than throwing, including the timeout. This runs in
|
|
81
|
+
* front of an export, and a capability probe that can itself stop one has become a liability
|
|
82
|
+
* rather than a check.
|
|
83
|
+
*/
|
|
84
|
+
export function framesReachEncoder(): Promise<FrameDelivery> {
|
|
85
|
+
asked ??= run();
|
|
86
|
+
return asked;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function run(): Promise<FrameDelivery> {
|
|
90
|
+
if (
|
|
91
|
+
typeof document === 'undefined' ||
|
|
92
|
+
typeof VideoEncoder !== 'function' ||
|
|
93
|
+
typeof VideoDecoder !== 'function' ||
|
|
94
|
+
typeof VideoFrame !== 'function'
|
|
95
|
+
) {
|
|
96
|
+
return 'unknown';
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return await Promise.race([
|
|
100
|
+
roundTrip(),
|
|
101
|
+
new Promise<FrameDelivery>((resolve) => {
|
|
102
|
+
setTimeout(() => resolve('unknown'), PROBE_TIMEOUT_MS);
|
|
103
|
+
}),
|
|
104
|
+
]);
|
|
105
|
+
} catch {
|
|
106
|
+
return 'unknown';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function roundTrip(): Promise<FrameDelivery> {
|
|
111
|
+
const codec = clipCodecs(PROBE_SIZE, PROBE_SIZE)[0];
|
|
112
|
+
if (codec === undefined) return 'unknown';
|
|
113
|
+
|
|
114
|
+
const canvas = document.createElement('canvas');
|
|
115
|
+
canvas.width = PROBE_SIZE;
|
|
116
|
+
canvas.height = PROBE_SIZE;
|
|
117
|
+
const ctx = canvas.getContext('2d', { alpha: false });
|
|
118
|
+
if (ctx === null) return 'unknown';
|
|
119
|
+
ctx.fillStyle = PROBE_COLOR;
|
|
120
|
+
ctx.fillRect(0, 0, PROBE_SIZE, PROBE_SIZE);
|
|
121
|
+
|
|
122
|
+
const chunks: EncodedVideoChunk[] = [];
|
|
123
|
+
/* The type the decoder's own config field takes, which is wider than `BufferSource`: it
|
|
124
|
+
accommodates a `SharedArrayBuffer`, and narrowing it here would only be a cast back. */
|
|
125
|
+
let description: AllowSharedBufferSource | undefined;
|
|
126
|
+
let failed = false;
|
|
127
|
+
const config = {
|
|
128
|
+
codec,
|
|
129
|
+
width: PROBE_SIZE,
|
|
130
|
+
height: PROBE_SIZE,
|
|
131
|
+
bitrate: 1_000_000,
|
|
132
|
+
framerate: 30,
|
|
133
|
+
};
|
|
134
|
+
const support = await VideoEncoder.isConfigSupported(config);
|
|
135
|
+
if (support.supported !== true) return 'unknown';
|
|
136
|
+
|
|
137
|
+
const encoder = new VideoEncoder({
|
|
138
|
+
output: (chunk, meta) => {
|
|
139
|
+
if (description === undefined && meta?.decoderConfig?.description !== undefined) {
|
|
140
|
+
description = meta.decoderConfig.description;
|
|
141
|
+
}
|
|
142
|
+
chunks.push(chunk);
|
|
143
|
+
},
|
|
144
|
+
error: () => {
|
|
145
|
+
failed = true;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
encoder.configure(config);
|
|
149
|
+
const frame = new VideoFrame(canvas, { timestamp: 0, duration: 33_333 });
|
|
150
|
+
try {
|
|
151
|
+
encoder.encode(frame, { keyFrame: true });
|
|
152
|
+
} finally {
|
|
153
|
+
frame.close();
|
|
154
|
+
}
|
|
155
|
+
await encoder.flush();
|
|
156
|
+
encoder.close();
|
|
157
|
+
if (failed || chunks.length === 0) return 'unknown';
|
|
158
|
+
|
|
159
|
+
let decoded: [number, number, number] | null = null;
|
|
160
|
+
const decoder = new VideoDecoder({
|
|
161
|
+
output: (picture) => {
|
|
162
|
+
try {
|
|
163
|
+
const read = document.createElement('canvas');
|
|
164
|
+
read.width = PROBE_SIZE;
|
|
165
|
+
read.height = PROBE_SIZE;
|
|
166
|
+
const readCtx = read.getContext('2d', { alpha: false });
|
|
167
|
+
if (readCtx !== null) {
|
|
168
|
+
readCtx.drawImage(picture, 0, 0);
|
|
169
|
+
const px = readCtx.getImageData(PROBE_SIZE / 2, PROBE_SIZE / 2, 1, 1).data;
|
|
170
|
+
decoded = [px[0] ?? 0, px[1] ?? 0, px[2] ?? 0];
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
picture.close();
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
error: () => {
|
|
177
|
+
failed = true;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
decoder.configure({
|
|
181
|
+
codec,
|
|
182
|
+
codedWidth: PROBE_SIZE,
|
|
183
|
+
codedHeight: PROBE_SIZE,
|
|
184
|
+
...(description === undefined ? {} : { description }),
|
|
185
|
+
});
|
|
186
|
+
for (const chunk of chunks) decoder.decode(chunk);
|
|
187
|
+
await decoder.flush();
|
|
188
|
+
decoder.close();
|
|
189
|
+
if (failed) return 'unknown';
|
|
190
|
+
return deliveryVerdict(decoded);
|
|
191
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
|
|
2
|
+
/**
|
|
3
|
+
* Recording what the engine draws: encoding a clip, and delivering frames to it.
|
|
4
|
+
*
|
|
5
|
+
* The muxer dependency lives here rather than in core, which is the point — a game that
|
|
6
|
+
* records nothing installs no muxer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { ClipEncoder, frameTimestampUs } from './clipEncoder.ts';
|
|
10
|
+
export type { ClipAudioCodec, ClipEncoderOptions } from './clipEncoder.ts';
|
|
11
|
+
export { clipEncodingSupported, offlineEncodingSupported } from './clipSupport.ts';
|
|
12
|
+
export type { ClipEncodeRequest } from './clipSupport.ts';
|
|
13
|
+
export { framesReachEncoder } from './frameDelivery.ts';
|
|
14
|
+
export type { FrameDelivery } from './frameDelivery.ts';
|