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,142 @@
|
|
|
1
|
+
import { h, nextId } from './dom.js';
|
|
2
|
+
import { seekTo, videoReady } from '../core/video/frames.js';
|
|
3
|
+
|
|
4
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
5
|
+
|
|
6
|
+
/** Account for the slider thumb's inset and sample the middle of the hovered frame. */
|
|
7
|
+
export function timelineFrameAt(clientX, { left, width }, duration, fps) {
|
|
8
|
+
const inset = Math.min(7, width / 2);
|
|
9
|
+
const fraction = clamp((clientX - left - inset) / Math.max(1, width - inset * 2), 0, 1);
|
|
10
|
+
const count = Math.max(1, Math.ceil(duration * fps - 1e-6));
|
|
11
|
+
const index = Math.min(count - 1, Math.floor(fraction * duration * fps));
|
|
12
|
+
return { index, time: index / fps, seekTime: Math.max(0, Math.min((index + 0.5) / fps, duration - 1e-4)) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function timelineTime(time) {
|
|
16
|
+
const total = Math.max(0, Math.round(time * 100));
|
|
17
|
+
const hours = Math.floor(total / 360000), minutes = Math.floor(total / 6000) % 60;
|
|
18
|
+
const seconds = String(Math.floor(total / 100) % 60).padStart(2, '0');
|
|
19
|
+
return `${hours ? `${hours}:${String(minutes).padStart(2, '0')}` : minutes}:${seconds}.${String(total % 100).padStart(2, '0')}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A shared player-style hover preview for the playback and interval timelines. */
|
|
23
|
+
export function createTimelinePreview(app) {
|
|
24
|
+
const canvas = h('canvas', { hidden: true, 'aria-label': 'Frame at hovered time' });
|
|
25
|
+
const hint = h('span', { text: 'Loading frame...' });
|
|
26
|
+
const time = h('span.num.itp-timeline-time');
|
|
27
|
+
const el = h('div.itp-timeline-preview', { id: nextId('itp-timeline'), role: 'tooltip', hidden: true },
|
|
28
|
+
h('div.itp-timeline-image', {}, canvas, hint), time);
|
|
29
|
+
let request = null, session = null, timer = null, idleTimer = null;
|
|
30
|
+
|
|
31
|
+
function release(s) {
|
|
32
|
+
s.video.pause(); s.video.removeAttribute('src'); s.video.load(); s.cache.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function dispose() {
|
|
36
|
+
if (!session) return;
|
|
37
|
+
session.disposed = true;
|
|
38
|
+
if (!session.running) release(session);
|
|
39
|
+
session = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hide() {
|
|
43
|
+
el.hidden = true; request = null; clearTimeout(timer); clearTimeout(idleTimer);
|
|
44
|
+
idleTimer = setTimeout(dispose, 10000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reset() { hide(); clearTimeout(idleTimer); dispose(); }
|
|
48
|
+
|
|
49
|
+
function position(target, clientX) {
|
|
50
|
+
const host = el.parentElement.getBoundingClientRect(), track = target.getBoundingClientRect();
|
|
51
|
+
const width = Math.min(180, Math.max(1, host.width - 16));
|
|
52
|
+
el.style.width = `${width}px`;
|
|
53
|
+
el.style.left = `${clamp(clientX - host.left - width / 2, 8, Math.max(8, host.width - width - 8))}px`;
|
|
54
|
+
el.style.bottom = `${host.bottom - track.top + 10}px`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function paint(frame) {
|
|
58
|
+
canvas.width = frame.width; canvas.height = frame.height;
|
|
59
|
+
const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = false;
|
|
60
|
+
ctx.drawImage(frame, 0, 0);
|
|
61
|
+
canvas.hidden = false; hint.hidden = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function thumbnail(frame) {
|
|
65
|
+
const scale = Math.min(1, 168 / frame.width, 104 / frame.height);
|
|
66
|
+
const c = h('canvas', { width: Math.max(1, Math.round(frame.width * scale)), height: Math.max(1, Math.round(frame.height * scale)) });
|
|
67
|
+
const ctx = c.getContext('2d'); ctx.imageSmoothingEnabled = false;
|
|
68
|
+
ctx.drawImage(frame, 0, 0, c.width, c.height);
|
|
69
|
+
return c;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function render(s) {
|
|
73
|
+
if (s.running || s.failed) return;
|
|
74
|
+
s.running = true;
|
|
75
|
+
try {
|
|
76
|
+
await s.ready;
|
|
77
|
+
while (!s.disposed && request?.item === s.item) {
|
|
78
|
+
const current = request;
|
|
79
|
+
let frame = s.cache.get(current.key);
|
|
80
|
+
if (!frame) {
|
|
81
|
+
// Never seek the main video's decoder: hover must not affect playback or selection.
|
|
82
|
+
await seekTo(s.video, current.seekTime);
|
|
83
|
+
if (s.disposed) break;
|
|
84
|
+
if (request?.key !== current.key) continue;
|
|
85
|
+
const result = await app.engine.process(s.video, current.settings);
|
|
86
|
+
if (s.disposed) break;
|
|
87
|
+
frame = thumbnail(result.toCanvas(1));
|
|
88
|
+
s.cache.set(current.key, frame);
|
|
89
|
+
if (s.cache.size > 32) s.cache.delete(s.cache.keys().next().value);
|
|
90
|
+
}
|
|
91
|
+
if (request?.key === current.key && app.current === s.item) { paint(frame); break; }
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
if (!s.disposed) {
|
|
95
|
+
s.failed = true;
|
|
96
|
+
if (request?.item === s.item) { canvas.hidden = true; hint.hidden = false; hint.textContent = 'Preview unavailable'; }
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
s.running = false;
|
|
100
|
+
if (s.disposed) release(s);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function load() {
|
|
105
|
+
if (!request || !app.engine) return;
|
|
106
|
+
if (session?.item !== request.item) {
|
|
107
|
+
dispose();
|
|
108
|
+
const video = h('video', { muted: true, playsInline: true, preload: 'auto', crossOrigin: 'anonymous' });
|
|
109
|
+
video.muted = true; video.src = request.item.source.currentSrc || request.item.source.src;
|
|
110
|
+
session = { item: request.item, video, cache: new Map(), running: false, disposed: false, failed: false,
|
|
111
|
+
ready: videoReady(video, { timeout: 10000 }) };
|
|
112
|
+
}
|
|
113
|
+
render(session);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function bind(target, { getFrame } = {}) {
|
|
117
|
+
target.setAttribute('aria-describedby', el.id);
|
|
118
|
+
target.addEventListener('pointermove', e => {
|
|
119
|
+
const item = app.current;
|
|
120
|
+
if (e.pointerType === 'touch' || item?.type !== 'video' || !Number.isFinite(item.duration) || item.duration <= 0) { hide(); return; }
|
|
121
|
+
clearTimeout(idleTimer);
|
|
122
|
+
const fps = item.fps || 30;
|
|
123
|
+
const frame = timelineFrameAt(e.clientX, target.getBoundingClientRect(), item.duration, fps);
|
|
124
|
+
const { export: ignored, ...visual } = app.store.settings;
|
|
125
|
+
const key = `${fps}/${frame.index}/${JSON.stringify(visual)}`;
|
|
126
|
+
const previous = request;
|
|
127
|
+
request = { item, key, ...frame, settings: app.store.settings };
|
|
128
|
+
position(target, e.clientX); el.hidden = false; time.textContent = timelineTime(frame.time);
|
|
129
|
+
const cached = getFrame?.(frame.index) || (session?.item === item && session.cache.get(key));
|
|
130
|
+
if (cached) { clearTimeout(timer); paint(cached); return; }
|
|
131
|
+
if (previous?.item === item && previous.key === key) return;
|
|
132
|
+
canvas.hidden = true; hint.hidden = false; hint.textContent = session?.failed ? 'Preview unavailable' : 'Loading frame...';
|
|
133
|
+
clearTimeout(timer); timer = setTimeout(load, 80);
|
|
134
|
+
});
|
|
135
|
+
target.addEventListener('pointerleave', hide);
|
|
136
|
+
target.addEventListener('pointercancel', hide);
|
|
137
|
+
target.addEventListener('blur', hide);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
window.addEventListener('resize', hide);
|
|
141
|
+
return { el, bind, hide, reset };
|
|
142
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The saved workspace: every open file (its bytes, name and own settings) plus the shared settings,
|
|
2
|
+
// kept in this browser's IndexedDB. Nothing is uploaded. One workspace per browser profile.
|
|
3
|
+
|
|
4
|
+
const DB = 'image-to-pixel';
|
|
5
|
+
const STORE = 'workspace';
|
|
6
|
+
const KEY = 'current';
|
|
7
|
+
const VERSION = 1;
|
|
8
|
+
|
|
9
|
+
function open() {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
if (typeof indexedDB === 'undefined') { reject(new Error('This browser has no IndexedDB')); return; }
|
|
12
|
+
const req = indexedDB.open(DB, 1);
|
|
13
|
+
req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE); };
|
|
14
|
+
req.onsuccess = () => resolve(req.result);
|
|
15
|
+
req.onerror = () => reject(req.error);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function run(mode, fn) {
|
|
20
|
+
const db = await open();
|
|
21
|
+
try {
|
|
22
|
+
return await new Promise((resolve, reject) => {
|
|
23
|
+
const tx = db.transaction(STORE, mode);
|
|
24
|
+
const req = fn(tx.objectStore(STORE));
|
|
25
|
+
tx.oncomplete = () => resolve(req && req.result);
|
|
26
|
+
tx.onerror = () => reject(tx.error || (req && req.error));
|
|
27
|
+
tx.onabort = () => reject(tx.error || new Error('Saving was cancelled'));
|
|
28
|
+
});
|
|
29
|
+
} finally {
|
|
30
|
+
db.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Save the workspace.
|
|
36
|
+
* @param {{ shared: object, current: number, items: { name: string, type: 'image'|'video', file: Blob, own: object|null }[] }} ws
|
|
37
|
+
* @returns {Promise<number>} total bytes of the saved files
|
|
38
|
+
*/
|
|
39
|
+
export async function saveWorkspace(ws) {
|
|
40
|
+
const record = { version: VERSION, savedAt: Date.now(), ...ws };
|
|
41
|
+
await run('readwrite', s => s.put(record, KEY));
|
|
42
|
+
return ws.items.reduce((a, it) => a + (it.file ? it.file.size : 0), 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The saved workspace, or null. */
|
|
46
|
+
export async function loadWorkspace() {
|
|
47
|
+
try {
|
|
48
|
+
const rec = await run('readonly', s => s.get(KEY));
|
|
49
|
+
return rec && rec.version === VERSION && Array.isArray(rec.items) ? rec : null;
|
|
50
|
+
} catch (_) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function forgetWorkspace() {
|
|
56
|
+
await run('readwrite', s => s.delete(KEY));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when the error means the browser ran out of storage for us. */
|
|
60
|
+
export const isQuotaError = e => !!e && (e.name === 'QuotaExceededError' || /quota|space/i.test(e.message || ''));
|
|
61
|
+
|
|
62
|
+
export const fmtBytes = n => (n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`);
|
package/lib/index.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
// image-to-pixel core: no UI. Works in browsers (WebGL 2 via ShaderMate, CPU fallback) and,
|
|
2
|
-
// for { width, height, data } inputs, in Node on the CPU.
|
|
3
|
-
|
|
4
|
-
export { createPixelator, pixelate, makeResult } from './core/engine.js';
|
|
5
|
-
export {
|
|
6
|
-
DEFAULTS, DITHER_METHODS, SETTINGS_VERSION, MAX_SIZE, LEGACY_DITHER,
|
|
7
|
-
defaultSettings, normalizeSettings, mergeSettings, resolveSize, ditherKind, cropRect, ASPECTS,
|
|
8
|
-
encodeSettings, decodeSettings, compactSettings, settingsToJSON, settingsFromJSON,
|
|
9
|
-
} from './core/settings.js';
|
|
10
|
-
export { makePlan } from './core/plan.js';
|
|
11
|
-
export { runCPU } from './core/cpu/pipeline.js';
|
|
12
|
-
export { PALETTES, getPalette } from './core/palette/builtin.js';
|
|
13
|
-
export { parsePalette, toGPL, toHexFile, toPaletteJSON } from './core/palette/parse.js';
|
|
14
|
-
export { extractPalette } from './core/palette/extract.js';
|
|
15
|
-
export { fetchLospec, lospecSlug } from './core/palette/lospec.js';
|
|
16
|
-
export { thresholdMap, parseMatrix, bayer } from './core/dither/matrices.js';
|
|
17
|
-
export { KERNELS } from './core/dither/kernels.js';
|
|
18
|
-
export { PRESETS, getPreset } from './core/presets.js';
|
|
19
|
-
export { parseColor, toHex, oklab } from './core/color.js';
|
|
20
|
-
export { toFrame, loadImage } from './core/source.js';
|
|
21
|
-
export { upscale, resizeNearest, uniqueColors, detectGrid, hasTransparency } from './core/export/scale.js';
|
|
22
|
-
export { toCanvas, downloadBlob } from './core/export/canvas.js';
|
|
23
|
-
export { encodePNG } from './core/export/png.js';
|
|
24
|
-
export { encodeGIF } from './core/export/gif.js';
|
|
25
|
-
export { createZip } from './core/export/zip.js';
|
|
26
|
-
export { packSpriteSheet, buildTileset } from './core/export/sheet.js';
|
|
27
|
-
export { forEachFrame, estimateFps, seekTo, frameTimes, videoReady, mediaError, sniffVideoCodec } from './core/video/frames.js';
|
|
28
|
-
|
|
29
|
-
/* global __ITP_VERSION__ */
|
|
30
|
-
// The build replaces __ITP_VERSION__ with package.json's version; unbundled source reports 'dev'.
|
|
31
|
-
export const VERSION = typeof __ITP_VERSION__ === 'string' ? __ITP_VERSION__ : 'dev';
|
|
1
|
+
// image-to-pixel core: no UI. Works in browsers (WebGL 2 via ShaderMate, CPU fallback) and,
|
|
2
|
+
// for { width, height, data } inputs, in Node on the CPU.
|
|
3
|
+
|
|
4
|
+
export { createPixelator, pixelate, makeResult } from './core/engine.js';
|
|
5
|
+
export {
|
|
6
|
+
DEFAULTS, DITHER_METHODS, SETTINGS_VERSION, MAX_SIZE, LEGACY_DITHER,
|
|
7
|
+
defaultSettings, normalizeSettings, mergeSettings, resolveSize, ditherKind, cropRect, ASPECTS,
|
|
8
|
+
encodeSettings, decodeSettings, compactSettings, settingsToJSON, settingsFromJSON,
|
|
9
|
+
} from './core/settings.js';
|
|
10
|
+
export { makePlan } from './core/plan.js';
|
|
11
|
+
export { runCPU } from './core/cpu/pipeline.js';
|
|
12
|
+
export { PALETTES, getPalette } from './core/palette/builtin.js';
|
|
13
|
+
export { parsePalette, toGPL, toHexFile, toPaletteJSON } from './core/palette/parse.js';
|
|
14
|
+
export { extractPalette } from './core/palette/extract.js';
|
|
15
|
+
export { fetchLospec, lospecSlug } from './core/palette/lospec.js';
|
|
16
|
+
export { thresholdMap, parseMatrix, bayer } from './core/dither/matrices.js';
|
|
17
|
+
export { KERNELS } from './core/dither/kernels.js';
|
|
18
|
+
export { PRESETS, getPreset } from './core/presets.js';
|
|
19
|
+
export { parseColor, toHex, oklab } from './core/color.js';
|
|
20
|
+
export { toFrame, loadImage } from './core/source.js';
|
|
21
|
+
export { upscale, resizeNearest, uniqueColors, detectGrid, hasTransparency } from './core/export/scale.js';
|
|
22
|
+
export { toCanvas, downloadBlob } from './core/export/canvas.js';
|
|
23
|
+
export { encodePNG } from './core/export/png.js';
|
|
24
|
+
export { encodeGIF } from './core/export/gif.js';
|
|
25
|
+
export { createZip } from './core/export/zip.js';
|
|
26
|
+
export { packSpriteSheet, buildTileset } from './core/export/sheet.js';
|
|
27
|
+
export { forEachFrame, estimateFps, seekTo, frameTimes, videoReady, mediaError, sniffVideoCodec } from './core/video/frames.js';
|
|
28
|
+
|
|
29
|
+
/* global __ITP_VERSION__ */
|
|
30
|
+
// The build replaces __ITP_VERSION__ with package.json's version; unbundled source reports 'dev'.
|
|
31
|
+
export const VERSION = typeof __ITP_VERSION__ === 'string' ? __ITP_VERSION__ : 'dev';
|
package/lib/video.js
CHANGED
|
@@ -1,92 +1,122 @@
|
|
|
1
|
-
// image-to-pixel/video: encode processed frames to MP4 (H.264) or WebM (VP9/VP8) with WebCodecs
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
1
|
+
// image-to-pixel/video: encode processed frames to MP4 (H.264) or WebM (VP9/VP8) with WebCodecs,
|
|
2
|
+
// optionally with the source's soundtrack (AAC or Opus in MP4, Opus in WebM).
|
|
3
|
+
// The muxers are this project's own (lib/core/video/mux-*.js): MIT, no dependencies.
|
|
4
|
+
|
|
5
|
+
import { createMp4Muxer } from './core/video/mux-mp4.js';
|
|
6
|
+
import { createWebmMuxer } from './core/video/mux-webm.js';
|
|
7
|
+
import { decodeAudio, encodeAudio, audioCodecSupported, findAudioConfig } from './core/video/audio.js';
|
|
8
|
+
import { upscale } from './core/export/scale.js';
|
|
9
|
+
|
|
10
|
+
export { forEachFrame, estimateFps, seekTo, frameTimes, videoReady } from './core/video/frames.js';
|
|
11
|
+
export { createMp4Muxer, createWebmMuxer, decodeAudio, encodeAudio, audioCodecSupported, findAudioConfig };
|
|
12
|
+
|
|
13
|
+
const AVC = ['avc1.640033', 'avc1.4d0033', 'avc1.42003e', 'avc1.640028', 'avc1.42001f'];
|
|
14
|
+
const VP = [['vp09.00.10.08', 'vp9'], ['vp8', 'vp8']];
|
|
15
|
+
|
|
16
|
+
/** Which formats this browser can encode. `aac` / `opus`: audio for MP4 (either) and WebM (Opus). */
|
|
17
|
+
export async function videoSupport(width = 640, height = 480) {
|
|
18
|
+
const out = { mp4: false, webm: false, webcodecs: typeof VideoEncoder !== 'undefined', aac: false, opus: false };
|
|
19
|
+
if (out.webcodecs) {
|
|
20
|
+
for (const codec of AVC) if ((await VideoEncoder.isConfigSupported({ codec, width, height }).catch(() => ({}))).supported) { out.mp4 = true; break; }
|
|
21
|
+
for (const [codec] of VP) if ((await VideoEncoder.isConfigSupported({ codec, width, height }).catch(() => ({}))).supported) { out.webm = true; break; }
|
|
22
|
+
}
|
|
23
|
+
out.aac = await audioCodecSupported('aac');
|
|
24
|
+
out.opus = await audioCodecSupported('opus');
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const even = n => n + (n & 1);
|
|
29
|
+
|
|
30
|
+
/** The audio codec and encoder config for a container, or null when the browser can't encode a suitable one. */
|
|
31
|
+
async function pickAudio(format, pcm, bitrate) {
|
|
32
|
+
const order = format === 'mp4' ? ['aac', 'opus'] : ['opus']; // WebM only holds Opus (or Vorbis)
|
|
33
|
+
for (const codec of order) {
|
|
34
|
+
const config = await findAudioConfig(codec, pcm.sampleRate, pcm.numberOfChannels, bitrate);
|
|
35
|
+
if (config) return { codec, config };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Start an encoder; add frames one at a time (so a long clip never sits in memory), then finish().
|
|
42
|
+
* @param {{ format: 'mp4' | 'webm', fps: number, width: number, height: number, scale?: number, bitrate?: number,
|
|
43
|
+
* audio?: { sampleRate: number, numberOfChannels: number, channels: Float32Array[] } | null,
|
|
44
|
+
* audioBitrate?: number }} opts
|
|
45
|
+
* width/height: size of the frames you will add (before scaling)
|
|
46
|
+
* audio: PCM to add as a soundtrack, e.g. from decodeAudio(file, { start, end }). It is cut to the
|
|
47
|
+
* video's length. When the browser can't encode audio the video is still made, without it
|
|
48
|
+
* (check `encoder.audioCodec`).
|
|
49
|
+
*/
|
|
50
|
+
export async function createVideoEncoder(opts) {
|
|
51
|
+
const scale = Math.max(1, Math.round(opts.scale || 1));
|
|
52
|
+
const W = even(opts.width * scale), H = even(opts.height * scale);
|
|
53
|
+
const fps = opts.fps || 30;
|
|
54
|
+
const bitrate = opts.bitrate || Math.min(20e6, Math.max(2e6, W * H * fps * 0.15));
|
|
55
|
+
if (typeof VideoEncoder === 'undefined') throw new Error('This browser has no WebCodecs VideoEncoder. Export a GIF or PNG sequence instead.');
|
|
56
|
+
|
|
57
|
+
let codec = null, muxCodec = null;
|
|
58
|
+
const list = opts.format === 'mp4' ? AVC.map(c => [c, 'avc']) : VP;
|
|
59
|
+
for (const [c, m] of list) {
|
|
60
|
+
const ok = await VideoEncoder.isConfigSupported({ codec: c, width: W, height: H, bitrate, framerate: fps }).catch(() => ({}));
|
|
61
|
+
if (ok.supported) { codec = c; muxCodec = m; break; }
|
|
62
|
+
}
|
|
63
|
+
if (!codec) throw new Error(`No ${opts.format.toUpperCase()} encoder available in this browser at ${W}×${H}.`);
|
|
64
|
+
|
|
65
|
+
const pcm = opts.audio && opts.audio.channels && opts.audio.channels.length ? opts.audio : null;
|
|
66
|
+
const picked = pcm ? await pickAudio(opts.format, pcm, opts.audioBitrate || 128000) : null;
|
|
67
|
+
const audioCodec = picked ? picked.codec : null;
|
|
68
|
+
const audioTrack = picked ? { codec: audioCodec, sampleRate: picked.config.sampleRate, numberOfChannels: picked.config.numberOfChannels, bitrate: picked.config.bitrate || 128000 } : null;
|
|
69
|
+
|
|
70
|
+
const muxer = opts.format === 'mp4'
|
|
71
|
+
? createMp4Muxer({ width: W, height: H, audio: audioTrack })
|
|
72
|
+
: createWebmMuxer({ width: W, height: H, codec: muxCodec, fps, audio: audioTrack });
|
|
73
|
+
|
|
74
|
+
let error = null;
|
|
75
|
+
const encoder = new VideoEncoder({
|
|
76
|
+
output: (chunk, meta) => muxer.addChunk(chunk, meta),
|
|
77
|
+
error: e => { error = e; },
|
|
78
|
+
});
|
|
79
|
+
// avc.format 'avc': length-prefixed NAL units plus a decoder config record, which MP4 needs
|
|
80
|
+
encoder.configure({ codec, width: W, height: H, bitrate, framerate: fps, latencyMode: 'quality', ...(opts.format === 'mp4' ? { avc: { format: 'avc' } } : {}) });
|
|
81
|
+
|
|
82
|
+
const canvas = new OffscreenCanvas(W, H);
|
|
83
|
+
const ctx = canvas.getContext('2d');
|
|
84
|
+
let i = 0;
|
|
85
|
+
const frameUs = 1e6 / fps;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
width: W, height: H, codec,
|
|
89
|
+
/** 'aac' or 'opus' when the soundtrack will be included, null otherwise. */
|
|
90
|
+
audioCodec,
|
|
91
|
+
/** @param {{ width, height, data }} img */
|
|
92
|
+
async add(img) {
|
|
93
|
+
if (error) throw error;
|
|
94
|
+
const up = scale > 1 ? upscale(img, scale) : img;
|
|
95
|
+
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
|
|
96
|
+
ctx.putImageData(new ImageData(new Uint8ClampedArray(up.data), up.width, up.height), 0, 0);
|
|
97
|
+
const frame = new VideoFrame(canvas, { timestamp: Math.round(i * frameUs), duration: Math.round(frameUs) });
|
|
98
|
+
encoder.encode(frame, { keyFrame: i % Math.max(1, Math.round(fps * 2)) === 0 });
|
|
99
|
+
frame.close();
|
|
100
|
+
i++;
|
|
101
|
+
if (encoder.encodeQueueSize > 8) await new Promise(r => setTimeout(r, 0));
|
|
102
|
+
},
|
|
103
|
+
/** @returns {Promise<Blob>} */
|
|
104
|
+
async finish() {
|
|
105
|
+
await encoder.flush();
|
|
106
|
+
if (error) throw error;
|
|
107
|
+
encoder.close();
|
|
108
|
+
if (audioCodec && i > 0) {
|
|
109
|
+
// the soundtrack ends with the last frame
|
|
110
|
+
await encodeAudio(pcm, { config: picked.config, maxDuration: i / fps, onChunk: (chunk, meta) => muxer.addAudioChunk(chunk, meta) });
|
|
111
|
+
}
|
|
112
|
+
return new Blob([muxer.finish()], { type: opts.format === 'mp4' ? 'video/mp4' : 'video/webm' });
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Convenience: encode an array of frames in one go. */
|
|
118
|
+
export async function encodeVideo(frames, opts) {
|
|
119
|
+
const enc = await createVideoEncoder({ ...opts, width: frames[0].width, height: frames[0].height });
|
|
120
|
+
for (const f of frames) await enc.add(f);
|
|
121
|
+
return enc.finish();
|
|
122
|
+
}
|