image-to-pixel 3.0.0 → 3.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 +21 -21
- package/README.md +2 -2
- package/dist/editor.css +1 -1
- package/dist/editor.css.map +2 -2
- package/dist/editor.esm.js +60 -60
- package/dist/editor.esm.js.map +4 -4
- package/dist/fonts/README.md +6 -6
- package/dist/fonts/geist-LICENSE.txt +93 -93
- package/dist/image-to-pixel.bundle.esm.js +16 -16
- package/dist/image-to-pixel.bundle.esm.js.map +3 -3
- package/dist/image-to-pixel.cjs +2 -2
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +2 -2
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +2 -2
- package/dist/image-to-pixel.js.map +2 -2
- package/dist/image-to-pixel.min.js +11 -11
- package/dist/image-to-pixel.min.js.map +3 -3
- package/dist/video.esm.js +2 -2
- package/dist/video.esm.js.map +4 -4
- package/lib/core/color.js +69 -69
- package/lib/core/cpu/pipeline.js +21 -21
- package/lib/core/cpu/stages.js +227 -227
- package/lib/core/dither/bluenoise-data.js +3 -3
- package/lib/core/dither/kernels.js +27 -27
- package/lib/core/dither/matrices.js +95 -95
- package/lib/core/engine.js +158 -158
- package/lib/core/export/canvas.js +22 -22
- package/lib/core/export/crc32.js +15 -15
- package/lib/core/export/gif.js +153 -153
- package/lib/core/export/png.js +94 -94
- package/lib/core/export/scale.js +103 -103
- package/lib/core/export/sheet.js +95 -95
- package/lib/core/export/zip.js +55 -55
- package/lib/core/gpu/backend.js +159 -159
- package/lib/core/gpu/shaders.js +258 -258
- package/lib/core/palette/builtin.js +36 -36
- package/lib/core/palette/extract.js +69 -69
- package/lib/core/palette/lospec.js +24 -24
- package/lib/core/palette/parse.js +112 -112
- package/lib/core/plan.js +58 -58
- package/lib/core/presets.js +42 -42
- package/lib/core/settings.js +274 -274
- package/lib/core/source.js +89 -89
- package/lib/core/video/audio.js +159 -0
- package/lib/core/video/frames.js +38 -0
- package/lib/core/video/mux-mp4.js +213 -24
- package/lib/core/video/mux-webm.js +63 -22
- package/lib/editor/controls.js +139 -139
- package/lib/editor/dom.js +65 -65
- package/lib/editor/exporter.js +43 -3
- package/lib/editor/icons.js +4 -0
- package/lib/editor/index.js +218 -9
- package/lib/editor/inspector.js +19 -4
- package/lib/editor/library.js +8 -2
- package/lib/editor/persist.js +18 -18
- package/lib/editor/samples.js +90 -90
- package/lib/editor/stage.js +239 -239
- package/lib/editor/store.js +80 -80
- package/lib/editor/styles/editor.css +34 -1
- package/lib/editor/styles/fonts/README.md +6 -6
- package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
- package/lib/editor/styles/tokens.css +89 -89
- package/lib/editor/ui.js +97 -97
- package/lib/editor/workspace.js +62 -0
- package/lib/index.js +31 -29
- package/lib/video.js +37 -7
- package/package.json +1 -1
- package/types/editor.d.ts +34 -34
- package/types/index.d.ts +196 -196
- package/types/video.d.ts +61 -5
package/lib/core/source.js
CHANGED
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
// Turning whatever the caller has into a "frame": { width, height, canvas?, data }.
|
|
2
|
-
// Accepts URLs, Blobs/Files, <img>, <canvas>, OffscreenCanvas, ImageBitmap, <video> (current frame),
|
|
3
|
-
// VideoFrame, ImageData or any { width, height, data } RGBA object, plus p5 / q5 images.
|
|
4
|
-
|
|
5
|
-
export const MAX_SOURCE = 4096;
|
|
6
|
-
const hasDOM = typeof document !== 'undefined';
|
|
7
|
-
|
|
8
|
-
const isRGBA = s => s && typeof s === 'object' && 'data' in s && 'width' in s && 'height' in s && s.data && s.data.length === s.width * s.height * 4;
|
|
9
|
-
|
|
10
|
-
/** Load a URL or Blob into something drawable. */
|
|
11
|
-
export async function loadImage(src) {
|
|
12
|
-
if (typeof Blob !== 'undefined' && src instanceof Blob) {
|
|
13
|
-
if (typeof createImageBitmap === 'function') {
|
|
14
|
-
try { return await createImageBitmap(src); } catch { /* fall through (e.g. SVG) */ }
|
|
15
|
-
}
|
|
16
|
-
src = URL.createObjectURL(src);
|
|
17
|
-
}
|
|
18
|
-
if (!hasDOM) throw new Error('Loading images by URL needs a browser; pass { width, height, data } in Node.');
|
|
19
|
-
const img = new Image();
|
|
20
|
-
img.crossOrigin = 'anonymous';
|
|
21
|
-
img.decoding = 'async';
|
|
22
|
-
img.src = src;
|
|
23
|
-
await img.decode();
|
|
24
|
-
return img;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Natural pixel size of a drawable. */
|
|
28
|
-
export function sizeOf(el) {
|
|
29
|
-
return {
|
|
30
|
-
width: el.naturalWidth || el.videoWidth || el.displayWidth || el.codedWidth || el.width || 0,
|
|
31
|
-
height: el.naturalHeight || el.videoHeight || el.displayHeight || el.codedHeight || el.height || 0,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function unwrap(src) {
|
|
36
|
-
// p5.Image / q5 image / p5.Graphics / p5.Renderer
|
|
37
|
-
if (src && !isRGBA(src) && typeof src === 'object') {
|
|
38
|
-
if (src.canvas && typeof src.canvas === 'object' && 'getContext' in src.canvas) return src.canvas;
|
|
39
|
-
if (src.elt && typeof src.elt === 'object' && 'getContext' in src.elt) return src.elt;
|
|
40
|
-
}
|
|
41
|
-
return src;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* @param {any} source
|
|
46
|
-
* @param {{ maxSize?: number, canvas?: HTMLCanvasElement }} [opts] canvas: scratch canvas to reuse
|
|
47
|
-
*/
|
|
48
|
-
export async function toFrame(source, opts = {}) {
|
|
49
|
-
const maxSize = opts.maxSize || MAX_SOURCE;
|
|
50
|
-
let src = unwrap(source);
|
|
51
|
-
if (typeof src === 'string' || (typeof Blob !== 'undefined' && src instanceof Blob)) src = await loadImage(src);
|
|
52
|
-
if (src && typeof src.complete === 'boolean' && !src.complete && src.decode) await src.decode();
|
|
53
|
-
|
|
54
|
-
if (isRGBA(src)) {
|
|
55
|
-
const frame = { width: src.width, height: src.height, data: src.data, canvas: null };
|
|
56
|
-
if (hasDOM) {
|
|
57
|
-
Object.defineProperty(frame, 'canvas', {
|
|
58
|
-
configurable: true,
|
|
59
|
-
get() {
|
|
60
|
-
const c = document.createElement('canvas'); c.width = src.width; c.height = src.height;
|
|
61
|
-
const img = new ImageData(new Uint8ClampedArray(src.data), src.width, src.height);
|
|
62
|
-
c.getContext('2d').putImageData(img, 0, 0);
|
|
63
|
-
Object.defineProperty(frame, 'canvas', { value: c });
|
|
64
|
-
return c;
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
return frame;
|
|
69
|
-
}
|
|
70
|
-
if (!hasDOM) throw new Error('Unsupported source in a non-browser environment. Pass { width, height, data }.');
|
|
71
|
-
|
|
72
|
-
let { width, height } = sizeOf(src);
|
|
73
|
-
if (!width || !height) throw new Error('Source has no size yet (is the image or video loaded?)');
|
|
74
|
-
const k = Math.min(1, maxSize / Math.max(width, height));
|
|
75
|
-
width = Math.max(1, Math.round(width * k)); height = Math.max(1, Math.round(height * k));
|
|
76
|
-
|
|
77
|
-
const canvas = opts.canvas || document.createElement('canvas');
|
|
78
|
-
canvas.width = width; canvas.height = height;
|
|
79
|
-
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
80
|
-
ctx.clearRect(0, 0, width, height);
|
|
81
|
-
ctx.imageSmoothingEnabled = k < 1;
|
|
82
|
-
ctx.imageSmoothingQuality = 'high';
|
|
83
|
-
ctx.drawImage(src, 0, 0, width, height);
|
|
84
|
-
let cached = null;
|
|
85
|
-
return {
|
|
86
|
-
width, height, canvas, dirty: true,
|
|
87
|
-
get data() { return (cached ||= ctx.getImageData(0, 0, width, height).data); },
|
|
88
|
-
};
|
|
89
|
-
}
|
|
1
|
+
// Turning whatever the caller has into a "frame": { width, height, canvas?, data }.
|
|
2
|
+
// Accepts URLs, Blobs/Files, <img>, <canvas>, OffscreenCanvas, ImageBitmap, <video> (current frame),
|
|
3
|
+
// VideoFrame, ImageData or any { width, height, data } RGBA object, plus p5 / q5 images.
|
|
4
|
+
|
|
5
|
+
export const MAX_SOURCE = 4096;
|
|
6
|
+
const hasDOM = typeof document !== 'undefined';
|
|
7
|
+
|
|
8
|
+
const isRGBA = s => s && typeof s === 'object' && 'data' in s && 'width' in s && 'height' in s && s.data && s.data.length === s.width * s.height * 4;
|
|
9
|
+
|
|
10
|
+
/** Load a URL or Blob into something drawable. */
|
|
11
|
+
export async function loadImage(src) {
|
|
12
|
+
if (typeof Blob !== 'undefined' && src instanceof Blob) {
|
|
13
|
+
if (typeof createImageBitmap === 'function') {
|
|
14
|
+
try { return await createImageBitmap(src); } catch { /* fall through (e.g. SVG) */ }
|
|
15
|
+
}
|
|
16
|
+
src = URL.createObjectURL(src);
|
|
17
|
+
}
|
|
18
|
+
if (!hasDOM) throw new Error('Loading images by URL needs a browser; pass { width, height, data } in Node.');
|
|
19
|
+
const img = new Image();
|
|
20
|
+
img.crossOrigin = 'anonymous';
|
|
21
|
+
img.decoding = 'async';
|
|
22
|
+
img.src = src;
|
|
23
|
+
await img.decode();
|
|
24
|
+
return img;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Natural pixel size of a drawable. */
|
|
28
|
+
export function sizeOf(el) {
|
|
29
|
+
return {
|
|
30
|
+
width: el.naturalWidth || el.videoWidth || el.displayWidth || el.codedWidth || el.width || 0,
|
|
31
|
+
height: el.naturalHeight || el.videoHeight || el.displayHeight || el.codedHeight || el.height || 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unwrap(src) {
|
|
36
|
+
// p5.Image / q5 image / p5.Graphics / p5.Renderer
|
|
37
|
+
if (src && !isRGBA(src) && typeof src === 'object') {
|
|
38
|
+
if (src.canvas && typeof src.canvas === 'object' && 'getContext' in src.canvas) return src.canvas;
|
|
39
|
+
if (src.elt && typeof src.elt === 'object' && 'getContext' in src.elt) return src.elt;
|
|
40
|
+
}
|
|
41
|
+
return src;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {any} source
|
|
46
|
+
* @param {{ maxSize?: number, canvas?: HTMLCanvasElement }} [opts] canvas: scratch canvas to reuse
|
|
47
|
+
*/
|
|
48
|
+
export async function toFrame(source, opts = {}) {
|
|
49
|
+
const maxSize = opts.maxSize || MAX_SOURCE;
|
|
50
|
+
let src = unwrap(source);
|
|
51
|
+
if (typeof src === 'string' || (typeof Blob !== 'undefined' && src instanceof Blob)) src = await loadImage(src);
|
|
52
|
+
if (src && typeof src.complete === 'boolean' && !src.complete && src.decode) await src.decode();
|
|
53
|
+
|
|
54
|
+
if (isRGBA(src)) {
|
|
55
|
+
const frame = { width: src.width, height: src.height, data: src.data, canvas: null };
|
|
56
|
+
if (hasDOM) {
|
|
57
|
+
Object.defineProperty(frame, 'canvas', {
|
|
58
|
+
configurable: true,
|
|
59
|
+
get() {
|
|
60
|
+
const c = document.createElement('canvas'); c.width = src.width; c.height = src.height;
|
|
61
|
+
const img = new ImageData(new Uint8ClampedArray(src.data), src.width, src.height);
|
|
62
|
+
c.getContext('2d').putImageData(img, 0, 0);
|
|
63
|
+
Object.defineProperty(frame, 'canvas', { value: c });
|
|
64
|
+
return c;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return frame;
|
|
69
|
+
}
|
|
70
|
+
if (!hasDOM) throw new Error('Unsupported source in a non-browser environment. Pass { width, height, data }.');
|
|
71
|
+
|
|
72
|
+
let { width, height } = sizeOf(src);
|
|
73
|
+
if (!width || !height) throw new Error('Source has no size yet (is the image or video loaded?)');
|
|
74
|
+
const k = Math.min(1, maxSize / Math.max(width, height));
|
|
75
|
+
width = Math.max(1, Math.round(width * k)); height = Math.max(1, Math.round(height * k));
|
|
76
|
+
|
|
77
|
+
const canvas = opts.canvas || document.createElement('canvas');
|
|
78
|
+
canvas.width = width; canvas.height = height;
|
|
79
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
80
|
+
ctx.clearRect(0, 0, width, height);
|
|
81
|
+
ctx.imageSmoothingEnabled = k < 1;
|
|
82
|
+
ctx.imageSmoothingQuality = 'high';
|
|
83
|
+
ctx.drawImage(src, 0, 0, width, height);
|
|
84
|
+
let cached = null;
|
|
85
|
+
return {
|
|
86
|
+
width, height, canvas, dirty: true,
|
|
87
|
+
get data() { return (cached ||= ctx.getImageData(0, 0, width, height).data); },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Audio for video export: read a source file's soundtrack as PCM, and encode PCM with WebCodecs.
|
|
2
|
+
// The muxers (mux-mp4.js, mux-webm.js) take the encoded chunks as a second track next to the video.
|
|
3
|
+
|
|
4
|
+
/** WebCodecs codec strings for the audio formats the muxers can store. */
|
|
5
|
+
export const AUDIO_CODECS = { aac: 'mp4a.40.2', opus: 'opus' };
|
|
6
|
+
|
|
7
|
+
const DEFAULT_RATE = 48000; // Opus only encodes at 48 kHz (and lower); AAC is fine with it too
|
|
8
|
+
|
|
9
|
+
async function toArrayBuffer(source) {
|
|
10
|
+
if (source instanceof ArrayBuffer) return source.slice(0);
|
|
11
|
+
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
12
|
+
if (typeof Blob !== 'undefined' && source instanceof Blob) return source.arrayBuffer();
|
|
13
|
+
const url = typeof source === 'string' ? source : source && (source.currentSrc || source.src);
|
|
14
|
+
if (!url) throw new TypeError('decodeAudio() takes a File, Blob, ArrayBuffer, URL or <video>/<audio> element');
|
|
15
|
+
const res = await fetch(url);
|
|
16
|
+
if (!res.ok) throw new Error(`Couldn't load the audio: ${res.status} ${res.statusText}`);
|
|
17
|
+
return res.arrayBuffer();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Decode the audio track of a video (or audio) file into PCM, resampled and trimmed.
|
|
22
|
+
* Resolves with null when there is no audio track, it can't be decoded, or Web Audio isn't available (Node).
|
|
23
|
+
* More than two channels are mixed down to stereo with the standard Web Audio speaker rules.
|
|
24
|
+
* @param {File|Blob|ArrayBuffer|ArrayBufferView|string|HTMLMediaElement} source
|
|
25
|
+
* @param {{ start?: number, end?: number, sampleRate?: number, channels?: 1 | 2 }} [opts] start/end in seconds
|
|
26
|
+
* @returns {Promise<null | { sampleRate: number, numberOfChannels: number, channels: Float32Array[], duration: number }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function decodeAudio(source, opts = {}) {
|
|
29
|
+
if (typeof OfflineAudioContext === 'undefined') return null;
|
|
30
|
+
const sampleRate = opts.sampleRate || DEFAULT_RATE;
|
|
31
|
+
const bytes = await toArrayBuffer(source);
|
|
32
|
+
let decoded;
|
|
33
|
+
try {
|
|
34
|
+
decoded = await new OfflineAudioContext(1, 1, sampleRate).decodeAudioData(bytes);
|
|
35
|
+
} catch (_) {
|
|
36
|
+
return null; // no audio track, or a codec the browser can't decode
|
|
37
|
+
}
|
|
38
|
+
const start = Math.max(0, opts.start || 0);
|
|
39
|
+
const end = Math.min(decoded.duration, opts.end > start ? opts.end : decoded.duration);
|
|
40
|
+
const length = Math.floor((end - start) * sampleRate);
|
|
41
|
+
if (length < 1) return null;
|
|
42
|
+
const numberOfChannels = Math.min(opts.channels || 2, decoded.numberOfChannels);
|
|
43
|
+
// Rendering the decoded buffer through an offline graph trims it and mixes 5.1 etc. down properly.
|
|
44
|
+
const ctx = new OfflineAudioContext(numberOfChannels, length, sampleRate);
|
|
45
|
+
const node = ctx.createBufferSource();
|
|
46
|
+
node.buffer = decoded;
|
|
47
|
+
node.connect(ctx.destination);
|
|
48
|
+
node.start(0, start, length / sampleRate);
|
|
49
|
+
const out = await ctx.startRendering();
|
|
50
|
+
const channels = [];
|
|
51
|
+
for (let c = 0; c < numberOfChannels; c++) channels.push(out.getChannelData(c));
|
|
52
|
+
return { sampleRate, numberOfChannels, channels, duration: length / sampleRate };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find an AudioEncoder configuration this browser accepts for `codec`, or null.
|
|
57
|
+
* Platform AAC encoders (Windows Media Foundation, macOS AudioToolbox) are picky about sample rate and
|
|
58
|
+
* bitrate, so a few common combinations are tried: the requested rate first, then 48 and 44.1 kHz.
|
|
59
|
+
* @returns {Promise<AudioEncoderConfig | null>} the config to pass to encodeAudio({ config })
|
|
60
|
+
*/
|
|
61
|
+
export async function findAudioConfig(codec, sampleRate = DEFAULT_RATE, numberOfChannels = 2, bitrate = 128000) {
|
|
62
|
+
if (typeof AudioEncoder === 'undefined' || !AUDIO_CODECS[codec]) return null;
|
|
63
|
+
const rates = codec === 'opus' ? [48000] : [...new Set([sampleRate, 48000, 44100])];
|
|
64
|
+
const bitrates = [...new Set([bitrate, 128000, 192000, 160000, 96000, 0])];
|
|
65
|
+
for (const rate of rates) {
|
|
66
|
+
for (const br of bitrates) {
|
|
67
|
+
const config = {
|
|
68
|
+
codec: AUDIO_CODECS[codec], sampleRate: rate, numberOfChannels,
|
|
69
|
+
...(br ? { bitrate: br } : {}),
|
|
70
|
+
...(codec === 'aac' ? { aac: { format: 'aac' } } : {}), // raw frames + AudioSpecificConfig, which MP4 needs
|
|
71
|
+
};
|
|
72
|
+
const r = await AudioEncoder.isConfigSupported(config).catch(() => ({}));
|
|
73
|
+
if (r.supported) return config;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Can this browser encode `codec` ('aac' | 'opus') with these parameters (or a close variant)? */
|
|
80
|
+
export async function audioCodecSupported(codec, sampleRate = DEFAULT_RATE, numberOfChannels = 2) {
|
|
81
|
+
return !!(await findAudioConfig(codec, sampleRate, numberOfChannels));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resample PCM with Web Audio (browser only). */
|
|
85
|
+
export async function resamplePcm(pcm, sampleRate) {
|
|
86
|
+
if (pcm.sampleRate === sampleRate) return pcm;
|
|
87
|
+
const { numberOfChannels, channels } = pcm;
|
|
88
|
+
const length = Math.max(1, Math.round(channels[0].length * sampleRate / pcm.sampleRate));
|
|
89
|
+
const ctx = new OfflineAudioContext(numberOfChannels, length, sampleRate);
|
|
90
|
+
const buf = ctx.createBuffer(numberOfChannels, channels[0].length, pcm.sampleRate);
|
|
91
|
+
for (let c = 0; c < numberOfChannels; c++) buf.copyToChannel(channels[c], c);
|
|
92
|
+
const node = ctx.createBufferSource();
|
|
93
|
+
node.buffer = buf;
|
|
94
|
+
node.connect(ctx.destination);
|
|
95
|
+
node.start();
|
|
96
|
+
const out = await ctx.startRendering();
|
|
97
|
+
const res = [];
|
|
98
|
+
for (let c = 0; c < numberOfChannels; c++) res.push(out.getChannelData(c));
|
|
99
|
+
return { sampleRate, numberOfChannels, channels: res, duration: length / sampleRate };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Encode PCM (as returned by decodeAudio) with WebCodecs' AudioEncoder.
|
|
104
|
+
* @param {{ sampleRate: number, numberOfChannels: number, channels: Float32Array[] }} pcm
|
|
105
|
+
* @param {{ codec?: 'aac' | 'opus', config?: AudioEncoderConfig, bitrate?: number, maxDuration?: number,
|
|
106
|
+
* onChunk: (chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) => void }} opts
|
|
107
|
+
* config: from findAudioConfig(); otherwise built from codec + bitrate. PCM at a different rate is resampled.
|
|
108
|
+
* maxDuration: seconds; longer audio is cut (used to match the video's length)
|
|
109
|
+
*/
|
|
110
|
+
export async function encodeAudio(pcm, opts) {
|
|
111
|
+
if (typeof AudioEncoder === 'undefined') throw new Error('This browser has no WebCodecs AudioEncoder');
|
|
112
|
+
const config = opts.config || {
|
|
113
|
+
codec: AUDIO_CODECS[opts.codec], sampleRate: pcm.sampleRate, numberOfChannels: pcm.numberOfChannels,
|
|
114
|
+
bitrate: opts.bitrate || 128000,
|
|
115
|
+
...(opts.codec === 'aac' ? { aac: { format: 'aac' } } : {}),
|
|
116
|
+
};
|
|
117
|
+
if (config.sampleRate !== pcm.sampleRate) pcm = await resamplePcm(pcm, config.sampleRate);
|
|
118
|
+
const { sampleRate, numberOfChannels, channels } = pcm;
|
|
119
|
+
let error = null;
|
|
120
|
+
const encoder = new AudioEncoder({ output: (chunk, meta) => opts.onChunk(chunk, meta), error: e => { error = e; } });
|
|
121
|
+
encoder.configure(config);
|
|
122
|
+
const limit = Number.isFinite(opts.maxDuration) ? Math.floor(opts.maxDuration * sampleRate) : Infinity;
|
|
123
|
+
const total = Math.min(channels[0].length, limit);
|
|
124
|
+
const BLOCK = Math.round(sampleRate / 10); // 100 ms per AudioData
|
|
125
|
+
for (let o = 0; o < total; o += BLOCK) {
|
|
126
|
+
if (error) break;
|
|
127
|
+
const n = Math.min(BLOCK, total - o);
|
|
128
|
+
const data = new Float32Array(n * numberOfChannels);
|
|
129
|
+
for (let c = 0; c < numberOfChannels; c++) data.set(channels[c].subarray(o, o + n), c * n);
|
|
130
|
+
const frame = new AudioData({ format: 'f32-planar', sampleRate, numberOfFrames: n, numberOfChannels, timestamp: Math.round(o * 1e6 / sampleRate), data });
|
|
131
|
+
encoder.encode(frame);
|
|
132
|
+
frame.close();
|
|
133
|
+
if (encoder.encodeQueueSize > 16) await new Promise(r => setTimeout(r, 0));
|
|
134
|
+
}
|
|
135
|
+
if (!error) await encoder.flush().catch(e => { error = e; });
|
|
136
|
+
if (encoder.state !== 'closed') encoder.close();
|
|
137
|
+
if (error) throw error;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** OpusHead (RFC 7845), little-endian: what Matroska's CodecPrivate holds for Opus. */
|
|
141
|
+
export function opusHead(numberOfChannels, sampleRate, preSkip = 312) {
|
|
142
|
+
const b = new Uint8Array(19);
|
|
143
|
+
b.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]); // 'OpusHead'
|
|
144
|
+
const dv = new DataView(b.buffer);
|
|
145
|
+
dv.setUint8(8, 1);
|
|
146
|
+
dv.setUint8(9, numberOfChannels);
|
|
147
|
+
dv.setUint16(10, preSkip, true);
|
|
148
|
+
dv.setUint32(12, sampleRate, true);
|
|
149
|
+
dv.setInt16(16, 0, true);
|
|
150
|
+
dv.setUint8(18, 0);
|
|
151
|
+
return b;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Read { channels, preSkip, sampleRate } from an OpusHead, or null if it isn't one. */
|
|
155
|
+
export function parseOpusHead(b) {
|
|
156
|
+
if (!b || b.length < 19 || String.fromCharCode(...b.subarray(0, 8)) !== 'OpusHead') return null;
|
|
157
|
+
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
158
|
+
return { channels: dv.getUint8(9), preSkip: dv.getUint16(10, true), sampleRate: dv.getUint32(12, true) };
|
|
159
|
+
}
|
package/lib/core/video/frames.js
CHANGED
|
@@ -67,6 +67,44 @@ export async function sniffVideoCodec(file) {
|
|
|
67
67
|
return '';
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Does this MP4/MOV/WebM file have an audio track? Reads only the container headers: for MP4/MOV the
|
|
72
|
+
* top-level boxes are walked to 'moov' (which phones often write at the end) and only that box is read.
|
|
73
|
+
* @param {Blob} file
|
|
74
|
+
* @returns {Promise<boolean | null>} null when it can't tell
|
|
75
|
+
*/
|
|
76
|
+
export async function hasAudioTrack(file) {
|
|
77
|
+
try {
|
|
78
|
+
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
|
|
79
|
+
if (head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) {
|
|
80
|
+
// WebM/Matroska: Tracks sit near the start; audio CodecIDs all begin with A_
|
|
81
|
+
const text = new TextDecoder('latin1').decode(await file.slice(0, 4 << 20).arrayBuffer());
|
|
82
|
+
return /A_(OPUS|VORBIS|AAC|MPEG|AC3|EAC3|FLAC|PCM|DTS|TRUEHD|ALAC)/.test(text);
|
|
83
|
+
}
|
|
84
|
+
for (let o = 0; o + 8 <= file.size;) {
|
|
85
|
+
const dv = new DataView(await file.slice(o, o + 16).arrayBuffer());
|
|
86
|
+
let size = dv.getUint32(0);
|
|
87
|
+
const type = String.fromCharCode(dv.getUint8(4), dv.getUint8(5), dv.getUint8(6), dv.getUint8(7));
|
|
88
|
+
if (size === 1) size = Number(dv.getBigUint64(8));
|
|
89
|
+
else if (size === 0) size = file.size - o;
|
|
90
|
+
if (size < 8) return null;
|
|
91
|
+
if (type === 'moov') {
|
|
92
|
+
const moov = new Uint8Array(await file.slice(o, o + size).arrayBuffer());
|
|
93
|
+
// each track's 'hdlr' box names its kind 12 bytes after the box type: 'vide', 'soun', …
|
|
94
|
+
for (let i = 4; i + 16 <= moov.length; i++) {
|
|
95
|
+
if (moov[i] === 0x68 && moov[i + 1] === 0x64 && moov[i + 2] === 0x6c && moov[i + 3] === 0x72
|
|
96
|
+
&& moov[i + 12] === 0x73 && moov[i + 13] === 0x6f && moov[i + 14] === 0x75 && moov[i + 15] === 0x6e) return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
o += size;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
} catch (_) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
/** Seek and resolve once the frame at `time` is decoded and paintable. */
|
|
71
109
|
export async function seekTo(video, time) {
|
|
72
110
|
time = Math.max(0, Math.min(time, (video.duration || 0) - 1e-4));
|