image-to-pixel 3.0.0 → 3.1.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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -2
  3. package/dist/editor.css +1 -1
  4. package/dist/editor.css.map +2 -2
  5. package/dist/editor.esm.js +60 -60
  6. package/dist/editor.esm.js.map +4 -4
  7. package/dist/fonts/README.md +6 -6
  8. package/dist/fonts/geist-LICENSE.txt +93 -93
  9. package/dist/image-to-pixel.bundle.esm.js +16 -16
  10. package/dist/image-to-pixel.bundle.esm.js.map +3 -3
  11. package/dist/image-to-pixel.cjs +2 -2
  12. package/dist/image-to-pixel.cjs.map +2 -2
  13. package/dist/image-to-pixel.esm.js +2 -2
  14. package/dist/image-to-pixel.esm.js.map +2 -2
  15. package/dist/image-to-pixel.js +2 -2
  16. package/dist/image-to-pixel.js.map +2 -2
  17. package/dist/image-to-pixel.min.js +11 -11
  18. package/dist/image-to-pixel.min.js.map +3 -3
  19. package/dist/video.esm.js +2 -2
  20. package/dist/video.esm.js.map +4 -4
  21. package/lib/core/color.js +69 -69
  22. package/lib/core/cpu/pipeline.js +21 -21
  23. package/lib/core/cpu/stages.js +227 -227
  24. package/lib/core/dither/bluenoise-data.js +3 -3
  25. package/lib/core/dither/kernels.js +27 -27
  26. package/lib/core/dither/matrices.js +95 -95
  27. package/lib/core/engine.js +158 -158
  28. package/lib/core/export/canvas.js +22 -22
  29. package/lib/core/export/crc32.js +15 -15
  30. package/lib/core/export/gif.js +153 -153
  31. package/lib/core/export/png.js +94 -94
  32. package/lib/core/export/scale.js +103 -103
  33. package/lib/core/export/sheet.js +95 -95
  34. package/lib/core/export/zip.js +55 -55
  35. package/lib/core/gpu/backend.js +159 -159
  36. package/lib/core/gpu/shaders.js +258 -258
  37. package/lib/core/palette/builtin.js +36 -36
  38. package/lib/core/palette/extract.js +69 -69
  39. package/lib/core/palette/lospec.js +24 -24
  40. package/lib/core/palette/parse.js +112 -112
  41. package/lib/core/plan.js +58 -58
  42. package/lib/core/presets.js +42 -42
  43. package/lib/core/settings.js +274 -274
  44. package/lib/core/source.js +89 -89
  45. package/lib/core/video/audio.js +159 -0
  46. package/lib/core/video/frames.js +38 -0
  47. package/lib/core/video/mux-mp4.js +213 -24
  48. package/lib/core/video/mux-webm.js +63 -22
  49. package/lib/editor/controls.js +139 -139
  50. package/lib/editor/dom.js +65 -65
  51. package/lib/editor/exporter.js +43 -3
  52. package/lib/editor/icons.js +4 -0
  53. package/lib/editor/index.js +218 -9
  54. package/lib/editor/inspector.js +19 -4
  55. package/lib/editor/library.js +8 -2
  56. package/lib/editor/persist.js +18 -18
  57. package/lib/editor/samples.js +90 -90
  58. package/lib/editor/stage.js +239 -239
  59. package/lib/editor/store.js +80 -80
  60. package/lib/editor/styles/editor.css +34 -1
  61. package/lib/editor/styles/fonts/README.md +6 -6
  62. package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
  63. package/lib/editor/styles/tokens.css +89 -89
  64. package/lib/editor/ui.js +97 -97
  65. package/lib/editor/workspace.js +62 -0
  66. package/lib/index.js +31 -29
  67. package/lib/video.js +37 -7
  68. package/package.json +1 -1
  69. package/types/editor.d.ts +34 -34
  70. package/types/index.d.ts +196 -196
  71. package/types/video.d.ts +61 -5
@@ -1,139 +1,139 @@
1
- // Inspector controls. Each returns { el, set(value) } so the inspector can sync from state
2
- // without rebuilding (which would steal focus mid-drag).
3
-
4
- import { h, nextId } from './dom.js';
5
- import { icon } from './icons.js';
6
-
7
- /**
8
- * Collapsible inspector section: a header button (works the same in every browser, unlike
9
- * <details>) plus an optional reset icon that appears when the section differs from its defaults.
10
- * @param {string} title
11
- * @param {{ open?: boolean, onToggle?: (open: boolean) => void, onReset?: () => void, resetLabel?: string }} opts
12
- */
13
- export function section(title, opts = {}, ...children) {
14
- const id = nextId('sec');
15
- const hintEl = h('span.hint', {});
16
- const toggle = h('button.itp-sec-toggle', { type: 'button', 'aria-controls': id },
17
- h('span.title', { text: title }), hintEl, h('span.chev', { html: icon('chevron') }));
18
- const reset = opts.onReset ? h('button.itp-btn.small.icon.itp-sec-reset', {
19
- type: 'button', hidden: true, title: opts.resetLabel || `Reset ${title.toLowerCase()}`, 'aria-label': opts.resetLabel || `Reset ${title.toLowerCase()} to defaults`,
20
- html: icon('reset'), onclick: () => opts.onReset(),
21
- }) : null;
22
- const body = h('div.body', { id }, ...children);
23
- const el = h('section.itp-sec', {}, h('div.itp-sec-head', {}, toggle, reset), body);
24
- const setOpen = open => {
25
- el.dataset.open = String(open);
26
- toggle.setAttribute('aria-expanded', String(open));
27
- body.hidden = !open;
28
- };
29
- toggle.addEventListener('click', () => { const open = el.dataset.open !== 'true'; setOpen(open); opts.onToggle?.(open); });
30
- setOpen(opts.open !== false);
31
- return {
32
- el, body,
33
- setHint(t) { hintEl.textContent = t; },
34
- /** Show the reset icon when the section's settings differ from the defaults. */
35
- setChanged(changed) { if (reset) reset.hidden = !changed; },
36
- setOpen,
37
- get open() { return el.dataset.open === 'true'; },
38
- };
39
- }
40
-
41
- /** Labelled field wrapper. */
42
- export function field(label, control, { help = '', value = null, id } = {}) {
43
- const lab = h('div.lab', {}, h('label', { for: id || control.id || null, text: label }), value);
44
- return h('div.itp-field', {}, lab, control, help ? h('div.help', { text: help }) : null);
45
- }
46
-
47
- /**
48
- * Slider + number box.
49
- * @param {{ label: string, min: number, max: number, step?: number, value: number, unit?: string,
50
- * scale?: number, help?: string, onInput: (v:number)=>void, onCommit?: ()=>void }} o
51
- * scale: display multiplier (0..1 shown as 0..100 with scale 100)
52
- */
53
- export function slider(o) {
54
- const id = nextId('sl');
55
- const k = o.scale || 1;
56
- const step = o.step || 1;
57
- const range = h('input', { type: 'range', id, min: o.min * k, max: o.max * k, step: step * k, 'aria-label': o.label });
58
- const num = h('input.itp-input', { type: 'number', min: o.min * k, max: o.max * k, step: step * k, 'aria-label': `${o.label} value` });
59
- const paint = () => { const p = (range.value - range.min) / (range.max - range.min) * 100; range.style.setProperty('--p', `${p}%`); };
60
- const set = v => { const d = round(v * k, step * k); range.value = d; num.value = d; paint(); };
61
- range.addEventListener('input', () => { num.value = range.value; paint(); o.onInput(Number(range.value) / k); });
62
- range.addEventListener('change', () => o.onCommit && o.onCommit());
63
- num.addEventListener('change', () => {
64
- const v = Math.min(o.max * k, Math.max(o.min * k, Number(num.value) || 0));
65
- set(v / k); o.onInput(v / k); o.onCommit && o.onCommit();
66
- });
67
- range.addEventListener('dblclick', () => { if (o.reset != null) { set(o.reset); o.onInput(o.reset); o.onCommit && o.onCommit(); } });
68
- set(o.value);
69
- const el = field(o.label, h('div.itp-slider', {}, range, num), { help: o.help, id });
70
- return { el, set, input: range };
71
- }
72
-
73
- const round = (v, step) => {
74
- const d = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
75
- return Number((Math.round(v / step) * step).toFixed(d));
76
- };
77
-
78
- /** <select> with options [{ value, label, group? }]. */
79
- export function select(o) {
80
- const id = nextId('se');
81
- const el = h('select.itp-select', { id, 'aria-label': o.label });
82
- const groups = new Map();
83
- for (const opt of o.options) {
84
- const node = h('option', { value: opt.value, text: opt.label });
85
- if (opt.group) {
86
- if (!groups.has(opt.group)) { const g = h('optgroup', { label: opt.group }); groups.set(opt.group, g); el.append(g); }
87
- groups.get(opt.group).append(node);
88
- } else el.append(node);
89
- }
90
- el.addEventListener('change', () => o.onChange(el.value));
91
- const wrapped = o.bare ? el : field(o.label, el, { help: o.help, id });
92
- return { el: wrapped, select: el, set(v) { el.value = v; } };
93
- }
94
-
95
- /** Segmented toggle [{ value, label, icon?, title? }]. */
96
- export function segmented(o) {
97
- const btns = o.options.map(opt => h('button', {
98
- type: 'button', 'aria-pressed': 'false', title: opt.title || null,
99
- html: (opt.icon ? icon(opt.icon) : '') + (opt.label ? `<span>${opt.label}</span>` : ''),
100
- onclick: () => o.onChange(opt.value),
101
- }));
102
- const seg = h('div.itp-seg', { role: 'group', 'aria-label': o.label }, ...btns);
103
- const set = v => btns.forEach((b, i) => b.setAttribute('aria-pressed', String(o.options[i].value === v)));
104
- set(o.value);
105
- const el = o.bare ? seg : field(o.label, seg, { help: o.help });
106
- return { el, set };
107
- }
108
-
109
- /** On/off switch. */
110
- export function toggle(o) {
111
- const input = h('input', { type: 'checkbox', role: 'switch' });
112
- input.addEventListener('change', () => o.onChange(input.checked));
113
- const lab = h('label.itp-switch', {}, h('span', { text: o.label }), input);
114
- const el = o.help ? h('div.itp-field', {}, lab, h('div.help', { text: o.help })) : lab;
115
- return { el, set(v) { input.checked = !!v; } };
116
- }
117
-
118
- /** Colour input with hex readout. */
119
- export function colorInput(o) {
120
- const input = h('input', { type: 'color', 'aria-label': o.label });
121
- const hex = h('span.mono', {});
122
- input.addEventListener('input', () => { hex.textContent = input.value; o.onChange(input.value); });
123
- const box = h('label.itp-color', {}, input, hex);
124
- const set = v => { input.value = v; hex.textContent = v; };
125
- set(o.value || '#000000');
126
- return { el: o.bare ? box : field(o.label, box), set };
127
- }
128
-
129
- /** Number box (no slider). */
130
- export function numberInput(o) {
131
- const id = nextId('nu');
132
- const input = h('input.itp-input.num', { type: 'number', id, min: o.min, max: o.max, step: o.step || 1, 'aria-label': o.label });
133
- input.addEventListener('change', () => {
134
- const v = Math.min(o.max, Math.max(o.min, Number(input.value) || o.min));
135
- if (input.value !== '' || !input.placeholder) input.value = v;
136
- o.onChange(v);
137
- });
138
- return { el: o.bare ? input : field(o.label, input, { help: o.help, id }), input, set(v) { input.value = v; } };
139
- }
1
+ // Inspector controls. Each returns { el, set(value) } so the inspector can sync from state
2
+ // without rebuilding (which would steal focus mid-drag).
3
+
4
+ import { h, nextId } from './dom.js';
5
+ import { icon } from './icons.js';
6
+
7
+ /**
8
+ * Collapsible inspector section: a header button (works the same in every browser, unlike
9
+ * <details>) plus an optional reset icon that appears when the section differs from its defaults.
10
+ * @param {string} title
11
+ * @param {{ open?: boolean, onToggle?: (open: boolean) => void, onReset?: () => void, resetLabel?: string }} opts
12
+ */
13
+ export function section(title, opts = {}, ...children) {
14
+ const id = nextId('sec');
15
+ const hintEl = h('span.hint', {});
16
+ const toggle = h('button.itp-sec-toggle', { type: 'button', 'aria-controls': id },
17
+ h('span.title', { text: title }), hintEl, h('span.chev', { html: icon('chevron') }));
18
+ const reset = opts.onReset ? h('button.itp-btn.small.icon.itp-sec-reset', {
19
+ type: 'button', hidden: true, title: opts.resetLabel || `Reset ${title.toLowerCase()}`, 'aria-label': opts.resetLabel || `Reset ${title.toLowerCase()} to defaults`,
20
+ html: icon('reset'), onclick: () => opts.onReset(),
21
+ }) : null;
22
+ const body = h('div.body', { id }, ...children);
23
+ const el = h('section.itp-sec', {}, h('div.itp-sec-head', {}, toggle, reset), body);
24
+ const setOpen = open => {
25
+ el.dataset.open = String(open);
26
+ toggle.setAttribute('aria-expanded', String(open));
27
+ body.hidden = !open;
28
+ };
29
+ toggle.addEventListener('click', () => { const open = el.dataset.open !== 'true'; setOpen(open); opts.onToggle?.(open); });
30
+ setOpen(opts.open !== false);
31
+ return {
32
+ el, body,
33
+ setHint(t) { hintEl.textContent = t; },
34
+ /** Show the reset icon when the section's settings differ from the defaults. */
35
+ setChanged(changed) { if (reset) reset.hidden = !changed; },
36
+ setOpen,
37
+ get open() { return el.dataset.open === 'true'; },
38
+ };
39
+ }
40
+
41
+ /** Labelled field wrapper. */
42
+ export function field(label, control, { help = '', value = null, id } = {}) {
43
+ const lab = h('div.lab', {}, h('label', { for: id || control.id || null, text: label }), value);
44
+ return h('div.itp-field', {}, lab, control, help ? h('div.help', { text: help }) : null);
45
+ }
46
+
47
+ /**
48
+ * Slider + number box.
49
+ * @param {{ label: string, min: number, max: number, step?: number, value: number, unit?: string,
50
+ * scale?: number, help?: string, onInput: (v:number)=>void, onCommit?: ()=>void }} o
51
+ * scale: display multiplier (0..1 shown as 0..100 with scale 100)
52
+ */
53
+ export function slider(o) {
54
+ const id = nextId('sl');
55
+ const k = o.scale || 1;
56
+ const step = o.step || 1;
57
+ const range = h('input', { type: 'range', id, min: o.min * k, max: o.max * k, step: step * k, 'aria-label': o.label });
58
+ const num = h('input.itp-input', { type: 'number', min: o.min * k, max: o.max * k, step: step * k, 'aria-label': `${o.label} value` });
59
+ const paint = () => { const p = (range.value - range.min) / (range.max - range.min) * 100; range.style.setProperty('--p', `${p}%`); };
60
+ const set = v => { const d = round(v * k, step * k); range.value = d; num.value = d; paint(); };
61
+ range.addEventListener('input', () => { num.value = range.value; paint(); o.onInput(Number(range.value) / k); });
62
+ range.addEventListener('change', () => o.onCommit && o.onCommit());
63
+ num.addEventListener('change', () => {
64
+ const v = Math.min(o.max * k, Math.max(o.min * k, Number(num.value) || 0));
65
+ set(v / k); o.onInput(v / k); o.onCommit && o.onCommit();
66
+ });
67
+ range.addEventListener('dblclick', () => { if (o.reset != null) { set(o.reset); o.onInput(o.reset); o.onCommit && o.onCommit(); } });
68
+ set(o.value);
69
+ const el = field(o.label, h('div.itp-slider', {}, range, num), { help: o.help, id });
70
+ return { el, set, input: range };
71
+ }
72
+
73
+ const round = (v, step) => {
74
+ const d = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
75
+ return Number((Math.round(v / step) * step).toFixed(d));
76
+ };
77
+
78
+ /** <select> with options [{ value, label, group? }]. */
79
+ export function select(o) {
80
+ const id = nextId('se');
81
+ const el = h('select.itp-select', { id, 'aria-label': o.label });
82
+ const groups = new Map();
83
+ for (const opt of o.options) {
84
+ const node = h('option', { value: opt.value, text: opt.label });
85
+ if (opt.group) {
86
+ if (!groups.has(opt.group)) { const g = h('optgroup', { label: opt.group }); groups.set(opt.group, g); el.append(g); }
87
+ groups.get(opt.group).append(node);
88
+ } else el.append(node);
89
+ }
90
+ el.addEventListener('change', () => o.onChange(el.value));
91
+ const wrapped = o.bare ? el : field(o.label, el, { help: o.help, id });
92
+ return { el: wrapped, select: el, set(v) { el.value = v; } };
93
+ }
94
+
95
+ /** Segmented toggle [{ value, label, icon?, title? }]. */
96
+ export function segmented(o) {
97
+ const btns = o.options.map(opt => h('button', {
98
+ type: 'button', 'aria-pressed': 'false', title: opt.title || null,
99
+ html: (opt.icon ? icon(opt.icon) : '') + (opt.label ? `<span>${opt.label}</span>` : ''),
100
+ onclick: () => o.onChange(opt.value),
101
+ }));
102
+ const seg = h('div.itp-seg', { role: 'group', 'aria-label': o.label }, ...btns);
103
+ const set = v => btns.forEach((b, i) => b.setAttribute('aria-pressed', String(o.options[i].value === v)));
104
+ set(o.value);
105
+ const el = o.bare ? seg : field(o.label, seg, { help: o.help });
106
+ return { el, set };
107
+ }
108
+
109
+ /** On/off switch. */
110
+ export function toggle(o) {
111
+ const input = h('input', { type: 'checkbox', role: 'switch' });
112
+ input.addEventListener('change', () => o.onChange(input.checked));
113
+ const lab = h('label.itp-switch', {}, h('span', { text: o.label }), input);
114
+ const el = o.help ? h('div.itp-field', {}, lab, h('div.help', { text: o.help })) : lab;
115
+ return { el, set(v) { input.checked = !!v; } };
116
+ }
117
+
118
+ /** Colour input with hex readout. */
119
+ export function colorInput(o) {
120
+ const input = h('input', { type: 'color', 'aria-label': o.label });
121
+ const hex = h('span.mono', {});
122
+ input.addEventListener('input', () => { hex.textContent = input.value; o.onChange(input.value); });
123
+ const box = h('label.itp-color', {}, input, hex);
124
+ const set = v => { input.value = v; hex.textContent = v; };
125
+ set(o.value || '#000000');
126
+ return { el: o.bare ? box : field(o.label, box), set };
127
+ }
128
+
129
+ /** Number box (no slider). */
130
+ export function numberInput(o) {
131
+ const id = nextId('nu');
132
+ const input = h('input.itp-input.num', { type: 'number', id, min: o.min, max: o.max, step: o.step || 1, 'aria-label': o.label });
133
+ input.addEventListener('change', () => {
134
+ const v = Math.min(o.max, Math.max(o.min, Number(input.value) || o.min));
135
+ if (input.value !== '' || !input.placeholder) input.value = v;
136
+ o.onChange(v);
137
+ });
138
+ return { el: o.bare ? input : field(o.label, input, { help: o.help, id }), input, set(v) { input.value = v; } };
139
+ }
package/lib/editor/dom.js CHANGED
@@ -1,65 +1,65 @@
1
- // Tiny DOM helpers. No framework: the editor is small enough to wire by hand.
2
-
3
- /** Create an element: h('button.itp-btn.icon', { onclick, title }, child…) */
4
- export function h(tag, props = {}, ...children) {
5
- const [name, ...classes] = tag.split('.');
6
- const el = document.createElement(name || 'div');
7
- if (classes.length) el.className = classes.join(' ');
8
- for (const [k, v] of Object.entries(props || {})) {
9
- if (v == null || v === false) continue;
10
- if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
11
- else if (k === 'html') el.innerHTML = v;
12
- else if (k === 'text') el.textContent = v;
13
- else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
14
- else if (k === 'dataset') Object.assign(el.dataset, v);
15
- else if (k in el && typeof v !== 'string' && k !== 'list') el[k] = v;
16
- else el.setAttribute(k, v === true ? '' : v);
17
- }
18
- append(el, children);
19
- return el;
20
- }
21
-
22
- export function append(el, children) {
23
- for (const c of children.flat(Infinity)) {
24
- if (c == null || c === false) continue;
25
- el.append(c instanceof Node ? c : document.createTextNode(String(c)));
26
- }
27
- return el;
28
- }
29
-
30
- /** Replace all children. */
31
- export function render(el, ...children) { el.replaceChildren(); return append(el, children); }
32
-
33
- export const $ = (sel, root = document) => root.querySelector(sel);
34
-
35
- /** Element from an HTML string (single root). */
36
- export function html(str) {
37
- const t = document.createElement('template');
38
- t.innerHTML = str.trim();
39
- return t.content.firstElementChild;
40
- }
41
-
42
- export const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
43
-
44
- let uid = 0;
45
- export const nextId = (p = 'itp') => `${p}-${++uid}`;
46
-
47
- /** Debounce helper. */
48
- export function debounce(fn, ms) {
49
- let t;
50
- return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
51
- }
52
-
53
- /** Read a File as text. */
54
- export const readText = file => file.text();
55
-
56
- /** Open a file picker and resolve with the chosen files. */
57
- export function pickFiles({ accept = '', multiple = false } = {}) {
58
- return new Promise(res => {
59
- const input = h('input', { type: 'file', accept, multiple, style: { display: 'none' } });
60
- input.addEventListener('change', () => { res([...input.files]); input.remove(); });
61
- input.addEventListener('cancel', () => { res([]); input.remove(); });
62
- document.body.append(input);
63
- input.click();
64
- });
65
- }
1
+ // Tiny DOM helpers. No framework: the editor is small enough to wire by hand.
2
+
3
+ /** Create an element: h('button.itp-btn.icon', { onclick, title }, child…) */
4
+ export function h(tag, props = {}, ...children) {
5
+ const [name, ...classes] = tag.split('.');
6
+ const el = document.createElement(name || 'div');
7
+ if (classes.length) el.className = classes.join(' ');
8
+ for (const [k, v] of Object.entries(props || {})) {
9
+ if (v == null || v === false) continue;
10
+ if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
11
+ else if (k === 'html') el.innerHTML = v;
12
+ else if (k === 'text') el.textContent = v;
13
+ else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
14
+ else if (k === 'dataset') Object.assign(el.dataset, v);
15
+ else if (k in el && typeof v !== 'string' && k !== 'list') el[k] = v;
16
+ else el.setAttribute(k, v === true ? '' : v);
17
+ }
18
+ append(el, children);
19
+ return el;
20
+ }
21
+
22
+ export function append(el, children) {
23
+ for (const c of children.flat(Infinity)) {
24
+ if (c == null || c === false) continue;
25
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
26
+ }
27
+ return el;
28
+ }
29
+
30
+ /** Replace all children. */
31
+ export function render(el, ...children) { el.replaceChildren(); return append(el, children); }
32
+
33
+ export const $ = (sel, root = document) => root.querySelector(sel);
34
+
35
+ /** Element from an HTML string (single root). */
36
+ export function html(str) {
37
+ const t = document.createElement('template');
38
+ t.innerHTML = str.trim();
39
+ return t.content.firstElementChild;
40
+ }
41
+
42
+ export const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
43
+
44
+ let uid = 0;
45
+ export const nextId = (p = 'itp') => `${p}-${++uid}`;
46
+
47
+ /** Debounce helper. */
48
+ export function debounce(fn, ms) {
49
+ let t;
50
+ return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
51
+ }
52
+
53
+ /** Read a File as text. */
54
+ export const readText = file => file.text();
55
+
56
+ /** Open a file picker and resolve with the chosen files. */
57
+ export function pickFiles({ accept = '', multiple = false } = {}) {
58
+ return new Promise(res => {
59
+ const input = h('input', { type: 'file', accept, multiple, style: { display: 'none' } });
60
+ input.addEventListener('change', () => { res([...input.files]); input.remove(); });
61
+ input.addEventListener('cancel', () => { res([]); input.remove(); });
62
+ document.body.append(input);
63
+ input.click();
64
+ });
65
+ }
@@ -20,6 +20,23 @@ export function exportSize(s, r, item) {
20
20
  /** PNG options for one exported image. */
21
21
  const imageOpts = (s, r, item) => (s.export.size === 'source' && item ? { ...exportSize(s, r, item), indexed: s.export.indexed } : pngOpts(s));
22
22
 
23
+ /**
24
+ * Whole-number scales to try for a video, best first. 'scale' = the PNG export scale; otherwise a target
25
+ * height ('1080', '720', '2160', measured on the short side), falling back to smaller sizes if the
26
+ * browser's encoder refuses the big one. Never below 240 px tall.
27
+ */
28
+ export function videoScales(size, r, exportScale = 4) {
29
+ const min = Math.max(1, Math.ceil(240 / Math.min(r.width, r.height)));
30
+ const fit = target => {
31
+ const long = Math.round(target * 16 / 9), portrait = r.height > r.width;
32
+ const W = portrait ? target : long, H = portrait ? long : target;
33
+ return Math.max(min, Math.min(Math.floor(W / r.width), Math.floor(H / r.height)));
34
+ };
35
+ const first = size === 'scale' ? Math.max(min, exportScale) : fit(Number(size) || 1080);
36
+ const list = [first, fit(1080), fit(720), min].filter((v, i, a) => v <= first && a.indexOf(v) === i);
37
+ return list.sort((a, b) => b - a);
38
+ }
39
+
23
40
  export function createExporter(app) {
24
41
  const S = () => app.store.settings;
25
42
 
@@ -139,15 +156,38 @@ export function createExporter(app) {
139
156
  }
140
157
  // MP4 / WebM through WebCodecs
141
158
  await task(`Encoding ${opts.format.toUpperCase()}`, async (signal, progress) => {
142
- const { createVideoEncoder } = await import('../video.js');
159
+ const { createVideoEncoder, decodeAudio } = await import('../video.js');
160
+ // Keep audio: the video's own soundtrack, cut to the exported range (null when it has none)
161
+ const item = app.current;
162
+ let audio = null;
163
+ if (opts.audio && opts.source === 'video' && item && item.type === 'video' && item.file) {
164
+ progress(0, 'Reading audio');
165
+ const end = opts.end > opts.start ? opts.end : item.source.duration;
166
+ audio = await decodeAudio(item.file, { start: opts.start, end }).catch(e => { console.warn('[export] audio', e); return null; });
167
+ }
143
168
  let enc = null;
144
169
  await frames(opts, signal, progress, async r => {
145
- enc ||= await createVideoEncoder({ format: opts.format, fps: opts.fps, width: r.width, height: r.height, scale: Math.max(s.export.scale, Math.ceil(240 / r.height)) });
170
+ if (!enc) {
171
+ // Players stretch small videos with smoothing, which blurs pixel art. So the frames are upscaled
172
+ // by a whole number to about the chosen size first (and H.264's colour subsampling stays inside each pixel).
173
+ let tries = videoScales(opts.videoSize, r, s.export.scale), err = null;
174
+ for (const scale of tries) {
175
+ try { enc = await createVideoEncoder({ format: opts.format, fps: opts.fps, width: r.width, height: r.height, scale, audio }); break; }
176
+ catch (e) { err = e; if (!/encoder available/.test(e.message)) throw e; }
177
+ }
178
+ if (!enc) throw err;
179
+ }
146
180
  await enc.add(r);
147
181
  });
182
+ if (audio && enc.audioCodec) progress(1, 'Encoding audio');
148
183
  const blob = await enc.finish();
149
184
  downloadBlob(blob, `${name}.${opts.format}`);
150
- return `Saved ${opts.format.toUpperCase()} (${(blob.size / 1024 / 1024).toFixed(1)} MB)`;
185
+ const sound = !opts.audio || opts.source !== 'video' ? ''
186
+ : enc.audioCodec === 'opus' && opts.format === 'mp4' ? ', with Opus audio: VLC and browsers play it, Windows Media Player and QuickTime may not'
187
+ : enc.audioCodec ? ', with audio'
188
+ : audio ? ", without audio (this browser can't encode it)"
189
+ : ', no audio track in the source';
190
+ return `Saved ${opts.format.toUpperCase()} (${(blob.size / 1024 / 1024).toFixed(1)} MB${sound})`;
151
191
  });
152
192
  }
153
193
 
@@ -29,6 +29,10 @@ const P = {
29
29
  pause: '<path d="M8.5 5.5v13M15.5 5.5v13"/>',
30
30
  stepBack: '<path d="M7 5.5v13M18 6l-8 6 8 6V6Z"/>',
31
31
  stepForward: '<path d="M17 5.5v13M6 6l8 6-8 6V6Z"/>',
32
+ save: '<path d="M5 4.5h11l3.5 3.5v10A1.5 1.5 0 0 1 18 19.5H6A1.5 1.5 0 0 1 4.5 18V6A1.5 1.5 0 0 1 6 4.5Z"/><path d="M8 4.5v4h7v-4M8 19.5v-6h8v6"/>',
33
+ rename: '<path d="M4 20h4L19 9l-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>',
34
+ volume: '<path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4z"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10"/>',
35
+ volumeOff: '<path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4z"/><path d="m16 9.5 5 5M21 9.5l-5 5"/>',
32
36
  trash: '<path d="M4.5 7h15M9.5 7V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2M6.5 7l1 12a1.5 1.5 0 0 0 1.5 1.4h6a1.5 1.5 0 0 0 1.5-1.4l1-12"/>',
33
37
  link: '<path d="M10 14a4 4 0 0 0 5.7 0l2.8-2.8a4 4 0 0 0-5.7-5.7L11.5 6.8"/><path d="M14 10a4 4 0 0 0-5.7 0l-2.8 2.8a4 4 0 0 0 5.7 5.7l1.3-1.3"/>',
34
38
  braces: '<path d="M8.5 4.5H8A2 2 0 0 0 6 6.5v3A2.5 2.5 0 0 1 3.5 12 2.5 2.5 0 0 1 6 14.5v3a2 2 0 0 0 2 2h.5M15.5 4.5h.5a2 2 0 0 1 2 2v3a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0-2.5 2.5v3a2 2 0 0 1-2 2h-.5"/>',