diff --git a/docs/src/fragments/commands/api.md b/docs/src/fragments/commands/api.md index 0a6fa9efc9..2a75a05184 100644 --- a/docs/src/fragments/commands/api.md +++ b/docs/src/fragments/commands/api.md @@ -64,4 +64,14 @@ sentry api organizations/ --dry-run When querying the Events API (`/events/` endpoint), valid dataset values are: `spans`, `transactions`, `logs`, `errors`, `discover`. +### Binary responses + +Endpoints that return binary data — image attachments, minidumps, debug files — are streamed to stdout as raw bytes, so you can redirect them straight to a file: + +```bash +sentry api "projects/my-org/my-project/events/EVENT_ID/attachments/ATTACHMENT_ID/?download=1" > screenshot.png +``` + +When the response is a PNG or JPEG image **and** you're on a sixel-capable terminal, the image is rendered inline instead of dumping raw bytes into your session. Redirecting or piping stdout always keeps the raw bytes. Set `SENTRY_NO_SIXEL=1` to disable inline rendering. + For full API documentation, see the [Sentry API Reference](https://docs.sentry.io/api/). diff --git a/plugins/sentry-cli/skills/sentry-cli/references/api.md b/plugins/sentry-cli/skills/sentry-cli/references/api.md index 0b4f360d6e..dc3cb75176 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/api.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/api.md @@ -64,6 +64,8 @@ sentry api organizations/ --verbose # Preview the request without sending sentry api organizations/ --dry-run + +sentry api "projects/my-org/my-project/events/EVENT_ID/attachments/ATTACHMENT_ID/?download=1" > screenshot.png ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/commands/api.ts b/src/commands/api.ts index 25686fe674..908cd05abb 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -16,6 +16,8 @@ import { CommandOutput } from "../lib/formatters/output.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { logger } from "../lib/logger.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; +import { canRenderSixel, terminalPixelWidth } from "../lib/sixel.js"; +import { imageBytesToSixel } from "../lib/sixel-image.js"; const log = logger.withTag("api"); @@ -1140,7 +1142,7 @@ function logResponse(response: { status: number; headers: Headers }): void { * * - silent: no body (OutputError(null) on error for exit code only) * - binary error: short status/content-type summary (never dump bytes) - * - binary success: raw Uint8Array (TTY warn only; no hard-refuse) + * - binary success: raw Uint8Array (the func decides TTY rendering/warning) * - text/JSON: body as-is for the formatter path * * Extracted from the command func to keep cognitive complexity under the lint threshold. @@ -1176,16 +1178,51 @@ export function resolveApiResponseOutput( throw new OutputError(response.body); } - // Binary success: stream raw bytes. TTY warn only — hard-refuse belongs - // with a future --output flag. - if (isBinary && options.isTTY) { - log.warn( - "Binary response written to a TTY — redirect stdout to a file " + - "(e.g. `> file.bin`) to capture raw bytes cleanly." + // Binary success: return raw bytes. The command func decides whether to + // render images inline (sixel) or warn about the raw-byte dump for a TTY. + return response.body; +} + +/** + * For a binary response headed to an interactive TTY, either render it inline + * as a sixel image (when it's a supported image format and the terminal + * advertises sixel support) or warn that raw bytes are being dumped. + * + * @param body - The raw response bytes. + * @param headers - Response headers (Content-Type is used as a decode hint). + * @param allowSixel - Whether inline sixel rendering is permitted. Pass `false` + * in `--json` mode: the raw bytes still stream out unchanged, but injecting a + * sixel escape sequence would corrupt machine-readable output. The raw-dump + * warning still fires so the user knows their terminal is about to be flooded. + * @returns A sixel escape string to write instead of the raw bytes, or + * `undefined` to fall through to the raw-byte behavior. + * @internal Exported for testing + */ +export function resolveBinaryTtyOutput( + body: Uint8Array, + headers: Headers, + allowSixel = true +): string | undefined { + if (allowSixel && canRenderSixel()) { + // Cap the rendered width to the terminal's pixel budget so a wide image + // doesn't overflow the columns and garble the session. Falls back to the + // encoder's default when the terminal didn't report a cell width. + const maxWidth = terminalPixelWidth(); + const sixel = imageBytesToSixel( + body, + headers.get("content-type"), + maxWidth ); + if (sixel) { + return sixel; + } } - return response.body; + log.warn( + "Binary response written to a TTY — redirect stdout to a file " + + "(e.g. `> file.bin`) to capture raw bytes cleanly." + ); + return; } export const apiCommand = buildCommand({ @@ -1370,6 +1407,23 @@ export const apiCommand = buildCommand({ if (output === undefined) { return; } + + // Binary body headed to an interactive TTY. Render supported images inline + // as sixel when the terminal is capable, otherwise warn about the raw dump. + // In --json mode we skip sixel (it would corrupt machine-readable output) + // but still warn — renderCommandOutput dumps the raw bytes either way. + // Redirected/piped stdout skips this entirely (raw bytes flow untouched). + if (output instanceof Uint8Array && this.stdout.isTTY) { + const sixel = resolveBinaryTtyOutput( + output, + response.headers, + !flags.json + ); + if (sixel !== undefined) { + return yield new CommandOutput(sixel); + } + } + // Binary Uint8Array bodies are written raw by renderCommandOutput (no // formatter, no trailing newline). Text/JSON go through formatApiResponse. return yield new CommandOutput(output); diff --git a/src/lib/env-registry.ts b/src/lib/env-registry.ts index a075bacbce..f31fcf6018 100644 --- a/src/lib/env-registry.ts +++ b/src/lib/env-registry.ts @@ -202,7 +202,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_NO_SIXEL", description: - "Disable the sixel image banner on terminals that support it; the block-art banner is shown instead.", + "Disable sixel graphics on terminals that support it; the banner falls back to block art, and image attachments printed by `sentry api` are written as raw bytes instead of rendered inline.", example: "1", }, { diff --git a/src/lib/sixel-image.ts b/src/lib/sixel-image.ts new file mode 100644 index 0000000000..295922cd97 --- /dev/null +++ b/src/lib/sixel-image.ts @@ -0,0 +1,607 @@ +/** + * Runtime image → sixel encoding. + * + * Turns a decoded raster image (PNG or JPEG) into a full-color sixel escape + * sequence for inline display on sixel-capable terminals. This is the runtime + * companion to {@link ./sixel.ts}, which only knows about the baked, single + * static banner — here we quantize an arbitrary image to a small palette and + * emit the sixel bytes on demand. + * + * Used by `sentry api` to render image attachments (screenshots, etc.) inline + * instead of dumping raw bytes into an interactive terminal. + * + * Design notes: + * - Decoding uses the already-bundled `pngjs` / `jpeg-js` (no new deps, both + * pure-JS so they work in the bundled binary). + * - Color is quantized to a fixed palette via a median-cut algorithm, keeping + * the escape sequence small while staying visually faithful. Sixel palette + * components are 0–100 (percent), so colors are downscaled from 0–255. + * - Fully-transparent pixels are left undrawn (sixel P2=1), so images with an + * alpha channel float on the terminal background. + * - Large images are downscaled (nearest-neighbor) so they fit the terminal + * and the escape sequence stays a reasonable size. + */ + +import { decode as decodeJpeg } from "jpeg-js"; +import { PNG } from "pngjs"; +import { logger } from "./logger.js"; + +const log = logger.withTag("sixel-image"); + +/** A decoded image: RGBA pixels row-major, 4 bytes per pixel. */ +export type DecodedImage = { + width: number; + height: number; + /** RGBA bytes, length `width * height * 4`. */ + data: Uint8Array | Buffer; +}; + +/** Image formats we can decode at runtime. */ +export type SupportedImageFormat = "png" | "jpeg"; + +/** Number of palette entries the image is quantized to. */ +const PALETTE_SIZE = 128; + +/** Alpha at or above which a pixel is considered opaque enough to draw. */ +const ALPHA_THRESHOLD = 128; + +/** + * Default cap on the rendered pixel width. Images wider than this are + * downscaled so the sixel fits a typical terminal and stays small. Height is + * scaled proportionally. + */ +const DEFAULT_MAX_WIDTH = 800; + +/** + * Default cap on the rendered pixel height. Long screenshots (narrow but very + * tall) would otherwise skip width-based downscaling entirely and produce a + * huge escape sequence with heavy CPU/memory cost, so height is bounded too. + */ +const DEFAULT_MAX_HEIGHT = 2000; + +/** + * Hard ceiling on either declared image dimension, checked from the header + * before the pure-JS decoders allocate `width * height * 4` bytes. A crafted + * file can claim enormous dimensions in a tiny header (e.g. a 1 KB PNG + * declaring 100000×100000 would make the decoder request ~40 GB and OOM the + * process — an OOM that `try/catch` cannot recover from). 20000px comfortably + * exceeds any real screenshot while bounding a pre-decode allocation to ~1.6 GB + * worst case for a single dimension. + */ +const MAX_DECODE_DIMENSION = 20_000; + +/** + * True for JPEG marker ids that carry no length-prefixed payload: SOI/EOI + * (D8/D9), the RSTn restart markers (D0–D7), and TEM (01). These advance the + * walker by just the two marker bytes. + */ +function isStandaloneJpegMarker(marker: number): boolean { + return marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9); +} + +/** + * True for JPEG Start-of-Frame markers (SOF0–SOF15, C0–CF) that carry the + * frame dimensions, excluding the non-frame markers DHT (C4), JPG (C8), and + * DAC (CC) that share the 0xCn range. + */ +function isSofJpegMarker(marker: number): boolean { + return ( + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc + ); +} + +/** + * Walk JPEG marker segments looking for a Start-of-Frame and read its declared + * width/height without decoding pixel data. Returns `undefined` when no SOF is + * found within the buffer or the stream is malformed. + */ +function readJpegDimensions( + body: Uint8Array +): { width: number; height: number } | undefined { + // Must read up to offset+8 (the 2-byte width) for a SOF, so require + // offset+8 to be in bounds (offset+9 <= body.length). + let offset = 2; // skip the leading SOI (FF D8) + while (offset + 9 <= body.length) { + // Markers begin with 0xFF; runs of 0xFF are legal fill bytes, so skip them + // one at a time rather than mis-reading them as a length-prefixed segment. + if (body[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = body[offset + 1] as number; + if (marker === 0xff) { + offset += 1; // fill byte; re-examine from the next 0xFF + continue; + } + if (isStandaloneJpegMarker(marker)) { + offset += 2; + continue; + } + if (isSofJpegMarker(marker)) { + const view = new DataView(body.buffer, body.byteOffset, body.byteLength); + // SOF payload: [2-byte length][1-byte precision][2-byte height][2-byte width] + return { + height: view.getUint16(offset + 5), + width: view.getUint16(offset + 7), + }; + } + // Length-prefixed segment: advance past the 2-byte length (which counts + // its own 2 bytes) plus the leading marker. + const segLength = + (body[offset + 2] as number) * 256 + (body[offset + 3] as number); + if (segLength < 2) { + return; // malformed length; bail rather than loop forever + } + offset += 2 + segLength; + } + return; +} + +/** + * Read the declared pixel dimensions from a PNG or JPEG header without decoding + * the pixel data, so callers can reject oversized images before the decoder + * allocates a full pixel buffer. Returns `undefined` when the header is too + * short or malformed to read dimensions from. + * + * - PNG: the IHDR chunk always follows the 8-byte signature; width and height + * are big-endian uint32 at byte offsets 16 and 20. + * - JPEG: scan the marker segments for a Start-of-Frame and read its payload. + */ +export function readImageDimensions( + body: Uint8Array, + format: SupportedImageFormat +): { width: number; height: number } | undefined { + if (format === "png") { + if (body.length < 24) { + return; + } + const view = new DataView(body.buffer, body.byteOffset, body.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; + } + return readJpegDimensions(body); +} + +/** + * Detect a supported image format from an HTTP Content-Type and/or the leading + * magic bytes of the body. Returns `undefined` for formats we can't decode. + * + * Magic-byte sniffing is the source of truth (servers mislabel attachments); + * the Content-Type is only used as a fallback when the bytes are inconclusive. + */ +export function detectImageFormat( + body: Uint8Array, + contentType?: string | null +): SupportedImageFormat | undefined { + // PNG signature: 89 50 4E 47 0D 0A 1A 0A + if ( + body.length >= 8 && + body[0] === 0x89 && + body[1] === 0x50 && + body[2] === 0x4e && + body[3] === 0x47 + ) { + return "png"; + } + // JPEG signature: FF D8 FF + if ( + body.length >= 3 && + body[0] === 0xff && + body[1] === 0xd8 && + body[2] === 0xff + ) { + return "jpeg"; + } + // Fall back to the declared Content-Type when the bytes were inconclusive. + const ct = contentType?.toLowerCase() ?? ""; + if (ct.includes("image/png")) { + return "png"; + } + if (ct.includes("image/jpeg") || ct.includes("image/jpg")) { + return "jpeg"; + } + return; +} + +/** + * Decode PNG or JPEG bytes into RGBA pixels. Returns `undefined` if the bytes + * fail to decode (corrupt or an unexpected sub-format the decoder rejects). + */ +export function decodeImage( + body: Uint8Array, + format: SupportedImageFormat +): DecodedImage | undefined { + const dims = readImageDimensions(body, format); + if ( + dims && + (dims.width > MAX_DECODE_DIMENSION || dims.height > MAX_DECODE_DIMENSION) + ) { + log.debug( + `Refusing to decode ${format} image: declared dimensions ${dims.width}×${dims.height} exceed ${MAX_DECODE_DIMENSION}px cap` + ); + return; + } + try { + if (format === "png") { + const png = PNG.sync.read(Buffer.from(body)); + return { width: png.width, height: png.height, data: png.data }; + } + // jpeg-js: request RGBA output explicitly so the pixel layout matches PNG + // and pixelAt's 4-byte stride. formatAsRGBA already defaults to true, but + // pinning it guards against a default change and mirrors snapshots/diff.ts. + const jpeg = decodeJpeg(Buffer.from(body), { + useTArray: true, + formatAsRGBA: true, + }); + return { width: jpeg.width, height: jpeg.height, data: jpeg.data }; + } catch (error) { + log.debug(`Failed to decode ${format} image for sixel rendering`, error); + return; + } +} + +/** Read the RGBA tuple at pixel (x, y). */ +function pixelAt( + img: DecodedImage, + x: number, + y: number +): [number, number, number, number] { + const i = (y * img.width + x) * 4; + return [ + img.data[i] ?? 0, + img.data[i + 1] ?? 0, + img.data[i + 2] ?? 0, + img.data[i + 3] ?? 255, + ]; +} + +/** + * Downscale an image so it fits within `maxWidth` × `maxHeight` pixels, using + * nearest-neighbor sampling and preserving aspect ratio (scaled by whichever + * dimension is over its cap). Returns the source unchanged when it already + * fits. Cheap and dependency-free — quality is fine for terminal previews. + * + * Bounding height as well as width matters for long screenshots: a narrow but + * very tall image would otherwise skip width-based scaling entirely and blow up + * the palette pass and escape-sequence size. + */ +export function downscale( + img: DecodedImage, + maxWidth: number, + maxHeight: number +): DecodedImage { + if (img.width <= maxWidth && img.height <= maxHeight) { + return img; + } + const scale = Math.min(maxWidth / img.width, maxHeight / img.height); + const width = Math.max(1, Math.round(img.width * scale)); + const height = Math.max(1, Math.round(img.height * scale)); + const data = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + const srcY = Math.min(img.height - 1, Math.floor(y / scale)); + for (let x = 0; x < width; x++) { + const srcX = Math.min(img.width - 1, Math.floor(x / scale)); + const [r, g, b, a] = pixelAt(img, srcX, srcY); + const di = (y * width + x) * 4; + data[di] = r; + data[di + 1] = g; + data[di + 2] = b; + data[di + 3] = a; + } + } + return { width, height, data }; +} + +/** An RGB color box used by the median-cut quantizer. */ +type ColorBox = { + colors: [number, number, number][]; +}; + +/** The channel (0=r, 1=g, 2=b) with the largest spread in a box. */ +function widestChannel(box: ColorBox): { channel: number; spread: number } { + const min = [255, 255, 255]; + const max = [0, 0, 0]; + for (const c of box.colors) { + for (let ch = 0; ch < 3; ch++) { + const v = c[ch] as number; + if (v < (min[ch] as number)) { + min[ch] = v; + } + if (v > (max[ch] as number)) { + max[ch] = v; + } + } + } + const spread = [ + (max[0] as number) - (min[0] as number), + (max[1] as number) - (min[1] as number), + (max[2] as number) - (min[2] as number), + ]; + let widest = 0; + for (let ch = 1; ch < 3; ch++) { + if ((spread[ch] as number) > (spread[widest] as number)) { + widest = ch; + } + } + return { channel: widest, spread: spread[widest] as number }; +} + +/** Average color of a box, rounded to a single representative RGB tuple. */ +function averageColor(box: ColorBox): [number, number, number] { + let r = 0; + let g = 0; + let b = 0; + for (const c of box.colors) { + r += c[0]; + g += c[1]; + b += c[2]; + } + const n = Math.max(1, box.colors.length); + return [Math.round(r / n), Math.round(g / n), Math.round(b / n)]; +} + +/** Collect the RGB values of every sufficiently-opaque pixel in `img`. */ +function collectOpaqueColors(img: DecodedImage): [number, number, number][] { + const colors: [number, number, number][] = []; + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const [r, g, b, a] = pixelAt(img, x, y); + if (a >= ALPHA_THRESHOLD) { + colors.push([r, g, b]); + } + } + } + return colors; +} + +/** + * Pick the splittable box (spread > 0) with the most colors, or `undefined` + * when none can be usefully divided further. + */ +function selectSplitTarget( + boxes: ColorBox[] +): { index: number; channel: number } | undefined { + let index = -1; + let channel = 0; + let maxCount = 0; + for (let i = 0; i < boxes.length; i++) { + const box = boxes[i] as ColorBox; + const widest = widestChannel(box); + if (widest.spread > 0 && box.colors.length > maxCount) { + maxCount = box.colors.length; + index = i; + channel = widest.channel; + } + } + return index === -1 ? undefined : { index, channel }; +} + +/** Split a box in two at the median of the given channel. */ +function splitBox(box: ColorBox, channel: number): [ColorBox, ColorBox] { + const sorted = [...box.colors].sort( + (a, b) => (a[channel] as number) - (b[channel] as number) + ); + const mid = Math.floor(sorted.length / 2); + return [{ colors: sorted.slice(0, mid) }, { colors: sorted.slice(mid) }]; +} + +/** + * Build a palette of at most `size` colors from the opaque pixels of `img` + * using median-cut quantization. + */ +export function buildPalette( + img: DecodedImage, + size: number +): [number, number, number][] { + const colors = collectOpaqueColors(img); + if (colors.length === 0) { + return []; + } + + let boxes: ColorBox[] = [{ colors }]; + while (boxes.length < size) { + const target = selectSplitTarget(boxes); + if (!target) { + break; + } + const [left, right] = splitBox( + boxes[target.index] as ColorBox, + target.channel + ); + boxes = [ + ...boxes.slice(0, target.index), + left, + right, + ...boxes.slice(target.index + 1), + ]; + } + + return boxes.filter((box) => box.colors.length > 0).map(averageColor); +} + +/** Find the palette index whose color is nearest (squared RGB distance). */ +function nearestPaletteIndex( + palette: [number, number, number][], + r: number, + g: number, + b: number +): number { + let best = 0; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < palette.length; i++) { + const p = palette[i] as [number, number, number]; + const dr = p[0] - r; + const dg = p[1] - g; + const db = p[2] - b; + const dist = dr * dr + dg * dg + db * db; + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; +} + +/** Sixel palette component scale is 0–100 (percent), not 0–255. */ +const to100 = (v: number): number => Math.round((v / 255) * 100); + +/** + * A quantized image plane: per-pixel palette indices (row-major), where `-1` + * marks a transparent (undrawn) pixel. + */ +type IndexedPlane = { + indices: Int32Array; + width: number; + height: number; +}; + +/** + * Run-length encode one color plane of a 6-row band into sixel characters. + * Each column contributes one char whose bit `i` marks row `y0+i` painted in + * `colorIndex`. `!N` is sixel run-length compression. + */ +function encodeColorBand( + plane: IndexedPlane, + y0: number, + colorIndex: number +): string { + const { indices, width, height } = plane; + const parts: string[] = []; + let runChar = -1; + let runLen = 0; + const flush = (): void => { + if (runLen > 0) { + const ch = String.fromCharCode(runChar); + parts.push(runLen > 3 ? `!${runLen}${ch}` : ch.repeat(runLen)); + runLen = 0; + } + }; + for (let x = 0; x < width; x++) { + let value = 0; + for (let i = 0; i < 6; i++) { + const y = y0 + i; + if (y < height && indices[y * width + x] === colorIndex) { + value += 2 ** i; + } + } + const ch = 0x3f + value; + if (ch === runChar) { + runLen += 1; + } else { + flush(); + runChar = ch; + runLen = 1; + } + } + flush(); + return `#${colorIndex}${parts.join("")}`; +} + +/** Palette indices painted somewhere in the 6-row band starting at `y0`. */ +function colorsInBand(plane: IndexedPlane, y0: number): number[] { + const { indices, width, height } = plane; + const seen = new Set(); + for (let x = 0; x < width; x++) { + for (let i = 0; i < 6; i++) { + const y = y0 + i; + if (y < height) { + const idx = indices[y * width + x]; + if (idx !== undefined && idx >= 0) { + seen.add(idx); + } + } + } + } + return [...seen]; +} + +/** + * Encode a decoded image as a full-color sixel escape sequence. + * + * Transparent pixels (alpha below {@link ALPHA_THRESHOLD}) are left undrawn so + * the image floats on the terminal background. Returns `undefined` when the + * image has no drawable (opaque) pixels. + * + * @param img - Decoded RGBA image. + * @param maxWidth - Cap on rendered pixel width; wider images are downscaled. + * The effective cap is the smaller of this and {@link DEFAULT_MAX_WIDTH}, so + * passing the terminal's pixel width keeps the image from overflowing while + * still bounding the escape-sequence size. Omit to use the default ceiling. + */ +export function encodeImageToSixel( + img: DecodedImage, + maxWidth?: number +): string | undefined { + const effectiveMaxWidth = Math.min( + maxWidth ?? DEFAULT_MAX_WIDTH, + DEFAULT_MAX_WIDTH + ); + const scaled = downscale(img, effectiveMaxWidth, DEFAULT_MAX_HEIGHT); + const palette = buildPalette(scaled, PALETTE_SIZE); + if (palette.length === 0) { + return; + } + + const { width, height } = scaled; + // Per-pixel palette index, or -1 for transparent (undrawn). + const indices = new Int32Array(width * height).fill(-1); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const [r, g, b, a] = pixelAt(scaled, x, y); + if (a >= ALPHA_THRESHOLD) { + indices[y * width + x] = nearestPaletteIndex(palette, r, g, b); + } + } + } + const plane: IndexedPlane = { indices, width, height }; + + // DCS: P1=0 aspect, P2=1 (unpainted = transparent), P3=0 grid; then raster + // attributes "1;1;W;H so the terminal reserves the right pixel box. + let out = `\x1bP0;1;0q"1;1;${width};${height}`; + palette.forEach(([r, g, b], i) => { + out += `#${i};2;${to100(r)};${to100(g)};${to100(b)}`; + }); + + const totalBands = Math.ceil(height / 6); + for (let band = 0; band < totalBands; band++) { + const y0 = band * 6; + out += colorsInBand(plane, y0) + .map((c) => encodeColorBand(plane, y0, c)) + .join("$"); + if (band < totalBands - 1) { + out += "-"; + } + } + + out += "\x1b\\"; // String Terminator + return out; +} + +/** + * Convenience: decode image bytes and encode them as a sixel string in one + * step. Returns `undefined` when the format is unsupported, the bytes fail to + * decode, or the image has nothing drawable. + * + * @param body - Raw image bytes. + * @param contentType - Optional HTTP Content-Type, used as a decode-format hint. + * @param maxWidth - Cap on rendered pixel width (clamped to + * {@link DEFAULT_MAX_WIDTH}). Omit to use the default ceiling. + */ +export function imageBytesToSixel( + body: Uint8Array, + contentType?: string | null, + maxWidth?: number +): string | undefined { + const format = detectImageFormat(body, contentType); + if (!format) { + return; + } + const decoded = decodeImage(body, format); + if (!decoded) { + return; + } + return encodeImageToSixel(decoded, maxWidth); +} diff --git a/src/lib/sixel.ts b/src/lib/sixel.ts index 0d4ee4a186..dcfaa82595 100644 --- a/src/lib/sixel.ts +++ b/src/lib/sixel.ts @@ -190,6 +190,41 @@ export function detectSixelCaps(): SixelCaps { return cached; } +/** + * True when the current terminal can display sixel graphics right now: it's an + * interactive TTY, not opted out (plain-output / SENTRY_NO_SIXEL / non-unix), + * and it advertised sixel support in the DA1 probe. + * + * Used by callers that render arbitrary images (not just the baked banner), + * e.g. `sentry api` displaying image attachments inline. + */ +export function canRenderSixel(): boolean { + if (optedOut()) { + return false; + } + return detectSixelCaps().supported; +} + +/** + * The usable image width in device pixels for the current terminal — the + * number of columns times the reported character-cell width. Returns + * `undefined` when the terminal didn't report a cell width (in which case a + * caller can't know the pixel budget and should fall back to a safe default). + * + * Callers rendering arbitrary images (e.g. `sentry api` attachments) use this + * to downscale so a wide image doesn't overflow the terminal and garble the + * session, mirroring the fit check {@link sixelFits} does for the banner. + */ +export function terminalPixelWidth( + columns: number = process.stdout.columns ?? 80 +): number | undefined { + const caps = detectSixelCaps(); + if (!(caps.supported && caps.cellWidth && caps.cellWidth > 0)) { + return; + } + return columns * caps.cellWidth; +} + /** * The baked sixel banner escape string when the terminal supports sixel and the * image fits `columns`; otherwise `undefined` so the caller falls back to the diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index cf149b46b4..0a29f4ffda 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -28,6 +28,7 @@ import { prepareRequestOptions, readStdin, resolveApiResponseOutput, + resolveBinaryTtyOutput, resolveBody, resolveEffectiveHeaders, resolveRequestUrl, @@ -1073,6 +1074,47 @@ describe("resolveApiResponseOutput", () => { }); }); +describe("resolveBinaryTtyOutput", () => { + let stderrOutput: string; + let originalWrite: typeof process.stderr.write; + + beforeEach(() => { + stderrOutput = ""; + originalWrite = process.stderr.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrOutput += + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stderr.write = originalWrite; + }); + + test("falls through to raw bytes (undefined) when sixel is unavailable", () => { + // The test environment is non-interactive, so canRenderSixel() is false + // and no inline image is produced — the caller keeps the raw bytes. + const out = resolveBinaryTtyOutput( + new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + new Headers({ "content-type": "image/png" }) + ); + expect(out).toBeUndefined(); + }); + + test("still warns about the raw dump when sixel is disallowed (--json)", () => { + // In --json mode inline sixel is skipped so it can't corrupt the output, + // but the raw bytes still stream out, so the TTY warning must still fire. + const out = resolveBinaryTtyOutput( + new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + new Headers({ "content-type": "image/png" }), + false + ); + expect(out).toBeUndefined(); + expect(stderrOutput).toContain("Binary response written to a TTY"); + }); +}); + describe("readStdin", () => { test("reads content from stdin stream", async () => { const mockStdin = createMockStdin("hello world"); diff --git a/test/lib/sixel-image.test.ts b/test/lib/sixel-image.test.ts new file mode 100644 index 0000000000..137f49eefe --- /dev/null +++ b/test/lib/sixel-image.test.ts @@ -0,0 +1,284 @@ +/** + * Runtime image → sixel encoder tests. + * + * Exercises format detection (magic bytes + Content-Type fallback), PNG/JPEG + * decoding, median-cut palette construction, nearest-neighbor downscaling, and + * the shape of the emitted sixel escape sequence. Real terminal I/O is not + * involved — everything here is pure and deterministic. + */ + +import { encode as encodeJpeg } from "jpeg-js"; +import { PNG } from "pngjs"; +import { describe, expect, test } from "vitest"; +import { + buildPalette, + type DecodedImage, + decodeImage, + detectImageFormat, + downscale, + encodeImageToSixel, + imageBytesToSixel, + readImageDimensions, +} from "../../src/lib/sixel-image.js"; + +const ESC = "\x1b"; + +/** Build a solid-color RGBA image of the given size. */ +function solidImage( + width: number, + height: number, + rgba: [number, number, number, number] +): DecodedImage { + const data = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + data[i * 4] = rgba[0]; + data[i * 4 + 1] = rgba[1]; + data[i * 4 + 2] = rgba[2]; + data[i * 4 + 3] = rgba[3]; + } + return { width, height, data }; +} + +/** Encode an RGBA DecodedImage as PNG bytes. */ +function toPngBytes(img: DecodedImage): Uint8Array { + const png = new PNG({ width: img.width, height: img.height }); + png.data = Buffer.from(img.data); + return new Uint8Array(PNG.sync.write(png)); +} + +/** Encode an RGB(A) DecodedImage as JPEG bytes. */ +function toJpegBytes(img: DecodedImage): Uint8Array { + const { data } = encodeJpeg( + { data: Buffer.from(img.data), width: img.width, height: img.height }, + 90 + ); + return new Uint8Array(data); +} + +describe("detectImageFormat", () => { + test("detects PNG from magic bytes", () => { + const png = toPngBytes(solidImage(2, 2, [255, 0, 0, 255])); + expect(detectImageFormat(png)).toBe("png"); + }); + + test("detects JPEG from magic bytes", () => { + const jpeg = toJpegBytes(solidImage(4, 4, [0, 128, 0, 255])); + expect(detectImageFormat(jpeg)).toBe("jpeg"); + }); + + test("magic bytes win over a mislabeled Content-Type", () => { + const png = toPngBytes(solidImage(2, 2, [0, 0, 255, 255])); + expect(detectImageFormat(png, "application/octet-stream")).toBe("png"); + }); + + test("falls back to Content-Type when bytes are inconclusive", () => { + const notAnImage = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + expect(detectImageFormat(notAnImage, "image/png")).toBe("png"); + expect(detectImageFormat(notAnImage, "image/jpeg")).toBe("jpeg"); + }); + + test("returns undefined for unsupported input", () => { + const notAnImage = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + expect(detectImageFormat(notAnImage)).toBeUndefined(); + expect(detectImageFormat(notAnImage, "image/gif")).toBeUndefined(); + }); +}); + +describe("decodeImage", () => { + test("round-trips a PNG to RGBA pixels", () => { + const src = solidImage(3, 2, [10, 20, 30, 255]); + const decoded = decodeImage(toPngBytes(src), "png"); + expect(decoded).toBeDefined(); + expect(decoded?.width).toBe(3); + expect(decoded?.height).toBe(2); + expect(decoded?.data[0]).toBe(10); + expect(decoded?.data[1]).toBe(20); + expect(decoded?.data[2]).toBe(30); + }); + + test("returns undefined for corrupt bytes", () => { + const garbage = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]); + expect(decodeImage(garbage, "png")).toBeUndefined(); + }); + + test("decodes a JPEG to a 4-byte RGBA layout", () => { + // JPEG is lossy, so assert the stride/alpha rather than exact colors. + // pixelAt assumes a 4-byte RGBA stride, so decodeImage must always hand + // back RGBA (we pass formatAsRGBA explicitly); a 3-byte RGB buffer would + // be misread. Guards against a jpeg-js default change under semver bumps. + const src = solidImage(8, 8, [200, 40, 60, 255]); + const decoded = decodeImage(toJpegBytes(src), "jpeg"); + expect(decoded).toBeDefined(); + expect(decoded?.width).toBe(8); + expect(decoded?.height).toBe(8); + expect(decoded?.data.length).toBe(8 * 8 * 4); + // Alpha channel is fully opaque for a JPEG (no transparency). + expect(decoded?.data[3]).toBe(255); + // First pixel's red should be close to the source (lossy but not shifted). + expect(Math.abs((decoded?.data[0] ?? 0) - 200)).toBeLessThan(40); + }); + + test("refuses to decode a PNG declaring huge dimensions (OOM guard)", () => { + // Real 1×1 PNG, then overwrite the IHDR width/height with 100000×100000 + // so the pre-decode dimension guard fires before pngjs allocates. + const png = toPngBytes(solidImage(1, 1, [0, 0, 0, 255])); + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + view.setUint32(16, 100_000); + view.setUint32(20, 100_000); + expect(decodeImage(png, "png")).toBeUndefined(); + }); +}); + +describe("readImageDimensions", () => { + test("reads PNG dimensions from the IHDR header", () => { + const png = toPngBytes(solidImage(7, 3, [1, 2, 3, 255])); + expect(readImageDimensions(png, "png")).toEqual({ width: 7, height: 3 }); + }); + + test("reads JPEG dimensions from the SOF marker", () => { + const jpeg = toJpegBytes(solidImage(12, 8, [4, 5, 6, 255])); + expect(readImageDimensions(jpeg, "jpeg")).toEqual({ width: 12, height: 8 }); + }); + + test("reads JPEG dimensions past fill bytes and standalone markers", () => { + // Minimal JPEG-ish stream: SOI, a 0xFF fill byte, an RSTn standalone + // marker, then a SOF0 declaring 3×5. Exercises the marker walker's + // fill-byte and standalone-marker handling so the SOF isn't skipped. + const jpeg = new Uint8Array([ + 0xff, + 0xd8, // SOI + 0xff, // stray fill byte + 0xff, + 0xd0, // RST0 (standalone, no length) + 0xff, + 0xc0, // SOF0 + 0x00, + 0x11, // segment length (17) + 0x08, // precision + 0x00, + 0x05, // height = 5 + 0x00, + 0x03, // width = 3 + 0x03, + 0x01, + 0x22, + 0x00, // (partial component data, unread) + ]); + expect(readImageDimensions(jpeg, "jpeg")).toEqual({ width: 3, height: 5 }); + }); + + test("returns undefined for a truncated header", () => { + expect( + readImageDimensions(new Uint8Array([0x89, 0x50]), "png") + ).toBeUndefined(); + }); +}); + +describe("downscale", () => { + test("returns the source unchanged when already within bounds", () => { + const img = solidImage(10, 5, [1, 2, 3, 255]); + expect(downscale(img, 20, 20)).toBe(img); + }); + + test("scales width down and height proportionally", () => { + const img = solidImage(100, 50, [1, 2, 3, 255]); + const out = downscale(img, 20, 2000); + expect(out.width).toBe(20); + expect(out.height).toBe(10); + }); + + test("scales a tall image down by its height cap", () => { + // Narrow but very tall: width is within bounds, height is 10x over. + const img = solidImage(40, 4000, [1, 2, 3, 255]); + const out = downscale(img, 800, 400); + expect(out.height).toBe(400); + expect(out.width).toBe(4); + }); +}); + +describe("buildPalette", () => { + test("skips fully transparent pixels", () => { + const img = solidImage(4, 4, [255, 0, 0, 0]); + expect(buildPalette(img, 16)).toEqual([]); + }); + + test("produces a single entry for a solid opaque image", () => { + const img = solidImage(4, 4, [128, 64, 32, 255]); + const palette = buildPalette(img, 16); + expect(palette.length).toBe(1); + expect(palette[0]).toEqual([128, 64, 32]); + }); + + test("never exceeds the requested size", () => { + // A gradient with many distinct colors. + const width = 32; + const height = 1; + const data = new Uint8Array(width * height * 4); + for (let x = 0; x < width; x++) { + data[x * 4] = x * 8; + data[x * 4 + 1] = 255 - x * 8; + data[x * 4 + 2] = 100; + data[x * 4 + 3] = 255; + } + const palette = buildPalette({ width, height, data }, 8); + expect(palette.length).toBeLessThanOrEqual(8); + expect(palette.length).toBeGreaterThan(0); + }); +}); + +describe("encodeImageToSixel", () => { + test("emits a well-formed DCS sixel payload", () => { + const img = solidImage(6, 6, [200, 100, 50, 255]); + const sixel = encodeImageToSixel(img); + expect(sixel).toBeDefined(); + expect(sixel?.startsWith(`${ESC}P0;1;0q`)).toBe(true); + expect(sixel?.endsWith(`${ESC}\\`)).toBe(true); + // Raster attributes reserve the pixel box. + expect(sixel).toContain('"1;1;6;6'); + // At least one palette definition and one color-plane selector. + expect(sixel).toContain("#0;2;"); + }); + + test("returns undefined when the image is fully transparent", () => { + const img = solidImage(4, 4, [255, 255, 255, 0]); + expect(encodeImageToSixel(img)).toBeUndefined(); + }); + + test("downscales to a caller-supplied maxWidth narrower than the image", () => { + // 200px-wide image, terminal budget of 40px → raster attributes report 40. + const img = solidImage(200, 20, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img, 40); + expect(sixel).toContain('"1;1;40;'); + }); + + test("clamps maxWidth to the default ceiling for very wide budgets", () => { + // A 2000px image with a 5000px budget must still cap at DEFAULT_MAX_WIDTH (800). + const img = solidImage(2000, 10, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img, 5000); + expect(sixel).toContain('"1;1;800;'); + }); + + test("bounds the height of a narrow but very tall image", () => { + // 40px wide (within budget) but 5000px tall — scaled uniformly to the + // DEFAULT_MAX_HEIGHT (2000) so the escape sequence stays bounded. Width + // scales proportionally: 40 * (2000/5000) = 16. + const img = solidImage(40, 5000, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img); + expect(sixel).toContain('"1;1;16;2000'); + }); +}); + +describe("imageBytesToSixel", () => { + test("decodes and encodes a PNG in one step", () => { + const png = toPngBytes(solidImage(8, 8, [0, 150, 255, 255])); + const sixel = imageBytesToSixel(png, "image/png"); + expect(sixel).toBeDefined(); + expect(sixel?.startsWith(`${ESC}P`)).toBe(true); + expect(sixel?.endsWith(`${ESC}\\`)).toBe(true); + }); + + test("returns undefined for unsupported bytes", () => { + const notAnImage = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + expect(imageBytesToSixel(notAnImage, "text/plain")).toBeUndefined(); + }); +}); diff --git a/test/lib/sixel.test.ts b/test/lib/sixel.test.ts index 5ec423d50b..a013d10074 100644 --- a/test/lib/sixel.test.ts +++ b/test/lib/sixel.test.ts @@ -27,6 +27,7 @@ import { readReply, sixelBanner, sixelFits, + terminalPixelWidth, } from "../../src/lib/sixel.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; @@ -105,6 +106,19 @@ describe("sixelFits", () => { }); }); +describe("terminalPixelWidth", () => { + afterEach(() => { + __resetSixelCache(); + }); + + test("returns undefined when the terminal has no sixel caps (non-TTY test env)", () => { + // Under the test runner stdin/stdout are not TTYs, so the probe reports + // unsupported and there's no cell width to derive a pixel budget from. + __resetSixelCache(); + expect(terminalPixelWidth(80)).toBeUndefined(); + }); +}); + describe("sixelBanner", () => { test("returns undefined in a non-interactive (test) environment", () => { // Under vitest stdin/stdout are not TTYs, so the probe opts out and no