@emaxe/tuigram 1.4.0 → 1.5.1

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.
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Модуль для работы с воспроизведением видео в терминале.
3
+ * Отвечает за обнаружение ffmpeg/ffplay, запуск декодирования видеопотока
4
+ * в сырые RGB24-кадры и их быстрое преобразование в Unicode Half-Block псевдографику.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import zlib from "node:zlib";
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { config } from "../config.js";
12
+ import { rgbaToHex } from "./image.js";
13
+
14
+ /**
15
+ * Ищет путь к исполняемому файлу ffmpeg:
16
+ * 1. В переменной окружения FFMPEG_PATH или config.ffmpegPath
17
+ * 2. В пользовательском каталоге данных: <dataDir>/bin/ffmpeg
18
+ * 3. В системном PATH
19
+ * @returns {string|null} абсолютный путь к ffmpeg или null
20
+ */
21
+ export function findFfmpegPath() {
22
+ if (config.ffmpegPath && fs.existsSync(config.ffmpegPath)) {
23
+ return path.resolve(config.ffmpegPath);
24
+ }
25
+ if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
26
+ return path.resolve(process.env.FFMPEG_PATH);
27
+ }
28
+
29
+ const localBin = path.join(config.dataDir, "bin", process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
30
+ if (fs.existsSync(localBin)) {
31
+ return localBin;
32
+ }
33
+
34
+ // Проверяем наличие ffmpeg в системном PATH
35
+ try {
36
+ const cmd = process.platform === "win32" ? "where" : "which";
37
+ const res = spawnSync(cmd, ["ffmpeg"], { encoding: "utf8", timeout: 2000 });
38
+ if (res.status === 0 && res.stdout) {
39
+ const firstLine = res.stdout.trim().split(/\r?\n/)[0];
40
+ if (firstLine && fs.existsSync(firstLine)) {
41
+ return firstLine;
42
+ }
43
+ return "ffmpeg";
44
+ }
45
+ } catch {
46
+ // Игнорируем ошибки проверки системного PATH
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Ищет путь к утилите ffplay для воспроизведения звука.
54
+ * @returns {string|null}
55
+ */
56
+ export function findFfplayPath() {
57
+ if (config.ffplayPath && fs.existsSync(config.ffplayPath)) {
58
+ return path.resolve(config.ffplayPath);
59
+ }
60
+ if (process.env.FFPLAY_PATH && fs.existsSync(process.env.FFPLAY_PATH)) {
61
+ return path.resolve(process.env.FFPLAY_PATH);
62
+ }
63
+
64
+ const localBin = path.join(config.dataDir, "bin", process.platform === "win32" ? "ffplay.exe" : "ffplay");
65
+ if (fs.existsSync(localBin)) {
66
+ return localBin;
67
+ }
68
+
69
+ try {
70
+ const cmd = process.platform === "win32" ? "where" : "which";
71
+ const res = spawnSync(cmd, ["ffplay"], { encoding: "utf8", timeout: 2000 });
72
+ if (res.status === 0 && res.stdout) {
73
+ const firstLine = res.stdout.trim().split(/\r?\n/)[0];
74
+ if (firstLine && fs.existsSync(firstLine)) {
75
+ return firstLine;
76
+ }
77
+ return "ffplay";
78
+ }
79
+ } catch {
80
+ // Игнорируем ошибки проверки системного PATH
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Проверяет, доступен ли ffmpeg для работы.
88
+ * @returns {boolean}
89
+ */
90
+ export function isFfmpegAvailable() {
91
+ const ffmpegPath = findFfmpegPath();
92
+ if (!ffmpegPath) return false;
93
+ try {
94
+ const res = spawnSync(ffmpegPath, ["-version"], { encoding: "utf8", timeout: 3000 });
95
+ return res.status === 0;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Проверяет, является ли сообщение видеофайлом или видеозаметкой («кружочком»).
103
+ * @param {object} msg
104
+ * @returns {boolean}
105
+ */
106
+ export function isMessageVideo(msg) {
107
+ if (!msg?.media) return false;
108
+ const media = msg.media;
109
+ if (media.className === "MessageMediaDocument") {
110
+ const doc = media.document || {};
111
+ if (typeof doc.mimeType === "string" && doc.mimeType.startsWith("video/")) {
112
+ return true;
113
+ }
114
+ const attributes = doc.attributes || [];
115
+ for (const attr of attributes) {
116
+ if (attr.className === "DocumentAttributeVideo") {
117
+ return true;
118
+ }
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Преобразует сырой буфер пикселей RGB24 в многострочную псевдографику Blessed (Unicode Half-Block ▀).
126
+ *
127
+ * Оптимизация: соседние ячейки с одинаковыми цветами группируются, что сокращает размер
128
+ * строки тегов в несколько раз и ускоряет рендеринг в терминале.
129
+ *
130
+ * @param {Buffer|Uint8Array} rgbData буфер сырых байтов RGB24 (длина = width * height * 3)
131
+ * @param {number} width ширина кадра в символах/ячейках
132
+ * @param {number} height высота кадра в пикселях (должна быть четной, = rows * 2)
133
+ * @returns {string}
134
+ */
135
+ export function rgb24ToHalfBlockBlessed(rgbData, width, height) {
136
+ if (!rgbData || rgbData.length < width * height * 3) return "";
137
+ const rows = Math.floor(height / 2);
138
+ const lines = [];
139
+
140
+ for (let r = 0; r < rows; r++) {
141
+ let line = "";
142
+ let prevFg = "";
143
+ let prevBg = "";
144
+
145
+ const topRowOffset = (2 * r) * width * 3;
146
+ const botRowOffset = (2 * r + 1) * width * 3;
147
+
148
+ for (let c = 0; c < width; c++) {
149
+ const topIdx = topRowOffset + c * 3;
150
+ const botIdx = botRowOffset + c * 3;
151
+
152
+ const r1 = rgbData[topIdx];
153
+ const g1 = rgbData[topIdx + 1];
154
+ const b1 = rgbData[topIdx + 2];
155
+
156
+ const r2 = rgbData[botIdx];
157
+ const g2 = rgbData[botIdx + 1];
158
+ const b2 = rgbData[botIdx + 2];
159
+
160
+ const fgHex = rgbaToHex(r1, g1, b1);
161
+ const bgHex = rgbaToHex(r2, g2, b2);
162
+
163
+ if (fgHex !== prevFg && bgHex !== prevBg) {
164
+ line += `{${fgHex}-fg}{${bgHex}-bg}▀`;
165
+ prevFg = fgHex;
166
+ prevBg = bgHex;
167
+ } else if (fgHex !== prevFg) {
168
+ line += `{${fgHex}-fg}▀`;
169
+ prevFg = fgHex;
170
+ } else if (bgHex !== prevBg) {
171
+ line += `{${bgHex}-bg}▀`;
172
+ prevBg = bgHex;
173
+ } else {
174
+ line += "▀";
175
+ }
176
+ }
177
+ lines.push(line);
178
+ }
179
+
180
+ return lines.join("\n");
181
+ }
182
+
183
+ /**
184
+ * Запускает фоновый процесс ffmpeg для декодирования видео в сырые RGB24-кадры
185
+ * и выполняет их синхронизированную по времени выдачу в колбэк onFrame.
186
+ * @param {string} ffmpegPath
187
+ * @param {string} videoPath
188
+ * @param {object} options
189
+ * @param {number} [options.width=60] ширина кадра в символах
190
+ * @param {number} [options.height=30] высота кадра в пикселях (четная)
191
+ * @param {number} [options.fps=15] частота кадров
192
+ * @param {(frameText: string, frameIndex: number) => void} options.onFrame колбэк для каждого кадра
193
+ * @param {() => void} [options.onEnd] колбэк завершения видео
194
+ * @param {(err: Error) => void} [options.onError] колбэк ошибки
195
+ * @returns {{ pause: () => void, resume: () => void, kill: () => void, isPaused: () => boolean }}
196
+ */
197
+ export function spawnVideoPlayer(ffmpegPath, videoPath, {
198
+ width = 60,
199
+ height = 30,
200
+ fps = 15,
201
+ onFrame,
202
+ onEnd,
203
+ onError,
204
+ } = {}) {
205
+ const frameSize = width * height * 3;
206
+ const args = [
207
+ "-hide_banner",
208
+ "-loglevel", "error",
209
+ "-i", videoPath,
210
+ "-vf", `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`,
211
+ "-f", "rawvideo",
212
+ "-pix_fmt", "rgb24",
213
+ "-r", String(fps),
214
+ "pipe:1"
215
+ ];
216
+
217
+ let child = null;
218
+ let paused = false;
219
+ let killed = false;
220
+ let isEof = false;
221
+ let timer = null;
222
+
223
+ let accumulator = Buffer.alloc(0);
224
+ const frameQueue = [];
225
+ let frameCounter = 0;
226
+ let displayedFrameIndex = -1;
227
+
228
+ let startTime = null;
229
+ let totalPausedTime = 0;
230
+ let pauseStartTime = 0;
231
+
232
+ try {
233
+ child = spawn(ffmpegPath, args, { stdio: ["ignore", "pipe", "ignore"] });
234
+ } catch (err) {
235
+ onError?.(err);
236
+ return { pause() {}, resume() {}, kill() {}, isPaused: () => false };
237
+ }
238
+
239
+ const stdout = child.stdout;
240
+
241
+ stdout.on("data", (chunk) => {
242
+ if (killed) return;
243
+ accumulator = Buffer.concat([accumulator, chunk]);
244
+
245
+ while (accumulator.length >= frameSize) {
246
+ const frameBuf = accumulator.subarray(0, frameSize);
247
+ accumulator = accumulator.subarray(frameSize);
248
+ frameQueue.push({ index: frameCounter++, buffer: frameBuf });
249
+ }
250
+
251
+ // Ограничиваем очередь кадров (~2 секунды при 15 fps), чтобы не тратить память
252
+ if (frameQueue.length >= 30 && !stdout.isPaused()) {
253
+ stdout.pause();
254
+ }
255
+ });
256
+
257
+ child.on("error", (err) => {
258
+ if (timer) {
259
+ clearInterval(timer);
260
+ timer = null;
261
+ }
262
+ try { stdout?.destroy(); } catch {}
263
+ if (!killed) {
264
+ onError?.(err);
265
+ }
266
+ });
267
+
268
+ child.on("close", () => {
269
+ isEof = true;
270
+ });
271
+
272
+ function tick() {
273
+ if (killed || paused) return;
274
+
275
+ // Ждем получения первого кадра для точной синхронизации времени старта
276
+ if (startTime === null) {
277
+ if (frameQueue.length === 0) {
278
+ if (isEof) {
279
+ if (timer) {
280
+ clearInterval(timer);
281
+ timer = null;
282
+ }
283
+ try { stdout?.destroy(); } catch {}
284
+ onEnd?.();
285
+ }
286
+ return;
287
+ }
288
+ startTime = performance.now();
289
+ }
290
+
291
+ const now = performance.now();
292
+ const elapsed = (now - startTime - totalPausedTime) / 1000;
293
+ const targetFrameIndex = Math.floor(elapsed * fps);
294
+
295
+ if (targetFrameIndex > displayedFrameIndex) {
296
+ let frameToRender = null;
297
+
298
+ // Выбираем актуальный кадр и отбрасываем устаревшие, если рендеринг отстал от таймера
299
+ while (frameQueue.length > 0 && frameQueue[0].index <= targetFrameIndex) {
300
+ const item = frameQueue.shift();
301
+ if (item.index === targetFrameIndex || frameQueue.length === 0) {
302
+ frameToRender = item;
303
+ }
304
+ }
305
+
306
+ if (frameToRender) {
307
+ displayedFrameIndex = frameToRender.index;
308
+ const text = rgb24ToHalfBlockBlessed(frameToRender.buffer, width, height);
309
+ onFrame?.(text, displayedFrameIndex + 1);
310
+ }
311
+
312
+ // Если в буфере освободилось место, возобновляем чтение stdout
313
+ if (frameQueue.length < 15 && stdout.isPaused()) {
314
+ stdout.resume();
315
+ }
316
+ }
317
+
318
+ // Проверяем окончание воспроизведения
319
+ if (isEof && frameQueue.length === 0) {
320
+ if (displayedFrameIndex >= frameCounter - 1) {
321
+ if (timer) {
322
+ clearInterval(timer);
323
+ timer = null;
324
+ }
325
+ try { stdout?.destroy(); } catch {}
326
+ onEnd?.();
327
+ }
328
+ }
329
+ }
330
+
331
+ timer = setInterval(tick, 10);
332
+ if (typeof timer.unref === "function") {
333
+ timer.unref();
334
+ }
335
+
336
+ return {
337
+ pause() {
338
+ if (!paused && !killed) {
339
+ paused = true;
340
+ pauseStartTime = performance.now();
341
+ if (child && child.pid) {
342
+ try {
343
+ child.kill("SIGSTOP");
344
+ } catch {
345
+ // Игнорируем ошибку паузы
346
+ }
347
+ }
348
+ }
349
+ },
350
+ resume() {
351
+ if (paused && !killed) {
352
+ paused = false;
353
+ if (pauseStartTime > 0) {
354
+ if (startTime !== null) {
355
+ totalPausedTime += performance.now() - pauseStartTime;
356
+ }
357
+ pauseStartTime = 0;
358
+ }
359
+ if (child && child.pid) {
360
+ try {
361
+ child.kill("SIGCONT");
362
+ } catch {
363
+ // Игнорируем ошибку возобновления
364
+ }
365
+ }
366
+ }
367
+ },
368
+ kill() {
369
+ killed = true;
370
+ paused = false;
371
+ if (timer) {
372
+ clearInterval(timer);
373
+ timer = null;
374
+ }
375
+ try { stdout?.destroy(); } catch {}
376
+ accumulator = Buffer.alloc(0);
377
+ frameQueue.length = 0;
378
+ if (child) {
379
+ if (child.pid) {
380
+ try {
381
+ child.kill("SIGKILL");
382
+ } catch {
383
+ // Игнорируем ошибку завершения
384
+ }
385
+ }
386
+ child = null;
387
+ }
388
+ },
389
+ isPaused() {
390
+ return paused;
391
+ }
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Запускает фоновое воспроизведение звука для видеофайла.
397
+ * На macOS использует встроенный afplay для нативных форматов, на других платформах — ffplay (если найден).
398
+ * @param {string} videoPath
399
+ * @param {object} [options]
400
+ * @param {string} [options.ffplayPath]
401
+ * @returns {{ pause: () => void, resume: () => void, kill: () => void }}
402
+ */
403
+ export function spawnAudioPlayer(videoPath, { ffplayPath } = {}) {
404
+ let child = null;
405
+ let paused = false;
406
+ let killed = false;
407
+
408
+ const ext = path.extname(videoPath).toLowerCase();
409
+ const isDarwin = process.platform === "darwin";
410
+ const darwinNativeFormats = new Set([".mp4", ".mov", ".m4v", ".m4a", ".mp3", ".wav", ".aac", ".aiff"]);
411
+
412
+ try {
413
+ if (isDarwin && darwinNativeFormats.has(ext)) {
414
+ child = spawn("afplay", [videoPath], { stdio: "ignore" });
415
+ } else if (ffplayPath) {
416
+ child = spawn(ffplayPath, ["-nodisp", "-autoexit", "-loglevel", "error", "-i", videoPath], { stdio: "ignore" });
417
+ } else if (isDarwin) {
418
+ child = spawn("afplay", [videoPath], { stdio: "ignore" });
419
+ }
420
+ } catch {
421
+ // Игнорируем ошибки запуска аудиоплеера
422
+ }
423
+
424
+ if (child) {
425
+ child.on("error", () => {
426
+ // Игнорируем ошибки аудиоплеера (например, отсутствие звуковой дорожки)
427
+ });
428
+ }
429
+
430
+ return {
431
+ pause() {
432
+ if (!paused && child && !killed) {
433
+ paused = true;
434
+ if (child.pid) {
435
+ try { child.kill("SIGSTOP"); } catch {}
436
+ }
437
+ }
438
+ },
439
+ resume() {
440
+ if (paused && child && !killed) {
441
+ paused = false;
442
+ if (child.pid) {
443
+ try { child.kill("SIGCONT"); } catch {}
444
+ }
445
+ }
446
+ },
447
+ kill() {
448
+ killed = true;
449
+ if (child) {
450
+ if (child.pid) {
451
+ try { child.kill("SIGKILL"); } catch {}
452
+ }
453
+ child = null;
454
+ }
455
+ }
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Извлекает первый файл из ZIP-архива в памяти средствами стандартной библиотеки Node.js.
461
+ * @param {Buffer} zipBuffer
462
+ * @returns {{ name: string, data: Buffer }}
463
+ */
464
+ export function extractFirstFileFromZip(zipBuffer) {
465
+ let offset = 0;
466
+ while (offset < zipBuffer.length - 30) {
467
+ if (zipBuffer.readUInt32LE(offset) !== 0x04034b50) {
468
+ offset++;
469
+ continue;
470
+ }
471
+ const method = zipBuffer.readUInt16LE(offset + 8);
472
+ const compressedSize = zipBuffer.readUInt32LE(offset + 18);
473
+ const nameLen = zipBuffer.readUInt16LE(offset + 26);
474
+ const extraLen = zipBuffer.readUInt16LE(offset + 28);
475
+ const name = zipBuffer.toString("utf8", offset + 30, offset + 30 + nameLen);
476
+ const dataStart = offset + 30 + nameLen + extraLen;
477
+ const compData = zipBuffer.subarray(dataStart, dataStart + compressedSize);
478
+
479
+ if (name.endsWith("/")) {
480
+ offset = dataStart + compressedSize;
481
+ continue;
482
+ }
483
+
484
+ let uncompData;
485
+ if (method === 0) {
486
+ uncompData = compData;
487
+ } else if (method === 8) {
488
+ uncompData = zlib.inflateRawSync(compData);
489
+ } else {
490
+ throw new Error(`Неподдерживаемый метод сжатия ZIP: ${method}`);
491
+ }
492
+ return { name, data: uncompData };
493
+ }
494
+ throw new Error("Файл не найден в ZIP-архиве");
495
+ }