-
-
Notifications
You must be signed in to change notification settings - Fork 30
Expand file tree
/
Copy pathindex.js
More file actions
285 lines (235 loc) · 9.34 KB
/
index.js
File metadata and controls
285 lines (235 loc) · 9.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
import process from 'node:process';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import chalk from 'chalk';
import {Jimp, intToRGBA} from 'jimp';
import termImg from 'term-img';
import renderGif from 'render-gif';
import logUpdate from 'log-update';
import {imageDimensionsFromData} from 'image-dimensions';
import supportsTerminalGraphics from 'supports-terminal-graphics';
// `log-update` adds an extra newline so the generated frames need to be 2 pixels shorter.
const ROW_OFFSET = 2;
const PIXEL = '\u2584';
function scale(width, height, originalWidth, originalHeight) {
const originalRatio = originalWidth / originalHeight;
const factor = (width / height > originalRatio ? height / originalHeight : width / originalWidth);
width = factor * originalWidth;
height = factor * originalHeight;
return {width, height};
}
function checkAndGetDimensionValue(value, percentageBase) {
if (typeof value === 'string' && value.endsWith('%')) {
const percentageValue = Number.parseFloat(value);
if (!Number.isNaN(percentageValue) && percentageValue > 0 && percentageValue <= 100) {
return Math.floor(percentageValue / 100 * percentageBase);
}
}
if (typeof value === 'number') {
return value;
}
throw new Error(`${value} is not a valid dimension value`);
}
function calculateWidthHeight(imageWidth, imageHeight, inputWidth, inputHeight, preserveAspectRatio) {
const terminalColumns = process.stdout.columns || 80;
const terminalRows = Math.max(1, (process.stdout.rows || 24) - ROW_OFFSET);
let width;
let height;
if (inputHeight && inputWidth) {
width = checkAndGetDimensionValue(inputWidth, terminalColumns);
height = checkAndGetDimensionValue(inputHeight, terminalRows) * 2;
if (preserveAspectRatio) {
({width, height} = scale(width, height, imageWidth, imageHeight));
}
} else if (inputWidth) {
width = checkAndGetDimensionValue(inputWidth, terminalColumns);
height = imageHeight * width / imageWidth;
} else if (inputHeight) {
height = checkAndGetDimensionValue(inputHeight, terminalRows) * 2;
width = imageWidth * height / imageHeight;
} else {
({width, height} = scale(terminalColumns, terminalRows * 2, imageWidth, imageHeight));
}
if (width > terminalColumns) {
({width, height} = scale(terminalColumns, terminalRows * 2, width, height));
}
width = Math.round(width);
height = Math.round(height);
return {width, height};
}
async function render(buffer, {width: inputWidth, height: inputHeight, preserveAspectRatio}) {
const image = await Jimp.fromBuffer(Buffer.from(buffer)); // eslint-disable-line n/prefer-global/buffer
const {bitmap} = image;
const {width, height} = calculateWidthHeight(bitmap.width, bitmap.height, inputWidth, inputHeight, preserveAspectRatio);
image.resize({w: width, h: height});
const lines = [];
for (let y = 0; y < bitmap.height - 1; y += 2) {
let line = '';
for (let x = 0; x < bitmap.width; x++) {
const {r, g, b, a} = intToRGBA(image.getPixelColor(x, y));
const {r: r2, g: g2, b: b2} = intToRGBA(image.getPixelColor(x, y + 1));
line += a === 0 ? chalk.reset.rgb(r2, g2, b2)(PIXEL) : chalk.bgRgb(r, g, b).rgb(r2, g2, b2)(PIXEL);
}
lines.push(line);
}
return lines.join('\n');
}
// Kitty graphics protocol implementation
function drawImageWithKitty(buffer, columns, rows) {
const base64Data = buffer.toString('base64');
const chunks = [];
// Split base64 data into chunks for Kitty protocol (4KB chunks)
for (let index = 0; index < base64Data.length; index += 4096) {
chunks.push(base64Data.slice(index, index + 4096));
}
// Build control data
let controlData = 'f=100,a=T'; // PNG format, transmit and display
// Add sizing parameters if specified
if (columns !== undefined && columns > 0) {
controlData += `,c=${Math.round(columns)}`;
}
if (rows !== undefined && rows > 0) {
controlData += `,r=${Math.round(rows)}`;
}
// Send image data using Kitty graphics protocol
for (let index = 0; index < chunks.length; index++) {
const chunk = chunks[index];
const isLast = index === chunks.length - 1;
if (index === 0) {
// First chunk includes all control data
process.stdout.write(`\u001B_G${controlData},m=${isLast ? 0 : 1};${chunk}\u001B\\`);
} else {
// Subsequent chunks only need the m flag
process.stdout.write(`\u001B_Gm=${isLast ? 0 : 1};${chunk}\u001B\\`);
}
}
}
async function renderKitty(buffer, {width: inputWidth, height: inputHeight, preserveAspectRatio}) {
// Terminal dimensions with safety margins
const terminalColumns = (process.stdout.columns || 80) - 2;
const terminalRows = Math.max(1, (process.stdout.rows || 24) - 4);
// Calculate display size in terminal cells
let columns;
let rows;
// Handle width
if (typeof inputWidth === 'string' && inputWidth.endsWith('%')) {
const percentage = Number.parseFloat(inputWidth) / 100;
columns = Math.floor(terminalColumns * percentage);
} else if (typeof inputWidth === 'number') {
columns = Math.min(inputWidth, terminalColumns);
} else {
// Default: use full width if no width specified
columns = terminalColumns;
}
// Handle height
if (typeof inputHeight === 'string' && inputHeight.endsWith('%')) {
const percentage = Number.parseFloat(inputHeight) / 100;
rows = Math.floor(terminalRows * percentage);
} else if (typeof inputHeight === 'number') {
rows = Math.min(inputHeight, terminalRows);
} else if (preserveAspectRatio) {
// If preserveAspectRatio and no height specified, set max height
rows = terminalRows;
} else {
// Only set full height if not preserving aspect ratio
rows = terminalRows;
}
// For PNG images, we can send the original buffer directly
let imageBuffer = buffer;
// Check if the buffer is already PNG format
const isPng = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47;
if (!isPng) {
// Convert to PNG if needed
const image = await Jimp.fromBuffer(Buffer.from(buffer)); // eslint-disable-line n/prefer-global/buffer
imageBuffer = await image.getBuffer('image/png');
}
// When preserving aspect ratio, we need to fit within both dimensions
// The Kitty protocol doesn't directly support "fit within bounds while maintaining aspect ratio"
// when both c and r are specified, so we need to calculate which dimension to use
if (preserveAspectRatio) {
// Get image dimensions to determine which constraint to use
const dimensions = imageDimensionsFromData(buffer);
if (dimensions && dimensions.width && dimensions.height) {
const imageAspectRatio = dimensions.width / dimensions.height;
// Terminal cells are approximately 2:1 (height:width)
const cellAspectRatio = 0.5;
const terminalAspectRatio = (columns * cellAspectRatio) / rows;
if (imageAspectRatio > terminalAspectRatio) {
// Image is wider than terminal space - constrain by width
drawImageWithKitty(imageBuffer, columns, undefined);
} else {
// Image is taller than terminal space - constrain by height
drawImageWithKitty(imageBuffer, undefined, rows);
}
} else {
// Fallback if we can't get dimensions
drawImageWithKitty(imageBuffer, columns, undefined);
}
} else {
// Not preserving aspect ratio, use both dimensions
drawImageWithKitty(imageBuffer, columns, rows);
}
// Return empty string as the drawing is done directly
return '';
}
const terminalImage = {};
terminalImage.buffer = async (buffer, {width = '100%', height = '100%', preserveAspectRatio = true, isGifFrame = false, preferNativeRender = true} = {}) => {
// If not using native terminal rendering, fallback to ANSI
if (!preferNativeRender) {
return render(buffer, {height, width, preserveAspectRatio});
}
// Check for terminal graphics support
// Note: We disable graphics protocols for GIF frames as they don't work well with log-update
if (!isGifFrame && supportsTerminalGraphics.stdout.kitty) {
// Use terminal graphics protocol for high-quality rendering
try {
return await renderKitty(buffer, {width, height, preserveAspectRatio});
} catch {
return render(buffer, {height, width, preserveAspectRatio});
}
}
// Fall back to iTerm2 or ANSI blocks
return termImg(buffer, {
width,
height,
fallback: () => render(buffer, {height, width, preserveAspectRatio}),
});
};
terminalImage.file = async (filePath, options = {}) =>
terminalImage.buffer(await fsPromises.readFile(filePath), options);
terminalImage.gifBuffer = (buffer, options = {}) => {
options = {
renderFrame: logUpdate,
maximumFrameRate: 30,
...options,
};
const finalize = () => {
options.renderFrame.done?.();
};
const dimensions = imageDimensionsFromData(buffer);
if (dimensions?.width < 2 || dimensions?.height < 2) {
throw new Error('The image is too small to be rendered.');
}
// Check if we can use native terminal support for GIF
// Note: Kitty doesn't have native GIF animation support, so we always use frame-by-frame
const result = termImg(buffer, {
width: options.width,
height: options.height,
fallback: () => false,
});
if (result) {
options.renderFrame(result);
return finalize;
}
// Render GIF frame by frame (works with Kitty, iTerm2, and ANSI blocks)
const animation = renderGif(buffer, async frameData => {
options.renderFrame(await terminalImage.buffer(frameData, {...options, isGifFrame: true}));
}, options);
return () => {
animation.isPlaying = false;
finalize();
};
};
terminalImage.gifFile = (filePath, options = {}) =>
terminalImage.gifBuffer(fs.readFileSync(filePath), options);
export default terminalImage;