@buntui/extensions 0.0.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/dist/framerate.d.ts +3 -0
- package/dist/framerate.js +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +2 -0
- package/dist/matrix.d.ts +3 -0
- package/dist/matrix.js +4 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +7 -0
- package/dist/snake.d.ts +3 -0
- package/dist/snake.js +4 -0
- package/dist/utils/color.d.ts +10 -0
- package/dist/utils/color.js +35 -0
- package/dist/videoplayer.d.ts +3 -0
- package/dist/videoplayer.js +4 -0
- package/dist/widgets/framerate/FrameRateWatcher.d.ts +20 -0
- package/dist/widgets/framerate/FrameRateWatcher.js +87 -0
- package/dist/widgets/logger/LoggerWidget.d.ts +33 -0
- package/dist/widgets/logger/LoggerWidget.js +170 -0
- package/dist/widgets/matrix/MatrixWidget.d.ts +13 -0
- package/dist/widgets/matrix/MatrixWidget.js +139 -0
- package/dist/widgets/matrix/charset.d.ts +1 -0
- package/dist/widgets/matrix/charset.js +11 -0
- package/dist/widgets/matrix/defaults.d.ts +3 -0
- package/dist/widgets/matrix/defaults.js +17 -0
- package/dist/widgets/matrix/matrix-column.d.ts +24 -0
- package/dist/widgets/matrix/matrix-column.js +58 -0
- package/dist/widgets/matrix/types.d.ts +38 -0
- package/dist/widgets/matrix/types.js +0 -0
- package/dist/widgets/snake/SnakeWidget.d.ts +14 -0
- package/dist/widgets/snake/SnakeWidget.js +384 -0
- package/dist/widgets/snake/defaults.d.ts +3 -0
- package/dist/widgets/snake/defaults.js +19 -0
- package/dist/widgets/snake/types.d.ts +25 -0
- package/dist/widgets/snake/types.js +0 -0
- package/dist/widgets/videoplayer/VideoPlayerWidget.d.ts +16 -0
- package/dist/widgets/videoplayer/VideoPlayerWidget.js +465 -0
- package/dist/widgets/videoplayer/braille.d.ts +18 -0
- package/dist/widgets/videoplayer/braille.js +69 -0
- package/dist/widgets/videoplayer/defaults.d.ts +3 -0
- package/dist/widgets/videoplayer/defaults.js +17 -0
- package/dist/widgets/videoplayer/types.d.ts +21 -0
- package/dist/widgets/videoplayer/types.js +0 -0
- package/package.json +50 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { widgets, } from '@buntui/core';
|
|
2
|
+
import { encodeBrailleFrame, isVideoFile } from './braille';
|
|
3
|
+
import { DEFAULT_VIDEOPLAYER_COLOR_SCHEME, DEFAULT_VIDEOPLAYER_OPTIONS } from './defaults';
|
|
4
|
+
export class VideoPlayerWidget extends widgets.InteractiveWidget {
|
|
5
|
+
#x;
|
|
6
|
+
#y;
|
|
7
|
+
#width;
|
|
8
|
+
#height;
|
|
9
|
+
#src;
|
|
10
|
+
#colorScheme;
|
|
11
|
+
#loop;
|
|
12
|
+
#threshold;
|
|
13
|
+
#invert;
|
|
14
|
+
#targetFps;
|
|
15
|
+
#audioSrc;
|
|
16
|
+
#audioTempFile;
|
|
17
|
+
#frameData;
|
|
18
|
+
#frameCols = 0;
|
|
19
|
+
#frameRows = 0;
|
|
20
|
+
#frameCount = 0;
|
|
21
|
+
#fps = 30;
|
|
22
|
+
#frameInterval = 33;
|
|
23
|
+
#playerState = 'loading';
|
|
24
|
+
#currentFrame = 0;
|
|
25
|
+
#accumulator = 0;
|
|
26
|
+
#loadingProgress = '';
|
|
27
|
+
#audioProcess;
|
|
28
|
+
#ffmpegProcess;
|
|
29
|
+
#loading = false;
|
|
30
|
+
get #frameOffsetMs() {
|
|
31
|
+
return (this.#currentFrame / this.#fps) * 1000;
|
|
32
|
+
}
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
super();
|
|
35
|
+
const resolved = { ...DEFAULT_VIDEOPLAYER_OPTIONS, ...options };
|
|
36
|
+
const rect = this.initRect(resolved.x, resolved.y, resolved.width, resolved.height);
|
|
37
|
+
this.#x = rect.x;
|
|
38
|
+
this.#y = rect.y;
|
|
39
|
+
this.#width = rect.width;
|
|
40
|
+
this.#height = rect.height;
|
|
41
|
+
const schemeOverride = resolved.colorScheme ?? {};
|
|
42
|
+
this.#colorScheme = {
|
|
43
|
+
dotRgba: schemeOverride.dotRgba ?? DEFAULT_VIDEOPLAYER_COLOR_SCHEME.dotRgba,
|
|
44
|
+
bgRgba: schemeOverride.bgRgba ?? DEFAULT_VIDEOPLAYER_COLOR_SCHEME.bgRgba,
|
|
45
|
+
textRgba: schemeOverride.textRgba ?? DEFAULT_VIDEOPLAYER_COLOR_SCHEME.textRgba,
|
|
46
|
+
};
|
|
47
|
+
this.#src = resolved.src;
|
|
48
|
+
this.#loop = resolved.loop ?? false;
|
|
49
|
+
this.#threshold = resolved.threshold ?? 128;
|
|
50
|
+
this.#invert = resolved.invert ?? false;
|
|
51
|
+
this.#targetFps = resolved.fps ?? 30;
|
|
52
|
+
if (resolved.audioSrc) {
|
|
53
|
+
this.#audioSrc = resolved.audioSrc;
|
|
54
|
+
}
|
|
55
|
+
else if (resolved.src && isVideoFile(resolved.src)) {
|
|
56
|
+
this.#audioSrc = resolved.src;
|
|
57
|
+
}
|
|
58
|
+
else if (resolved.src) {
|
|
59
|
+
this.#audioSrc = resolved.src.replace(/\.bin$/v, '.mp3');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
this.#audioSrc = undefined;
|
|
63
|
+
}
|
|
64
|
+
if (resolved.data) {
|
|
65
|
+
this.#parseBinData(resolved.data);
|
|
66
|
+
}
|
|
67
|
+
else if (!resolved.src) {
|
|
68
|
+
this.#playerState = 'error';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
mounted() {
|
|
72
|
+
super.mounted();
|
|
73
|
+
}
|
|
74
|
+
unmounted() {
|
|
75
|
+
this.#stopAudio();
|
|
76
|
+
this.#killFfmpeg();
|
|
77
|
+
this.#cleanupTempAudio();
|
|
78
|
+
super.unmounted();
|
|
79
|
+
}
|
|
80
|
+
updateRect(rect) {
|
|
81
|
+
if (rect.x !== undefined) {
|
|
82
|
+
this.#x = rect.x;
|
|
83
|
+
}
|
|
84
|
+
if (rect.y !== undefined) {
|
|
85
|
+
this.#y = rect.y;
|
|
86
|
+
}
|
|
87
|
+
if (rect.width !== undefined) {
|
|
88
|
+
this.#width = rect.width;
|
|
89
|
+
}
|
|
90
|
+
if (rect.height !== undefined) {
|
|
91
|
+
this.#height = rect.height;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
containsPoint(x, y) {
|
|
95
|
+
return x >= this.#x
|
|
96
|
+
&& x < this.#x + this.#width
|
|
97
|
+
&& y >= this.#y
|
|
98
|
+
&& y < this.#y + this.#height;
|
|
99
|
+
}
|
|
100
|
+
get rect() {
|
|
101
|
+
return {
|
|
102
|
+
x: this.#x, y: this.#y, width: this.#width, height: this.#height,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
handleKey(event) {
|
|
106
|
+
const { key } = event;
|
|
107
|
+
if (!key) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (key === ' ') {
|
|
111
|
+
switch (this.#playerState) {
|
|
112
|
+
case 'ready':
|
|
113
|
+
case 'ended': {
|
|
114
|
+
if (this.#playerState === 'ended') {
|
|
115
|
+
this.#currentFrame = 0;
|
|
116
|
+
}
|
|
117
|
+
this.#playerState = 'playing';
|
|
118
|
+
this.#accumulator = 0;
|
|
119
|
+
this.#startAudio(0);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'playing': {
|
|
123
|
+
this.#playerState = 'paused';
|
|
124
|
+
this.#stopAudio();
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'paused': {
|
|
128
|
+
this.#startAudio(this.#frameOffsetMs);
|
|
129
|
+
this.#playerState = 'playing';
|
|
130
|
+
this.#accumulator = 0;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'loading':
|
|
134
|
+
case 'error': {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (key === 'r' || key === 'R') {
|
|
140
|
+
this.#currentFrame = 0;
|
|
141
|
+
this.#stopAudio();
|
|
142
|
+
this.#playerState = 'ready';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
update(dt) {
|
|
146
|
+
if (this.#playerState !== 'playing' || !this.#frameData) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.#accumulator += dt;
|
|
150
|
+
if (this.#accumulator >= this.#frameInterval) {
|
|
151
|
+
this.#accumulator -= this.#frameInterval;
|
|
152
|
+
this.#currentFrame++;
|
|
153
|
+
if (this.#currentFrame >= this.#frameCount) {
|
|
154
|
+
if (this.#loop) {
|
|
155
|
+
this.#currentFrame = 0;
|
|
156
|
+
this.#startAudio(0);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
this.#currentFrame = this.#frameCount - 1;
|
|
160
|
+
this.#playerState = 'ended';
|
|
161
|
+
this.#stopAudio();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
emitDrawCommands(buffer) {
|
|
167
|
+
const w = this.#width;
|
|
168
|
+
const h = this.#height;
|
|
169
|
+
if (w <= 0 || h <= 0) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const absX = this.#x;
|
|
173
|
+
const absY = this.#y;
|
|
174
|
+
const { bgRgba } = this.#colorScheme;
|
|
175
|
+
buffer.pushClip(absX, absY, w, h);
|
|
176
|
+
buffer.drawRect({
|
|
177
|
+
x: absX, y: absY, width: w, height: h, bgRgba,
|
|
178
|
+
});
|
|
179
|
+
if (this.#playerState === 'loading') {
|
|
180
|
+
if (!this.#loading && this.#src && !this.#frameData) {
|
|
181
|
+
this.#loading = true;
|
|
182
|
+
this.#load().catch(() => {
|
|
183
|
+
this.#playerState = 'error';
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const message = this.#loadingProgress || (this.#src ? `Loading: ${this.#src}` : 'Loading...');
|
|
187
|
+
this.#drawOverlay(buffer, absX, absY, w, h, message);
|
|
188
|
+
buffer.popClip();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (this.#playerState === 'error') {
|
|
192
|
+
const message = this.#src ? `Error: ${this.#src}` : 'No src or data provided';
|
|
193
|
+
this.#drawOverlay(buffer, absX, absY, w, h, message);
|
|
194
|
+
buffer.popClip();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Render current frame — batch by row to stay within DrawListBuffer limits
|
|
198
|
+
if (this.#frameData && this.#frameCols > 0 && this.#frameRows > 0) {
|
|
199
|
+
const offsetX = Math.max(0, Math.floor((w - this.#frameCols) / 2));
|
|
200
|
+
const offsetY = Math.max(0, Math.floor((h - this.#frameRows) / 2));
|
|
201
|
+
const rowSize = this.#frameCols;
|
|
202
|
+
const frameStart = this.#currentFrame * (rowSize * this.#frameRows);
|
|
203
|
+
const { dotRgba } = this.#colorScheme;
|
|
204
|
+
for (let row = 0; row < this.#frameRows; row++) {
|
|
205
|
+
const renderY = absY + offsetY + row;
|
|
206
|
+
if (renderY < absY || renderY >= absY + h) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
let rowText = '';
|
|
210
|
+
for (let col = 0; col < this.#frameCols; col++) {
|
|
211
|
+
const index = frameStart + (row * rowSize) + col;
|
|
212
|
+
const brailleByte = this.#frameData[index];
|
|
213
|
+
rowText += brailleByte === 0 ? ' ' : String.fromCodePoint(0x28_00 + brailleByte);
|
|
214
|
+
}
|
|
215
|
+
buffer.drawText({
|
|
216
|
+
x: absX + offsetX,
|
|
217
|
+
y: renderY,
|
|
218
|
+
text: rowText,
|
|
219
|
+
fgRgba: dotRgba,
|
|
220
|
+
bgRgba,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// State overlay
|
|
225
|
+
switch (this.#playerState) {
|
|
226
|
+
case 'ready': {
|
|
227
|
+
const hint = this.#audioSrc ? 'SPACE to play (with audio)' : 'SPACE to play';
|
|
228
|
+
this.#drawOverlay(buffer, absX, absY, w, h, hint);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case 'paused': {
|
|
232
|
+
this.#drawOverlay(buffer, absX, absY, w, h, 'PAUSED');
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'ended': {
|
|
236
|
+
this.#drawOverlay(buffer, absX, absY, w, h, 'END SPACE=replay R=reset');
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case 'playing': {
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Progress bar at bottom
|
|
244
|
+
if (this.#frameCount > 0 && h > 2) {
|
|
245
|
+
const progress = (this.#currentFrame + 1) / this.#frameCount;
|
|
246
|
+
const barWidth = Math.max(1, Math.floor(w * progress));
|
|
247
|
+
buffer.drawRect({
|
|
248
|
+
x: absX, y: absY + h - 1, width: barWidth, height: 1, bgRgba: this.#colorScheme.textRgba,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
buffer.popClip();
|
|
252
|
+
}
|
|
253
|
+
#drawOverlay(buffer, absX, absY, w, h, text) {
|
|
254
|
+
buffer.drawText({
|
|
255
|
+
x: absX + Math.max(0, Math.floor((w - text.length) / 2)),
|
|
256
|
+
y: absY + Math.floor(h / 2),
|
|
257
|
+
text,
|
|
258
|
+
fgRgba: this.#colorScheme.textRgba,
|
|
259
|
+
bgRgba: this.#colorScheme.bgRgba,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
#parseBinData(raw) {
|
|
263
|
+
if (raw.length < 12 || raw[0] !== 0xBA || raw[1] !== 0xAD || raw[2] !== 0x01) {
|
|
264
|
+
this.#playerState = 'error';
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.#fps = raw[3];
|
|
268
|
+
this.#frameCols = raw[4] | (raw[5] << 8);
|
|
269
|
+
this.#frameRows = raw[6] | (raw[7] << 8);
|
|
270
|
+
const frameArea = this.#frameCols * this.#frameRows;
|
|
271
|
+
this.#frameCount = raw[8] | (raw[9] << 8) | (raw[10] << 16) | (raw[11] << 24);
|
|
272
|
+
this.#frameInterval = Math.floor(1000 / this.#fps);
|
|
273
|
+
const expectedSize = 12 + (this.#frameCount * frameArea);
|
|
274
|
+
if (raw.length < expectedSize) {
|
|
275
|
+
this.#playerState = 'error';
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
this.#frameData = raw.slice(12, expectedSize);
|
|
279
|
+
this.#playerState = 'ready';
|
|
280
|
+
}
|
|
281
|
+
async #load() {
|
|
282
|
+
const src = this.#src;
|
|
283
|
+
if (src.endsWith('.bin')) {
|
|
284
|
+
await this.#loadFromBinFile(src);
|
|
285
|
+
}
|
|
286
|
+
else if (isVideoFile(src)) {
|
|
287
|
+
await this.#loadFromVideo(src);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
this.#playerState = 'error';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async #loadFromBinFile(path) {
|
|
294
|
+
try {
|
|
295
|
+
const file = Bun.file(path);
|
|
296
|
+
const buffer = await file.arrayBuffer();
|
|
297
|
+
this.#parseBinData(new Uint8Array(buffer));
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
this.#playerState = 'error';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async #loadFromVideo(path) {
|
|
304
|
+
const cols = Math.max(1, this.#width);
|
|
305
|
+
const rows = Math.max(1, this.#height);
|
|
306
|
+
const pixelW = cols * 2;
|
|
307
|
+
const pixelH = rows * 4;
|
|
308
|
+
const rawFrameSize = pixelW * pixelH;
|
|
309
|
+
this.#fps = this.#targetFps;
|
|
310
|
+
this.#frameInterval = Math.floor(1000 / this.#fps);
|
|
311
|
+
this.#loadingProgress = 'Decoding 0 frames...';
|
|
312
|
+
try {
|
|
313
|
+
const proc = Bun.spawn([
|
|
314
|
+
'ffmpeg',
|
|
315
|
+
'-i',
|
|
316
|
+
path,
|
|
317
|
+
'-vf',
|
|
318
|
+
`scale=${pixelW}:${pixelH}`,
|
|
319
|
+
'-pix_fmt',
|
|
320
|
+
'gray',
|
|
321
|
+
'-r',
|
|
322
|
+
String(this.#fps),
|
|
323
|
+
'-f',
|
|
324
|
+
'rawvideo',
|
|
325
|
+
'-v',
|
|
326
|
+
'quiet',
|
|
327
|
+
'pipe:1',
|
|
328
|
+
], {
|
|
329
|
+
stdout: 'pipe',
|
|
330
|
+
stderr: 'ignore',
|
|
331
|
+
});
|
|
332
|
+
this.#ffmpegProcess = proc;
|
|
333
|
+
const chunks = [];
|
|
334
|
+
let frameCount = 0;
|
|
335
|
+
const reader = proc.stdout.getReader();
|
|
336
|
+
let leftover = new Uint8Array(0);
|
|
337
|
+
while (true) {
|
|
338
|
+
// eslint-disable-next-line no-await-in-loop
|
|
339
|
+
const { done, value } = await reader.read();
|
|
340
|
+
if (done) {
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
const merged = new Uint8Array(leftover.length + value.length);
|
|
344
|
+
merged.set(leftover);
|
|
345
|
+
merged.set(value, leftover.length);
|
|
346
|
+
let offset = 0;
|
|
347
|
+
while (offset + rawFrameSize <= merged.length) {
|
|
348
|
+
const rawFrame = merged.slice(offset, offset + rawFrameSize);
|
|
349
|
+
chunks.push(encodeBrailleFrame(rawFrame, cols, rows, this.#threshold, this.#invert));
|
|
350
|
+
frameCount++;
|
|
351
|
+
offset += rawFrameSize;
|
|
352
|
+
if (frameCount % 100 === 0) {
|
|
353
|
+
this.#loadingProgress = `Decoding ${frameCount} frames...`;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
leftover = merged.slice(offset);
|
|
357
|
+
}
|
|
358
|
+
this.#ffmpegProcess = undefined;
|
|
359
|
+
const exitCode = await proc.exited;
|
|
360
|
+
if (exitCode !== 0 && frameCount === 0) {
|
|
361
|
+
this.#playerState = 'error';
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Assemble frame data
|
|
365
|
+
const frameArea = cols * rows;
|
|
366
|
+
this.#frameCols = cols;
|
|
367
|
+
this.#frameRows = rows;
|
|
368
|
+
this.#frameCount = frameCount;
|
|
369
|
+
this.#frameData = new Uint8Array(frameCount * frameArea);
|
|
370
|
+
let writeOffset = 0;
|
|
371
|
+
for (const chunk of chunks) {
|
|
372
|
+
this.#frameData.set(chunk, writeOffset);
|
|
373
|
+
writeOffset += chunk.length;
|
|
374
|
+
}
|
|
375
|
+
// Extract audio to temp WAV for byte-accurate seeking
|
|
376
|
+
try {
|
|
377
|
+
const temporaryAudio = `${path}.buntui-audio.wav`;
|
|
378
|
+
const audioProc = Bun.spawn([
|
|
379
|
+
'ffmpeg',
|
|
380
|
+
'-i',
|
|
381
|
+
path,
|
|
382
|
+
'-vn',
|
|
383
|
+
'-f',
|
|
384
|
+
'wav',
|
|
385
|
+
'-y',
|
|
386
|
+
temporaryAudio,
|
|
387
|
+
], {
|
|
388
|
+
stdout: 'ignore',
|
|
389
|
+
stderr: 'ignore',
|
|
390
|
+
});
|
|
391
|
+
const audioExit = await audioProc.exited;
|
|
392
|
+
if (audioExit === 0) {
|
|
393
|
+
this.#audioSrc = temporaryAudio;
|
|
394
|
+
this.#audioTempFile = temporaryAudio;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Audio extraction failed, keep original source
|
|
399
|
+
}
|
|
400
|
+
this.#loadingProgress = '';
|
|
401
|
+
this.#playerState = 'ready';
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
this.#playerState = 'error';
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
#killFfmpeg() {
|
|
408
|
+
if (this.#ffmpegProcess) {
|
|
409
|
+
try {
|
|
410
|
+
this.#ffmpegProcess.kill();
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// Process may have already exited
|
|
414
|
+
}
|
|
415
|
+
this.#ffmpegProcess = undefined;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
#cleanupTempAudio() {
|
|
419
|
+
if (this.#audioTempFile) {
|
|
420
|
+
try {
|
|
421
|
+
void Bun.file(this.#audioTempFile).unlink();
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// File may already be gone
|
|
425
|
+
}
|
|
426
|
+
this.#audioTempFile = undefined;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
#startAudio(offsetMs) {
|
|
430
|
+
if (!this.#audioSrc) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.#stopAudio();
|
|
434
|
+
const args = ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet'];
|
|
435
|
+
if (offsetMs > 0) {
|
|
436
|
+
args.push('-ss', String(offsetMs / 1000));
|
|
437
|
+
}
|
|
438
|
+
args.push(this.#audioSrc);
|
|
439
|
+
try {
|
|
440
|
+
this.#audioProcess = Bun.spawn(args, {
|
|
441
|
+
stdin: 'ignore',
|
|
442
|
+
stdout: 'ignore',
|
|
443
|
+
stderr: 'ignore',
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// Ffplay not available, skip audio
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
#stopAudio() {
|
|
451
|
+
if (this.#audioProcess) {
|
|
452
|
+
try {
|
|
453
|
+
this.#audioProcess.kill();
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Process may have already exited
|
|
457
|
+
}
|
|
458
|
+
this.#audioProcess = undefined;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
export function createVideoPlayerWidget(options) {
|
|
463
|
+
return new VideoPlayerWidget(options);
|
|
464
|
+
}
|
|
465
|
+
export default VideoPlayerWidget;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Braille dot bit positions within a 2x4 cell grid.
|
|
3
|
+
* Each entry: [dx, dy, bitIndex]
|
|
4
|
+
* Maps pixel positions to the Unicode braille dot encoding.
|
|
5
|
+
*/
|
|
6
|
+
export declare const BRAILLE_DOTS: ReadonlyArray<readonly [number, number, number]>;
|
|
7
|
+
export declare function isVideoFile(path: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Encode a raw grayscale frame into braille dot patterns.
|
|
10
|
+
* Each 2x4 pixel block becomes one braille cell byte.
|
|
11
|
+
*
|
|
12
|
+
* @param pixels - Raw grayscale pixel data (width * height bytes)
|
|
13
|
+
* @param cols - Number of braille columns (width / 2)
|
|
14
|
+
* @param rows - Number of braille rows (height / 4)
|
|
15
|
+
* @param threshold - Brightness threshold (0-255). Pixels darker -> dots
|
|
16
|
+
* @param invert - If true, bright pixels become dots instead
|
|
17
|
+
*/
|
|
18
|
+
export declare function encodeBrailleFrame(pixels: Uint8Array, cols: number, rows: number, threshold: number, invert: boolean): Uint8Array;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Braille dot bit positions within a 2x4 cell grid.
|
|
3
|
+
* Each entry: [dx, dy, bitIndex]
|
|
4
|
+
* Maps pixel positions to the Unicode braille dot encoding.
|
|
5
|
+
*/
|
|
6
|
+
export const BRAILLE_DOTS = [
|
|
7
|
+
[0, 0, 0],
|
|
8
|
+
[0, 1, 1],
|
|
9
|
+
[0, 2, 2],
|
|
10
|
+
[1, 0, 3],
|
|
11
|
+
[1, 1, 4],
|
|
12
|
+
[1, 2, 5],
|
|
13
|
+
[0, 3, 6],
|
|
14
|
+
[1, 3, 7],
|
|
15
|
+
];
|
|
16
|
+
const VIDEO_EXTENSIONS = new Set([
|
|
17
|
+
'.mp4',
|
|
18
|
+
'.mkv',
|
|
19
|
+
'.avi',
|
|
20
|
+
'.webm',
|
|
21
|
+
'.mov',
|
|
22
|
+
'.flv',
|
|
23
|
+
'.wmv',
|
|
24
|
+
'.ts',
|
|
25
|
+
'.mts',
|
|
26
|
+
'.m4v',
|
|
27
|
+
'.ogv',
|
|
28
|
+
'.3gp',
|
|
29
|
+
]);
|
|
30
|
+
export function isVideoFile(path) {
|
|
31
|
+
const dot = path.lastIndexOf('.');
|
|
32
|
+
if (dot === -1) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return VIDEO_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Encode a raw grayscale frame into braille dot patterns.
|
|
39
|
+
* Each 2x4 pixel block becomes one braille cell byte.
|
|
40
|
+
*
|
|
41
|
+
* @param pixels - Raw grayscale pixel data (width * height bytes)
|
|
42
|
+
* @param cols - Number of braille columns (width / 2)
|
|
43
|
+
* @param rows - Number of braille rows (height / 4)
|
|
44
|
+
* @param threshold - Brightness threshold (0-255). Pixels darker -> dots
|
|
45
|
+
* @param invert - If true, bright pixels become dots instead
|
|
46
|
+
*/
|
|
47
|
+
export function encodeBrailleFrame(pixels, cols, rows, threshold, invert) {
|
|
48
|
+
const pixelW = cols * 2;
|
|
49
|
+
const pixelH = rows * 4;
|
|
50
|
+
const result = new Uint8Array(cols * rows);
|
|
51
|
+
for (let row = 0; row < rows; row++) {
|
|
52
|
+
for (let col = 0; col < cols; col++) {
|
|
53
|
+
let byte = 0;
|
|
54
|
+
for (const [dx, dy, bit] of BRAILLE_DOTS) {
|
|
55
|
+
const px = (col * 2) + dx;
|
|
56
|
+
const py = (row * 4) + dy;
|
|
57
|
+
if (px < pixelW && py < pixelH) {
|
|
58
|
+
const brightness = pixels[(py * pixelW) + px];
|
|
59
|
+
const isOn = invert ? brightness >= threshold : brightness < threshold;
|
|
60
|
+
if (isOn) { // eslint-disable-line max-depth
|
|
61
|
+
byte |= (1 << bit);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
result[(row * cols) + col] = byte;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { rgbToRgba } from '@buntui/core';
|
|
2
|
+
export const DEFAULT_VIDEOPLAYER_COLOR_SCHEME = {
|
|
3
|
+
dotRgba: rgbToRgba(0xFF, 0xFF, 0xFF),
|
|
4
|
+
bgRgba: rgbToRgba(0x00, 0x00, 0x00),
|
|
5
|
+
textRgba: rgbToRgba(0x88, 0x88, 0x88),
|
|
6
|
+
};
|
|
7
|
+
export const DEFAULT_VIDEOPLAYER_OPTIONS = {
|
|
8
|
+
x: 0,
|
|
9
|
+
y: 0,
|
|
10
|
+
width: '100%',
|
|
11
|
+
height: '100%',
|
|
12
|
+
colorScheme: DEFAULT_VIDEOPLAYER_COLOR_SCHEME,
|
|
13
|
+
loop: false,
|
|
14
|
+
threshold: 128,
|
|
15
|
+
invert: false,
|
|
16
|
+
fps: 30,
|
|
17
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TuiSizeValue } from '@buntui/core';
|
|
2
|
+
export type VideoPlayerState = 'loading' | 'ready' | 'playing' | 'paused' | 'ended' | 'error';
|
|
3
|
+
export type VideoPlayerColorScheme = {
|
|
4
|
+
dotRgba: number;
|
|
5
|
+
bgRgba: number;
|
|
6
|
+
textRgba: number;
|
|
7
|
+
};
|
|
8
|
+
export type VideoPlayerWidgetOptions = {
|
|
9
|
+
x?: TuiSizeValue;
|
|
10
|
+
y?: TuiSizeValue;
|
|
11
|
+
width?: TuiSizeValue;
|
|
12
|
+
height?: TuiSizeValue;
|
|
13
|
+
src?: string;
|
|
14
|
+
audioSrc?: string;
|
|
15
|
+
data?: Uint8Array;
|
|
16
|
+
colorScheme?: Partial<VideoPlayerColorScheme>;
|
|
17
|
+
loop?: boolean;
|
|
18
|
+
threshold?: number;
|
|
19
|
+
invert?: boolean;
|
|
20
|
+
fps?: number;
|
|
21
|
+
};
|
|
File without changes
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@buntui/extensions",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/AlphaFoxz/buntui.git",
|
|
7
|
+
"directory": "packages/extensions"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./matrix": {
|
|
17
|
+
"types": "./dist/matrix.d.ts",
|
|
18
|
+
"default": "./dist/matrix.js"
|
|
19
|
+
},
|
|
20
|
+
"./framerate": {
|
|
21
|
+
"types": "./dist/framerate.d.ts",
|
|
22
|
+
"default": "./dist/framerate.js"
|
|
23
|
+
},
|
|
24
|
+
"./snake": {
|
|
25
|
+
"types": "./dist/snake.d.ts",
|
|
26
|
+
"default": "./dist/snake.js"
|
|
27
|
+
},
|
|
28
|
+
"./videoplayer": {
|
|
29
|
+
"types": "./dist/videoplayer.d.ts",
|
|
30
|
+
"default": "./dist/videoplayer.js"
|
|
31
|
+
},
|
|
32
|
+
"./logger": {
|
|
33
|
+
"types": "./dist/logger.d.ts",
|
|
34
|
+
"default": "./dist/logger.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist/"
|
|
39
|
+
],
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc --project ./tsconfig.json"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@buntui/core": "^0.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "^1.3.14"
|
|
49
|
+
}
|
|
50
|
+
}
|