image-to-pixel 3.0.1 → 3.2.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.
@@ -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
+ }
@@ -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));
@@ -126,19 +164,26 @@ export function frameTimes({ duration, fps, start = 0, end = duration, maxFrames
126
164
  /**
127
165
  * Visit frames one by one.
128
166
  * @param {HTMLVideoElement} video
129
- * @param {{ fps: number, start?: number, end?: number, maxFrames?: number, signal?: AbortSignal,
167
+ * @param {{ fps: number, start?: number, end?: number, maxFrames?: number, times?: number[], signal?: AbortSignal,
130
168
  * onFrame: (info: { index: number, time: number, total: number }) => any }} opts
131
169
  */
132
170
  export async function forEachFrame(video, opts) {
133
171
  await videoReady(video);
134
172
  const wasPaused = video.paused;
135
173
  video.pause();
136
- const times = frameTimes({ duration: video.duration, ...opts });
137
- for (let i = 0; i < times.length; i++) {
138
- if (opts.signal && opts.signal.aborted) throw new DOMException('Aborted', 'AbortError');
139
- await seekTo(video, times[i]);
140
- await opts.onFrame({ index: i, time: times[i], total: times.length });
174
+ // Explicit timestamps allow non-contiguous frame selections.
175
+ const times = Array.isArray(opts.times)
176
+ ? opts.times.filter(t => Number.isFinite(t)).slice(0, opts.maxFrames ?? Infinity)
177
+ : frameTimes({ duration: video.duration, ...opts });
178
+ try {
179
+ for (let i = 0; i < times.length; i++) {
180
+ if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
181
+ await seekTo(video, times[i]);
182
+ if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
183
+ await opts.onFrame({ index: i, time: times[i], total: times.length });
184
+ }
185
+ } finally {
186
+ if (!wasPaused) video.play().catch(() => { });
141
187
  }
142
- if (!wasPaused) video.play().catch(() => { });
143
188
  return times.length;
144
189
  }