image-to-pixel 3.0.0 → 3.0.1
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 +69 -69
- package/dist/editor.css.map +1 -1
- package/dist/editor.esm.js +1 -1
- package/dist/editor.esm.js.map +1 -1
- package/dist/fonts/README.md +6 -6
- package/dist/fonts/geist-LICENSE.txt +93 -93
- package/dist/image-to-pixel.bundle.esm.js +13 -13
- package/dist/image-to-pixel.bundle.esm.js.map +2 -2
- 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 +2 -2
- package/dist/video.esm.js +1 -1
- package/dist/video.esm.js.map +1 -1
- 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/frames.js +144 -144
- package/lib/core/video/mux-mp4.js +131 -131
- package/lib/core/video/mux-webm.js +127 -127
- package/lib/editor/controls.js +139 -139
- package/lib/editor/dom.js +65 -65
- package/lib/editor/exporter.js +185 -185
- package/lib/editor/icons.js +61 -61
- package/lib/editor/index.js +793 -793
- package/lib/editor/inspector.js +328 -328
- package/lib/editor/library.js +182 -182
- 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 +385 -385
- 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/index.js +3 -1
- package/lib/video.js +92 -92
- package/package.json +92 -92
- package/types/editor.d.ts +34 -34
- package/types/index.d.ts +196 -196
- package/types/video.d.ts +43 -43
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
|
+
}
|
package/lib/core/video/frames.js
CHANGED
|
@@ -1,144 +1,144 @@
|
|
|
1
|
-
// Frame-accurate stepping through an HTMLVideoElement by seeking to the middle of each frame.
|
|
2
|
-
// Seeking (instead of playing) means every frame is visited exactly once, regardless of how
|
|
3
|
-
// long processing takes, and the same input always produces the same frames.
|
|
4
|
-
|
|
5
|
-
const wait = (el, ev, ms = 4000) => new Promise((res, rej) => {
|
|
6
|
-
const t = setTimeout(() => { el.removeEventListener(ev, ok); rej(new Error(`Timed out waiting for "${ev}"`)); }, ms);
|
|
7
|
-
const ok = () => { clearTimeout(t); res(); };
|
|
8
|
-
el.addEventListener(ev, ok, { once: true });
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
const MEDIA_ERRORS = {
|
|
12
|
-
1: 'loading was aborted',
|
|
13
|
-
2: 'a network error stopped the download',
|
|
14
|
-
3: 'the browser could not decode it',
|
|
15
|
-
4: "this browser can't play this video's format",
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/** Turn a <video>'s MediaError into a readable Error (err.code is the MediaError code). */
|
|
19
|
-
export function mediaError(video) {
|
|
20
|
-
const code = video.error ? video.error.code : 0;
|
|
21
|
-
const err = new Error(MEDIA_ERRORS[code] || 'the video could not be loaded');
|
|
22
|
-
err.code = code;
|
|
23
|
-
if (video.error && video.error.message) err.detail = video.error.message;
|
|
24
|
-
return err;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Resolve on `ok`, reject straight away if the media element reports an error. */
|
|
28
|
-
const waitOrFail = (video, ok, ms) => new Promise((res, rej) => {
|
|
29
|
-
const done = () => { clearTimeout(t); video.removeEventListener(ok, onOk); video.removeEventListener('error', onErr); };
|
|
30
|
-
const onOk = () => { done(); res(); };
|
|
31
|
-
const onErr = () => { done(); rej(mediaError(video)); };
|
|
32
|
-
const t = setTimeout(() => { done(); rej(new Error(`no response from the video decoder after ${ms / 1000} s`)); }, ms);
|
|
33
|
-
video.addEventListener(ok, onOk, { once: true });
|
|
34
|
-
video.addEventListener('error', onErr, { once: true });
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Wait until a video's metadata (size, duration) and first frame are available.
|
|
39
|
-
* Rejects immediately with a readable message when the browser can't play the file.
|
|
40
|
-
*/
|
|
41
|
-
export async function videoReady(video, { timeout = 60000 } = {}) {
|
|
42
|
-
if (video.error) throw mediaError(video);
|
|
43
|
-
if (video.readyState < 1) await waitOrFail(video, 'loadedmetadata', timeout);
|
|
44
|
-
if (video.readyState < 2) await waitOrFail(video, 'loadeddata', timeout).catch(e => { if (e.code) throw e; });
|
|
45
|
-
return video;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Best-effort codec name for an MP4/MOV/WebM file, read from its bytes. Used to explain playback errors.
|
|
50
|
-
* @param {Blob} file
|
|
51
|
-
* @returns {Promise<string>} e.g. 'H.265 (HEVC)', or '' when unknown
|
|
52
|
-
*/
|
|
53
|
-
export async function sniffVideoCodec(file) {
|
|
54
|
-
const buf = new Uint8Array(await file.arrayBuffer());
|
|
55
|
-
const NAMES = { avc1: 'H.264', avc3: 'H.264', hvc1: 'H.265 (HEVC)', hev1: 'H.265 (HEVC)', av01: 'AV1', vp09: 'VP9', vp08: 'VP8', mp4v: 'MPEG-4 Part 2', apcn: 'ProRes', apch: 'ProRes', apcs: 'ProRes', apco: 'ProRes', ap4h: 'ProRes 4444', dvh1: 'Dolby Vision', dvhe: 'Dolby Vision' };
|
|
56
|
-
// MP4/MOV: the first sample entry of the 'stsd' box names the codec
|
|
57
|
-
for (let i = 0; i + 24 < buf.length; i++) {
|
|
58
|
-
if (buf[i] === 0x73 && buf[i + 1] === 0x74 && buf[i + 2] === 0x73 && buf[i + 3] === 0x64) {
|
|
59
|
-
const type = String.fromCharCode(buf[i + 16], buf[i + 17], buf[i + 18], buf[i + 19]);
|
|
60
|
-
if (NAMES[type]) return NAMES[type];
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
// WebM/Matroska: CodecID strings such as V_VP9, V_AV1, V_MPEGH/ISO/HEVC
|
|
64
|
-
const head = new TextDecoder('latin1').decode(buf.subarray(0, Math.min(buf.length, 1 << 20)));
|
|
65
|
-
const m = /V_(VP8|VP9|AV1|MPEG4\/ISO\/AVC|MPEGH\/ISO\/HEVC)/.exec(head);
|
|
66
|
-
if (m) return { VP8: 'VP8', VP9: 'VP9', AV1: 'AV1', 'MPEG4/ISO/AVC': 'H.264', 'MPEGH/ISO/HEVC': 'H.265 (HEVC)' }[m[1]];
|
|
67
|
-
return '';
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Seek and resolve once the frame at `time` is decoded and paintable. */
|
|
71
|
-
export async function seekTo(video, time) {
|
|
72
|
-
time = Math.max(0, Math.min(time, (video.duration || 0) - 1e-4));
|
|
73
|
-
if (Math.abs(video.currentTime - time) < 1e-6 && video.readyState >= 2) return;
|
|
74
|
-
const presented = 'requestVideoFrameCallback' in video
|
|
75
|
-
? new Promise(res => { video.requestVideoFrameCallback(() => res()); setTimeout(res, 500); })
|
|
76
|
-
: null;
|
|
77
|
-
const seeked = wait(video, 'seeked');
|
|
78
|
-
video.currentTime = time;
|
|
79
|
-
await seeked;
|
|
80
|
-
if (presented) await presented;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Estimate the frame rate from presented frames (requestVideoFrameCallback), falling back to `fallback`.
|
|
85
|
-
* Plays muted for a moment and restores the position.
|
|
86
|
-
*/
|
|
87
|
-
export async function estimateFps(video, fallback = 30) {
|
|
88
|
-
if (!('requestVideoFrameCallback' in video)) return fallback;
|
|
89
|
-
await videoReady(video);
|
|
90
|
-
const was = { t: video.currentTime, muted: video.muted, paused: video.paused };
|
|
91
|
-
const times = [];
|
|
92
|
-
try {
|
|
93
|
-
video.muted = true;
|
|
94
|
-
await new Promise((res) => {
|
|
95
|
-
const done = setTimeout(res, 1200);
|
|
96
|
-
const cb = (_, meta) => {
|
|
97
|
-
times.push(meta.mediaTime);
|
|
98
|
-
if (times.length >= 12) { clearTimeout(done); res(); } else video.requestVideoFrameCallback(cb);
|
|
99
|
-
};
|
|
100
|
-
video.requestVideoFrameCallback(cb);
|
|
101
|
-
video.play().catch(() => { clearTimeout(done); res(); });
|
|
102
|
-
});
|
|
103
|
-
} finally {
|
|
104
|
-
video.pause();
|
|
105
|
-
video.muted = was.muted;
|
|
106
|
-
await seekTo(video, was.t).catch(() => { });
|
|
107
|
-
}
|
|
108
|
-
const deltas = [];
|
|
109
|
-
for (let i = 1; i < times.length; i++) { const d = times[i] - times[i - 1]; if (d > 0.001) deltas.push(d); }
|
|
110
|
-
if (deltas.length < 3) return fallback;
|
|
111
|
-
deltas.sort((a, b) => a - b);
|
|
112
|
-
const fps = 1 / deltas[Math.floor(deltas.length / 2)];
|
|
113
|
-
// snap to common rates
|
|
114
|
-
for (const r of [12, 15, 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60]) if (Math.abs(fps - r) < 0.6) return r;
|
|
115
|
-
return Math.round(fps);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Frame times (seconds, middle of each frame) for a clip. */
|
|
119
|
-
export function frameTimes({ duration, fps, start = 0, end = duration, maxFrames = Infinity }) {
|
|
120
|
-
const out = [];
|
|
121
|
-
const n = Math.min(maxFrames, Math.max(1, Math.floor((end - start) * fps + 1e-6)));
|
|
122
|
-
for (let i = 0; i < n; i++) out.push(start + (i + 0.5) / fps);
|
|
123
|
-
return out;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Visit frames one by one.
|
|
128
|
-
* @param {HTMLVideoElement} video
|
|
129
|
-
* @param {{ fps: number, start?: number, end?: number, maxFrames?: number, signal?: AbortSignal,
|
|
130
|
-
* onFrame: (info: { index: number, time: number, total: number }) => any }} opts
|
|
131
|
-
*/
|
|
132
|
-
export async function forEachFrame(video, opts) {
|
|
133
|
-
await videoReady(video);
|
|
134
|
-
const wasPaused = video.paused;
|
|
135
|
-
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 });
|
|
141
|
-
}
|
|
142
|
-
if (!wasPaused) video.play().catch(() => { });
|
|
143
|
-
return times.length;
|
|
144
|
-
}
|
|
1
|
+
// Frame-accurate stepping through an HTMLVideoElement by seeking to the middle of each frame.
|
|
2
|
+
// Seeking (instead of playing) means every frame is visited exactly once, regardless of how
|
|
3
|
+
// long processing takes, and the same input always produces the same frames.
|
|
4
|
+
|
|
5
|
+
const wait = (el, ev, ms = 4000) => new Promise((res, rej) => {
|
|
6
|
+
const t = setTimeout(() => { el.removeEventListener(ev, ok); rej(new Error(`Timed out waiting for "${ev}"`)); }, ms);
|
|
7
|
+
const ok = () => { clearTimeout(t); res(); };
|
|
8
|
+
el.addEventListener(ev, ok, { once: true });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const MEDIA_ERRORS = {
|
|
12
|
+
1: 'loading was aborted',
|
|
13
|
+
2: 'a network error stopped the download',
|
|
14
|
+
3: 'the browser could not decode it',
|
|
15
|
+
4: "this browser can't play this video's format",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Turn a <video>'s MediaError into a readable Error (err.code is the MediaError code). */
|
|
19
|
+
export function mediaError(video) {
|
|
20
|
+
const code = video.error ? video.error.code : 0;
|
|
21
|
+
const err = new Error(MEDIA_ERRORS[code] || 'the video could not be loaded');
|
|
22
|
+
err.code = code;
|
|
23
|
+
if (video.error && video.error.message) err.detail = video.error.message;
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolve on `ok`, reject straight away if the media element reports an error. */
|
|
28
|
+
const waitOrFail = (video, ok, ms) => new Promise((res, rej) => {
|
|
29
|
+
const done = () => { clearTimeout(t); video.removeEventListener(ok, onOk); video.removeEventListener('error', onErr); };
|
|
30
|
+
const onOk = () => { done(); res(); };
|
|
31
|
+
const onErr = () => { done(); rej(mediaError(video)); };
|
|
32
|
+
const t = setTimeout(() => { done(); rej(new Error(`no response from the video decoder after ${ms / 1000} s`)); }, ms);
|
|
33
|
+
video.addEventListener(ok, onOk, { once: true });
|
|
34
|
+
video.addEventListener('error', onErr, { once: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wait until a video's metadata (size, duration) and first frame are available.
|
|
39
|
+
* Rejects immediately with a readable message when the browser can't play the file.
|
|
40
|
+
*/
|
|
41
|
+
export async function videoReady(video, { timeout = 60000 } = {}) {
|
|
42
|
+
if (video.error) throw mediaError(video);
|
|
43
|
+
if (video.readyState < 1) await waitOrFail(video, 'loadedmetadata', timeout);
|
|
44
|
+
if (video.readyState < 2) await waitOrFail(video, 'loadeddata', timeout).catch(e => { if (e.code) throw e; });
|
|
45
|
+
return video;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Best-effort codec name for an MP4/MOV/WebM file, read from its bytes. Used to explain playback errors.
|
|
50
|
+
* @param {Blob} file
|
|
51
|
+
* @returns {Promise<string>} e.g. 'H.265 (HEVC)', or '' when unknown
|
|
52
|
+
*/
|
|
53
|
+
export async function sniffVideoCodec(file) {
|
|
54
|
+
const buf = new Uint8Array(await file.arrayBuffer());
|
|
55
|
+
const NAMES = { avc1: 'H.264', avc3: 'H.264', hvc1: 'H.265 (HEVC)', hev1: 'H.265 (HEVC)', av01: 'AV1', vp09: 'VP9', vp08: 'VP8', mp4v: 'MPEG-4 Part 2', apcn: 'ProRes', apch: 'ProRes', apcs: 'ProRes', apco: 'ProRes', ap4h: 'ProRes 4444', dvh1: 'Dolby Vision', dvhe: 'Dolby Vision' };
|
|
56
|
+
// MP4/MOV: the first sample entry of the 'stsd' box names the codec
|
|
57
|
+
for (let i = 0; i + 24 < buf.length; i++) {
|
|
58
|
+
if (buf[i] === 0x73 && buf[i + 1] === 0x74 && buf[i + 2] === 0x73 && buf[i + 3] === 0x64) {
|
|
59
|
+
const type = String.fromCharCode(buf[i + 16], buf[i + 17], buf[i + 18], buf[i + 19]);
|
|
60
|
+
if (NAMES[type]) return NAMES[type];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// WebM/Matroska: CodecID strings such as V_VP9, V_AV1, V_MPEGH/ISO/HEVC
|
|
64
|
+
const head = new TextDecoder('latin1').decode(buf.subarray(0, Math.min(buf.length, 1 << 20)));
|
|
65
|
+
const m = /V_(VP8|VP9|AV1|MPEG4\/ISO\/AVC|MPEGH\/ISO\/HEVC)/.exec(head);
|
|
66
|
+
if (m) return { VP8: 'VP8', VP9: 'VP9', AV1: 'AV1', 'MPEG4/ISO/AVC': 'H.264', 'MPEGH/ISO/HEVC': 'H.265 (HEVC)' }[m[1]];
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Seek and resolve once the frame at `time` is decoded and paintable. */
|
|
71
|
+
export async function seekTo(video, time) {
|
|
72
|
+
time = Math.max(0, Math.min(time, (video.duration || 0) - 1e-4));
|
|
73
|
+
if (Math.abs(video.currentTime - time) < 1e-6 && video.readyState >= 2) return;
|
|
74
|
+
const presented = 'requestVideoFrameCallback' in video
|
|
75
|
+
? new Promise(res => { video.requestVideoFrameCallback(() => res()); setTimeout(res, 500); })
|
|
76
|
+
: null;
|
|
77
|
+
const seeked = wait(video, 'seeked');
|
|
78
|
+
video.currentTime = time;
|
|
79
|
+
await seeked;
|
|
80
|
+
if (presented) await presented;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Estimate the frame rate from presented frames (requestVideoFrameCallback), falling back to `fallback`.
|
|
85
|
+
* Plays muted for a moment and restores the position.
|
|
86
|
+
*/
|
|
87
|
+
export async function estimateFps(video, fallback = 30) {
|
|
88
|
+
if (!('requestVideoFrameCallback' in video)) return fallback;
|
|
89
|
+
await videoReady(video);
|
|
90
|
+
const was = { t: video.currentTime, muted: video.muted, paused: video.paused };
|
|
91
|
+
const times = [];
|
|
92
|
+
try {
|
|
93
|
+
video.muted = true;
|
|
94
|
+
await new Promise((res) => {
|
|
95
|
+
const done = setTimeout(res, 1200);
|
|
96
|
+
const cb = (_, meta) => {
|
|
97
|
+
times.push(meta.mediaTime);
|
|
98
|
+
if (times.length >= 12) { clearTimeout(done); res(); } else video.requestVideoFrameCallback(cb);
|
|
99
|
+
};
|
|
100
|
+
video.requestVideoFrameCallback(cb);
|
|
101
|
+
video.play().catch(() => { clearTimeout(done); res(); });
|
|
102
|
+
});
|
|
103
|
+
} finally {
|
|
104
|
+
video.pause();
|
|
105
|
+
video.muted = was.muted;
|
|
106
|
+
await seekTo(video, was.t).catch(() => { });
|
|
107
|
+
}
|
|
108
|
+
const deltas = [];
|
|
109
|
+
for (let i = 1; i < times.length; i++) { const d = times[i] - times[i - 1]; if (d > 0.001) deltas.push(d); }
|
|
110
|
+
if (deltas.length < 3) return fallback;
|
|
111
|
+
deltas.sort((a, b) => a - b);
|
|
112
|
+
const fps = 1 / deltas[Math.floor(deltas.length / 2)];
|
|
113
|
+
// snap to common rates
|
|
114
|
+
for (const r of [12, 15, 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60]) if (Math.abs(fps - r) < 0.6) return r;
|
|
115
|
+
return Math.round(fps);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Frame times (seconds, middle of each frame) for a clip. */
|
|
119
|
+
export function frameTimes({ duration, fps, start = 0, end = duration, maxFrames = Infinity }) {
|
|
120
|
+
const out = [];
|
|
121
|
+
const n = Math.min(maxFrames, Math.max(1, Math.floor((end - start) * fps + 1e-6)));
|
|
122
|
+
for (let i = 0; i < n; i++) out.push(start + (i + 0.5) / fps);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Visit frames one by one.
|
|
128
|
+
* @param {HTMLVideoElement} video
|
|
129
|
+
* @param {{ fps: number, start?: number, end?: number, maxFrames?: number, signal?: AbortSignal,
|
|
130
|
+
* onFrame: (info: { index: number, time: number, total: number }) => any }} opts
|
|
131
|
+
*/
|
|
132
|
+
export async function forEachFrame(video, opts) {
|
|
133
|
+
await videoReady(video);
|
|
134
|
+
const wasPaused = video.paused;
|
|
135
|
+
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 });
|
|
141
|
+
}
|
|
142
|
+
if (!wasPaused) video.play().catch(() => { });
|
|
143
|
+
return times.length;
|
|
144
|
+
}
|