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
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "image-to-pixel",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Turn images and video into pixel art: dithering, palettes, batch and video. A WebGL engine (via ShaderMate) with a CPU fallback, plus a full editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/types/index.d.ts
CHANGED
|
@@ -192,5 +192,7 @@ export function estimateFps(video: HTMLVideoElement, fallback?: number): Promise
|
|
|
192
192
|
export function frameTimes(opts: { duration: number; fps: number; start?: number; end?: number; maxFrames?: number }): number[];
|
|
193
193
|
export function forEachFrame(video: HTMLVideoElement, opts: {
|
|
194
194
|
fps: number; start?: number; end?: number; maxFrames?: number; signal?: AbortSignal;
|
|
195
|
+
/** Explicit frame timestamps (seconds), in selection order. */
|
|
196
|
+
times?: number[];
|
|
195
197
|
onFrame: (info: { index: number; time: number; total: number }) => unknown;
|
|
196
198
|
}): Promise<number>;
|