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.
- package/README.md +72 -69
- package/dist/editor.css +1 -1
- package/dist/editor.css.map +2 -2
- package/dist/editor.esm.js +54 -54
- package/dist/editor.esm.js.map +4 -4
- 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 +13 -9
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +13 -9
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +13 -9
- 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/video/audio.js +159 -0
- package/lib/core/video/frames.js +52 -7
- package/lib/core/video/mux-mp4.js +320 -131
- package/lib/core/video/mux-webm.js +168 -127
- package/lib/editor/exporter.js +103 -21
- package/lib/editor/icons.js +65 -61
- package/lib/editor/index.js +288 -30
- package/lib/editor/inspector.js +66 -15
- package/lib/editor/library.js +188 -182
- package/lib/editor/storyboard.js +566 -0
- package/lib/editor/styles/editor.css +71 -1
- package/lib/editor/timeline-preview.js +142 -0
- package/lib/editor/workspace.js +62 -0
- package/lib/index.js +31 -31
- package/lib/video.js +122 -92
- package/package.json +92 -92
- package/types/index.d.ts +2 -0
- package/types/video.d.ts +99 -43
|
@@ -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));
|
|
@@ -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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
}
|