feat: timg -p kitty
Browse files- src/tools/ImageShowTool/ImageShowTool.tsx +45 -46
- src/utils/terminalImage.ts +83 -41
src/tools/ImageShowTool/ImageShowTool.tsx
CHANGED
|
@@ -4,12 +4,11 @@ import React, { useContext, useEffect, useRef } from 'react'
|
|
| 4 |
import { z } from 'zod/v4'
|
| 5 |
import { RawAnsi, Text } from '../../ink.js'
|
| 6 |
import { TerminalWriteContext } from '../../ink/useTerminalNotification.js'
|
|
|
|
| 7 |
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 8 |
import { logForDebugging } from '../../utils/debug.js'
|
| 9 |
import {
|
| 10 |
detectImageProtocol,
|
| 11 |
-
encodeKittyImage,
|
| 12 |
-
isInsideTmux,
|
| 13 |
renderImageWithTimgSync,
|
| 14 |
} from '../../utils/terminalImage.js'
|
| 15 |
|
|
@@ -122,19 +121,15 @@ function getToolUseSummary(input: Partial<Input>): string | null {
|
|
| 122 |
}
|
| 123 |
|
| 124 |
/**
|
| 125 |
-
*
|
| 126 |
-
*
|
| 127 |
-
*
|
| 128 |
-
* renderToolResultMessage), so Ink knows the image dimensions and its
|
| 129 |
-
* virtual cursor stays in sync with the terminal.
|
| 130 |
*/
|
| 131 |
function TerminalImageDisplay({
|
| 132 |
-
|
| 133 |
-
format,
|
| 134 |
message,
|
| 135 |
}: {
|
| 136 |
-
|
| 137 |
-
format?: string
|
| 138 |
message: string
|
| 139 |
}): React.ReactNode {
|
| 140 |
const writeRaw = useContext(TerminalWriteContext)
|
|
@@ -143,21 +138,9 @@ function TerminalImageDisplay({
|
|
| 143 |
useEffect(() => {
|
| 144 |
if (renderedRef.current || !writeRaw) return
|
| 145 |
renderedRef.current = true
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
// dimensions and cursor position stays correct.
|
| 150 |
-
// Only use Kitty when it can reach the terminal natively.
|
| 151 |
-
const protocol = detectImageProtocol()
|
| 152 |
-
if (protocol === 'kitty' && !isInsideTmux() && base64 && format) {
|
| 153 |
-
const buf = Buffer.from(base64, 'base64')
|
| 154 |
-
const sequence = encodeKittyImage(buf, format)
|
| 155 |
-
if (sequence) {
|
| 156 |
-
writeRaw(sequence + '\n')
|
| 157 |
-
logForDebugging('ImageShow: displayed via Kitty protocol')
|
| 158 |
-
}
|
| 159 |
-
}
|
| 160 |
-
}, [base64, format, writeRaw])
|
| 161 |
|
| 162 |
return <Text dimColor>{message}</Text>
|
| 163 |
}
|
|
@@ -166,7 +149,7 @@ export const ImageShowTool = buildTool({
|
|
| 166 |
name: IMAGE_TOOL_NAME,
|
| 167 |
description:
|
| 168 |
'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
|
| 169 |
-
'Uses the Kitty graphics protocol when
|
| 170 |
'or falls back to timg Unicode-block rendering for universal compatibility. ' +
|
| 171 |
'Supports both URLs (https://) and local file paths. Images are shown inline above the tool result.',
|
| 172 |
|
|
@@ -191,7 +174,7 @@ export const ImageShowTool = buildTool({
|
|
| 191 |
},
|
| 192 |
|
| 193 |
async prompt(_options): Promise<string> {
|
| 194 |
-
return `ImageShow displays a PNG/JPEG/GIF/WebP image directly in the terminal. Uses the Kitty graphics protocol when
|
| 195 |
},
|
| 196 |
|
| 197 |
async checkPermissions(): Promise<{ behavior: 'allow' }> {
|
|
@@ -248,6 +231,7 @@ export const ImageShowTool = buildTool({
|
|
| 248 |
message: string
|
| 249 |
base64?: string
|
| 250 |
format?: string
|
|
|
|
| 251 |
timgOutput?: string
|
| 252 |
},
|
| 253 |
_progressMessages,
|
|
@@ -257,8 +241,20 @@ export const ImageShowTool = buildTool({
|
|
| 257 |
return null
|
| 258 |
}
|
| 259 |
|
| 260 |
-
//
|
| 261 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
if (content.timgOutput) {
|
| 263 |
// Strip cursor hide/show sequences that timg adds
|
| 264 |
const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
|
|
@@ -272,13 +268,7 @@ export const ImageShowTool = buildTool({
|
|
| 272 |
return <RawAnsi lines={lines} width={width} />
|
| 273 |
}
|
| 274 |
|
| 275 |
-
return
|
| 276 |
-
<TerminalImageDisplay
|
| 277 |
-
base64={content.base64}
|
| 278 |
-
format={content.format}
|
| 279 |
-
message={content.message}
|
| 280 |
-
/>
|
| 281 |
-
)
|
| 282 |
},
|
| 283 |
|
| 284 |
async call(input: Input): Promise<{
|
|
@@ -288,6 +278,7 @@ export const ImageShowTool = buildTool({
|
|
| 288 |
imageData?: { base64: string; mediaType: string }
|
| 289 |
base64?: string
|
| 290 |
format?: string
|
|
|
|
| 291 |
timgOutput?: string
|
| 292 |
}
|
| 293 |
}> {
|
|
@@ -320,20 +311,27 @@ export const ImageShowTool = buildTool({
|
|
| 320 |
|
| 321 |
logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
|
| 322 |
|
| 323 |
-
//
|
| 324 |
-
//
|
| 325 |
-
//
|
| 326 |
-
//
|
| 327 |
-
//
|
| 328 |
const protocol = detectImageProtocol()
|
|
|
|
| 329 |
let timgOutput: string | undefined
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
| 334 |
}
|
| 335 |
}
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
return {
|
| 338 |
data: {
|
| 339 |
success: true,
|
|
@@ -344,6 +342,7 @@ export const ImageShowTool = buildTool({
|
|
| 344 |
},
|
| 345 |
base64: buffer.toString('base64'),
|
| 346 |
format,
|
|
|
|
| 347 |
timgOutput,
|
| 348 |
},
|
| 349 |
}
|
|
|
|
| 4 |
import { z } from 'zod/v4'
|
| 5 |
import { RawAnsi, Text } from '../../ink.js'
|
| 6 |
import { TerminalWriteContext } from '../../ink/useTerminalNotification.js'
|
| 7 |
+
import { wrapForMultiplexer } from '../../ink/termio/osc.js'
|
| 8 |
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 9 |
import { logForDebugging } from '../../utils/debug.js'
|
| 10 |
import {
|
| 11 |
detectImageProtocol,
|
|
|
|
|
|
|
| 12 |
renderImageWithTimgSync,
|
| 13 |
} from '../../utils/terminalImage.js'
|
| 14 |
|
|
|
|
| 121 |
}
|
| 122 |
|
| 123 |
/**
|
| 124 |
+
* Renders a pre-generated Kitty protocol image sequence by writing it
|
| 125 |
+
* directly to stdout via writeRaw (bypassing Ink's virtual DOM so the
|
| 126 |
+
* native image protocol reaches the terminal cleanly).
|
|
|
|
|
|
|
| 127 |
*/
|
| 128 |
function TerminalImageDisplay({
|
| 129 |
+
sequence,
|
|
|
|
| 130 |
message,
|
| 131 |
}: {
|
| 132 |
+
sequence: string
|
|
|
|
| 133 |
message: string
|
| 134 |
}): React.ReactNode {
|
| 135 |
const writeRaw = useContext(TerminalWriteContext)
|
|
|
|
| 138 |
useEffect(() => {
|
| 139 |
if (renderedRef.current || !writeRaw) return
|
| 140 |
renderedRef.current = true
|
| 141 |
+
writeRaw(sequence + '\n')
|
| 142 |
+
logForDebugging('ImageShow: displayed via Kitty protocol (timg)')
|
| 143 |
+
}, [sequence, writeRaw])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
return <Text dimColor>{message}</Text>
|
| 146 |
}
|
|
|
|
| 149 |
name: IMAGE_TOOL_NAME,
|
| 150 |
description:
|
| 151 |
'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
|
| 152 |
+
'Uses the Kitty graphics protocol via timg when the terminal supports it, ' +
|
| 153 |
'or falls back to timg Unicode-block rendering for universal compatibility. ' +
|
| 154 |
'Supports both URLs (https://) and local file paths. Images are shown inline above the tool result.',
|
| 155 |
|
|
|
|
| 174 |
},
|
| 175 |
|
| 176 |
async prompt(_options): Promise<string> {
|
| 177 |
+
return `ImageShow displays a PNG/JPEG/GIF/WebP image directly in the terminal. Uses the Kitty graphics protocol via timg when the terminal supports it (Kitty, Ghostty, WezTerm, foot), or falls back to timg Unicode-block rendering for universal terminal compatibility. Supports local file paths (e.g. /tmp/image.png) and HTTPS URLs (e.g. https://example.com/image.png). The image is shown inline above the tool result.`
|
| 178 |
},
|
| 179 |
|
| 180 |
async checkPermissions(): Promise<{ behavior: 'allow' }> {
|
|
|
|
| 231 |
message: string
|
| 232 |
base64?: string
|
| 233 |
format?: string
|
| 234 |
+
kittyOutput?: string
|
| 235 |
timgOutput?: string
|
| 236 |
},
|
| 237 |
_progressMessages,
|
|
|
|
| 241 |
return null
|
| 242 |
}
|
| 243 |
|
| 244 |
+
// Kitty protocol output rendered via writeRaw (out-of-band) for native
|
| 245 |
+
// terminal image rendering. The text message tells Ink the image exists
|
| 246 |
+
// so it doesn't leave a blank gap.
|
| 247 |
+
if (content.kittyOutput) {
|
| 248 |
+
return (
|
| 249 |
+
<TerminalImageDisplay
|
| 250 |
+
sequence={content.kittyOutput}
|
| 251 |
+
message={content.message}
|
| 252 |
+
/>
|
| 253 |
+
)
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// timg block-mode output rendered in-band via RawAnsi so Ink knows the
|
| 257 |
+
// image dimensions and its virtual cursor stays in sync with the terminal.
|
| 258 |
if (content.timgOutput) {
|
| 259 |
// Strip cursor hide/show sequences that timg adds
|
| 260 |
const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
|
|
|
|
| 268 |
return <RawAnsi lines={lines} width={width} />
|
| 269 |
}
|
| 270 |
|
| 271 |
+
return <Text dimColor>{content.message}</Text>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
},
|
| 273 |
|
| 274 |
async call(input: Input): Promise<{
|
|
|
|
| 278 |
imageData?: { base64: string; mediaType: string }
|
| 279 |
base64?: string
|
| 280 |
format?: string
|
| 281 |
+
kittyOutput?: string
|
| 282 |
timgOutput?: string
|
| 283 |
}
|
| 284 |
}> {
|
|
|
|
| 311 |
|
| 312 |
logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
|
| 313 |
|
| 314 |
+
// Try native Kitty protocol rendering via timg when the terminal supports it.
|
| 315 |
+
// timg's -p kitty produces Kitty protocol escape sequences natively,
|
| 316 |
+
// which are then wrapped for tmux passthrough if needed and written
|
| 317 |
+
// directly to stdout via writeRaw in TerminalImageDisplay.
|
| 318 |
+
// Falls back to Unicode block rendering for universal compatibility.
|
| 319 |
const protocol = detectImageProtocol()
|
| 320 |
+
let kittyOutput: string | undefined
|
| 321 |
let timgOutput: string | undefined
|
| 322 |
+
|
| 323 |
+
if (protocol === 'kitty') {
|
| 324 |
+
const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
|
| 325 |
+
if (rawKitty) {
|
| 326 |
+
kittyOutput = wrapForMultiplexer(rawKitty)
|
| 327 |
+
logForDebugging('ImageShow: generated via timg Kitty protocol')
|
| 328 |
}
|
| 329 |
}
|
| 330 |
|
| 331 |
+
if (!kittyOutput) {
|
| 332 |
+
timgOutput = renderImageWithTimgSync(buffer, format, undefined, undefined, 'blocks') ?? undefined
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
return {
|
| 336 |
data: {
|
| 337 |
success: true,
|
|
|
|
| 342 |
},
|
| 343 |
base64: buffer.toString('base64'),
|
| 344 |
format,
|
| 345 |
+
kittyOutput,
|
| 346 |
timgOutput,
|
| 347 |
},
|
| 348 |
}
|
src/utils/terminalImage.ts
CHANGED
|
@@ -58,6 +58,14 @@ export function detectImageProtocol(): ImageProtocol {
|
|
| 58 |
return 'kitty'
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return null
|
| 62 |
}
|
| 63 |
|
|
@@ -133,25 +141,32 @@ export function getImageProtocolSummary(): string {
|
|
| 133 |
}
|
| 134 |
|
| 135 |
/**
|
| 136 |
-
*
|
| 137 |
-
*
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
| 139 |
*
|
| 140 |
-
*
|
| 141 |
-
*
|
| 142 |
-
*
|
|
|
|
|
|
|
| 143 |
*
|
| 144 |
* @param buffer - The raw image buffer (decoded)
|
| 145 |
* @param format - Image format (png, jpeg, gif, webp)
|
| 146 |
-
* @param columns - Optional terminal width in character columns
|
| 147 |
-
* @param rows - Optional terminal height in character rows
|
| 148 |
-
* @
|
|
|
|
| 149 |
*/
|
| 150 |
export async function renderImageWithTimg(
|
| 151 |
buffer: Buffer,
|
| 152 |
format: string,
|
| 153 |
columns?: number,
|
| 154 |
rows?: number,
|
|
|
|
| 155 |
): Promise<string | null> {
|
| 156 |
try {
|
| 157 |
const timgPath = await which('timg')
|
|
@@ -170,25 +185,37 @@ export async function renderImageWithTimg(
|
|
| 170 |
try {
|
| 171 |
writeFileSync(tmpFile, buffer)
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 177 |
-
{ timeout: 15000, preserveOutputOnError: true },
|
| 178 |
-
)
|
| 179 |
-
if (quarter.code === 0 && quarter.stdout) {
|
| 180 |
-
result = quarter.stdout
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
// Fallback to half blocks (2 pixels per cell, max compatibility)
|
| 184 |
-
if (!result) {
|
| 185 |
-
const half = await execFileNoThrow(
|
| 186 |
timgPath,
|
| 187 |
-
['-p', '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
{ timeout: 15000, preserveOutputOnError: true },
|
| 189 |
)
|
| 190 |
-
if (
|
| 191 |
-
result =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
}
|
| 193 |
}
|
| 194 |
} finally {
|
|
@@ -217,6 +244,7 @@ export function renderImageWithTimgSync(
|
|
| 217 |
format: string,
|
| 218 |
columns?: number,
|
| 219 |
rows?: number,
|
|
|
|
| 220 |
): string | null {
|
| 221 |
try {
|
| 222 |
const timgPath = whichSync('timg')
|
|
@@ -233,29 +261,43 @@ export function renderImageWithTimgSync(
|
|
| 233 |
try {
|
| 234 |
writeFileSync(tmpFile, buffer)
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
const stdout = execFileSync(
|
| 239 |
-
timgPath,
|
| 240 |
-
['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 241 |
-
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 242 |
-
)
|
| 243 |
-
if (stdout) result = stdout
|
| 244 |
-
} catch {
|
| 245 |
-
// fall through to half blocks
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
// Fallback to half blocks (2 pixels per cell, max compatibility)
|
| 249 |
-
if (!result) {
|
| 250 |
try {
|
| 251 |
const stdout = execFileSync(
|
| 252 |
timgPath,
|
| 253 |
-
['-p', '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 255 |
)
|
| 256 |
if (stdout) result = stdout
|
| 257 |
} catch {
|
| 258 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
}
|
| 260 |
}
|
| 261 |
} finally {
|
|
|
|
| 58 |
return 'kitty'
|
| 59 |
}
|
| 60 |
|
| 61 |
+
// Inside tmux, TERM_PROGRAM may reveal the host terminal if forwarded
|
| 62 |
+
if (process.env.TMUX) {
|
| 63 |
+
const hostTerm = process.env.TERM_PROGRAM ?? ''
|
| 64 |
+
if (['kitty', 'WezTerm', 'ghostty', 'foot'].includes(hostTerm)) {
|
| 65 |
+
return 'kitty'
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
return null
|
| 70 |
}
|
| 71 |
|
|
|
|
| 141 |
}
|
| 142 |
|
| 143 |
/**
|
| 144 |
+
* Supported rendering modes for timg-based image display.
|
| 145 |
+
*/
|
| 146 |
+
export type TimgMode = 'kitty' | 'blocks'
|
| 147 |
+
|
| 148 |
+
/**
|
| 149 |
+
* Render an image using the `timg` utility.
|
| 150 |
*
|
| 151 |
+
* In `kitty` mode, uses the Kitty graphics protocol for native-quality
|
| 152 |
+
* rendering in terminals that support it.
|
| 153 |
+
*
|
| 154 |
+
* In `blocks` mode, renders with Unicode quarter-block characters for
|
| 155 |
+
* universal compatibility, falling back to half-blocks if needed.
|
| 156 |
*
|
| 157 |
* @param buffer - The raw image buffer (decoded)
|
| 158 |
* @param format - Image format (png, jpeg, gif, webp)
|
| 159 |
+
* @param columns - Optional terminal width in character columns
|
| 160 |
+
* @param rows - Optional terminal height in character rows
|
| 161 |
+
* @param mode - Rendering mode: `'kitty'` or `'blocks'` (default: `'blocks'`)
|
| 162 |
+
* @returns Escape sequence string for rendering, or null on failure
|
| 163 |
*/
|
| 164 |
export async function renderImageWithTimg(
|
| 165 |
buffer: Buffer,
|
| 166 |
format: string,
|
| 167 |
columns?: number,
|
| 168 |
rows?: number,
|
| 169 |
+
mode: TimgMode = 'blocks',
|
| 170 |
): Promise<string | null> {
|
| 171 |
try {
|
| 172 |
const timgPath = await which('timg')
|
|
|
|
| 185 |
try {
|
| 186 |
writeFileSync(tmpFile, buffer)
|
| 187 |
|
| 188 |
+
if (mode === 'kitty') {
|
| 189 |
+
// Native Kitty protocol rendering
|
| 190 |
+
const kitty = await execFileNoThrow(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
timgPath,
|
| 192 |
+
['-p', 'kitty', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 193 |
+
{ timeout: 30000, preserveOutputOnError: true },
|
| 194 |
+
)
|
| 195 |
+
if (kitty.code === 0 && kitty.stdout) {
|
| 196 |
+
result = kitty.stdout
|
| 197 |
+
}
|
| 198 |
+
} else {
|
| 199 |
+
// Try quarter blocks first (4 pixels per cell, better quality)
|
| 200 |
+
const quarter = await execFileNoThrow(
|
| 201 |
+
timgPath,
|
| 202 |
+
['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 203 |
{ timeout: 15000, preserveOutputOnError: true },
|
| 204 |
)
|
| 205 |
+
if (quarter.code === 0 && quarter.stdout) {
|
| 206 |
+
result = quarter.stdout
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Fallback to half blocks (2 pixels per cell, max compatibility)
|
| 210 |
+
if (!result) {
|
| 211 |
+
const half = await execFileNoThrow(
|
| 212 |
+
timgPath,
|
| 213 |
+
['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 214 |
+
{ timeout: 15000, preserveOutputOnError: true },
|
| 215 |
+
)
|
| 216 |
+
if (half.code === 0 && half.stdout) {
|
| 217 |
+
result = half.stdout
|
| 218 |
+
}
|
| 219 |
}
|
| 220 |
}
|
| 221 |
} finally {
|
|
|
|
| 244 |
format: string,
|
| 245 |
columns?: number,
|
| 246 |
rows?: number,
|
| 247 |
+
mode: TimgMode = 'blocks',
|
| 248 |
): string | null {
|
| 249 |
try {
|
| 250 |
const timgPath = whichSync('timg')
|
|
|
|
| 261 |
try {
|
| 262 |
writeFileSync(tmpFile, buffer)
|
| 263 |
|
| 264 |
+
if (mode === 'kitty') {
|
| 265 |
+
// Native Kitty protocol rendering
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
try {
|
| 267 |
const stdout = execFileSync(
|
| 268 |
timgPath,
|
| 269 |
+
['-p', 'kitty', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 270 |
+
{ encoding: 'utf8', timeout: 30000, maxBuffer: 50 * 1024 * 1024 },
|
| 271 |
+
)
|
| 272 |
+
if (stdout) result = stdout
|
| 273 |
+
} catch {
|
| 274 |
+
// failed
|
| 275 |
+
}
|
| 276 |
+
} else {
|
| 277 |
+
// Try quarter blocks first (4 pixels per cell, better quality)
|
| 278 |
+
try {
|
| 279 |
+
const stdout = execFileSync(
|
| 280 |
+
timgPath,
|
| 281 |
+
['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 282 |
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 283 |
)
|
| 284 |
if (stdout) result = stdout
|
| 285 |
} catch {
|
| 286 |
+
// fall through to half blocks
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// Fallback to half blocks (2 pixels per cell, max compatibility)
|
| 290 |
+
if (!result) {
|
| 291 |
+
try {
|
| 292 |
+
const stdout = execFileSync(
|
| 293 |
+
timgPath,
|
| 294 |
+
['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 295 |
+
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 296 |
+
)
|
| 297 |
+
if (stdout) result = stdout
|
| 298 |
+
} catch {
|
| 299 |
+
// ignored
|
| 300 |
+
}
|
| 301 |
}
|
| 302 |
}
|
| 303 |
} finally {
|