@three-blocks/devtools 11.0.0 → 11.1.0-alpha.395.g9b8ccb48a515
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/CHANGELOG.md +18 -0
- package/NOTICE +26 -0
- package/README.md +117 -170
- package/dist/asset-optimize-YJGWKD4Y.js +1 -0
- package/dist/asset-transforms-ZVZ4RD4L.js +11 -0
- package/dist/browser/shader-capture.js +2 -2
- package/dist/browser-smoke-4MYMHKWB.js +1 -0
- package/dist/chunk-4SVF5BRM.js +2 -0
- package/dist/chunk-7X4DHW36.js +1 -0
- package/dist/chunk-BBODS5RH.js +5 -0
- package/dist/{chunk-IILE5RBI.js → chunk-CMMYXGQ3.js} +1 -2
- package/dist/chunk-DCLDEPPB.js +253 -0
- package/dist/{chunk-5L2VS3BN.js → chunk-ECQE4D2U.js} +1 -2
- package/dist/chunk-IWQV456Z.js +3 -0
- package/dist/chunk-LSH64HEU.js +80 -0
- package/dist/{chunk-X5OFYHIY.js → chunk-MIJXPQPM.js} +2 -3
- package/dist/chunk-NDIPQLTU.js +16 -0
- package/dist/{chunk-NPLE7PPF.js → chunk-NJITVI23.js} +1 -2
- package/dist/chunk-O5FU2ASS.js +16 -0
- package/dist/chunk-QQKHDH26.js +1 -0
- package/dist/chunk-S5LI2C2H.js +5 -0
- package/dist/chunk-SBUKHO24.js +1 -0
- package/dist/chunk-TK566QET.js +1 -0
- package/dist/{chunk-65VEJRRT.js → chunk-WNSGAV6N.js} +1 -2
- package/dist/chunk-ZG3LO3VB.js +1 -0
- package/dist/{chunk-UDUICSME.js → chunk-ZTHZE36A.js} +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +26 -26
- package/dist/environment-bake-ON5I27N7.js +1 -0
- package/dist/index.d.ts +24 -12
- package/dist/index.js +1 -279
- package/dist/{shader-autowire-SPE5DM2X.js → shader-autowire-D2EOG6GF.js} +1 -2
- package/dist/shader-browser-6ICBHK3S.js +1 -0
- package/dist/shader-capture-server.d.ts +1 -1
- package/dist/shader-capture-server.js +1 -1
- package/dist/{shader-config-EOFU5D3Y.js → shader-config-AKZEKIRB.js} +1 -2
- package/dist/{shader-parity-QXJWR6OZ.js → shader-parity-7DEPQEDI.js} +1 -1
- package/dist/{shader-project-runner-S2AID2RJ.js → shader-project-runner-NV4GKHDX.js} +1 -2
- package/dist/shader-watch-JBUA4LP5.js +1 -0
- package/dist/shader-workflow-5EHHR32D.js +1 -0
- package/dist/status-GDVSLQWV.js +1 -0
- package/dist/text-atlas-YYWV7S6F.js +1 -0
- package/dist/transform.cjs +1 -1
- package/dist/transform.js +1 -1
- package/dist/webpack-loader.cjs +1 -1
- package/dist/webpack-loader.js +1 -1
- package/node/index.d.mts +70 -0
- package/node/index.mjs +407 -0
- package/node/ipc.mjs +31 -0
- package/node/layout.mjs +546 -0
- package/node/loader.mjs +103 -0
- package/node/media.mjs +454 -0
- package/node/recording.mjs +67 -0
- package/node/worker.mjs +1314 -0
- package/package.json +27 -11
- package/dist/asset-optimize-GG72JCOX.js +0 -6
- package/dist/browser-smoke-KFF7XV7F.js +0 -254
- package/dist/chunk-45AVKQQK.js +0 -80
- package/dist/chunk-5FT6AZD6.js +0 -82
- package/dist/chunk-7N6TU4JG.js +0 -17
- package/dist/chunk-EI7MMDWY.js +0 -1
- package/dist/chunk-HOZDD6PW.js +0 -2
- package/dist/chunk-LKXY5YU2.js +0 -17
- package/dist/chunk-MIUIIPS4.js +0 -1
- package/dist/chunk-NF4RFSUJ.js +0 -18
- package/dist/chunk-QJFPNM6W.js +0 -2
- package/dist/chunk-ZT6T2W4B.js +0 -2
- package/dist/chunk-ZZLAZ7GJ.js +0 -6
- package/dist/environment-bake-BGMGFAVK.js +0 -4
- package/dist/shader-browser-H5CB2ZQX.js +0 -2
- package/dist/shader-browser-UXXR6QI2.js +0 -1
- package/dist/shader-parity-XPKQQK26.js +0 -2
- package/dist/shader-watch-EORZ2SEC.js +0 -2
- package/dist/shader-workflow-7NQFDQI5.js +0 -2
- package/dist/status-PTBB4SW2.js +0 -2
- package/dist/text-atlas-UORTLOQJ.js +0 -2
- /package/dist/{chunk-TRHSN2DJ.cjs → chunk-LMLJIVNY.cjs} +0 -0
package/node/media.mjs
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import * as codecs from '@napi-rs/webcodecs';
|
|
2
|
+
|
|
3
|
+
// Decode on native FFmpeg threads, then expose straight-alpha sRGB pixels to
|
|
4
|
+
// ANGLE/Dawn. The generated Three.js shader and capture manifest stay unchanged.
|
|
5
|
+
const pixels = new WeakMap();
|
|
6
|
+
const pending = new WeakMap();
|
|
7
|
+
const NativeFrame = codecs.VideoFrame;
|
|
8
|
+
const cloneFrame = NativeFrame.prototype.clone;
|
|
9
|
+
const copyFrame = NativeFrame.prototype.copyTo;
|
|
10
|
+
|
|
11
|
+
async function rgbaPixels( frame ) {
|
|
12
|
+
|
|
13
|
+
const width = frame.visibleRect.width, height = frame.visibleRect.height;
|
|
14
|
+
// Read native accessors once, not millions of times inside the pixel loop.
|
|
15
|
+
const format = frame.format;
|
|
16
|
+
if ( ! /^(I420|I422|I444|NV12)(A)?$/.test( format ) ) {
|
|
17
|
+
|
|
18
|
+
const data = new Uint8Array( width * height * 4 );
|
|
19
|
+
await copyFrame.call( frame, data, { format: 'RGBA' } );
|
|
20
|
+
return { width, height, data };
|
|
21
|
+
|
|
22
|
+
}
|
|
23
|
+
// webcodecs 1.4.1 keeps decoder color-space overrides on VideoFrame but its
|
|
24
|
+
// FFmpeg RGBA copy reads the original bitstream range. Convert the raw planes
|
|
25
|
+
// using the declared range/matrix so full-range DATA bytes survive upload.
|
|
26
|
+
const bytes = new Uint8Array( frame.allocationSize() );
|
|
27
|
+
const { matrix, fullRange } = frame.colorSpace;
|
|
28
|
+
const planes = await copyFrame.call( frame, bytes );
|
|
29
|
+
const coefficients = { bt709: [ 0.2126, 0.0722 ], smpte170m: [ 0.299, 0.114 ], bt470bg: [ 0.299, 0.114 ], 'bt2020-ncl': [ 0.2627, 0.0593 ] };
|
|
30
|
+
const [ kr, kb ] = coefficients[ matrix ?? 'bt709' ] ?? [];
|
|
31
|
+
if ( kr === undefined ) throw new Error( `Unsupported native YUV matrix: ${matrix}` );
|
|
32
|
+
const yuv = { bytes, planes, format, fullRange, kr, kb };
|
|
33
|
+
let data;
|
|
34
|
+
// GPU uploads consume planes directly; only CPU/ANGLE readers need RGBA bytes.
|
|
35
|
+
return Object.defineProperty( { width, height, get data() { return data ??= convertYUV( width, height, yuv ); } }, 'yuv', { value: yuv } );
|
|
36
|
+
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function convertYUV( width, height, { bytes, planes, format, fullRange, kr, kb } ) {
|
|
40
|
+
|
|
41
|
+
const data = new Uint8Array( width * height * 4 ), kg = 1 - kr - kb;
|
|
42
|
+
const xSub = format.startsWith( 'I444' ) ? 1 : 2, ySub = /^(I420|NV12)/.test( format ) ? 2 : 1;
|
|
43
|
+
const sample = ( plane, x, y ) => bytes[ planes[ plane ].offset + y * planes[ plane ].stride + x ];
|
|
44
|
+
for ( let y = 0; y < height; y ++ ) for ( let x = 0; x < width; x ++ ) {
|
|
45
|
+
|
|
46
|
+
const cx = Math.floor( x / xSub ), cy = Math.floor( y / ySub );
|
|
47
|
+
const luma = ( sample( 0, x, y ) - ( fullRange ? 0 : 16 ) ) * ( fullRange ? 1 : 255 / 219 );
|
|
48
|
+
const u = ( sample( 1, format === 'NV12' ? cx * 2 : cx, cy ) - 128 ) * ( fullRange ? 1 : 255 / 224 );
|
|
49
|
+
const v = ( ( format === 'NV12' ? sample( 1, cx * 2 + 1, cy ) : sample( 2, cx, cy ) ) - 128 ) * ( fullRange ? 1 : 255 / 224 );
|
|
50
|
+
const offset = ( y * width + x ) * 4;
|
|
51
|
+
data[ offset ] = Math.max( 0, Math.min( 255, Math.round( luma + 2 * ( 1 - kr ) * v ) ) );
|
|
52
|
+
data[ offset + 1 ] = Math.max( 0, Math.min( 255, Math.round( luma - 2 * kb * ( 1 - kb ) / kg * u - 2 * kr * ( 1 - kr ) / kg * v ) ) );
|
|
53
|
+
data[ offset + 2 ] = Math.max( 0, Math.min( 255, Math.round( luma + 2 * ( 1 - kb ) * u ) ) );
|
|
54
|
+
data[ offset + 3 ] = format.endsWith( 'A' ) ? sample( 3, x, y ) : 255;
|
|
55
|
+
|
|
56
|
+
}
|
|
57
|
+
return data;
|
|
58
|
+
|
|
59
|
+
}
|
|
60
|
+
NativeFrame.prototype.preparePixels = function () {
|
|
61
|
+
|
|
62
|
+
if ( pixels.has( this ) ) return Promise.resolve();
|
|
63
|
+
if ( ! pending.has( this ) ) {
|
|
64
|
+
|
|
65
|
+
// Hold the native planes until asynchronous conversion finishes, even if
|
|
66
|
+
// the caller closes its frame immediately after submitting it to an encoder.
|
|
67
|
+
const source = cloneFrame.call( this );
|
|
68
|
+
pending.set( this, rgbaPixels( source ).then( image => { pixels.set( this, image ); } ).finally( () => source.close() ) );
|
|
69
|
+
|
|
70
|
+
}
|
|
71
|
+
return pending.get( this );
|
|
72
|
+
|
|
73
|
+
};
|
|
74
|
+
NativeFrame.prototype._toRGBA8 = function () {
|
|
75
|
+
|
|
76
|
+
if ( this.closed ) throw new DOMException( 'VideoFrame is closed.', 'InvalidStateError' );
|
|
77
|
+
const image = pixels.get( this );
|
|
78
|
+
if ( ! image ) throw new Error( 'VideoFrame pixels are not ready for GPU upload.' );
|
|
79
|
+
return image;
|
|
80
|
+
|
|
81
|
+
};
|
|
82
|
+
NativeFrame.prototype.clone = function () {
|
|
83
|
+
|
|
84
|
+
const frame = cloneFrame.call( this );
|
|
85
|
+
if ( pixels.has( this ) ) pixels.set( frame, pixels.get( this ) );
|
|
86
|
+
return frame;
|
|
87
|
+
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export async function installMedia( createElement ) {
|
|
91
|
+
|
|
92
|
+
for ( const name of [ 'AudioData', 'AudioDecoder', 'AudioEncoder', 'VideoEncoder', 'EncodedAudioChunk', 'EncodedVideoChunk', 'ImageDecoder', 'VideoColorSpace' ] ) globalThis[ name ] = codecs[ name ];
|
|
93
|
+
// Keep the native prototype: lifecycle instrumentation must observe decoded
|
|
94
|
+
// frames as well as frames constructed from canvas pixels.
|
|
95
|
+
globalThis.VideoFrame = new Proxy( NativeFrame, {
|
|
96
|
+
|
|
97
|
+
construct( _target, [ source, init ] ) {
|
|
98
|
+
|
|
99
|
+
const image = source._toRGBA8?.();
|
|
100
|
+
const frame = new NativeFrame( image ? new Uint8Array( image.data ) : source, image ? {
|
|
101
|
+
...init, format: 'RGBA', codedWidth: image.width, codedHeight: image.height,
|
|
102
|
+
} : init );
|
|
103
|
+
if ( image ) pixels.set( frame, image );
|
|
104
|
+
else if ( init?.format === 'RGBA' && ArrayBuffer.isView( source ) && ! init.layout && ! init.visibleRect ) pixels.set( frame, {
|
|
105
|
+
width: init.codedWidth, height: init.codedHeight, data: new Uint8Array( source.buffer, source.byteOffset, source.byteLength ).slice(),
|
|
106
|
+
} );
|
|
107
|
+
else void frame.preparePixels();
|
|
108
|
+
return frame;
|
|
109
|
+
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
} );
|
|
113
|
+
globalThis.VideoDecoder = class VideoDecoder extends codecs.VideoDecoder {
|
|
114
|
+
|
|
115
|
+
#config;
|
|
116
|
+
#flushed = false;
|
|
117
|
+
#avcLength = 4;
|
|
118
|
+
|
|
119
|
+
constructor( init ) {
|
|
120
|
+
|
|
121
|
+
let outputs = Promise.resolve();
|
|
122
|
+
super( { ...init, output: frame => {
|
|
123
|
+
|
|
124
|
+
outputs = outputs.then( async () => { await frame.preparePixels(); init.output( frame ); } ).catch( init.error );
|
|
125
|
+
|
|
126
|
+
} } );
|
|
127
|
+
const flush = this.flush.bind( this );
|
|
128
|
+
this.flush = async () => { this.#flushed = true; await flush(); await outputs; };
|
|
129
|
+
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
configure( config ) {
|
|
133
|
+
|
|
134
|
+
const original = structuredClone( config );
|
|
135
|
+
let length = 4;
|
|
136
|
+
if ( /^avc[13]\./.test( config.codec ) && config.description ) {
|
|
137
|
+
|
|
138
|
+
const source = config.description;
|
|
139
|
+
const description = new Uint8Array( ArrayBuffer.isView( source ) ? source.buffer.slice( source.byteOffset, source.byteOffset + source.byteLength ) : source.slice( 0 ) );
|
|
140
|
+
if ( description.length >= 5 && description[ 0 ] === 1 ) {
|
|
141
|
+
|
|
142
|
+
length = ( description[ 4 ] & 3 ) + 1;
|
|
143
|
+
// webcodecs 1.4.1's native decoder requires four-byte AVC lengths.
|
|
144
|
+
if ( length === 1 || length === 2 ) { description[ 4 ] |= 3; config = { ...config, description }; }
|
|
145
|
+
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
}
|
|
149
|
+
super.configure( config );
|
|
150
|
+
this.#config = original; this.#flushed = false; this.#avcLength = length;
|
|
151
|
+
|
|
152
|
+
}
|
|
153
|
+
decode( chunk ) {
|
|
154
|
+
|
|
155
|
+
// webcodecs 1.4.1 does not count flush outputs toward its hardware
|
|
156
|
+
// watchdog. Reconfigure in FIFO order after draining, before the next
|
|
157
|
+
// required keyframe, so repeated seeks do not trigger a false failure.
|
|
158
|
+
if ( this.#flushed && this.state === 'configured' ) this.configure( this.#config );
|
|
159
|
+
if ( this.#avcLength === 1 || this.#avcLength === 2 ) {
|
|
160
|
+
|
|
161
|
+
const bytes = Buffer.alloc( chunk.byteLength ), parts = [];
|
|
162
|
+
chunk.copyTo( bytes );
|
|
163
|
+
for ( let offset = 0; offset < bytes.length; ) {
|
|
164
|
+
|
|
165
|
+
if ( offset + this.#avcLength > bytes.length ) throw new DOMException( 'Truncated AVC length.', 'DataError' );
|
|
166
|
+
const size = bytes.readUIntBE( offset, this.#avcLength ); offset += this.#avcLength;
|
|
167
|
+
if ( size === 0 || offset + size > bytes.length ) throw new DOMException( 'Invalid AVC NAL length.', 'DataError' );
|
|
168
|
+
const prefix = Buffer.alloc( 4 ); prefix.writeUInt32BE( size );
|
|
169
|
+
parts.push( prefix, bytes.subarray( offset, offset + size ) ); offset += size;
|
|
170
|
+
|
|
171
|
+
}
|
|
172
|
+
chunk = new codecs.EncodedVideoChunk( { type: chunk.type, timestamp: chunk.timestamp, ...( chunk.duration === null ? {} : { duration: chunk.duration } ), data: Buffer.concat( parts ) } );
|
|
173
|
+
|
|
174
|
+
}
|
|
175
|
+
super.decode( chunk );
|
|
176
|
+
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
};
|
|
180
|
+
const states = new WeakMap();
|
|
181
|
+
const prepareVideo = element => {
|
|
182
|
+
|
|
183
|
+
if ( states.has( element ) ) return element;
|
|
184
|
+
const media = new NativeVideo(); states.set( element, media );
|
|
185
|
+
Object.setPrototypeOf( element, HTMLVideoElement.prototype );
|
|
186
|
+
const setAttribute = element.setAttribute.bind( element ), removeAttribute = element.removeAttribute.bind( element );
|
|
187
|
+
element.setAttribute = ( name, value ) => { setAttribute( name, value ); if ( name in media ) media.setAttribute( name, value ); };
|
|
188
|
+
element.removeAttribute = name => { removeAttribute( name ); if ( name === 'src' ) media.removeAttribute( name ); };
|
|
189
|
+
for ( const { name, value } of element.attributes ) if ( name !== 'src' && name in media ) media.setAttribute( name, value );
|
|
190
|
+
const source = element.getAttribute( 'src' ) || [ ...element.querySelectorAll( 'source' ) ].find( source => ! source.type || media.canPlayType( source.type ) )?.getAttribute( 'src' );
|
|
191
|
+
if ( source ) media.src = source;
|
|
192
|
+
return element;
|
|
193
|
+
|
|
194
|
+
};
|
|
195
|
+
globalThis.HTMLMediaElement = class HTMLMediaElement extends HTMLElement {};
|
|
196
|
+
globalThis.HTMLVideoElement = class HTMLVideoElement extends HTMLMediaElement {
|
|
197
|
+
|
|
198
|
+
constructor() { return prepareVideo( createElement( 'video' ) ); }
|
|
199
|
+
|
|
200
|
+
};
|
|
201
|
+
Object.assign( HTMLMediaElement, { HAVE_NOTHING: 0, HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2, HAVE_FUTURE_DATA: 3, HAVE_ENOUGH_DATA: 4 } );
|
|
202
|
+
const sample = new NativeVideo();
|
|
203
|
+
for ( const name of new Set( [ ...Object.keys( sample ), ...Object.getOwnPropertyNames( NativeVideo.prototype ), 'addEventListener', 'removeEventListener', 'onloadedmetadata', 'onloadeddata', 'oncanplay', 'oncanplaythrough', 'onerror', 'onseeked', 'onseeking', 'ontimeupdate', 'onended', 'onplay', 'onpause', 'onplaying' ] ) ) {
|
|
204
|
+
|
|
205
|
+
if ( [ 'constructor', 'style', 'setAttribute', 'removeAttribute' ].includes( name ) ) continue;
|
|
206
|
+
Object.defineProperty( HTMLMediaElement.prototype, name, typeof sample[ name ] === 'function'
|
|
207
|
+
? { configurable: true, writable: true, value: function ( ...args ) { return states.get( this )[ name ]( ...args ); } }
|
|
208
|
+
: { configurable: true, get() { return states.get( this )[ name ]; }, set( value ) { states.get( this )[ name ] = value; } } );
|
|
209
|
+
|
|
210
|
+
}
|
|
211
|
+
return prepareVideo;
|
|
212
|
+
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
class NativeVideo extends EventTarget {
|
|
216
|
+
|
|
217
|
+
readyState = 0;
|
|
218
|
+
HAVE_CURRENT_DATA = 2;
|
|
219
|
+
HAVE_ENOUGH_DATA = 4;
|
|
220
|
+
duration = NaN;
|
|
221
|
+
videoWidth = 0;
|
|
222
|
+
videoHeight = 0;
|
|
223
|
+
paused = true;
|
|
224
|
+
loop = false;
|
|
225
|
+
muted = true;
|
|
226
|
+
playbackRate = 1;
|
|
227
|
+
style = {};
|
|
228
|
+
#src = '';
|
|
229
|
+
#time = 0;
|
|
230
|
+
#loaded;
|
|
231
|
+
#demuxer;
|
|
232
|
+
#decoder;
|
|
233
|
+
#frame;
|
|
234
|
+
#decodedFrame;
|
|
235
|
+
#callbacks = new Map();
|
|
236
|
+
#nextCallback = 0;
|
|
237
|
+
#timer;
|
|
238
|
+
#chunks = [];
|
|
239
|
+
#operation = Promise.resolve();
|
|
240
|
+
#target = 0;
|
|
241
|
+
#generation = 0;
|
|
242
|
+
error = null;
|
|
243
|
+
seeking = false;
|
|
244
|
+
ended = false;
|
|
245
|
+
|
|
246
|
+
get src() { return this.#src; }
|
|
247
|
+
set src( value ) {
|
|
248
|
+
|
|
249
|
+
this.pause(); this.#generation ++;
|
|
250
|
+
this.#decoder?.close(); this.#demuxer?.close(); this.#frame?.close(); this.#decodedFrame?.close();
|
|
251
|
+
this.#decoder = this.#demuxer = this.#frame = this.#decodedFrame = undefined;
|
|
252
|
+
this.#chunks = []; this.#time = 0; this.readyState = 0; this.error = null;
|
|
253
|
+
this.#src = value ? new URL( value, location.href ).href : ''; this.#loaded = undefined;
|
|
254
|
+
if ( this.#src ) queueMicrotask( () => { void this.load().catch( error => this.#fail( error ) ); } );
|
|
255
|
+
|
|
256
|
+
}
|
|
257
|
+
get currentSrc() { return this.src; }
|
|
258
|
+
get currentTime() { return this.#time; }
|
|
259
|
+
set currentTime( value ) {
|
|
260
|
+
|
|
261
|
+
this.seeking = true; this.dispatchEvent( new Event( 'seeking' ) );
|
|
262
|
+
this.#operation = this.#operation.then( () => this.#seek( value ) ).then( () => { this.seeking = false; this.dispatchEvent( new Event( 'seeked' ) ); } ).catch( error => this.#fail( error ) );
|
|
263
|
+
|
|
264
|
+
}
|
|
265
|
+
dispatchEvent( event ) { this[ `on${event.type}` ]?.( event ); return super.dispatchEvent( event ); }
|
|
266
|
+
#fail( error ) { this.error = error; this.dispatchEvent( Object.assign( new Event( 'error' ), { error, message: error.message } ) ); }
|
|
267
|
+
|
|
268
|
+
async #load() {
|
|
269
|
+
|
|
270
|
+
const generation = this.#generation;
|
|
271
|
+
const response = await fetch( this.src );
|
|
272
|
+
if ( ! response.ok ) throw new Error( `Video ${this.src}: HTTP ${response.status}` );
|
|
273
|
+
const bytes = new Uint8Array( await response.arrayBuffer() );
|
|
274
|
+
if ( generation !== this.#generation ) return;
|
|
275
|
+
const Demuxer = bytes[ 0 ] === 0x1a && bytes[ 1 ] === 0x45 ? codecs.WebMDemuxer : codecs.Mp4Demuxer;
|
|
276
|
+
this.#demuxer = new Demuxer( {
|
|
277
|
+
videoOutput: chunk => this.#chunks.push( chunk ),
|
|
278
|
+
error: error => this.#fail( error ),
|
|
279
|
+
} );
|
|
280
|
+
await this.#demuxer.loadBuffer( bytes );
|
|
281
|
+
const config = this.#demuxer.videoDecoderConfig;
|
|
282
|
+
if ( ! config ) throw new Error( 'Media has no video track.' );
|
|
283
|
+
await this.#demuxer.demuxAsync();
|
|
284
|
+
if ( generation !== this.#generation ) return;
|
|
285
|
+
this.videoWidth = config.codedWidth; this.videoHeight = config.codedHeight;
|
|
286
|
+
this.duration = this.#demuxer.duration / 1e6;
|
|
287
|
+
this.#decoder = new VideoDecoder( {
|
|
288
|
+
output: frame => {
|
|
289
|
+
if ( ! this.#decodedFrame || frame.timestamp <= this.#target && frame.timestamp >= this.#decodedFrame.timestamp ) { this.#decodedFrame?.close(); this.#decodedFrame = frame; }
|
|
290
|
+
else frame.close();
|
|
291
|
+
},
|
|
292
|
+
error: error => this.#fail( error ),
|
|
293
|
+
} );
|
|
294
|
+
this.#decoder.configure( config );
|
|
295
|
+
this.readyState = 1; this.dispatchEvent( new Event( 'loadedmetadata' ) );
|
|
296
|
+
await this.#decode( 0 );
|
|
297
|
+
this.readyState = 4;
|
|
298
|
+
for ( const type of [ 'loadeddata', 'canplay', 'canplaythrough' ] ) this.dispatchEvent( new Event( type ) );
|
|
299
|
+
|
|
300
|
+
}
|
|
301
|
+
load() { return this.#loaded ??= this.#src ? this.#load() : Promise.resolve(); }
|
|
302
|
+
async #decode( time ) {
|
|
303
|
+
|
|
304
|
+
time = Math.max( 0, Math.min( time, this.duration ) );
|
|
305
|
+
this.#target = Math.round( time * 1e6 );
|
|
306
|
+
let start = 0;
|
|
307
|
+
for ( let i = 0; i < this.#chunks.length; i ++ ) if ( this.#chunks[ i ].type === 'key' && this.#chunks[ i ].timestamp <= this.#target ) start = i;
|
|
308
|
+
this.#decodedFrame?.close(); this.#decodedFrame = undefined;
|
|
309
|
+
this.#decoder.reset(); this.#decoder.configure( this.#demuxer.videoDecoderConfig );
|
|
310
|
+
// ponytail: decode one complete GOP per seek; use a streaming frame queue if HTML video playback becomes the capture bottleneck.
|
|
311
|
+
for ( let i = start; i < this.#chunks.length; i ++ ) {
|
|
312
|
+
|
|
313
|
+
const chunk = this.#chunks[ i ];
|
|
314
|
+
if ( i > start && chunk.type === 'key' && chunk.timestamp > this.#target ) break;
|
|
315
|
+
this.#decoder.decode( chunk );
|
|
316
|
+
|
|
317
|
+
}
|
|
318
|
+
await this.#decoder.flush();
|
|
319
|
+
if ( ! this.#decodedFrame ) throw new Error( `Video decoded no frame at ${time}s.` );
|
|
320
|
+
// Keep the last presented frame available to textures throughout a seek.
|
|
321
|
+
this.#frame?.close(); this.#frame = this.#decodedFrame; this.#decodedFrame = undefined; this.#time = time;
|
|
322
|
+
this.dispatchEvent( new Event( 'timeupdate' ) );
|
|
323
|
+
const callbacks = [ ...this.#callbacks.values() ]; this.#callbacks.clear();
|
|
324
|
+
for ( const callback of callbacks ) callback( performance.now(), { mediaTime: this.#frame.timestamp / 1e6, expectedDisplayTime: performance.now(), presentationTime: performance.now(), width: this.videoWidth, height: this.videoHeight, presentedFrames: Math.round( time * 30 ) + 1 } );
|
|
325
|
+
|
|
326
|
+
}
|
|
327
|
+
async #seek( time ) { await this.load(); await this.#decode( time ); }
|
|
328
|
+
async play() {
|
|
329
|
+
|
|
330
|
+
await this.load();
|
|
331
|
+
if ( ! this.paused ) return;
|
|
332
|
+
this.paused = false; this.ended = false; this.dispatchEvent( new Event( 'play' ) ); this.dispatchEvent( new Event( 'playing' ) );
|
|
333
|
+
const tick = async () => {
|
|
334
|
+
|
|
335
|
+
if ( this.paused ) return;
|
|
336
|
+
let time = this.#time + this.playbackRate / 30;
|
|
337
|
+
if ( time >= this.duration ) {
|
|
338
|
+
|
|
339
|
+
if ( ! this.loop ) { this.pause(); this.ended = true; this.dispatchEvent( new Event( 'ended' ) ); return; }
|
|
340
|
+
time = 0;
|
|
341
|
+
|
|
342
|
+
}
|
|
343
|
+
this.#operation = this.#operation.then( () => { if ( ! this.paused ) return this.#decode( time ); } );
|
|
344
|
+
try { await this.#operation; } catch ( error ) { this.#fail( error ); this.pause(); return; }
|
|
345
|
+
if ( ! this.paused ) this.#timer = setTimeout( tick, 1000 / 30 );
|
|
346
|
+
|
|
347
|
+
};
|
|
348
|
+
this.#timer = setTimeout( tick, 1000 / 30 );
|
|
349
|
+
|
|
350
|
+
}
|
|
351
|
+
pause() { const changed = ! this.paused; this.paused = true; clearTimeout( this.#timer ); if ( changed ) this.dispatchEvent( new Event( 'pause' ) ); }
|
|
352
|
+
requestVideoFrameCallback( callback ) { const id = ++ this.#nextCallback; this.#callbacks.set( id, callback ); return id; }
|
|
353
|
+
cancelVideoFrameCallback( id ) { this.#callbacks.delete( id ); }
|
|
354
|
+
canPlayType( type ) { return /video\/(mp4|webm)/.test( type ) ? 'probably' : ''; }
|
|
355
|
+
setAttribute( name, value ) { this[ name ] = [ 'loop', 'muted', 'autoplay', 'playsinline' ].includes( name ) ? true : value; }
|
|
356
|
+
removeAttribute( name ) { if ( name === 'src' ) this.src = ''; }
|
|
357
|
+
_toRGBA8() { return this.#frame?._toRGBA8(); }
|
|
358
|
+
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function installExternalTextures( device ) {
|
|
362
|
+
|
|
363
|
+
const yuvPipelines = new Map();
|
|
364
|
+
const copyYUV = ( image, source, destination, size ) => {
|
|
365
|
+
|
|
366
|
+
if ( ! image.yuv ) return false;
|
|
367
|
+
const { bytes, planes, format, fullRange, kr, kb } = image.yuv, kg = 1 - kr - kb;
|
|
368
|
+
const width = size.width ?? size[ 0 ], height = size.height ?? size[ 1 ] ?? 1;
|
|
369
|
+
const x = source.origin?.x ?? source.origin?.[ 0 ] ?? 0, y = source.origin?.y ?? source.origin?.[ 1 ] ?? 0;
|
|
370
|
+
if ( x + width > image.width || y + height > image.height ) throw new Error( 'External image copy exceeds source bounds.' );
|
|
371
|
+
const key = `${format}/${kr}/${kb}/${fullRange}`;
|
|
372
|
+
let pipeline = yuvPipelines.get( key );
|
|
373
|
+
if ( ! pipeline ) {
|
|
374
|
+
|
|
375
|
+
const module = device.createShaderModule( { code: `
|
|
376
|
+
@group(0) @binding(0) var<storage, read> bytes: array<u32>;
|
|
377
|
+
@group(0) @binding(1) var<uniform> p: array<vec4u, 4>;
|
|
378
|
+
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
|
379
|
+
fn sampleByte(offset: u32) -> f32 { return f32((bytes[offset / 4u] >> ((offset % 4u) * 8u)) & 255u); }
|
|
380
|
+
@compute @workgroup_size(8, 8) fn main(@builtin(global_invocation_id) id: vec3u) {
|
|
381
|
+
if (id.x >= p[3].x || id.y >= p[3].y) { return; }
|
|
382
|
+
let x = p[2].y + id.x;
|
|
383
|
+
let y = select(p[2].z + id.y, p[2].x - 1u - p[2].z - id.y, p[2].w == 1u);
|
|
384
|
+
let cx = (x / ${format.startsWith( 'I444' ) ? 1 : 2}u) * ${format === 'NV12' ? 2 : 1}u;
|
|
385
|
+
let cy = y / ${/^(I420|NV12)/.test( format ) ? 2 : 1}u;
|
|
386
|
+
let luma = (sampleByte(p[0].x + y * p[0].y + x) - ${fullRange ? '0.0' : '16.0'}) * ${fullRange ? '1.0' : 255 / 219};
|
|
387
|
+
let u = (sampleByte(p[0].z + cy * p[0].w + cx) - 128.0) * ${fullRange ? '1.0' : 255 / 224};
|
|
388
|
+
let v = (sampleByte(p[1].x + cy * p[1].y + cx) - 128.0) * ${fullRange ? '1.0' : 255 / 224};
|
|
389
|
+
var rgb = floor(clamp(vec3f(luma + ${2 * ( 1 - kr )} * v, luma - ${2 * kb * ( 1 - kb ) / kg} * u - ${2 * kr * ( 1 - kr ) / kg} * v, luma + ${2 * ( 1 - kb )} * u), vec3f(0.0), vec3f(255.0)) + 0.5) / 255.0;
|
|
390
|
+
let alpha = ${format.endsWith( 'A' ) ? 'sampleByte(p[1].z + y * p[1].w + x) / 255.0' : '1.0'};
|
|
391
|
+
if (p[3].z == 1u) { rgb = floor(rgb * alpha * 255.0 + 0.5) / 255.0; }
|
|
392
|
+
textureStore(output, id.xy, vec4f(rgb, alpha));
|
|
393
|
+
}` } );
|
|
394
|
+
pipeline = device.createComputePipeline( { layout: 'auto', compute: { module, entryPoint: 'main' } } );
|
|
395
|
+
yuvPipelines.set( key, pipeline );
|
|
396
|
+
|
|
397
|
+
}
|
|
398
|
+
const [ yp, up ] = planes, vp = planes[ format === 'NV12' ? 1 : 2 ], ap = format.endsWith( 'A' ) ? planes[ 3 ] : null;
|
|
399
|
+
const input = device.createBuffer( { size: Math.ceil( bytes.length / 4 ) * 4, usage: GPUBufferUsage.STORAGE, mappedAtCreation: true } );
|
|
400
|
+
new Uint8Array( input.getMappedRange() ).set( bytes ); input.unmap();
|
|
401
|
+
const parameters = device.createBuffer( { size: 64, usage: GPUBufferUsage.UNIFORM, mappedAtCreation: true } );
|
|
402
|
+
new Uint32Array( parameters.getMappedRange() ).set( [ yp.offset, yp.stride, up.offset, up.stride, vp.offset + ( format === 'NV12' ? 1 : 0 ), vp.stride, ap?.offset ?? 0, ap?.stride ?? 0, image.height, x, y, source.flipY ? 1 : 0, width, height, destination.premultipliedAlpha ? 1 : 0, 0 ] );
|
|
403
|
+
parameters.unmap();
|
|
404
|
+
const texture = device.createTexture( { size: [ width, height ], format: 'rgba8unorm', usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.COPY_SRC } );
|
|
405
|
+
try {
|
|
406
|
+
|
|
407
|
+
const encoder = device.createCommandEncoder(), pass = encoder.beginComputePass();
|
|
408
|
+
pass.setPipeline( pipeline );
|
|
409
|
+
pass.setBindGroup( 0, device.createBindGroup( { layout: pipeline.getBindGroupLayout( 0 ), entries: [ { binding: 0, resource: { buffer: input } }, { binding: 1, resource: { buffer: parameters } }, { binding: 2, resource: texture.createView() } ] } ) );
|
|
410
|
+
pass.dispatchWorkgroups( Math.ceil( width / 8 ), Math.ceil( height / 8 ) ); pass.end();
|
|
411
|
+
encoder.copyTextureToTexture( { texture }, destination, [ width, height ] );
|
|
412
|
+
device.queue.submit( [ encoder.finish() ] );
|
|
413
|
+
|
|
414
|
+
} finally { input.destroy(); parameters.destroy(); texture.destroy(); }
|
|
415
|
+
return true;
|
|
416
|
+
|
|
417
|
+
};
|
|
418
|
+
const shaderModule = device.createShaderModule.bind( device );
|
|
419
|
+
device.createShaderModule = descriptor => {
|
|
420
|
+
|
|
421
|
+
if ( ! /\btexture_external\b/.test( descriptor.code ) ) return shaderModule( descriptor );
|
|
422
|
+
const code = descriptor.code.replace( /\btexture_external\b/g, 'texture_2d<f32>' ).replace( /\btextureSampleBaseClampToEdge\b/g, 'tbNativeSampleExternal' );
|
|
423
|
+
return shaderModule( { ...descriptor, code: `${code}\nfn tbNativeSampleExternal(t: texture_2d<f32>, s: sampler, uv: vec2f) -> vec4f { let edge = vec2f(0.5) / vec2f(textureDimensions(t)); return textureSampleLevel(t, s, clamp(uv, edge, vec2f(1.0) - edge), 0.0); }` } );
|
|
424
|
+
|
|
425
|
+
};
|
|
426
|
+
const bindGroupLayout = device.createBindGroupLayout.bind( device );
|
|
427
|
+
device.createBindGroupLayout = descriptor => bindGroupLayout( { ...descriptor, entries: [ ...descriptor.entries ].map( entry => {
|
|
428
|
+
|
|
429
|
+
if ( ! entry.externalTexture ) return entry;
|
|
430
|
+
const { externalTexture, ...rest } = entry;
|
|
431
|
+
return { ...rest, texture: { sampleType: 'float', viewDimension: '2d', multisampled: false } };
|
|
432
|
+
|
|
433
|
+
} ) } );
|
|
434
|
+
const textures = new WeakMap();
|
|
435
|
+
device.importExternalTexture = ( { source, colorSpace = 'srgb' } ) => {
|
|
436
|
+
|
|
437
|
+
if ( colorSpace !== 'srgb' ) throw new Error( `Unsupported external texture color space: ${colorSpace}` );
|
|
438
|
+
const image = source._toRGBA8?.();
|
|
439
|
+
if ( ! image ) throw new Error( 'External texture source has no decoded pixels.' );
|
|
440
|
+
let record = textures.get( source );
|
|
441
|
+
if ( ! record || record.texture.width !== image.width || record.texture.height !== image.height ) {
|
|
442
|
+
|
|
443
|
+
record?.texture.destroy();
|
|
444
|
+
const texture = device.createTexture( { size: [ image.width, image.height ], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST } );
|
|
445
|
+
record = { texture, view: texture.createView() }; textures.set( source, record );
|
|
446
|
+
|
|
447
|
+
}
|
|
448
|
+
if ( ! copyYUV( image, {}, { texture: record.texture }, [ image.width, image.height ] ) ) device.queue.writeTexture( { texture: record.texture }, image.data, { bytesPerRow: image.width * 4 }, [ image.width, image.height ] );
|
|
449
|
+
return record.view;
|
|
450
|
+
|
|
451
|
+
};
|
|
452
|
+
return copyYUV;
|
|
453
|
+
|
|
454
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { VideoEncoder, VideoFrame, WebMMuxer } from '@napi-rs/webcodecs';
|
|
5
|
+
|
|
6
|
+
// Record actual native canvas readbacks on a wall clock. Duplicate the last
|
|
7
|
+
// frame when readback falls behind, preserving the duration of pointer tests.
|
|
8
|
+
export async function recordCanvas( readPixels, { path, fps = 30 } ) {
|
|
9
|
+
|
|
10
|
+
const muxer = new WebMMuxer();
|
|
11
|
+
let encoder, failure, stopped = false, index = 0;
|
|
12
|
+
const first = await readPixels();
|
|
13
|
+
const { width, height } = first;
|
|
14
|
+
muxer.addVideoTrack( { codec: 'vp8', width, height, frameRate: fps } );
|
|
15
|
+
encoder = new VideoEncoder( { output: ( chunk, metadata ) => muxer.addVideoChunk( chunk, metadata ), error: error => { failure = error; } } );
|
|
16
|
+
encoder.configure( { codec: 'vp8', width, height, bitrate: Math.max( 8_000_000, width * height * fps ), framerate: fps } );
|
|
17
|
+
const started = performance.now();
|
|
18
|
+
const encode = image => {
|
|
19
|
+
|
|
20
|
+
if ( image.width !== width || image.height !== height ) throw new Error( 'Canvas resized during recording.' );
|
|
21
|
+
const frame = new VideoFrame( image.data, { format: 'RGBA', codedWidth: width, codedHeight: height, timestamp: Math.round( index * 1e6 / fps ), duration: Math.round( 1e6 / fps ) } );
|
|
22
|
+
try { encoder.encode( frame, { keyFrame: index % ( fps * 2 ) === 0 } ); index ++; }
|
|
23
|
+
finally { frame.close(); }
|
|
24
|
+
|
|
25
|
+
};
|
|
26
|
+
encode( first );
|
|
27
|
+
const recording = ( async () => {
|
|
28
|
+
|
|
29
|
+
let previous = first;
|
|
30
|
+
while ( ! stopped ) {
|
|
31
|
+
|
|
32
|
+
await delay( Math.max( 0, started + index * 1000 / fps - performance.now() ) );
|
|
33
|
+
if ( stopped ) break;
|
|
34
|
+
const next = await readPixels();
|
|
35
|
+
while ( index < Math.floor( ( performance.now() - started ) * fps / 1000 ) ) encode( previous );
|
|
36
|
+
encode( next ); previous = next;
|
|
37
|
+
if ( failure ) throw failure;
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
} )();
|
|
42
|
+
// Keep failures observable at stop without an unhandled rejection mid-capture.
|
|
43
|
+
void recording.catch( error => { failure = error; } );
|
|
44
|
+
let completion;
|
|
45
|
+
return {
|
|
46
|
+
stop() {
|
|
47
|
+
|
|
48
|
+
return completion ??= ( async () => {
|
|
49
|
+
|
|
50
|
+
stopped = true;
|
|
51
|
+
try {
|
|
52
|
+
|
|
53
|
+
await recording; await encoder.flush();
|
|
54
|
+
if ( failure ) throw failure;
|
|
55
|
+
await mkdir( dirname( path ), { recursive: true } );
|
|
56
|
+
await writeFile( path, muxer.finalize() );
|
|
57
|
+
|
|
58
|
+
} finally { encoder.close(); }
|
|
59
|
+
|
|
60
|
+
} )();
|
|
61
|
+
|
|
62
|
+
},
|
|
63
|
+
async saveAs( destination ) { await this.stop(); await copyFile( path, destination ); },
|
|
64
|
+
async delete() { await this.stop(); await rm( path, { force: true } ); },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
}
|