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,566 @@
|
|
|
1
|
+
import { h } from './dom.js';
|
|
2
|
+
import { icon } from './icons.js';
|
|
3
|
+
import { ZOOM_STEPS } from './stage.js';
|
|
4
|
+
import { forEachFrame } from '../core/video/frames.js';
|
|
5
|
+
|
|
6
|
+
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
|
|
7
|
+
const seconds = n => `${n.toFixed(3)}s`;
|
|
8
|
+
const TILE_W = 140, TILE_H = 124, TILE_GAP = 10;
|
|
9
|
+
/** How much of a long clip the first frame-selection load covers. */
|
|
10
|
+
export const INITIAL_INTERVAL_SECONDS = 3;
|
|
11
|
+
/** The tile canvas never loads more frames than this at once; longer intervals load a window. */
|
|
12
|
+
export const MAX_TILE_FRAMES = 1000;
|
|
13
|
+
const DEFAULT_END_TOLERANCE = 0.05;
|
|
14
|
+
/** Slider geometry: the thumbs travel between the 7px track insets (see .itp-story-range-track). */
|
|
15
|
+
const THUMB_INSET = 7;
|
|
16
|
+
const GRAB_RADIUS = 11;
|
|
17
|
+
|
|
18
|
+
/** Both the frames and the animation use the same canvas/world coordinates. */
|
|
19
|
+
export function storyboardLayout(videoFps, frameCount, showAnimation = false) {
|
|
20
|
+
const cols = Math.max(1, Math.min(Math.max(1, frameCount), Math.round(videoFps / 2)));
|
|
21
|
+
const rows = Math.max(1, Math.ceil(frameCount / cols));
|
|
22
|
+
const frameWidth = cols * TILE_W + (cols - 1) * TILE_GAP;
|
|
23
|
+
const frameHeight = rows * TILE_H + (rows - 1) * TILE_GAP;
|
|
24
|
+
const animation = showAnimation ? { x: frameWidth + 24, y: 0, w: TILE_W, h: TILE_H + 24 } : null;
|
|
25
|
+
return { cols, width: animation ? animation.x + animation.w : frameWidth,
|
|
26
|
+
height: Math.max(frameHeight, animation?.h || 0), animation };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Frame-boundary indices; the end is exclusive, including a partial final frame. */
|
|
30
|
+
export function videoFrameRange(duration, fps, start = 0, end = duration) {
|
|
31
|
+
const count = Math.max(1, Math.ceil(duration * fps - 1e-6));
|
|
32
|
+
const first = clamp(Math.round(start * fps), 0, count - 1);
|
|
33
|
+
const last = clamp(end >= duration ? count : Math.round(end * fps), first + 1, count);
|
|
34
|
+
return { count, first, last };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Is this Animation End the untouched full-clip default? Its rounding to one decimal counts too. */
|
|
38
|
+
export function isFullClipDefault(end, duration) {
|
|
39
|
+
const roundedDuration = Math.round(duration * 10) / 10;
|
|
40
|
+
// Frame-boundary End values can include a few extra milliseconds (for example 10.005s).
|
|
41
|
+
return end >= duration - DEFAULT_END_TOLERANCE || Math.abs(end - roundedDuration) < 1e-6;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Keep a frame interval inside the tile budget, holding the edge that is not being dragged. */
|
|
45
|
+
export function limitVideoRange({ first, last }, keepEnd = false) {
|
|
46
|
+
if (last - first <= MAX_TILE_FRAMES) return { first, last };
|
|
47
|
+
return keepEnd ? { first: last - MAX_TILE_FRAMES, last } : { first, last: first + MAX_TILE_FRAMES };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Thumb centre in client px for a frame-boundary value on the interval bar. */
|
|
51
|
+
export function sliderThumbX(value, rect, count) {
|
|
52
|
+
const inset = Math.min(THUMB_INSET, rect.width / 2);
|
|
53
|
+
const travel = Math.max(0, rect.width - inset * 2);
|
|
54
|
+
return rect.left + inset + (value / Math.max(1, count)) * travel;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Frame-boundary index under a pointer position on the interval bar. */
|
|
58
|
+
export function sliderIndexAt(clientX, rect, count) {
|
|
59
|
+
const inset = Math.min(THUMB_INSET, rect.width / 2);
|
|
60
|
+
const travel = Math.max(1, rect.width - inset * 2);
|
|
61
|
+
return Math.round(clamp((clientX - rect.left - inset) / travel, 0, 1) * count);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Keep the first frame-selection load bounded for long videos, and every tile load inside its budget. */
|
|
65
|
+
export function initialVideoFrameRange(duration, fps, { start = 0, end = 0, rangeTouched = false } = {}) {
|
|
66
|
+
const explicit = rangeTouched || start > 1e-6 || (end > start && !isFullClipDefault(end, duration));
|
|
67
|
+
const requestedEnd = explicit ? end || duration : Math.min(duration, start + INITIAL_INTERVAL_SECONDS);
|
|
68
|
+
const requested = videoFrameRange(duration, fps, start, requestedEnd);
|
|
69
|
+
const limited = limitVideoRange(requested);
|
|
70
|
+
return limited.last === requested.last ? requested : { ...requested, last: limited.last, capped: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Main-preview interval picker and selectable, processed frame grid. */
|
|
74
|
+
export function createVideoStoryboard(app, { onModeChange, onZoomChange, onRangeChange, onSelectionChange, animationOptions }) {
|
|
75
|
+
let item = null, active = false, initialized = false, complete = false;
|
|
76
|
+
let fps = 30, playbackFps = 12, count = 1, first = 0, last = 1, zoom = 1;
|
|
77
|
+
let records = [], selected = new Set(), settingsKey = '';
|
|
78
|
+
let generation = 0, controller = null, timer = null;
|
|
79
|
+
let rangeDragging = false, pendingLoad = false, displayedCount = 0;
|
|
80
|
+
let playing = false, animationTimer = null, animationRecord = null, animationIndex = 0, animationFrames = [];
|
|
81
|
+
const button = (text, onclick, cls = '', props = {}) => h(`button.itp-btn.small${cls}`, { type: 'button', text, onclick, ...props });
|
|
82
|
+
|
|
83
|
+
const status = h('span.itp-story-status', { role: 'status', text: 'Choose a time interval below' });
|
|
84
|
+
const selectionLabel = h('span.num', { text: '0 selected' });
|
|
85
|
+
const selectAll = button('Select all', () => {
|
|
86
|
+
records.forEach(r => selected.add(r.index)); syncSelection();
|
|
87
|
+
});
|
|
88
|
+
const clear = button('Clear', () => { selected.clear(); syncSelection(); });
|
|
89
|
+
const zoomLabel = h('span.num', { text: 'Fit' });
|
|
90
|
+
const zoomOut = button('', () => zoomStep(-1), '.icon', { html: icon('zoomOut'), 'aria-label': 'Smaller frame tiles' });
|
|
91
|
+
const zoomIn = button('', () => zoomStep(1), '.icon', { html: icon('zoomIn'), 'aria-label': 'Larger frame tiles' });
|
|
92
|
+
const play = button('', toggleAnimation, '.icon.outline', { html: icon('play'), 'aria-label': 'Play selected', title: 'Play selected (Space)', 'aria-pressed': 'false' });
|
|
93
|
+
const grid = h('div.itp-story-grid', { role: 'group', 'aria-label': 'Video frames. Drag to pan, use the wheel to zoom, and click a frame to select it.' });
|
|
94
|
+
const board = h('canvas.itp-story-canvas', { tabindex: '0', 'aria-label': 'Video frames and selected animation. Drag to pan, scroll to zoom.' });
|
|
95
|
+
const overlay = h('div.itp-story-overlay', {},
|
|
96
|
+
h('div.itp-float.itp-story-head', {}, status, selectionLabel),
|
|
97
|
+
h('div.itp-float.itp-story-actions', {}, selectAll, clear,
|
|
98
|
+
h('div.itp-story-zoom', { role: 'group', 'aria-label': 'Frame tile size' }, zoomOut, zoomLabel, zoomIn), play));
|
|
99
|
+
grid.append(board, overlay);
|
|
100
|
+
const el = h('section.itp-storyboard', { 'aria-label': 'Select video frames', hidden: true }, grid);
|
|
101
|
+
const boardState = { x: 0, y: 0, z: 1, fitted: true, vw: 0, vh: 0, dpr: 1 };
|
|
102
|
+
|
|
103
|
+
const startSlider = h('input', { type: 'range', min: 0, max: 1, step: 1, value: 0, 'aria-label': 'Interval start frame' });
|
|
104
|
+
const endSlider = h('input', { type: 'range', min: 0, max: 1, step: 1, value: 1, 'aria-label': 'Interval end frame' });
|
|
105
|
+
const range = h('div.itp-story-range', {}, h('div.itp-story-range-track'), startSlider, endSlider);
|
|
106
|
+
const startInput = h('input.itp-input.num', { type: 'number', min: 0, step: '0.001', value: 0, 'aria-label': 'Interval start seconds' });
|
|
107
|
+
const endInput = h('input.itp-input.num', { type: 'number', min: 0, step: '0.001', value: 0, 'aria-label': 'Interval end seconds' });
|
|
108
|
+
const fpsLabel = h('span.muted.num');
|
|
109
|
+
const transport = h('div.itp-transport.itp-transport-tiled', { hidden: true, role: 'group', 'aria-label': 'Video time interval' },
|
|
110
|
+
h('label.itp-story-time', {}, 'In', startInput), range, h('label.itp-story-time', {}, 'Out', endInput), fpsLabel);
|
|
111
|
+
startSlider.addEventListener('input', () => changeRange(Number(startSlider.value), last, 'start'));
|
|
112
|
+
endSlider.addEventListener('input', () => changeRange(first, Number(endSlider.value), 'end'));
|
|
113
|
+
startInput.addEventListener('change', () => changeRange(Math.round(Number(startInput.value) * fps), last, 'start'));
|
|
114
|
+
endInput.addEventListener('change', () => changeRange(first, Math.round(Number(endInput.value) * fps), 'end'));
|
|
115
|
+
// The two inputs stay keyboard-operable, but pointer drags are handled here so the handles work in
|
|
116
|
+
// every browser: Firefox never forwards pointer events to ::-moz-range-thumb, so a transparent
|
|
117
|
+
// input cannot rely on its native thumb.
|
|
118
|
+
let dragEdge = null, dragPointer = null;
|
|
119
|
+
range.addEventListener('pointerdown', e => {
|
|
120
|
+
if (!initialized || rangeDragging || e.button !== 0) return;
|
|
121
|
+
const rect = range.getBoundingClientRect();
|
|
122
|
+
const dStart = Math.abs(e.clientX - sliderThumbX(first, rect, count));
|
|
123
|
+
const dEnd = Math.abs(e.clientX - sliderThumbX(last, rect, count));
|
|
124
|
+
const nearStart = dStart <= dEnd;
|
|
125
|
+
if ((nearStart ? dStart : dEnd) > GRAB_RADIUS) return; // the track: normal arrow, clicks do nothing
|
|
126
|
+
dragEdge = nearStart ? 'start' : 'end';
|
|
127
|
+
(nearStart ? startSlider : endSlider).focus({ preventScroll: true });
|
|
128
|
+
rangeDragging = true; dragPointer = e.pointerId;
|
|
129
|
+
range.setPointerCapture(e.pointerId);
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
if (pendingLoad) clearTimeout(timer);
|
|
132
|
+
});
|
|
133
|
+
range.addEventListener('pointermove', e => {
|
|
134
|
+
if (rangeDragging && e.pointerId !== dragPointer) return; // a second finger never moves the handle
|
|
135
|
+
const rect = range.getBoundingClientRect();
|
|
136
|
+
if (!rangeDragging) {
|
|
137
|
+
const distance = Math.min(Math.abs(e.clientX - sliderThumbX(first, rect, count)), Math.abs(e.clientX - sliderThumbX(last, rect, count)));
|
|
138
|
+
range.style.cursor = distance <= GRAB_RADIUS ? 'ew-resize' : '';
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const frame = sliderIndexAt(e.clientX, rect, count);
|
|
142
|
+
if (dragEdge === 'start') changeRange(frame, last, 'start'); else changeRange(first, frame, 'end');
|
|
143
|
+
});
|
|
144
|
+
range.addEventListener('pointerleave', () => { if (!rangeDragging) range.style.cursor = ''; });
|
|
145
|
+
const endRangeDrag = e => {
|
|
146
|
+
if (!rangeDragging || e.pointerId !== dragPointer) return;
|
|
147
|
+
rangeDragging = false; dragEdge = null; dragPointer = null;
|
|
148
|
+
if (pendingLoad) queueLoad();
|
|
149
|
+
};
|
|
150
|
+
window.addEventListener('pointerup', endRangeDrag);
|
|
151
|
+
window.addEventListener('pointercancel', endRangeDrag);
|
|
152
|
+
|
|
153
|
+
function syncRange(user = false) {
|
|
154
|
+
startSlider.max = endSlider.max = String(count);
|
|
155
|
+
startSlider.value = String(first); endSlider.value = String(last);
|
|
156
|
+
startSlider.setAttribute('aria-valuetext', seconds(first / fps));
|
|
157
|
+
endSlider.setAttribute('aria-valuetext', seconds(Math.min(item.duration, last / fps)));
|
|
158
|
+
startInput.max = endInput.max = String(item.duration);
|
|
159
|
+
startInput.value = (first / fps).toFixed(3);
|
|
160
|
+
endInput.value = Math.min(item.duration, last / fps).toFixed(3);
|
|
161
|
+
range.style.setProperty('--from', `${first / count * 100}%`);
|
|
162
|
+
range.style.setProperty('--to', `${last / count * 100}%`);
|
|
163
|
+
// Keep both thumbs reachable when the interval is only one frame wide.
|
|
164
|
+
startSlider.style.zIndex = first > count / 2 ? '3' : '2';
|
|
165
|
+
fpsLabel.textContent = `${fps} fps`;
|
|
166
|
+
onRangeChange?.({ start: first / fps, end: Math.min(item.duration, last / fps), user });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function changeRange(a, b, edge) {
|
|
170
|
+
if (!initialized || !Number.isFinite(a) || !Number.isFinite(b)) { if (item) syncRange(); return; }
|
|
171
|
+
const nextFirst = clamp(a, 0, edge === 'start' ? last - 1 : count - 1);
|
|
172
|
+
const nextLast = clamp(b, edge === 'end' ? first + 1 : nextFirst + 1, count);
|
|
173
|
+
const limited = limitVideoRange({ first: nextFirst, last: nextLast }, edge === 'end');
|
|
174
|
+
if (limited.first !== nextFirst || limited.last !== nextLast) notifyCapped();
|
|
175
|
+
applyRange(limited.first, limited.last, true);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function applyRange(a, b, user = false) {
|
|
179
|
+
if (a === first && b === last) { syncRange(user); return; }
|
|
180
|
+
first = a; last = b;
|
|
181
|
+
selected = new Set([...selected].filter(i => i >= first && i < last));
|
|
182
|
+
syncRange(user); queueLoad();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function setRange(start, end) {
|
|
186
|
+
if (!active || !initialized) return;
|
|
187
|
+
// An empty or backwards End follows the export rule: run to the end of the clip.
|
|
188
|
+
const r = videoFrameRange(item.duration, fps, start, end > start ? end : item.duration);
|
|
189
|
+
const limited = limitVideoRange(r);
|
|
190
|
+
if (limited.first !== r.first || limited.last !== r.last) notifyCapped();
|
|
191
|
+
applyRange(limited.first, limited.last);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function boardMetrics() {
|
|
195
|
+
return storyboardLayout(fps, displayedCount, !!animationRecord);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function boardResize() {
|
|
199
|
+
const r = grid.getBoundingClientRect();
|
|
200
|
+
const d = Math.min(3, window.devicePixelRatio || 1);
|
|
201
|
+
boardState.vw = r.width; boardState.vh = r.height; boardState.dpr = d;
|
|
202
|
+
board.width = Math.max(1, Math.round(r.width * d)); board.height = Math.max(1, Math.round(r.height * d));
|
|
203
|
+
if (boardState.fitted) fitBoard(); else drawBoard();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function fitBoard(notify = true) {
|
|
207
|
+
const m = boardMetrics();
|
|
208
|
+
if (!records.length) { boardState.x = boardState.vw / 2; boardState.y = boardState.vh / 2; drawBoard(); return; }
|
|
209
|
+
const height = boardContentHeight();
|
|
210
|
+
boardState.z = Math.max(0.05, Math.min(128, (boardState.vw - 24) / m.width, (height - 24) / m.height));
|
|
211
|
+
boardState.x = (boardState.vw - m.width * boardState.z) / 2;
|
|
212
|
+
boardState.y = (height - m.height * boardState.z) / 2;
|
|
213
|
+
boardState.fitted = true; syncZoom();
|
|
214
|
+
drawBoard(); if (notify) onZoomChange?.();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function focusAnimation() {
|
|
218
|
+
const r = boardMetrics().animation;
|
|
219
|
+
if (!r) return;
|
|
220
|
+
// Focus by panning only: playing must not change the canvas zoom.
|
|
221
|
+
const height = boardContentHeight();
|
|
222
|
+
boardState.x = boardState.vw / 2 - (r.x + r.w / 2) * boardState.z;
|
|
223
|
+
boardState.y = height / 2 - (r.y + r.h / 2) * boardState.z;
|
|
224
|
+
boardState.fitted = false; syncZoom(); drawBoard(); onZoomChange?.();
|
|
225
|
+
board.focus({ preventScroll: true });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function boardContentHeight() {
|
|
229
|
+
// Fit above the floating controls without shrinking the interactive canvas.
|
|
230
|
+
return Math.max(1, boardState.vh - overlay.offsetHeight - 24);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function tileRect(i) {
|
|
234
|
+
const m = boardMetrics();
|
|
235
|
+
const col = i % m.cols, row = Math.floor(i / m.cols);
|
|
236
|
+
return { x: col * (TILE_W + TILE_GAP), y: row * (TILE_H + TILE_GAP), w: TILE_W, h: TILE_H };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function drawBoard() {
|
|
240
|
+
const ctx = board.getContext('2d');
|
|
241
|
+
const { dpr, vw, vh, x, y, z } = boardState;
|
|
242
|
+
const css = getComputedStyle(el);
|
|
243
|
+
const stageColor = css.getPropertyValue('--stage').trim() || '#1b1c20';
|
|
244
|
+
const panelColor = css.getPropertyValue('--panel').trim() || '#25262b';
|
|
245
|
+
const raisedColor = css.getPropertyValue('--raised').trim() || '#303137';
|
|
246
|
+
const mutedColor = css.getPropertyValue('--muted').trim() || '#a1a3ad';
|
|
247
|
+
const accentColor = css.getPropertyValue('--accent').trim() || '#ffbd4a';
|
|
248
|
+
const accentSoft = css.getPropertyValue('--accent-soft').trim() || '#40351e';
|
|
249
|
+
const lineColor = css.getPropertyValue('--line').trim() || '#45464d';
|
|
250
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, board.width, board.height);
|
|
251
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
252
|
+
ctx.fillStyle = stageColor; ctx.fillRect(0, 0, vw, vh);
|
|
253
|
+
ctx.imageSmoothingEnabled = false;
|
|
254
|
+
const font = css.getPropertyValue('--font-ui').trim() || 'sans-serif';
|
|
255
|
+
function drawTile(record, r, isAnimation = false) {
|
|
256
|
+
const sx = x + r.x * z, sy = y + r.y * z, sw = r.w * z, sh = r.h * z;
|
|
257
|
+
if (sx > vw || sy > vh || sx + sw < 0 || sy + sh < 0) return;
|
|
258
|
+
const highlight = isAnimation || selected.has(record.index);
|
|
259
|
+
ctx.fillStyle = highlight ? accentSoft : panelColor;
|
|
260
|
+
ctx.fillRect(sx, sy, sw, sh);
|
|
261
|
+
const pad = 6 * z, labelH = 18 * z, titleH = isAnimation ? 24 * z : 0;
|
|
262
|
+
const scale = Math.min((sw - pad * 2) / record.canvas.width, (sh - titleH - labelH - pad * 2) / record.canvas.height);
|
|
263
|
+
const iw = record.canvas.width * scale, ih = record.canvas.height * scale;
|
|
264
|
+
ctx.fillStyle = raisedColor;
|
|
265
|
+
ctx.fillRect(sx + pad, sy + titleH + pad, sw - pad * 2, sh - titleH - labelH - pad * 2);
|
|
266
|
+
ctx.drawImage(record.canvas, sx + (sw - iw) / 2, sy + titleH + pad + (sh - titleH - labelH - pad * 2 - ih) / 2, iw, ih);
|
|
267
|
+
ctx.save(); ctx.beginPath(); ctx.rect(sx + pad, sy, sw - pad * 2, sh); ctx.clip();
|
|
268
|
+
ctx.font = `${11 * z}px ${font}`; ctx.fillStyle = mutedColor;
|
|
269
|
+
if (isAnimation) {
|
|
270
|
+
ctx.fillStyle = accentColor;
|
|
271
|
+
ctx.fillText('Selected animation', sx + pad, sy + 17 * z);
|
|
272
|
+
ctx.fillStyle = mutedColor;
|
|
273
|
+
ctx.fillText(`${animationIndex + 1} of ${animationFrames.length} / ${playbackFps} fps`, sx + pad, sy + sh - 6 * z);
|
|
274
|
+
} else ctx.fillText(`#${record.index} / ${seconds(record.time)}`, sx + pad, sy + sh - 6 * z);
|
|
275
|
+
ctx.restore();
|
|
276
|
+
ctx.strokeStyle = highlight ? accentColor : lineColor;
|
|
277
|
+
ctx.lineWidth = Math.max(1, z); ctx.strokeRect(sx + 0.5, sy + 0.5, sw - 1, sh - 1);
|
|
278
|
+
}
|
|
279
|
+
records.forEach((record, i) => drawTile(record, tileRect(i)));
|
|
280
|
+
if (animationRecord) drawTile(animationRecord, boardMetrics().animation, true);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function selectBoardPoint(clientX, clientY) {
|
|
284
|
+
if (!complete) return;
|
|
285
|
+
const r = grid.getBoundingClientRect();
|
|
286
|
+
const wx = (clientX - r.left - boardState.x) / boardState.z;
|
|
287
|
+
const wy = (clientY - r.top - boardState.y) / boardState.z;
|
|
288
|
+
const hit = records.find((record, i) => { const t = tileRect(i); return wx >= t.x && wx <= t.x + t.w && wy >= t.y && wy <= t.y + t.h; });
|
|
289
|
+
if (!hit) return;
|
|
290
|
+
if (selected.has(hit.index)) selected.delete(hit.index); else selected.add(hit.index);
|
|
291
|
+
syncSelection();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let boardDrag = null;
|
|
295
|
+
const boardPointers = new Map();
|
|
296
|
+
board.addEventListener('pointerdown', e => {
|
|
297
|
+
board.focus({ preventScroll: true }); board.setPointerCapture(e.pointerId);
|
|
298
|
+
boardPointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
299
|
+
if (boardPointers.size === 2) { boardDrag = null; return; }
|
|
300
|
+
boardDrag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, ox: boardState.x, oy: boardState.y, moved: false };
|
|
301
|
+
});
|
|
302
|
+
board.addEventListener('pointermove', e => {
|
|
303
|
+
if (!boardPointers.has(e.pointerId)) return;
|
|
304
|
+
const previous = boardPointers.get(e.pointerId);
|
|
305
|
+
boardPointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
306
|
+
if (boardPointers.size === 2) {
|
|
307
|
+
const [a, b] = [...boardPointers.values()];
|
|
308
|
+
const oldOther = [...boardPointers.entries()].find(([id]) => id !== e.pointerId)?.[1];
|
|
309
|
+
if (oldOther) {
|
|
310
|
+
const oldDistance = Math.hypot(previous.x - oldOther.x, previous.y - oldOther.y);
|
|
311
|
+
const newDistance = Math.hypot(a.x - b.x, a.y - b.y);
|
|
312
|
+
const r = grid.getBoundingClientRect();
|
|
313
|
+
if (oldDistance > 0) zoomTo(boardState.z * newDistance / oldDistance, (a.x + b.x) / 2 - r.left, (a.y + b.y) / 2 - r.top);
|
|
314
|
+
}
|
|
315
|
+
boardDrag = null;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (!boardDrag || boardDrag.pointerId !== e.pointerId) return;
|
|
319
|
+
const dx = e.clientX - boardDrag.x, dy = e.clientY - boardDrag.y;
|
|
320
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) boardDrag.moved = true;
|
|
321
|
+
boardState.x = boardDrag.ox + dx; boardState.y = boardDrag.oy + dy; boardState.fitted = false; drawBoard();
|
|
322
|
+
});
|
|
323
|
+
board.addEventListener('pointerup', e => {
|
|
324
|
+
boardPointers.delete(e.pointerId);
|
|
325
|
+
if (!boardDrag || boardDrag.pointerId !== e.pointerId) return;
|
|
326
|
+
const click = !boardDrag.moved; const { x, y } = e; boardDrag = null;
|
|
327
|
+
if (click) selectBoardPoint(x, y);
|
|
328
|
+
syncZoom(); onZoomChange?.();
|
|
329
|
+
});
|
|
330
|
+
board.addEventListener('pointercancel', e => { boardPointers.delete(e.pointerId); boardDrag = null; });
|
|
331
|
+
board.addEventListener('wheel', e => {
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
const r = grid.getBoundingClientRect(), cx = e.clientX - r.left, cy = e.clientY - r.top;
|
|
334
|
+
const trackpadPan = !e.ctrlKey && e.deltaMode === 0 && Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
|
335
|
+
if (trackpadPan) { boardState.x -= e.deltaX; boardState.y -= e.deltaY; boardState.fitted = false; drawBoard(); syncZoom(); onZoomChange?.(); return; }
|
|
336
|
+
if (e.ctrlKey && e.deltaMode === 0) zoomTo(boardState.z * Math.exp(-e.deltaY * 0.01), cx, cy);
|
|
337
|
+
else zoomStep(e.deltaY < 0 ? 1 : -1, cx, cy);
|
|
338
|
+
}, { passive: false });
|
|
339
|
+
board.addEventListener('dblclick', () => fitBoard());
|
|
340
|
+
new ResizeObserver(boardResize).observe(grid);
|
|
341
|
+
new ResizeObserver(() => { if (active && boardState.fitted) fitBoard(); }).observe(overlay);
|
|
342
|
+
|
|
343
|
+
/** Long intervals load a window of their frames; say so once, not on every drag step. */
|
|
344
|
+
let cappedNotice = 0;
|
|
345
|
+
function notifyCapped() {
|
|
346
|
+
const now = performance.now();
|
|
347
|
+
if (now - cappedNotice < 2000) return;
|
|
348
|
+
cappedNotice = now;
|
|
349
|
+
app.toast(`This interval is longer than ${MAX_TILE_FRAMES} frames, so only ${MAX_TILE_FRAMES} are loaded. Narrow the range to reach the rest.`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function cancelLoad() {
|
|
353
|
+
generation++; clearTimeout(timer); controller?.abort(); controller = null;
|
|
354
|
+
pendingLoad = false;
|
|
355
|
+
grid.setAttribute('aria-busy', 'false');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function visualKey() {
|
|
359
|
+
const { export: ignored, ...visual } = app.store.settings;
|
|
360
|
+
return JSON.stringify(visual);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function queueLoad() {
|
|
364
|
+
cancelLoad(); stopAnimation(true); complete = false;
|
|
365
|
+
syncButtons();
|
|
366
|
+
if (!active || !initialized) return;
|
|
367
|
+
pendingLoad = true;
|
|
368
|
+
status.textContent = `Waiting to load ${last - first} frames...`;
|
|
369
|
+
grid.setAttribute('aria-busy', 'true');
|
|
370
|
+
const token = generation;
|
|
371
|
+
if (!rangeDragging) timer = setTimeout(() => loadFrames(token), 300);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function loadFrames(token) {
|
|
375
|
+
const sourceItem = item, from = first, to = last, rate = fps;
|
|
376
|
+
pendingLoad = false; displayedCount = to - from;
|
|
377
|
+
records = []; drawBoard(); syncButtons();
|
|
378
|
+
status.textContent = `Loading ${to - from} frames...`;
|
|
379
|
+
const settings = app.store.settings;
|
|
380
|
+
settingsKey = visualKey();
|
|
381
|
+
const ctrl = new AbortController(); controller = ctrl;
|
|
382
|
+
const valid = () => token === generation && active && item === sourceItem && !ctrl.signal.aborted;
|
|
383
|
+
// A separate decoder never seeks or resumes the main preview's video.
|
|
384
|
+
const decoder = h('video', { muted: true, playsInline: true, preload: 'auto', crossOrigin: 'anonymous' });
|
|
385
|
+
decoder.muted = true; decoder.src = sourceItem.source.currentSrc || sourceItem.source.src;
|
|
386
|
+
try {
|
|
387
|
+
const times = Array.from({ length: to - from }, (_, i) => Math.min((from + i + 0.5) / rate, sourceItem.duration - 1e-4));
|
|
388
|
+
await forEachFrame(decoder, { fps: rate, times, signal: ctrl.signal, onFrame: async ({ index: offset, time, total }) => {
|
|
389
|
+
if (!valid()) return;
|
|
390
|
+
const result = await app.engine.process(decoder, settings);
|
|
391
|
+
if (!valid()) return;
|
|
392
|
+
const index = from + offset, canvas = result.toCanvas(1);
|
|
393
|
+
records.push({ index, time, result, canvas });
|
|
394
|
+
if (boardState.fitted && records.length === 1) fitBoard(); else drawBoard();
|
|
395
|
+
status.textContent = `Loading ${offset + 1} / ${total} frames`;
|
|
396
|
+
syncButtons();
|
|
397
|
+
} });
|
|
398
|
+
if (!valid()) return;
|
|
399
|
+
complete = true;
|
|
400
|
+
if (boardState.fitted) fitBoard();
|
|
401
|
+
status.textContent = `${records.length} frames / ${seconds(from / rate)} - ${seconds(Math.min(sourceItem.duration, to / rate))}`;
|
|
402
|
+
} catch (e) {
|
|
403
|
+
if (valid() && e.name !== 'AbortError') {
|
|
404
|
+
status.textContent = `Could not load frames: ${e.message}`;
|
|
405
|
+
app.toast(status.textContent, { kind: 'error' });
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
decoder.pause(); decoder.removeAttribute('src'); decoder.load();
|
|
409
|
+
if (valid()) { controller = null; grid.setAttribute('aria-busy', 'false'); syncButtons(); }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function syncButtons() {
|
|
414
|
+
const n = records.filter(r => selected.has(r.index)).length;
|
|
415
|
+
selectionLabel.textContent = `${n} selected`;
|
|
416
|
+
selectAll.disabled = !complete || !records.length;
|
|
417
|
+
clear.disabled = !selected.size;
|
|
418
|
+
play.disabled = !complete || !n;
|
|
419
|
+
onSelectionChange?.({ active, complete, count: n, result: records[0]?.result || null });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function syncSelection() {
|
|
423
|
+
syncButtons();
|
|
424
|
+
if (animationRecord) {
|
|
425
|
+
const resume = playing;
|
|
426
|
+
stopAnimation();
|
|
427
|
+
animationFrames = records.filter(r => selected.has(r.index));
|
|
428
|
+
if (!animationFrames.length) { stopAnimation(true); return; }
|
|
429
|
+
animationIndex = Math.max(0, animationFrames.findIndex(r => r.index === animationRecord.index));
|
|
430
|
+
animationRecord = animationFrames[animationIndex];
|
|
431
|
+
if (resume) { startAnimation(false); return; }
|
|
432
|
+
}
|
|
433
|
+
drawBoard();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function startAnimation(focus = true) {
|
|
437
|
+
const frames = records.filter(r => selected.has(r.index));
|
|
438
|
+
if (!active || !complete || !frames.length) return;
|
|
439
|
+
cancelAnimationFrame(animationTimer);
|
|
440
|
+
animationFrames = frames;
|
|
441
|
+
animationIndex = Math.max(0, frames.findIndex(r => r.index === animationRecord?.index));
|
|
442
|
+
playing = true; animationRecord = frames[animationIndex];
|
|
443
|
+
if (focus) focusAnimation();
|
|
444
|
+
syncPlayButton();
|
|
445
|
+
const rate = playbackFps;
|
|
446
|
+
const started = performance.now() - animationIndex * 1000 / rate;
|
|
447
|
+
const tick = () => {
|
|
448
|
+
const i = Math.floor((performance.now() - started) * rate / 1000) % frames.length;
|
|
449
|
+
if (animationIndex !== i) { animationRecord = frames[i]; animationIndex = i; drawBoard(); }
|
|
450
|
+
animationTimer = requestAnimationFrame(tick);
|
|
451
|
+
};
|
|
452
|
+
drawBoard();
|
|
453
|
+
tick();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function stopAnimation(hide = false) {
|
|
457
|
+
const wasVisible = !!animationRecord;
|
|
458
|
+
cancelAnimationFrame(animationTimer); animationTimer = null; playing = false;
|
|
459
|
+
if (hide) { animationRecord = null; animationFrames = []; animationIndex = 0; }
|
|
460
|
+
syncPlayButton();
|
|
461
|
+
if (hide && wasVisible) { boardState.fitted = true; fitBoard(); return; }
|
|
462
|
+
drawBoard();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function toggleAnimation() { if (playing) stopAnimation(); else startAnimation(); }
|
|
466
|
+
|
|
467
|
+
function syncPlayButton() {
|
|
468
|
+
const label = playing ? 'Pause selected' : 'Play selected';
|
|
469
|
+
play.innerHTML = icon(playing ? 'pause' : 'play');
|
|
470
|
+
play.setAttribute('aria-label', label);
|
|
471
|
+
play.title = `${label} (Space)`;
|
|
472
|
+
play.setAttribute('aria-pressed', String(playing));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function setPlaybackFps(value) {
|
|
476
|
+
const next = clamp(Number(value) || 12, 1, 60);
|
|
477
|
+
if (next === playbackFps) return;
|
|
478
|
+
playbackFps = next;
|
|
479
|
+
if (playing) startAnimation(false); else drawBoard();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function exportSelection() {
|
|
483
|
+
if (!active) return null;
|
|
484
|
+
return { active, complete, item, settings: app.store.settings, fps: playbackFps,
|
|
485
|
+
start: first / fps, end: Math.min(item.duration, last / fps),
|
|
486
|
+
renderedFrames: records.filter(r => selected.has(r.index)).map(r => ({ result: r.result, index: r.index })) };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function initialize() {
|
|
490
|
+
const target = item;
|
|
491
|
+
status.textContent = 'Detecting video frame rate...';
|
|
492
|
+
startSlider.disabled = endSlider.disabled = startInput.disabled = endInput.disabled = true;
|
|
493
|
+
if (target.fpsReady) await target.fpsReady;
|
|
494
|
+
if (item !== target || !active) return;
|
|
495
|
+
target.source.pause(); fps = target.fps || 30;
|
|
496
|
+
const opts = animationOptions();
|
|
497
|
+
const initial = initialVideoFrameRange(target.duration, fps, opts);
|
|
498
|
+
({ count, first, last } = initial);
|
|
499
|
+
if (initial.capped) notifyCapped();
|
|
500
|
+
setPlaybackFps(opts.fps);
|
|
501
|
+
initialized = true;
|
|
502
|
+
startSlider.disabled = endSlider.disabled = startInput.disabled = endInput.disabled = false;
|
|
503
|
+
syncRange(); queueLoad();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function setMode(tiled) {
|
|
507
|
+
if (!item || item.type !== 'video' || tiled === active) return;
|
|
508
|
+
active = tiled;
|
|
509
|
+
el.hidden = transport.hidden = !active;
|
|
510
|
+
if (active) {
|
|
511
|
+
boardResize();
|
|
512
|
+
if (!item.fpsReady) item.source.pause();
|
|
513
|
+
if (!initialized) initialize();
|
|
514
|
+
else {
|
|
515
|
+
const opts = animationOptions();
|
|
516
|
+
setRange(opts.start, opts.end || item.duration);
|
|
517
|
+
setPlaybackFps(opts.fps);
|
|
518
|
+
if (!complete || settingsKey !== visualKey()) queueLoad();
|
|
519
|
+
}
|
|
520
|
+
} else { rangeDragging = false; dragEdge = null; dragPointer = null; cancelLoad(); stopAnimation(true); }
|
|
521
|
+
syncButtons();
|
|
522
|
+
onModeChange(active);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function setItem(next) {
|
|
526
|
+
if (next === item) return;
|
|
527
|
+
if (active) setMode(false);
|
|
528
|
+
cancelLoad(); stopAnimation(true);
|
|
529
|
+
item = next; records = []; displayedCount = 0; selected.clear(); drawBoard();
|
|
530
|
+
initialized = complete = false; settingsKey = '';
|
|
531
|
+
syncButtons();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function refresh() {
|
|
535
|
+
if (active && initialized && settingsKey !== visualKey()) queueLoad();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function syncZoom() {
|
|
539
|
+
zoom = boardState.z;
|
|
540
|
+
zoomLabel.textContent = boardState.fitted ? 'Fit' : zoom >= 1 ? `${Math.round(zoom * 10) / 10}\u00d7` : `${Math.round(zoom * 100)}%`;
|
|
541
|
+
zoomOut.disabled = zoom <= 0.05; zoomIn.disabled = zoom >= 128;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function zoomTo(value, cx, cy) {
|
|
545
|
+
const nextZoom = clamp(value, 0.05, 128);
|
|
546
|
+
cx ??= boardState.vw / 2; cy ??= boardState.vh / 2;
|
|
547
|
+
const px = (cx - boardState.x) / boardState.z, py = (cy - boardState.y) / boardState.z;
|
|
548
|
+
boardState.z = nextZoom; boardState.x = cx - px * nextZoom; boardState.y = cy - py * nextZoom; boardState.fitted = false;
|
|
549
|
+
drawBoard();
|
|
550
|
+
syncZoom();
|
|
551
|
+
onZoomChange?.();
|
|
552
|
+
}
|
|
553
|
+
function zoomStep(direction, cx, cy) {
|
|
554
|
+
const next = direction > 0 ? ZOOM_STEPS.find(z => z > zoom + 1e-6) || zoom * 1.5
|
|
555
|
+
: [...ZOOM_STEPS].reverse().find(z => z < zoom - 1e-6) || zoom / 1.5;
|
|
556
|
+
zoomTo(next, cx, cy);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
syncButtons();
|
|
560
|
+
return { el, transport, range,
|
|
561
|
+
get active() { return active; }, get zoom() { return zoom; }, get fitted() { return boardState.fitted; },
|
|
562
|
+
frameCanvas: index => records.find(r => r.index === index)?.canvas,
|
|
563
|
+
/** The processed frame the tiles currently show, for stats and size readouts. */
|
|
564
|
+
currentResult: () => records[0]?.result || null,
|
|
565
|
+
setItem, setMode, setRange, setPlaybackFps, exportSelection, refresh, toggleAnimation, zoomTo, zoomStep, fit: fitBoard };
|
|
566
|
+
}
|