@emaxe/tuigram 1.1.0 → 1.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/.env.example +7 -0
- package/CHANGELOG.md +9 -1
- package/CHANGELOG.ru.md +9 -1
- package/README.md +20 -0
- package/README.ru.md +20 -0
- package/package.json +3 -1
- package/src/config.js +3 -0
- package/src/state.js +4 -4
- package/src/telegram/messages.js +58 -0
- package/src/ui/app.js +54 -3
- package/src/ui/components/chatView.js +53 -8
- package/src/utils/image.js +343 -0
package/.env.example
CHANGED
|
@@ -28,6 +28,13 @@ AUTO_SCROLL=true
|
|
|
28
28
|
# Показывать уведомления о наборе текста (true/false)
|
|
29
29
|
SHOW_TYPING=true
|
|
30
30
|
|
|
31
|
+
# Отображение цветных превью изображений в чате (true/false)
|
|
32
|
+
SHOW_IMAGES=true
|
|
33
|
+
|
|
34
|
+
# Максимальные размеры превью изображений в символах терминала (ширина x высота)
|
|
35
|
+
IMAGE_MAX_WIDTH=36
|
|
36
|
+
IMAGE_MAX_HEIGHT=14
|
|
37
|
+
|
|
31
38
|
# ── Proxy Settings ────────────────────────────────────────────────────────────
|
|
32
39
|
# Поддерживаются HTTP и SOCKS5 (как с авторизацией, так и без).
|
|
33
40
|
# Формат единого URL:
|
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
13
13
|
|
|
14
14
|
- English localization of the TUI and CLI interface strings.
|
|
15
15
|
|
|
16
|
+
## [1.2.0] — 2026-09-01
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Display color image previews directly in chat messages using high-resolution Unicode half-block characters (`▀` `U+2580`) with instant rendering of Telegram stripped thumbnails (`PhotoStrippedSize`).
|
|
21
|
+
- Configuration options `SHOW_IMAGES`, `IMAGE_MAX_WIDTH`, and `IMAGE_MAX_HEIGHT` to control image preview rendering in `.env` and `config.js`.
|
|
22
|
+
|
|
16
23
|
## [1.1.0] — 2026-08-31
|
|
17
24
|
|
|
18
25
|
### Added
|
|
@@ -111,7 +118,8 @@ First public release.
|
|
|
111
118
|
- A WCAG contrast test (3:1 threshold) applied to every theme **after** conversion
|
|
112
119
|
to xterm-256 — the colors are checked exactly as the user sees them.
|
|
113
120
|
|
|
114
|
-
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.
|
|
121
|
+
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.2.0...HEAD
|
|
122
|
+
[1.2.0]: https://github.com/emaxe/tuigram/compare/v1.1.0...v1.2.0
|
|
115
123
|
[1.1.0]: https://github.com/emaxe/tuigram/compare/v1.0.1...v1.1.0
|
|
116
124
|
[1.0.1]: https://github.com/emaxe/tuigram/compare/v1.0.0...v1.0.1
|
|
117
125
|
[1.0.0]: https://github.com/emaxe/tuigram/releases/tag/v1.0.0
|
package/CHANGELOG.ru.md
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
- Английская локализация строк интерфейса TUI и CLI.
|
|
15
15
|
|
|
16
|
+
## [1.2.0] — 2026-09-01
|
|
17
|
+
|
|
18
|
+
### Добавлено
|
|
19
|
+
|
|
20
|
+
- Отображение цветных превью изображений прямо в ленте сообщений чата с помощью псевдографики высокого разрешения (Unicode Half-Block `▀` `U+2580`) и мгновенного рендеринга миниатюр `PhotoStrippedSize`.
|
|
21
|
+
- Параметры конфигурации `SHOW_IMAGES`, `IMAGE_MAX_WIDTH` и `IMAGE_MAX_HEIGHT` в `.env` и `config.js` для управления отображением превью и их максимальным размером.
|
|
22
|
+
|
|
16
23
|
## [1.1.0] — 2026-08-31
|
|
17
24
|
|
|
18
25
|
### Добавлено
|
|
@@ -113,7 +120,8 @@
|
|
|
113
120
|
- Тест контраста по WCAG (порог 3:1) для каждой темы — **после** конверсии в xterm-256,
|
|
114
121
|
то есть ровно в том виде, в каком цвет увидит пользователь.
|
|
115
122
|
|
|
116
|
-
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.
|
|
123
|
+
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.2.0...HEAD
|
|
124
|
+
[1.2.0]: https://github.com/emaxe/tuigram/compare/v1.1.0...v1.2.0
|
|
117
125
|
[1.1.0]: https://github.com/emaxe/tuigram/compare/v1.0.1...v1.1.0
|
|
118
126
|
[1.0.1]: https://github.com/emaxe/tuigram/compare/v1.0.0...v1.0.1
|
|
119
127
|
[1.0.0]: https://github.com/emaxe/tuigram/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -55,6 +55,7 @@ TuiGram lets you use Telegram entirely from the terminal: browse your dialog lis
|
|
|
55
55
|
- [Slash commands](#-slash-commands-in-the-input-box)
|
|
56
56
|
- [Sending files and images](#-sending-files-and-images)
|
|
57
57
|
- [Themes](#-themes)
|
|
58
|
+
- [Inline image previews](#️-inline-image-previews)
|
|
58
59
|
- [Proxy configuration](#-proxy-configuration)
|
|
59
60
|
- [Command line usage (CLI)](#️-command-line-usage-cli)
|
|
60
61
|
- [Project structure](#-project-structure)
|
|
@@ -74,6 +75,7 @@ TuiGram lets you use Telegram entirely from the terminal: browse your dialog lis
|
|
|
74
75
|
- Instant search and filtering of chats by title and `@username` (`/`);
|
|
75
76
|
- Live typing indicator ("… is typing");
|
|
76
77
|
- Colored rendering of Telegram entities (bold, italic, monospace code, URLs, mentions, spoilers);
|
|
78
|
+
- High-resolution inline image previews (Unicode Half-Block `▀` pixel art);
|
|
77
79
|
- Media attachment indicators (photo, video, document, voice, sticker, poll);
|
|
78
80
|
- Message reactions (👍, 🔥, ❤️);
|
|
79
81
|
- Infinite upward pagination of message history (`PageUp` / `Ctrl+U`);
|
|
@@ -512,6 +514,23 @@ The contrast of every "text on background" pair is verified by an automated WCAG
|
|
|
512
514
|
|
|
513
515
|
---
|
|
514
516
|
|
|
517
|
+
## 🖼️ Inline image previews
|
|
518
|
+
|
|
519
|
+
TuiGram displays color image previews directly in the chat history using high-resolution Unicode half-block characters (`▀` `U+2580`). Each character cell renders 2 vertical subpixels with 24-bit RGB colors, producing crisp, proportional pixel art that scrolls naturally with messages and works across all terminals.
|
|
520
|
+
|
|
521
|
+
Previews are enabled by default and can be configured in `.env`:
|
|
522
|
+
|
|
523
|
+
```env
|
|
524
|
+
# Enable/disable image previews in chat (true/false)
|
|
525
|
+
SHOW_IMAGES=true
|
|
526
|
+
|
|
527
|
+
# Maximum preview size in terminal characters (width x height)
|
|
528
|
+
IMAGE_MAX_WIDTH=36
|
|
529
|
+
IMAGE_MAX_HEIGHT=14
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
515
534
|
## 🌐 Proxy configuration
|
|
516
535
|
|
|
517
536
|
TuiGram supports routing MTProto connections through HTTP (including HTTPS CONNECT) and SOCKS5/SOCKS4 proxies — both with and without username/password authentication.
|
|
@@ -612,6 +631,7 @@ TuiGram/
|
|
|
612
631
|
│ │ ├── init.js # tuigram init / paths — setup and diagnostics
|
|
613
632
|
│ │ └── formatters.js # Console table and log formatters
|
|
614
633
|
│ └── utils/
|
|
634
|
+
│ ├── image.js # Image decoding, resizing and Half-Block ANSI rendering
|
|
615
635
|
│ ├── storage.js # File operations and session persistence
|
|
616
636
|
│ └── time.js # Time and date formatting
|
|
617
637
|
├── scripts/
|
package/README.ru.md
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
- [Слэш-команды](#-слэш-команды-в-поле-ввода)
|
|
52
52
|
- [Отправка файлов и картинок](#-отправка-файлов-и-картинок)
|
|
53
53
|
- [Темы оформления](#-темы-оформления)
|
|
54
|
+
- [Превью изображений в сообщениях](#️-превью-изображений-в-сообщениях)
|
|
54
55
|
- [Прокси-сервер](#-прокси-сервер)
|
|
55
56
|
- [Использование через консоль (CLI)](#️-использование-через-консоль-cli)
|
|
56
57
|
- [Структура проекта](#-структура-проекта)
|
|
@@ -70,6 +71,7 @@
|
|
|
70
71
|
- Мгновенный поиск и фильтрация чатов по названию и `@username` (`/`);
|
|
71
72
|
- Отображение статуса набора текста («Собеседник печатает...») в реальном времени;
|
|
72
73
|
- Цветовое форматирование Telegram Entities (Bold, Italic, Monospace Code, URLs, Mentions, Spoilers);
|
|
74
|
+
- Цветные превью изображений высокого разрешения прямо в чате (Unicode Half-Block `▀` pixel art);
|
|
73
75
|
- Индикаторы медиа-вложений (фото, видео, документы, голосовые, стикеры, опросы);
|
|
74
76
|
- Реакции на сообщения (👍, 🔥, ❤️);
|
|
75
77
|
- Бесконечная пагинация истории сообщений вверх (`PageUp` / `Ctrl+U`);
|
|
@@ -506,6 +508,23 @@ TUI_THEME=light # светлая
|
|
|
506
508
|
|
|
507
509
|
---
|
|
508
510
|
|
|
511
|
+
## 🖼️ Превью изображений в сообщениях
|
|
512
|
+
|
|
513
|
+
TuiGram отображает цветные превью фотографий прямо в ленте сообщений чата с помощью псевдографики высокого разрешения (Unicode Half-Block `▀` `U+2580`). Каждое знакоместо терминала вмещает 2 вертикальных субпикселя с 24-битными цветами, обеспечивая чёткое пропорциональное изображение 1:1, которое плавно прокручивается вместе с сообщениями и поддерживается всеми терминалами.
|
|
514
|
+
|
|
515
|
+
Отображение превью включено по умолчанию и настраивается в `.env`:
|
|
516
|
+
|
|
517
|
+
```env
|
|
518
|
+
# Включение/отключение превью картинок в чате (true/false)
|
|
519
|
+
SHOW_IMAGES=true
|
|
520
|
+
|
|
521
|
+
# Максимальный размер превью в символах терминала (ширина x высота)
|
|
522
|
+
IMAGE_MAX_WIDTH=36
|
|
523
|
+
IMAGE_MAX_HEIGHT=14
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
---
|
|
527
|
+
|
|
509
528
|
## 🌐 Прокси-сервер
|
|
510
529
|
|
|
511
530
|
TuiGram поддерживает маршрутизацию MTProto-соединений через HTTP (включая HTTPS CONNECT) и SOCKS5/SOCKS4 прокси — как с авторизацией по логину и паролю, так и без неё.
|
|
@@ -606,6 +625,7 @@ TuiGram/
|
|
|
606
625
|
│ │ ├── init.js # tuigram init / paths — настройка и диагностика
|
|
607
626
|
│ │ └── formatters.js # Консольные форматтеры таблиц и логов
|
|
608
627
|
│ └── utils/
|
|
628
|
+
│ ├── image.js # Декодирование, масштабирование и Half-Block ANSI рендеринг изображений
|
|
609
629
|
│ ├── storage.js # Файловые операции и сохранение сессий
|
|
610
630
|
│ └── time.js # Форматирование времени и дат
|
|
611
631
|
├── scripts/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emaxe/tuigram",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Полнофункциональный TUI & CLI клиент Telegram на Node.js на базе MTProto (teleproto)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -70,7 +70,9 @@
|
|
|
70
70
|
"colorette": "^2.0.20",
|
|
71
71
|
"dotenv": "^16.4.7",
|
|
72
72
|
"input": "^1.0.1",
|
|
73
|
+
"jpeg-js": "^0.4.4",
|
|
73
74
|
"neo-blessed": "^0.2.0",
|
|
75
|
+
"pngjs": "^7.0.0",
|
|
74
76
|
"teleproto": "^1.229.0"
|
|
75
77
|
}
|
|
76
78
|
}
|
package/src/config.js
CHANGED
|
@@ -251,6 +251,9 @@ export const config = {
|
|
|
251
251
|
theme: process.env.TUI_THEME || "default",
|
|
252
252
|
autoScroll: String(process.env.AUTO_SCROLL || "true").toLowerCase() === "true",
|
|
253
253
|
showTyping: String(process.env.SHOW_TYPING || "true").toLowerCase() === "true",
|
|
254
|
+
showImages: String(process.env.SHOW_IMAGES || "true").toLowerCase() !== "false",
|
|
255
|
+
imageMaxWidth: parseInt(process.env.IMAGE_MAX_WIDTH || "36", 10) || 36,
|
|
256
|
+
imageMaxHeight: parseInt(process.env.IMAGE_MAX_HEIGHT || "14", 10) || 14,
|
|
254
257
|
|
|
255
258
|
proxy: parseProxyConfig(),
|
|
256
259
|
|
package/src/state.js
CHANGED
|
@@ -140,7 +140,7 @@ class AppState extends EventEmitter {
|
|
|
140
140
|
combined.sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
|
|
141
141
|
|
|
142
142
|
this.messagesByChat.set(chatId, combined);
|
|
143
|
-
this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend });
|
|
143
|
+
this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend, isUpdate: false });
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
@@ -181,7 +181,7 @@ class AppState extends EventEmitter {
|
|
|
181
181
|
this.emit("dialogs_updated", this.getVisibleDialogs());
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
this.emit("messages_updated", { chatId, messages: list, isPrepend: false });
|
|
184
|
+
this.emit("messages_updated", { chatId, messages: list, isPrepend: false, isNewMessage: true });
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/**
|
|
@@ -194,7 +194,7 @@ class AppState extends EventEmitter {
|
|
|
194
194
|
const index = list.findIndex((m) => m.id === updatedMessage.id);
|
|
195
195
|
if (index !== -1) {
|
|
196
196
|
list[index] = { ...list[index], ...updatedMessage };
|
|
197
|
-
this.emit("messages_updated", { chatId, messages: list, isPrepend: false });
|
|
197
|
+
this.emit("messages_updated", { chatId, messages: list, isPrepend: false, isUpdate: true });
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
@@ -208,7 +208,7 @@ class AppState extends EventEmitter {
|
|
|
208
208
|
const idSet = new Set(deletedIds);
|
|
209
209
|
const filtered = list.filter((m) => !idSet.has(m.id));
|
|
210
210
|
this.messagesByChat.set(chatId, filtered);
|
|
211
|
-
this.emit("messages_updated", { chatId, messages: filtered, isPrepend: false });
|
|
211
|
+
this.emit("messages_updated", { chatId, messages: filtered, isPrepend: false, isUpdate: true });
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/**
|
package/src/telegram/messages.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Api, errors } from "teleproto";
|
|
2
2
|
import { idToString, toMarkedId, getEntityDisplayName, entityCache, resolveEntity } from "./entities.js";
|
|
3
3
|
import { describeMedia } from "./formatter.js";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
import { renderStrippedThumbnail, renderImageBuffer } from "../utils/image.js";
|
|
4
6
|
|
|
5
7
|
const { FloodWaitError } = errors;
|
|
6
8
|
|
|
@@ -38,6 +40,27 @@ export function normalizeMessage(message) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
// Извлечение и рендеринг PhotoStrippedSize в псевдографику
|
|
44
|
+
let imagePreview = null;
|
|
45
|
+
if (config.showImages && message.media) {
|
|
46
|
+
const media = message.media;
|
|
47
|
+
const photo = media.photo;
|
|
48
|
+
const doc = media.document;
|
|
49
|
+
const sizes = photo?.sizes || doc?.thumbs || [];
|
|
50
|
+
const stripped = sizes.find((s) => s?.className === "PhotoStrippedSize" || s?.type === "i" || (s?.bytes && s.bytes.length > 0));
|
|
51
|
+
|
|
52
|
+
if (stripped?.bytes) {
|
|
53
|
+
const cacheKey = photo?.id
|
|
54
|
+
? `photo_${photo.id}`
|
|
55
|
+
: (doc?.id ? `doc_${doc.id}` : `msg_${message.id}`);
|
|
56
|
+
imagePreview = renderStrippedThumbnail(stripped.bytes, {
|
|
57
|
+
maxWidth: config.imageMaxWidth,
|
|
58
|
+
maxHeight: config.imageMaxHeight,
|
|
59
|
+
cacheKey,
|
|
60
|
+
}) || null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
return {
|
|
42
65
|
id: message.id,
|
|
43
66
|
date: message.date ? message.date * 1000 : Date.now(),
|
|
@@ -53,6 +76,7 @@ export function normalizeMessage(message) {
|
|
|
53
76
|
forwards: message.forwards || null,
|
|
54
77
|
media: message.media || null,
|
|
55
78
|
mediaDescription: describeMedia(message.media),
|
|
79
|
+
imagePreview,
|
|
56
80
|
entities: message.entities || [],
|
|
57
81
|
reactions,
|
|
58
82
|
rawMessage: message,
|
|
@@ -263,3 +287,37 @@ export async function markAsRead(client, rawPeer, maxId = 0) {
|
|
|
263
287
|
// Игнорируем незначительные сетевые ошибки прочтения
|
|
264
288
|
}
|
|
265
289
|
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Асинхронно загружает и декодирует превью изображения сообщения через MTProto.
|
|
293
|
+
* @param {import("teleproto").TelegramClient} client
|
|
294
|
+
* @param {object} rawMessage
|
|
295
|
+
* @param {object} [options]
|
|
296
|
+
* @param {number} [options.maxWidth]
|
|
297
|
+
* @param {number} [options.maxHeight]
|
|
298
|
+
* @returns {Promise<string>}
|
|
299
|
+
*/
|
|
300
|
+
export async function loadMessageImagePreview(client, rawMessage, { maxWidth = config.imageMaxWidth, maxHeight = config.imageMaxHeight } = {}) {
|
|
301
|
+
if (!rawMessage?.media) return "";
|
|
302
|
+
const media = rawMessage.media;
|
|
303
|
+
const isPhoto = media.className === "MessageMediaPhoto";
|
|
304
|
+
const isDoc = media.className === "MessageMediaDocument";
|
|
305
|
+
if (!isPhoto && !isDoc) return "";
|
|
306
|
+
|
|
307
|
+
const cacheKey = isPhoto
|
|
308
|
+
? `photo_full_${media.photo?.id || rawMessage.id}`
|
|
309
|
+
: `doc_full_${media.document?.id || rawMessage.id}`;
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const thumbBuf = await client.downloadMedia(media, { thumb: 1 });
|
|
313
|
+
if (!thumbBuf || thumbBuf.length === 0) return "";
|
|
314
|
+
return renderImageBuffer(thumbBuf, {
|
|
315
|
+
maxWidth,
|
|
316
|
+
maxHeight,
|
|
317
|
+
cacheKey,
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
return "";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
package/src/ui/app.js
CHANGED
|
@@ -16,7 +16,7 @@ import { state } from "../state.js";
|
|
|
16
16
|
import { config } from "../config.js";
|
|
17
17
|
import { fetchDialogs } from "../telegram/dialogs.js";
|
|
18
18
|
import { setMessagePalette } from "../telegram/formatter.js";
|
|
19
|
-
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead } from "../telegram/messages.js";
|
|
19
|
+
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
|
|
20
20
|
import { startTelegramListener } from "../telegram/listener.js";
|
|
21
21
|
import { logout } from "../telegram/auth.js";
|
|
22
22
|
import { ensureDir, inspectLocalFile } from "../utils/storage.js";
|
|
@@ -128,6 +128,7 @@ export async function startTui(client, me) {
|
|
|
128
128
|
state.setMessages(dialog.id, history.messages);
|
|
129
129
|
markAsRead(client, dialog.peerId).catch(() => {});
|
|
130
130
|
statusBar.showMessage(`Чат: ${dialog.title}`, "info");
|
|
131
|
+
fetchMissingImagePreviews(history.messages, dialog.id);
|
|
131
132
|
} catch (err) {
|
|
132
133
|
statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
|
|
133
134
|
}
|
|
@@ -157,6 +158,7 @@ export async function startTui(client, me) {
|
|
|
157
158
|
if (older.messages.length > 0) {
|
|
158
159
|
state.setMessages(state.activeChat.id, older.messages, true);
|
|
159
160
|
statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
|
|
161
|
+
fetchMissingImagePreviews(older.messages, state.activeChat.id);
|
|
160
162
|
}
|
|
161
163
|
} catch (err) {
|
|
162
164
|
statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
|
|
@@ -291,9 +293,13 @@ export async function startTui(client, me) {
|
|
|
291
293
|
chatView.setMessages(msgs);
|
|
292
294
|
});
|
|
293
295
|
|
|
294
|
-
state.on("messages_updated", ({ chatId, messages, isPrepend }) => {
|
|
296
|
+
state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage }) => {
|
|
295
297
|
if (state.activeChat?.id === chatId) {
|
|
296
|
-
|
|
298
|
+
// Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
|
|
299
|
+
// При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
|
|
300
|
+
// позиция скролла сохраняется!
|
|
301
|
+
const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
|
|
302
|
+
chatView.setMessages(messages, shouldScrollToBottom);
|
|
297
303
|
}
|
|
298
304
|
});
|
|
299
305
|
|
|
@@ -316,11 +322,41 @@ export async function startTui(client, me) {
|
|
|
316
322
|
}
|
|
317
323
|
});
|
|
318
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Фоново догружает превью изображений для сообщений, не имевших встроенного PhotoStrippedSize.
|
|
327
|
+
* @param {Array<object>} messages
|
|
328
|
+
* @param {string} chatId
|
|
329
|
+
*/
|
|
330
|
+
async function fetchMissingImagePreviews(messages, chatId) {
|
|
331
|
+
if (!config.showImages || !messages || messages.length === 0) return;
|
|
332
|
+
for (const msg of messages) {
|
|
333
|
+
if (state.activeChat?.id !== chatId) break;
|
|
334
|
+
if (msg.media && !msg.imagePreview && msg.rawMessage) {
|
|
335
|
+
const isPhoto = msg.media.className === "MessageMediaPhoto";
|
|
336
|
+
const isDoc = msg.media.className === "MessageMediaDocument";
|
|
337
|
+
if (isPhoto || isDoc) {
|
|
338
|
+
try {
|
|
339
|
+
const preview = await loadMessageImagePreview(client, msg.rawMessage);
|
|
340
|
+
if (preview && state.activeChat?.id === chatId) {
|
|
341
|
+
msg.imagePreview = preview;
|
|
342
|
+
state.updateMessage(chatId, msg);
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
// Игнорируем сетевые ошибки фоновой загрузки превью
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
319
352
|
// 6. Подключение фонового слушателя MTProto событий
|
|
320
353
|
listener = startTelegramListener(client);
|
|
321
354
|
|
|
322
355
|
listener.on("new_message", ({ peerId, message }) => {
|
|
323
356
|
state.addMessage(peerId, message);
|
|
357
|
+
if (!message.imagePreview && (message.media?.className === "MessageMediaPhoto" || message.media?.className === "MessageMediaDocument")) {
|
|
358
|
+
fetchMissingImagePreviews([message], peerId);
|
|
359
|
+
}
|
|
324
360
|
|
|
325
361
|
if (state.activeChat?.id === peerId) {
|
|
326
362
|
markAsRead(client, state.activeChat.peerId).catch(() => {});
|
|
@@ -465,6 +501,21 @@ export async function startTui(client, me) {
|
|
|
465
501
|
screen.key(["tab"], () => moveFocus(1));
|
|
466
502
|
screen.key(["S-tab"], () => moveFocus(-1));
|
|
467
503
|
|
|
504
|
+
screen.key(["pageup", "C-u"], () => {
|
|
505
|
+
if (!state.activeChat) return;
|
|
506
|
+
chatView.scrollBox.scroll(-10);
|
|
507
|
+
if (chatView.scrollBox.getScroll() <= 0) {
|
|
508
|
+
chatView.loadMore?.();
|
|
509
|
+
}
|
|
510
|
+
screen.render();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
screen.key(["pagedown", "C-d"], () => {
|
|
514
|
+
if (!state.activeChat) return;
|
|
515
|
+
chatView.scrollBox.scroll(10);
|
|
516
|
+
screen.render();
|
|
517
|
+
});
|
|
518
|
+
|
|
468
519
|
screen.key(["f1", "?"], () => {
|
|
469
520
|
releaseInputs();
|
|
470
521
|
helpModal.show();
|
|
@@ -38,7 +38,6 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
38
38
|
bottom: 0,
|
|
39
39
|
tags: true,
|
|
40
40
|
scrollable: true,
|
|
41
|
-
alwaysScroll: true,
|
|
42
41
|
mouse: true,
|
|
43
42
|
keys: true,
|
|
44
43
|
vi: true,
|
|
@@ -99,8 +98,19 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
99
98
|
|
|
100
99
|
// Текст сообщения и entities
|
|
101
100
|
let bodyText = formatMessageText(msg.text, msg.entities);
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
|
|
102
|
+
// Блок медиа-вложения и превью изображения
|
|
103
|
+
let mediaBlock = "";
|
|
104
|
+
if (msg.imagePreview) {
|
|
105
|
+
mediaBlock = msg.mediaDescription
|
|
106
|
+
? `${msg.mediaDescription}\n${msg.imagePreview}`
|
|
107
|
+
: msg.imagePreview;
|
|
108
|
+
} else if (msg.mediaDescription) {
|
|
109
|
+
mediaBlock = msg.mediaDescription;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (mediaBlock) {
|
|
113
|
+
bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
// Отступ строк текста сообщения
|
|
@@ -135,26 +145,60 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
135
145
|
*/
|
|
136
146
|
function setMessages(messages, autoScrollToBottom = true) {
|
|
137
147
|
currentMessages = messages;
|
|
148
|
+
const prevScroll = scrollBox.getScroll();
|
|
149
|
+
const prevHeight = scrollBox.getScrollHeight();
|
|
150
|
+
|
|
138
151
|
scrollBox.setContent(renderMessages(messages));
|
|
152
|
+
|
|
139
153
|
if (autoScrollToBottom) {
|
|
140
154
|
scrollBox.setScrollPerc(100);
|
|
155
|
+
} else {
|
|
156
|
+
// Сохраняем относительную позицию скролла: если добавились старые сообщения сверху,
|
|
157
|
+
// компенсируем сдвиг высоты ленты
|
|
158
|
+
const newHeight = scrollBox.getScrollHeight();
|
|
159
|
+
const addedLines = newHeight - prevHeight;
|
|
160
|
+
if (addedLines > 0 && prevScroll > 0) {
|
|
161
|
+
scrollBox.scrollTo(prevScroll + addedLines);
|
|
162
|
+
} else if (prevScroll > 0) {
|
|
163
|
+
scrollBox.scrollTo(prevScroll);
|
|
164
|
+
}
|
|
141
165
|
}
|
|
142
166
|
screen.render();
|
|
143
167
|
}
|
|
144
168
|
|
|
145
169
|
// Обработка прокрутки вверх для подгрузки истории
|
|
146
|
-
|
|
147
|
-
scrollBox.scroll(-
|
|
170
|
+
function handleScrollUp(step = 10) {
|
|
171
|
+
scrollBox.scroll(-step);
|
|
148
172
|
if (scrollBox.getScroll() <= 0) {
|
|
149
173
|
onLoadMoreHistory?.();
|
|
150
174
|
}
|
|
151
175
|
screen.render();
|
|
152
|
-
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function handleScrollDown(step = 10) {
|
|
179
|
+
scrollBox.scroll(step);
|
|
180
|
+
screen.render();
|
|
181
|
+
}
|
|
153
182
|
|
|
154
|
-
scrollBox.key(["
|
|
155
|
-
|
|
183
|
+
scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
|
|
184
|
+
scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
|
|
185
|
+
scrollBox.key(["up", "k"], () => handleScrollUp(2));
|
|
186
|
+
scrollBox.key(["down", "j"], () => handleScrollDown(2));
|
|
187
|
+
scrollBox.key(["home"], () => {
|
|
188
|
+
scrollBox.scrollTo(0);
|
|
189
|
+
onLoadMoreHistory?.();
|
|
156
190
|
screen.render();
|
|
157
191
|
});
|
|
192
|
+
scrollBox.key(["end"], () => {
|
|
193
|
+
scrollBox.setScrollPerc(100);
|
|
194
|
+
screen.render();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
scrollBox.on("wheelup", () => {
|
|
198
|
+
if (scrollBox.getScroll() <= 0) {
|
|
199
|
+
onLoadMoreHistory?.();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
158
202
|
|
|
159
203
|
// Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
|
|
160
204
|
// висит на Ctrl+A — иначе оно недостижимо.
|
|
@@ -169,6 +213,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
169
213
|
container,
|
|
170
214
|
scrollBox,
|
|
171
215
|
setMessages,
|
|
216
|
+
loadMore: () => onLoadMoreHistory?.(),
|
|
172
217
|
scrollToBottom: () => {
|
|
173
218
|
scrollBox.setScrollPerc(100);
|
|
174
219
|
screen.render();
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Модуль обработки и рендеринга изображений в терминале (Unicode Half-Block).
|
|
3
|
+
* Преобразует растровые изображения (JPEG, PNG, PhotoStrippedSize) в псевдографику
|
|
4
|
+
* высокого разрешения с разметкой blessed и 24-битными hex-цветами.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import jpegJs from "jpeg-js";
|
|
8
|
+
import { PNG } from "pngjs";
|
|
9
|
+
|
|
10
|
+
/** Заголовок стандартного JPEG для распаковки Telegram PhotoStrippedSize. */
|
|
11
|
+
const JPEG_HEADER = Buffer.from([
|
|
12
|
+
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
|
|
13
|
+
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
|
|
14
|
+
0x00, 0x28, 0x1c, 0x1e, 0x23, 0x1e, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2d,
|
|
15
|
+
0x2b, 0x28, 0x30, 0x3c, 0x64, 0x41, 0x3c, 0x37, 0x37, 0x3c, 0x7b, 0x58,
|
|
16
|
+
0x5d, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8f, 0x80, 0x8c, 0x8a, 0xa0,
|
|
17
|
+
0xb4, 0xe6, 0xc3, 0xa0, 0xaa, 0xda, 0xad, 0x8a, 0x8c, 0xc8, 0xff, 0xcb,
|
|
18
|
+
0xda, 0xee, 0xf5, 0xff, 0xff, 0xff, 0x9b, 0xc1, 0xff, 0xff, 0xf7, 0xfa,
|
|
19
|
+
0xff, 0xe6, 0xfd, 0xff, 0xf8, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x2b, 0x2d,
|
|
20
|
+
0x2d, 0x3c, 0x35, 0x3c, 0x75, 0x41, 0x41, 0x75, 0xf8, 0xa5, 0x8c, 0xa5,
|
|
21
|
+
0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
|
|
22
|
+
0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
|
|
23
|
+
0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
|
|
24
|
+
0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xff, 0xc0, 0x00,
|
|
25
|
+
0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11,
|
|
26
|
+
0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00, 0x01, 0x05,
|
|
27
|
+
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
28
|
+
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
|
|
29
|
+
0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
|
|
30
|
+
0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02,
|
|
31
|
+
0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
|
|
32
|
+
0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42,
|
|
33
|
+
0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
|
|
34
|
+
0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
|
|
35
|
+
0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47,
|
|
36
|
+
0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63,
|
|
37
|
+
0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77,
|
|
38
|
+
0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92,
|
|
39
|
+
0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5,
|
|
40
|
+
0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8,
|
|
41
|
+
0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2,
|
|
42
|
+
0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4,
|
|
43
|
+
0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
|
|
44
|
+
0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, 0x03, 0x01,
|
|
45
|
+
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
|
46
|
+
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
|
|
47
|
+
0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
|
|
48
|
+
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01,
|
|
49
|
+
0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
|
|
50
|
+
0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1,
|
|
51
|
+
0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16,
|
|
52
|
+
0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28,
|
|
53
|
+
0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46,
|
|
54
|
+
0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a,
|
|
55
|
+
0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76,
|
|
56
|
+
0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
|
|
57
|
+
0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3,
|
|
58
|
+
0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6,
|
|
59
|
+
0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9,
|
|
60
|
+
0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe2, 0xe3,
|
|
61
|
+
0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
|
|
62
|
+
0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02,
|
|
63
|
+
0x11, 0x03, 0x11, 0x00, 0x3f, 0x00
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** Завершающий маркер JPEG (End Of Image). */
|
|
67
|
+
const JPEG_FOOTER = Buffer.from([0xff, 0xd9]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Память кэша для готовых строк псевдографики.
|
|
71
|
+
* @type {Map<string, string>}
|
|
72
|
+
*/
|
|
73
|
+
export const imagePreviewCache = new Map();
|
|
74
|
+
const MAX_CACHE_SIZE = 500;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Распаковывает PhotoStrippedSize Telegram в валидный JPEG буфер.
|
|
78
|
+
* @param {Buffer|Uint8Array} stripped
|
|
79
|
+
* @returns {Buffer}
|
|
80
|
+
*/
|
|
81
|
+
export function strippedPhotoToJpg(stripped) {
|
|
82
|
+
if (!stripped || stripped.length < 3) {
|
|
83
|
+
return Buffer.isBuffer(stripped) ? stripped : Buffer.from(stripped || []);
|
|
84
|
+
}
|
|
85
|
+
const buf = Buffer.isBuffer(stripped) ? stripped : Buffer.from(stripped);
|
|
86
|
+
if (buf[0] !== 1) {
|
|
87
|
+
return buf;
|
|
88
|
+
}
|
|
89
|
+
const header = Buffer.from(JPEG_HEADER);
|
|
90
|
+
header[164] = buf[1];
|
|
91
|
+
header[166] = buf[2];
|
|
92
|
+
return Buffer.concat([header, buf.subarray(3), JPEG_FOOTER]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Декодирует буфер изображения (JPEG или PNG) в сырой RGBA буфер.
|
|
97
|
+
* @param {Buffer|Uint8Array} buffer
|
|
98
|
+
* @param {string} [mimeType]
|
|
99
|
+
* @returns {{ width: number, height: number, data: Uint8Array }}
|
|
100
|
+
*/
|
|
101
|
+
export function decodeImageBuffer(buffer, mimeType = "") {
|
|
102
|
+
if (!buffer || buffer.length === 0) {
|
|
103
|
+
throw new Error("Пустой буфер изображения");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
107
|
+
|
|
108
|
+
// Определение формата по magic bytes или mimeType
|
|
109
|
+
const isPng = (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
|
|
110
|
+
|| mimeType.includes("png");
|
|
111
|
+
|
|
112
|
+
if (isPng) {
|
|
113
|
+
const png = PNG.sync.read(buf);
|
|
114
|
+
return {
|
|
115
|
+
width: png.width,
|
|
116
|
+
height: png.height,
|
|
117
|
+
data: png.data,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// По умолчанию считаем JPEG
|
|
122
|
+
const decoded = jpegJs.decode(buf, { useTArray: true, formatAsRGBA: true });
|
|
123
|
+
return {
|
|
124
|
+
width: decoded.width,
|
|
125
|
+
height: decoded.height,
|
|
126
|
+
data: decoded.data,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Рассчитывает целевые размеры изображения в терминале с сохранением пропорций.
|
|
132
|
+
* Каждый терминальный ряд Half-Block вмещает 2 вертикальных субпикселя.
|
|
133
|
+
* @param {number} srcW Исходная ширина в пикселях
|
|
134
|
+
* @param {number} srcH Исходная высота в пикселях
|
|
135
|
+
* @param {number} [maxWidth=36] Максимальная ширина в колонках символов
|
|
136
|
+
* @param {number} [maxHeight=14] Максимальная высота в строках терминала
|
|
137
|
+
* @returns {{ dstW: number, dstH: number, rows: number }}
|
|
138
|
+
*/
|
|
139
|
+
export function calculateTargetDimensions(srcW, srcH, maxWidth = 36, maxHeight = 14) {
|
|
140
|
+
const safeW = Math.max(1, srcW || 1);
|
|
141
|
+
const safeH = Math.max(1, srcH || 1);
|
|
142
|
+
const maxPixelH = Math.max(2, maxHeight * 2);
|
|
143
|
+
|
|
144
|
+
const aspect = safeW / safeH;
|
|
145
|
+
const maxAspect = maxWidth / maxPixelH;
|
|
146
|
+
|
|
147
|
+
let dstW = maxWidth;
|
|
148
|
+
let dstPixelH = maxPixelH;
|
|
149
|
+
|
|
150
|
+
if (aspect >= maxAspect) {
|
|
151
|
+
dstW = Math.min(maxWidth, safeW);
|
|
152
|
+
dstPixelH = Math.max(2, Math.round(dstW / aspect));
|
|
153
|
+
} else {
|
|
154
|
+
dstPixelH = Math.min(maxPixelH, safeH * 2);
|
|
155
|
+
dstW = Math.max(1, Math.round(dstPixelH * aspect));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Округляем пиксельную высоту до четного числа (для парных субпикселей ▀)
|
|
159
|
+
const rows = Math.max(1, Math.ceil(dstPixelH / 2));
|
|
160
|
+
dstPixelH = rows * 2;
|
|
161
|
+
|
|
162
|
+
return { dstW, dstH: dstPixelH, rows };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Масштабирует RGBA буфер методом билинейной интерполяции.
|
|
167
|
+
* @param {Uint8Array} src RGBA буфер исходного изображения
|
|
168
|
+
* @param {number} srcW Исходная ширина
|
|
169
|
+
* @param {number} srcH Исходная высота
|
|
170
|
+
* @param {number} dstW Целевая ширина
|
|
171
|
+
* @param {number} dstH Целевая высота
|
|
172
|
+
* @returns {Uint8Array} Результирующий RGBA буфер длины dstW * dstH * 4
|
|
173
|
+
*/
|
|
174
|
+
export function resizeRgba(src, srcW, srcH, dstW, dstH) {
|
|
175
|
+
const dst = new Uint8Array(dstW * dstH * 4);
|
|
176
|
+
if (srcW === dstW && srcH === dstH) {
|
|
177
|
+
dst.set(src);
|
|
178
|
+
return dst;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const xRatio = srcW / dstW;
|
|
182
|
+
const yRatio = srcH / dstH;
|
|
183
|
+
|
|
184
|
+
for (let dy = 0; dy < dstH; dy++) {
|
|
185
|
+
const srcY = (dy + 0.5) * yRatio - 0.5;
|
|
186
|
+
const y0 = Math.max(0, Math.min(srcH - 1, Math.floor(srcY)));
|
|
187
|
+
const y1 = Math.max(0, Math.min(srcH - 1, y0 + 1));
|
|
188
|
+
const yWeight = Math.max(0, Math.min(1, srcY - y0));
|
|
189
|
+
|
|
190
|
+
const y0Offset = y0 * srcW * 4;
|
|
191
|
+
const y1Offset = y1 * srcW * 4;
|
|
192
|
+
const dstRowOffset = dy * dstW * 4;
|
|
193
|
+
|
|
194
|
+
for (let dx = 0; dx < dstW; dx++) {
|
|
195
|
+
const srcX = (dx + 0.5) * xRatio - 0.5;
|
|
196
|
+
const x0 = Math.max(0, Math.min(srcW - 1, Math.floor(srcX)));
|
|
197
|
+
const x1 = Math.max(0, Math.min(srcW - 1, x0 + 1));
|
|
198
|
+
const xWeight = Math.max(0, Math.min(1, srcX - x0));
|
|
199
|
+
|
|
200
|
+
const p00 = y0Offset + x0 * 4;
|
|
201
|
+
const p10 = y0Offset + x1 * 4;
|
|
202
|
+
const p01 = y1Offset + x0 * 4;
|
|
203
|
+
const p11 = y1Offset + x1 * 4;
|
|
204
|
+
|
|
205
|
+
const dstOffset = dstRowOffset + dx * 4;
|
|
206
|
+
|
|
207
|
+
// Интерполяция 4 каналов: R, G, B, A
|
|
208
|
+
for (let c = 0; c < 4; c++) {
|
|
209
|
+
const top = (1 - xWeight) * src[p00 + c] + xWeight * src[p10 + c];
|
|
210
|
+
const bot = (1 - xWeight) * src[p01 + c] + xWeight * src[p11 + c];
|
|
211
|
+
dst[dstOffset + c] = Math.round((1 - yWeight) * top + yWeight * bot);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return dst;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Преобразует компоненты цвета R, G, B в hex-строку вида "#rrggbb".
|
|
221
|
+
* @param {number} r
|
|
222
|
+
* @param {number} g
|
|
223
|
+
* @param {number} b
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
export function rgbaToHex(r, g, b) {
|
|
227
|
+
const hexR = (r < 16 ? "0" : "") + r.toString(16);
|
|
228
|
+
const hexG = (g < 16 ? "0" : "") + g.toString(16);
|
|
229
|
+
const hexB = (b < 16 ? "0" : "") + b.toString(16);
|
|
230
|
+
return `#${hexR}${hexG}${hexB}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Преобразует масштабированный RGBA буфер в многострочный текст Blessed
|
|
235
|
+
* с использованием символов верхнего полублока (▀ U+2580).
|
|
236
|
+
*
|
|
237
|
+
* Верхний пиксель ячейки задаётся через тег цвета текста (`{ #rrggbb-fg }`),
|
|
238
|
+
* нижний пиксель — через тег цвета фона (`{ #rrggbb-bg }`).
|
|
239
|
+
*
|
|
240
|
+
* @param {Uint8Array} data RGBA буфер
|
|
241
|
+
* @param {number} width Ширина в символах/пикселях
|
|
242
|
+
* @param {number} height Высота в пикселях (должна быть четной, = rows * 2)
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
export function rgbaToHalfBlockBlessed(data, width, height) {
|
|
246
|
+
const rows = Math.floor(height / 2);
|
|
247
|
+
const lines = [];
|
|
248
|
+
|
|
249
|
+
for (let r = 0; r < rows; r++) {
|
|
250
|
+
let line = "";
|
|
251
|
+
let currentFg = null;
|
|
252
|
+
let currentBg = null;
|
|
253
|
+
|
|
254
|
+
const topRowOffset = (r * 2) * width * 4;
|
|
255
|
+
const botRowOffset = (r * 2 + 1) * width * 4;
|
|
256
|
+
|
|
257
|
+
for (let x = 0; x < width; x++) {
|
|
258
|
+
const topOffset = topRowOffset + x * 4;
|
|
259
|
+
const botOffset = botRowOffset + x * 4;
|
|
260
|
+
|
|
261
|
+
const topHex = rgbaToHex(data[topOffset], data[topOffset + 1], data[topOffset + 2]);
|
|
262
|
+
const botHex = rgbaToHex(data[botOffset], data[botOffset + 1], data[botOffset + 2]);
|
|
263
|
+
|
|
264
|
+
if (topHex !== currentFg) {
|
|
265
|
+
line += `{${topHex}-fg}`;
|
|
266
|
+
currentFg = topHex;
|
|
267
|
+
}
|
|
268
|
+
if (botHex !== currentBg) {
|
|
269
|
+
line += `{${botHex}-bg}`;
|
|
270
|
+
currentBg = botHex;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
line += "▀";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (currentFg || currentBg) {
|
|
277
|
+
line += "{/}";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
lines.push(line);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Выполняет полный цикл декодирования, масштабирования и рендеринга изображения в Blessed-строку.
|
|
288
|
+
* @param {Buffer|Uint8Array} buffer Исходный буфер изображения (JPEG или PNG)
|
|
289
|
+
* @param {object} [options]
|
|
290
|
+
* @param {string} [options.mimeType=""] MIME-тип изображения
|
|
291
|
+
* @param {number} [options.maxWidth=36] Максимальная ширина
|
|
292
|
+
* @param {number} [options.maxHeight=14] Максимальная высота
|
|
293
|
+
* @param {string} [options.cacheKey] Ключ для сохранения в кэш
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
|
|
297
|
+
if (cacheKey && imagePreviewCache.has(cacheKey)) {
|
|
298
|
+
return imagePreviewCache.get(cacheKey);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const decoded = decodeImageBuffer(buffer, mimeType);
|
|
303
|
+
const { dstW, dstH } = calculateTargetDimensions(decoded.width, decoded.height, maxWidth, maxHeight);
|
|
304
|
+
const resized = resizeRgba(decoded.data, decoded.width, decoded.height, dstW, dstH);
|
|
305
|
+
const blessedText = rgbaToHalfBlockBlessed(resized, dstW, dstH);
|
|
306
|
+
|
|
307
|
+
if (cacheKey) {
|
|
308
|
+
if (imagePreviewCache.size >= MAX_CACHE_SIZE) {
|
|
309
|
+
const firstKey = imagePreviewCache.keys().next().value;
|
|
310
|
+
imagePreviewCache.delete(firstKey);
|
|
311
|
+
}
|
|
312
|
+
imagePreviewCache.set(cacheKey, blessedText);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return blessedText;
|
|
316
|
+
} catch {
|
|
317
|
+
return "";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Синхронно распаковывает и рендерит PhotoStrippedSize Telegram в Blessed Half-Block строку.
|
|
323
|
+
* @param {Buffer|Uint8Array} strippedBytes Байты PhotoStrippedSize
|
|
324
|
+
* @param {object} [options]
|
|
325
|
+
* @param {number} [options.maxWidth=36]
|
|
326
|
+
* @param {number} [options.maxHeight=14]
|
|
327
|
+
* @param {string} [options.cacheKey]
|
|
328
|
+
* @returns {string}
|
|
329
|
+
*/
|
|
330
|
+
export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
|
|
331
|
+
if (!strippedBytes || strippedBytes.length < 3) return "";
|
|
332
|
+
|
|
333
|
+
if (cacheKey && imagePreviewCache.has(cacheKey)) {
|
|
334
|
+
return imagePreviewCache.get(cacheKey);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const jpgBuf = strippedPhotoToJpg(strippedBytes);
|
|
339
|
+
return renderImageBuffer(jpgBuf, { mimeType: "image/jpeg", maxWidth, maxHeight, cacheKey });
|
|
340
|
+
} catch {
|
|
341
|
+
return "";
|
|
342
|
+
}
|
|
343
|
+
}
|