1 | import fs, {promises as fsPromises} from 'node:fs';
|
2 | import chalk from 'chalk';
|
3 | import Jimp from 'jimp';
|
4 | import termImg from 'term-img';
|
5 | import renderGif from 'render-gif';
|
6 | import logUpdate from 'log-update';
|
7 |
|
8 |
|
9 | const ROW_OFFSET = 2;
|
10 |
|
11 | const PIXEL = '\u2584';
|
12 |
|
13 | function scale(width, height, originalWidth, originalHeight) {
|
14 | const originalRatio = originalWidth / originalHeight;
|
15 | const factor = (width / height > originalRatio ? height / originalHeight : width / originalWidth);
|
16 | width = factor * originalWidth;
|
17 | height = factor * originalHeight;
|
18 | return {width, height};
|
19 | }
|
20 |
|
21 | function checkAndGetDimensionValue(value, percentageBase) {
|
22 | if (typeof value === 'string' && value.endsWith('%')) {
|
23 | const percentageValue = Number.parseFloat(value);
|
24 | if (!Number.isNaN(percentageValue) && percentageValue > 0 && percentageValue <= 100) {
|
25 | return Math.floor(percentageValue / 100 * percentageBase);
|
26 | }
|
27 | }
|
28 |
|
29 | if (typeof value === 'number') {
|
30 | return value;
|
31 | }
|
32 |
|
33 | throw new Error(`${value} is not a valid dimension value`);
|
34 | }
|
35 |
|
36 | function calculateWidthHeight(imageWidth, imageHeight, inputWidth, inputHeight, preserveAspectRatio) {
|
37 | const terminalColumns = process.stdout.columns || 80;
|
38 | const terminalRows = process.stdout.rows - ROW_OFFSET || 24;
|
39 |
|
40 | let width;
|
41 | let height;
|
42 |
|
43 | if (inputHeight && inputWidth) {
|
44 | width = checkAndGetDimensionValue(inputWidth, terminalColumns);
|
45 | height = checkAndGetDimensionValue(inputHeight, terminalRows) * 2;
|
46 |
|
47 | if (preserveAspectRatio) {
|
48 | ({width, height} = scale(width, height, imageWidth, imageHeight));
|
49 | }
|
50 | } else if (inputWidth) {
|
51 | width = checkAndGetDimensionValue(inputWidth, terminalColumns);
|
52 | height = imageHeight * width / imageWidth;
|
53 | } else if (inputHeight) {
|
54 | height = checkAndGetDimensionValue(inputHeight, terminalRows) * 2;
|
55 | width = imageWidth * height / imageHeight;
|
56 | } else {
|
57 | ({width, height} = scale(terminalColumns, terminalRows * 2, imageWidth, imageHeight));
|
58 | }
|
59 |
|
60 | if (width > terminalColumns) {
|
61 | ({width, height} = scale(terminalColumns, terminalRows * 2, width, height));
|
62 | }
|
63 |
|
64 | width = Math.round(width);
|
65 | height = Math.round(height);
|
66 |
|
67 | return {width, height};
|
68 | }
|
69 |
|
70 | async function render(buffer, {width: inputWidth, height: inputHeight, preserveAspectRatio}) {
|
71 | const image = await Jimp.read(buffer);
|
72 | const {bitmap} = image;
|
73 |
|
74 | const {width, height} = calculateWidthHeight(bitmap.width, bitmap.height, inputWidth, inputHeight, preserveAspectRatio);
|
75 |
|
76 | image.resize(width, height);
|
77 |
|
78 | let result = '';
|
79 | for (let y = 0; y < image.bitmap.height - 1; y += 2) {
|
80 | for (let x = 0; x < image.bitmap.width; x++) {
|
81 | const {r, g, b, a} = Jimp.intToRGBA(image.getPixelColor(x, y));
|
82 | const {r: r2, g: g2, b: b2} = Jimp.intToRGBA(image.getPixelColor(x, y + 1));
|
83 | result += a === 0 ? chalk.reset(' ') : chalk.bgRgb(r, g, b).rgb(r2, g2, b2)(PIXEL);
|
84 | }
|
85 |
|
86 | result += '\n';
|
87 | }
|
88 |
|
89 | return result;
|
90 | }
|
91 |
|
92 | const terminalImage = {};
|
93 |
|
94 | terminalImage.buffer = async (buffer, {width = '100%', height = '100%', preserveAspectRatio = true} = {}) => {
|
95 | return termImg(buffer, {
|
96 | width,
|
97 | height,
|
98 | fallback: () => render(buffer, {height, width, preserveAspectRatio})
|
99 | });
|
100 | };
|
101 |
|
102 | terminalImage.file = async (filePath, options = {}) =>
|
103 | terminalImage.buffer(await fsPromises.readFile(filePath), options);
|
104 |
|
105 | terminalImage.gifBuffer = (buffer, options = {}) => {
|
106 | options = {
|
107 | renderFrame: logUpdate,
|
108 | maximumFrameRate: 30,
|
109 | ...options
|
110 | };
|
111 |
|
112 | const finalize = () => {
|
113 | if (options.renderFrame.done) {
|
114 | options.renderFrame.done();
|
115 | }
|
116 | };
|
117 |
|
118 | const result = termImg(buffer, {
|
119 | width: options.width,
|
120 | height: options.height,
|
121 | fallback: () => false
|
122 | });
|
123 |
|
124 | if (result) {
|
125 | options.renderFrame(result);
|
126 | return finalize;
|
127 | }
|
128 |
|
129 | const animation = renderGif(buffer, async frameData => {
|
130 | options.renderFrame(await terminalImage.buffer(Buffer.from(frameData), options));
|
131 | }, options);
|
132 |
|
133 | return () => {
|
134 | animation.isPlaying = false;
|
135 | finalize();
|
136 | };
|
137 | };
|
138 |
|
139 | terminalImage.gifFile = (filePath, options = {}) =>
|
140 | terminalImage.gifBuffer(fs.readFileSync(filePath), options);
|
141 |
|
142 | export default terminalImage;
|