image-to-pixel 3.1.0 → 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 +4 -1
- 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 +13 -13
- 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 +3 -3
- package/lib/core/video/frames.js +189 -182
- package/lib/editor/exporter.js +267 -225
- package/lib/editor/index.js +1051 -1002
- package/lib/editor/inspector.js +379 -343
- package/lib/editor/storyboard.js +566 -0
- package/lib/editor/styles/editor.css +455 -418
- package/lib/editor/timeline-preview.js +142 -0
- package/package.json +1 -1
- package/types/index.d.ts +2 -0
package/lib/core/video/frames.js
CHANGED
|
@@ -1,182 +1,189 @@
|
|
|
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
|
-
/**
|
|
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
|
-
|
|
108
|
-
/** Seek and resolve once the frame at `time` is decoded and paintable. */
|
|
109
|
-
export async function seekTo(video, time) {
|
|
110
|
-
time = Math.max(0, Math.min(time, (video.duration || 0) - 1e-4));
|
|
111
|
-
if (Math.abs(video.currentTime - time) < 1e-6 && video.readyState >= 2) return;
|
|
112
|
-
const presented = 'requestVideoFrameCallback' in video
|
|
113
|
-
? new Promise(res => { video.requestVideoFrameCallback(() => res()); setTimeout(res, 500); })
|
|
114
|
-
: null;
|
|
115
|
-
const seeked = wait(video, 'seeked');
|
|
116
|
-
video.currentTime = time;
|
|
117
|
-
await seeked;
|
|
118
|
-
if (presented) await presented;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Estimate the frame rate from presented frames (requestVideoFrameCallback), falling back to `fallback`.
|
|
123
|
-
* Plays muted for a moment and restores the position.
|
|
124
|
-
*/
|
|
125
|
-
export async function estimateFps(video, fallback = 30) {
|
|
126
|
-
if (!('requestVideoFrameCallback' in video)) return fallback;
|
|
127
|
-
await videoReady(video);
|
|
128
|
-
const was = { t: video.currentTime, muted: video.muted, paused: video.paused };
|
|
129
|
-
const times = [];
|
|
130
|
-
try {
|
|
131
|
-
video.muted = true;
|
|
132
|
-
await new Promise((res) => {
|
|
133
|
-
const done = setTimeout(res, 1200);
|
|
134
|
-
const cb = (_, meta) => {
|
|
135
|
-
times.push(meta.mediaTime);
|
|
136
|
-
if (times.length >= 12) { clearTimeout(done); res(); } else video.requestVideoFrameCallback(cb);
|
|
137
|
-
};
|
|
138
|
-
video.requestVideoFrameCallback(cb);
|
|
139
|
-
video.play().catch(() => { clearTimeout(done); res(); });
|
|
140
|
-
});
|
|
141
|
-
} finally {
|
|
142
|
-
video.pause();
|
|
143
|
-
video.muted = was.muted;
|
|
144
|
-
await seekTo(video, was.t).catch(() => { });
|
|
145
|
-
}
|
|
146
|
-
const deltas = [];
|
|
147
|
-
for (let i = 1; i < times.length; i++) { const d = times[i] - times[i - 1]; if (d > 0.001) deltas.push(d); }
|
|
148
|
-
if (deltas.length < 3) return fallback;
|
|
149
|
-
deltas.sort((a, b) => a - b);
|
|
150
|
-
const fps = 1 / deltas[Math.floor(deltas.length / 2)];
|
|
151
|
-
// snap to common rates
|
|
152
|
-
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;
|
|
153
|
-
return Math.round(fps);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Frame times (seconds, middle of each frame) for a clip. */
|
|
157
|
-
export function frameTimes({ duration, fps, start = 0, end = duration, maxFrames = Infinity }) {
|
|
158
|
-
const out = [];
|
|
159
|
-
const n = Math.min(maxFrames, Math.max(1, Math.floor((end - start) * fps + 1e-6)));
|
|
160
|
-
for (let i = 0; i < n; i++) out.push(start + (i + 0.5) / fps);
|
|
161
|
-
return out;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Visit frames one by one.
|
|
166
|
-
* @param {HTMLVideoElement} video
|
|
167
|
-
* @param {{ fps: number, start?: number, end?: number, maxFrames?: number, signal?: AbortSignal,
|
|
168
|
-
* onFrame: (info: { index: number, time: number, total: number }) => any }} opts
|
|
169
|
-
*/
|
|
170
|
-
export async function forEachFrame(video, opts) {
|
|
171
|
-
await videoReady(video);
|
|
172
|
-
const wasPaused = video.paused;
|
|
173
|
-
video.pause();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
/**
|
|
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
|
+
|
|
108
|
+
/** Seek and resolve once the frame at `time` is decoded and paintable. */
|
|
109
|
+
export async function seekTo(video, time) {
|
|
110
|
+
time = Math.max(0, Math.min(time, (video.duration || 0) - 1e-4));
|
|
111
|
+
if (Math.abs(video.currentTime - time) < 1e-6 && video.readyState >= 2) return;
|
|
112
|
+
const presented = 'requestVideoFrameCallback' in video
|
|
113
|
+
? new Promise(res => { video.requestVideoFrameCallback(() => res()); setTimeout(res, 500); })
|
|
114
|
+
: null;
|
|
115
|
+
const seeked = wait(video, 'seeked');
|
|
116
|
+
video.currentTime = time;
|
|
117
|
+
await seeked;
|
|
118
|
+
if (presented) await presented;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Estimate the frame rate from presented frames (requestVideoFrameCallback), falling back to `fallback`.
|
|
123
|
+
* Plays muted for a moment and restores the position.
|
|
124
|
+
*/
|
|
125
|
+
export async function estimateFps(video, fallback = 30) {
|
|
126
|
+
if (!('requestVideoFrameCallback' in video)) return fallback;
|
|
127
|
+
await videoReady(video);
|
|
128
|
+
const was = { t: video.currentTime, muted: video.muted, paused: video.paused };
|
|
129
|
+
const times = [];
|
|
130
|
+
try {
|
|
131
|
+
video.muted = true;
|
|
132
|
+
await new Promise((res) => {
|
|
133
|
+
const done = setTimeout(res, 1200);
|
|
134
|
+
const cb = (_, meta) => {
|
|
135
|
+
times.push(meta.mediaTime);
|
|
136
|
+
if (times.length >= 12) { clearTimeout(done); res(); } else video.requestVideoFrameCallback(cb);
|
|
137
|
+
};
|
|
138
|
+
video.requestVideoFrameCallback(cb);
|
|
139
|
+
video.play().catch(() => { clearTimeout(done); res(); });
|
|
140
|
+
});
|
|
141
|
+
} finally {
|
|
142
|
+
video.pause();
|
|
143
|
+
video.muted = was.muted;
|
|
144
|
+
await seekTo(video, was.t).catch(() => { });
|
|
145
|
+
}
|
|
146
|
+
const deltas = [];
|
|
147
|
+
for (let i = 1; i < times.length; i++) { const d = times[i] - times[i - 1]; if (d > 0.001) deltas.push(d); }
|
|
148
|
+
if (deltas.length < 3) return fallback;
|
|
149
|
+
deltas.sort((a, b) => a - b);
|
|
150
|
+
const fps = 1 / deltas[Math.floor(deltas.length / 2)];
|
|
151
|
+
// snap to common rates
|
|
152
|
+
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;
|
|
153
|
+
return Math.round(fps);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Frame times (seconds, middle of each frame) for a clip. */
|
|
157
|
+
export function frameTimes({ duration, fps, start = 0, end = duration, maxFrames = Infinity }) {
|
|
158
|
+
const out = [];
|
|
159
|
+
const n = Math.min(maxFrames, Math.max(1, Math.floor((end - start) * fps + 1e-6)));
|
|
160
|
+
for (let i = 0; i < n; i++) out.push(start + (i + 0.5) / fps);
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Visit frames one by one.
|
|
166
|
+
* @param {HTMLVideoElement} video
|
|
167
|
+
* @param {{ fps: number, start?: number, end?: number, maxFrames?: number, times?: number[], signal?: AbortSignal,
|
|
168
|
+
* onFrame: (info: { index: number, time: number, total: number }) => any }} opts
|
|
169
|
+
*/
|
|
170
|
+
export async function forEachFrame(video, opts) {
|
|
171
|
+
await videoReady(video);
|
|
172
|
+
const wasPaused = video.paused;
|
|
173
|
+
video.pause();
|
|
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(() => { });
|
|
187
|
+
}
|
|
188
|
+
return times.length;
|
|
189
|
+
}
|