@gajae-code/tui 0.13.3 → 0.14.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.
@@ -1,5 +1,6 @@
1
1
  import * as zlib from "node:zlib";
2
2
 
3
+ import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../terminal-capabilities";
3
4
  import {
4
5
  OUROBOROS_HEART_STEPS,
5
6
  OUROBOROS_IDLE_STEPS,
@@ -231,7 +232,8 @@ const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
231
232
  };
232
233
 
233
234
  /** Para-para work dance beats: the working loop and each skin's burst "work-in" intro. */
234
- export const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, number]> = [
235
+ export type GajaeGifFrameTuple = readonly [GajaePixelFrameName, number];
236
+ export const PARA_PARA_STEPS: readonly GajaeGifFrameTuple[] = [
235
237
  ["danceL", 300],
236
238
  ["danceR", 300],
237
239
  ["base", 260],
@@ -329,19 +331,19 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
329
331
 
330
332
  /** Total burst duration (intro beats plus the looping tail). */
331
333
  export function petBurstDurationMs(burst: PetBurst): number {
332
- const introMs = burst.intro.reduce((sum, [, ms]) => sum + ms, 0);
334
+ const introMs = burst.intro.reduce((sum, [, delayMs]) => sum + delayMs, 0);
333
335
  return introMs + (burst.tail?.ms ?? 0);
334
336
  }
335
337
 
336
338
  /** The frame to show `elapsed` ms into a burst (`now` cycles the looping tail). */
337
339
  export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): PetFrameName {
338
340
  let t = elapsed;
339
- for (const [frame, ms] of burst.intro) {
340
- if (t < ms) return frame;
341
- t -= ms;
341
+ for (const [name, delayMs] of burst.intro) {
342
+ if (t < delayMs) return name;
343
+ t -= delayMs;
342
344
  }
343
345
  const tail = burst.tail;
344
- if (!tail) return burst.intro[burst.intro.length - 1][0];
346
+ if (!tail || tail.frames.length === 0) return burst.intro[burst.intro.length - 1]?.[0] ?? "base";
345
347
  return tail.frames[Math.floor(now / tail.stepMs) % tail.frames.length];
346
348
  }
347
349
 
@@ -354,6 +356,301 @@ export const __gajaePetTestHooks = {
354
356
  },
355
357
  };
356
358
 
359
+ export interface GajaeGifFrame {
360
+ readonly name: PetFrameName;
361
+ readonly delayMs: number;
362
+ }
363
+ export type GajaeGifTimeline = readonly GajaeGifFrame[];
364
+ export interface GajaeGifRectangle {
365
+ readonly width?: number;
366
+ readonly height?: number;
367
+ }
368
+ export interface GajaeGifDisplaySize {
369
+ /** iTerm2 display width: bare numbers are terminal cells; strings may use px or auto. */
370
+ readonly width: number | string;
371
+ /** iTerm2 display height: bare numbers are terminal cells; strings may use px or auto. */
372
+ readonly height: number | string;
373
+ }
374
+ export interface GajaeGifContentInset {
375
+ /** Transparent top padding in source pixels. */
376
+ readonly topPx?: number;
377
+ /** Transparent bottom padding in source pixels. */
378
+ readonly bottomPx?: number;
379
+ }
380
+ export interface GajaePetGifArtifact {
381
+ readonly bytes: Uint8Array;
382
+ readonly base64: string;
383
+ readonly width: number;
384
+ readonly height: number;
385
+ readonly frames: readonly GajaeGifFrame[];
386
+ readonly skin: PetSkinId;
387
+ readonly multipart: readonly string[];
388
+ readonly tmuxDcs: readonly string[];
389
+ }
390
+ export interface GajaePetGifOptions {
391
+ readonly skin?: PetSkinId;
392
+ readonly timeline?: GajaeGifTimeline;
393
+ readonly cellWidthPx?: number;
394
+ readonly cellHeightPx?: number;
395
+ readonly targetRows?: number;
396
+ readonly rectangle?: GajaeGifRectangle;
397
+ readonly displaySize?: GajaeGifDisplaySize;
398
+ readonly contentInset?: GajaeGifContentInset;
399
+ }
400
+ const GIF_CLEAR = 256,
401
+ GIF_END = 257;
402
+ function gifLzw(pixels: number[], minCodeSize = 8): Uint8Array {
403
+ const out: number[] = [],
404
+ codes = pixels.flatMap(p => [GIF_CLEAR, p]).concat(GIF_END);
405
+ let bits = 0,
406
+ value = 0;
407
+ for (const code of codes) {
408
+ value |= code << bits;
409
+ bits += minCodeSize + 1;
410
+ while (bits >= 8) {
411
+ out.push(value & 255);
412
+ value >>>= 8;
413
+ bits -= 8;
414
+ }
415
+ }
416
+ if (bits) out.push(value & 255);
417
+ const blocks: number[] = [minCodeSize];
418
+ for (let i = 0; i < out.length; i += 255) {
419
+ const part = out.slice(i, i + 255);
420
+ blocks.push(part.length, ...part);
421
+ }
422
+ blocks.push(0);
423
+ return Uint8Array.from(blocks);
424
+ }
425
+ export const idleTimeline = (): GajaeGifTimeline => [
426
+ { name: "base", delayMs: 700 },
427
+ { name: "gazeL", delayMs: 180 },
428
+ { name: "base", delayMs: 700 },
429
+ { name: "gazeR", delayMs: 180 },
430
+ { name: "flicker", delayMs: 120 },
431
+ ];
432
+ export const workingTimeline = (): GajaeGifTimeline => PARA_PARA_STEPS.map(([name, delayMs]) => ({ name, delayMs }));
433
+ export const burstTimeline = (skin: PetSkinId = "red"): GajaeGifTimeline => {
434
+ const burst = PET_SKINS[skin].burst;
435
+ const frames: GajaeGifFrame[] = burst.intro.map(([name, delayMs]) => ({ name, delayMs }));
436
+ const tail = burst.tail;
437
+ if (!tail || tail.frames.length === 0) return frames;
438
+ for (let elapsed = 0; elapsed < tail.ms; elapsed += tail.stepMs) {
439
+ frames.push({
440
+ name: tail.frames[Math.floor(elapsed / tail.stepMs) % tail.frames.length],
441
+ delayMs: Math.min(tail.stepMs, tail.ms - elapsed),
442
+ });
443
+ }
444
+ return frames;
445
+ };
446
+ export const previewTimeline = (skin: PetSkinId = "red"): GajaeGifTimeline => burstTimeline(skin);
447
+ function isGifTimeline(input: GajaePetGifOptions | GajaeGifTimeline): input is GajaeGifTimeline {
448
+ return Array.isArray(input);
449
+ }
450
+ function gifOptions(input: GajaePetGifOptions | GajaeGifTimeline): Required<
451
+ Pick<GajaePetGifOptions, "skin" | "timeline" | "cellWidthPx" | "cellHeightPx" | "targetRows">
452
+ > & {
453
+ rectangle?: GajaeGifRectangle;
454
+ displaySize?: GajaeGifDisplaySize;
455
+ contentInset?: GajaeGifContentInset;
456
+ } {
457
+ if (isGifTimeline(input)) {
458
+ return { skin: "red", timeline: input, cellWidthPx: 1, cellHeightPx: 1, targetRows: 16 };
459
+ }
460
+ return {
461
+ skin: input.skin ?? "red",
462
+ timeline: input.timeline ?? idleTimeline(),
463
+ cellWidthPx: input.cellWidthPx ?? 1,
464
+ cellHeightPx: input.cellHeightPx ?? 1,
465
+ targetRows: input.targetRows ?? 16,
466
+ rectangle: input.rectangle,
467
+ displaySize: input.displaySize,
468
+ contentInset: input.contentInset,
469
+ };
470
+ }
471
+ export function encodeGajaePetGif(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact {
472
+ const o = gifOptions(input),
473
+ rect = o.rectangle ?? {};
474
+ if (o.timeline.length === 0) throw new Error("GIF timeline must not be empty");
475
+ for (const frame of o.timeline) {
476
+ if (!Number.isFinite(frame.delayMs) || frame.delayMs < 0) throw new Error("Invalid GIF frame delay");
477
+ }
478
+ const width = rect.width ?? rect.height ?? Math.round(Math.max(1, o.targetRows * o.cellHeightPx));
479
+ const height = rect.height ?? Math.round(Math.max(1, o.targetRows * o.cellHeightPx));
480
+ const valid = (n: number, label: string): number => {
481
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 0xffff) throw new Error(`Invalid GIF ${label}`);
482
+ return n;
483
+ };
484
+ valid(width, "width");
485
+ valid(height, "height");
486
+ const inset = o.contentInset ?? {};
487
+ const topInset = inset.topPx ?? 0;
488
+ const bottomInset = inset.bottomPx ?? 0;
489
+ if (
490
+ ![topInset, bottomInset].every(value => Number.isFinite(value) && Number.isInteger(value) && value >= 0) ||
491
+ topInset + bottomInset >= height
492
+ )
493
+ throw new Error("Invalid GIF content inset");
494
+ const contentHeight = height - topInset - bottomInset;
495
+ if (width * height * o.timeline.length > 64 * 1024 * 1024) throw new Error("GIF allocation exceeds safety budget");
496
+ const paletteKeys = Object.keys(PET_SKINS[o.skin].palette).filter(k => k !== ".");
497
+ const palette = [[0, 0, 0], ...paletteKeys.map(k => PET_SKINS[o.skin].palette[k]!)];
498
+ const chunks: number[] = [
499
+ ...Buffer.from("GIF89a"),
500
+ width & 255,
501
+ width >> 8,
502
+ height & 255,
503
+ height >> 8,
504
+ 0xf7,
505
+ 0,
506
+ 0,
507
+ ...palette.flat(),
508
+ ...Array((256 - palette.length) * 3).fill(0),
509
+ 33,
510
+ 255,
511
+ 11,
512
+ ...Buffer.from("NETSCAPE2.0"),
513
+ 3,
514
+ 1,
515
+ 0,
516
+ 0,
517
+ 0,
518
+ ];
519
+ for (const frame of o.timeline) {
520
+ const pixels: number[] = [],
521
+ grid = PET_SKINS[o.skin].frames[frame.name];
522
+ if (!grid) throw new Error(`Unknown ${o.skin} GIF frame: ${frame.name}`);
523
+ for (let y = 0; y < height; y++)
524
+ for (let x = 0; x < width; x++) {
525
+ const contentY = y - topInset;
526
+ if (contentY < 0 || contentY >= contentHeight) {
527
+ pixels.push(0);
528
+ continue;
529
+ }
530
+ const sx = Math.min(15, Math.floor((x * 16) / width));
531
+ const sy = Math.min(15, Math.floor((contentY * 16) / contentHeight));
532
+ const ch = grid[sy][sx];
533
+ pixels.push(ch === "." ? 0 : Math.max(1, paletteKeys.indexOf(ch) + 1));
534
+ }
535
+ const delay = Math.round(frame.delayMs / 10);
536
+ chunks.push(
537
+ 33,
538
+ 249,
539
+ 4,
540
+ 0x09,
541
+ delay & 255,
542
+ delay >> 8,
543
+ 0,
544
+ 0,
545
+ 44,
546
+ 0,
547
+ 0,
548
+ 0,
549
+ 0,
550
+ width & 255,
551
+ width >> 8,
552
+ height & 255,
553
+ height >> 8,
554
+ 0,
555
+ ...gifLzw(pixels),
556
+ );
557
+ }
558
+ chunks.push(59);
559
+ const bytes = Uint8Array.from(chunks);
560
+ const base64 = Buffer.from(bytes).toString("base64");
561
+ const multipart = encodeITerm2Multipart(base64, {
562
+ width: o.displaySize?.width ?? `${width}px`,
563
+ height: o.displaySize?.height ?? `${height}px`,
564
+ });
565
+ const tmuxDcs = wrapITerm2RecordsForTmux(multipart);
566
+ return {
567
+ bytes,
568
+ base64,
569
+ width,
570
+ height,
571
+ frames: [...o.timeline],
572
+ skin: o.skin,
573
+ multipart,
574
+ tmuxDcs,
575
+ };
576
+ }
577
+ const gifCache = new Map<string, GajaePetGifArtifact>();
578
+ let gifCacheBytes = 0;
579
+ let gifCacheBase64Bytes = 0;
580
+ let gifCacheMultipartBytes = 0;
581
+ let gifCacheTmuxDcsBytes = 0;
582
+ let gifCacheEvictions = 0;
583
+ const GIF_CACHE_MAX_ENTRIES = 32;
584
+ const GIF_CACHE_MAX_BYTES = 8 * 1024 * 1024;
585
+ const byteLength = (value: string): number => Buffer.byteLength(value, "utf8");
586
+
587
+ export function getGajaePetGifCached(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact {
588
+ const o = gifOptions(input),
589
+ key = JSON.stringify([
590
+ o.skin,
591
+ o.timeline,
592
+ o.cellWidthPx,
593
+ o.cellHeightPx,
594
+ o.targetRows,
595
+ o.rectangle,
596
+ o.displaySize,
597
+ o.contentInset,
598
+ ]),
599
+ hit = gifCache.get(key);
600
+ if (hit) {
601
+ gifCache.delete(key);
602
+ gifCache.set(key, hit);
603
+ return hit;
604
+ }
605
+ const value = encodeGajaePetGif(o);
606
+ gifCache.set(key, value);
607
+ gifCacheBytes += value.bytes.byteLength;
608
+ gifCacheBase64Bytes += byteLength(value.base64);
609
+ gifCacheMultipartBytes += value.multipart.reduce((sum, record) => sum + byteLength(record), 0);
610
+ gifCacheTmuxDcsBytes += value.tmuxDcs.reduce((sum, record) => sum + byteLength(record), 0);
611
+ while (
612
+ gifCache.size > GIF_CACHE_MAX_ENTRIES ||
613
+ gifCacheBytes + gifCacheBase64Bytes + gifCacheMultipartBytes + gifCacheTmuxDcsBytes > GIF_CACHE_MAX_BYTES
614
+ ) {
615
+ const k = gifCache.keys().next().value as string,
616
+ old = gifCache.get(k)!;
617
+ gifCache.delete(k);
618
+ gifCacheBytes -= old.bytes.byteLength;
619
+ gifCacheBase64Bytes -= byteLength(old.base64);
620
+ gifCacheMultipartBytes -= old.multipart.reduce((sum, record) => sum + byteLength(record), 0);
621
+ gifCacheTmuxDcsBytes -= old.tmuxDcs.reduce((sum, record) => sum + byteLength(record), 0);
622
+ gifCacheEvictions++;
623
+ }
624
+ return value;
625
+ }
626
+ export function getGajaePetGifCacheStats(): {
627
+ size: number;
628
+ bytes: number;
629
+ gifBytes: number;
630
+ base64Bytes: number;
631
+ multipartBytes: number;
632
+ tmuxDcsBytes: number;
633
+ evictions: number;
634
+ } {
635
+ return {
636
+ size: gifCache.size,
637
+ bytes: gifCacheBytes + gifCacheBase64Bytes + gifCacheMultipartBytes + gifCacheTmuxDcsBytes,
638
+ gifBytes: gifCacheBytes,
639
+ base64Bytes: gifCacheBase64Bytes,
640
+ multipartBytes: gifCacheMultipartBytes,
641
+ tmuxDcsBytes: gifCacheTmuxDcsBytes,
642
+ evictions: gifCacheEvictions,
643
+ };
644
+ }
645
+ export function resetGajaePetGifCache(): void {
646
+ gifCache.clear();
647
+ gifCacheBytes = 0;
648
+ gifCacheBase64Bytes = 0;
649
+ gifCacheMultipartBytes = 0;
650
+ gifCacheTmuxDcsBytes = 0;
651
+ gifCacheEvictions = 0;
652
+ }
653
+ export const clearGajaePetGifCache = resetGajaePetGifCache;
357
654
  /** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */
358
655
  export function encodeGridSixel(
359
656
  grid: string[],
@@ -445,9 +742,12 @@ function validatePngGrid(
445
742
  scale: number,
446
743
  topPaddingPx: number,
447
744
  bottomPaddingPx: number,
745
+ leftPaddingPx: number,
746
+ rightPaddingPx: number,
448
747
  ): {
449
748
  gridWidth: number;
450
749
  gridHeight: number;
750
+ spriteWidth: number;
451
751
  width: number;
452
752
  spriteHeight: number;
453
753
  height: number;
@@ -461,19 +761,24 @@ function validatePngGrid(
461
761
  for (const [name, value] of [
462
762
  ["top padding", topPaddingPx],
463
763
  ["bottom padding", bottomPaddingPx],
764
+ ["left padding", leftPaddingPx],
765
+ ["right padding", rightPaddingPx],
464
766
  ] as const) {
465
767
  if (!Number.isSafeInteger(value) || value < 0)
466
768
  throw new Error(`iTerm2 pet ${name} must be a non-negative integer`);
467
769
  }
468
- const width = Math.round(gridWidth * scale);
770
+ const spriteWidth = Math.round(gridWidth * scale);
469
771
  const spriteHeight = Math.round(gridHeight * scale);
772
+ const width = spriteWidth + leftPaddingPx + rightPaddingPx;
470
773
  const height = spriteHeight + topPaddingPx + bottomPaddingPx;
471
774
  if (
472
- !Number.isSafeInteger(width) ||
775
+ !Number.isSafeInteger(spriteWidth) ||
473
776
  !Number.isSafeInteger(spriteHeight) ||
777
+ !Number.isSafeInteger(width) ||
474
778
  !Number.isSafeInteger(height) ||
475
- width <= 0 ||
779
+ spriteWidth <= 0 ||
476
780
  spriteHeight <= 0 ||
781
+ width <= 0 ||
477
782
  height <= 0 ||
478
783
  width > MAX_PET_PNG_DIMENSION ||
479
784
  height > MAX_PET_PNG_DIMENSION
@@ -485,33 +790,57 @@ function validatePngGrid(
485
790
  if (!Number.isSafeInteger(rawBytes) || rawBytes > MAX_PET_PNG_RAW_BYTES) {
486
791
  throw new Error("iTerm2 pet PNG allocation is out of bounds");
487
792
  }
488
- return { gridWidth, gridHeight, width, spriteHeight, height };
793
+ return { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height };
489
794
  }
490
795
 
491
- /** Encode a grid as an iTerm2 inline PNG. */
796
+ /**
797
+ * Encode a grid as an iTerm2 inline PNG spanning a terminal cell block.
798
+ *
799
+ * The escape's `width`/`height` are the reserved cell-block footprint in
800
+ * character cells (unitless numbers per the iTerm2 inline-images protocol).
801
+ * iTerm2 resolves cells against its own live font metrics, so the sprite
802
+ * scales with the real terminal geometry — including Retina, where iTerm2
803
+ * divides `Npx` values by the backing-scale factor and would render a fixed
804
+ * pixel box at half size.
805
+ */
492
806
  export function encodeGridIterm2(
493
807
  grid: string[],
494
808
  scale: number,
809
+ columns: number,
810
+ rows: number,
495
811
  topPaddingPx = 0,
496
812
  bottomPaddingPx = 0,
813
+ leftPaddingPx = 0,
814
+ rightPaddingPx = 0,
497
815
  palette: Palette = RED_PALETTE,
498
816
  ): string {
499
- const { gridWidth, gridHeight, width, spriteHeight, height } = validatePngGrid(
817
+ for (const [name, value] of [
818
+ ["column count", columns],
819
+ ["row count", rows],
820
+ ] as const) {
821
+ if (!Number.isSafeInteger(value) || value <= 0) {
822
+ throw new Error(`iTerm2 pet ${name} must be a positive integer`);
823
+ }
824
+ }
825
+ const { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height } = validatePngGrid(
500
826
  grid,
501
827
  scale,
502
828
  topPaddingPx,
503
829
  bottomPaddingPx,
830
+ leftPaddingPx,
831
+ rightPaddingPx,
504
832
  );
505
833
  const raw = Buffer.alloc((width * 4 + 1) * height);
506
834
  for (let y = 0; y < height; y++) {
507
835
  for (let x = 0; x < width; x++) {
836
+ const sourceX = x - leftPaddingPx;
508
837
  const sourceY = y - topPaddingPx;
509
838
  const rgb =
510
- sourceY < 0 || sourceY >= spriteHeight
839
+ sourceX < 0 || sourceX >= spriteWidth || sourceY < 0 || sourceY >= spriteHeight
511
840
  ? null
512
841
  : palette[
513
842
  grid[Math.min(gridHeight - 1, Math.floor(sourceY / scale))][
514
- Math.min(gridWidth - 1, Math.floor(x / scale))
843
+ Math.min(gridWidth - 1, Math.floor(sourceX / scale))
515
844
  ]
516
845
  ];
517
846
  const offset = y * (width * 4 + 1) + 1 + x * 4;
@@ -534,10 +863,12 @@ export function encodeGridIterm2(
534
863
  pngChunk("IDAT", compressed),
535
864
  pngChunk("IEND", new Uint8Array()),
536
865
  ]);
537
- // Use explicit pixel dimensions. Cell dimensions are only a layout hint for
538
- // the surrounding TUI; asking iTerm2 to fit a small PNG into a cell box makes
539
- // it stretch or shrink differently from Kitty/Ghostty.
540
- const params = `width=${width}px;height=${height}px;preserveAspectRatio=0;inline=1`;
866
+ // Size in character cells, not pixels: iTerm2 applies its own cell metrics
867
+ // (and divides px values by the Retina backing scale), so cells keep the
868
+ // padded canvas 1:1 with the reserved block at every geometry.
869
+ // iTerm uses the supplied filename when a user drags this inline image out.
870
+ // The unique name lets the composer discard only this pet's automatic path paste.
871
+ const params = `name=Z2FqYWUtcGV0LnBuZw==;width=${columns};height=${rows};preserveAspectRatio=0;inline=1`;
541
872
  return `\x1b]1337;File=${params}:${png.toString("base64")}\x1b\\`;
542
873
  }
543
874
 
@@ -602,7 +933,9 @@ export interface GajaePixelFrames {
602
933
  frames: Record<string, string>;
603
934
  /** protocol the frames were encoded for */
604
935
  protocol: "sixel" | "kitty" | "iterm2";
936
+ /** Scaled sprite width before transparent cell-block padding. */
605
937
  widthPx: number;
938
+ /** Encoded raster height, including protocol-specific transparent padding. */
606
939
  heightPx: number;
607
940
  columns: number;
608
941
  rows: number;
@@ -614,6 +947,17 @@ export interface GajaePixelFrames {
614
947
  * Build overlay pixel frames exactly `targetRows` terminal rows tall when the
615
948
  * terminal cells permit it. Each skin owns its source resolution so future
616
949
  * additions can opt into denser art without changing the terminal footprint.
950
+ *
951
+ * Geometry contract:
952
+ * - `scale = max(1, targetRows * cellHeightPx / gridHeight)`
953
+ * - `columns = ceil(scaledSpriteWidthPx / cellWidthPx)`
954
+ * - `rows = ceil(scaledSpriteHeightPx / cellHeightPx)`
955
+ * - the square sprite is centered in a `columns * cellWidthPx` PNG canvas
956
+ *
957
+ * iTerm2 receives unitless `width=columns;height=rasterRows`, so it resolves the
958
+ * padded block with its live cell metrics. The PNG has that block's pixel aspect
959
+ * ratio, allowing `preserveAspectRatio=0` without stretching the authored square
960
+ * sprite. Kitty and Sixel retain their protocol-specific paths.
617
961
  */
618
962
  export function buildGajaePixelFrames(options: {
619
963
  protocol: "sixel" | "kitty" | "iterm2";
@@ -625,9 +969,9 @@ export function buildGajaePixelFrames(options: {
625
969
  /** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
626
970
  kittyCellYOffsetPx?: number;
627
971
  kittyImageId?: number;
628
- /** Transparent iTerm2-only top padding for half-cell vertical alignment. */
972
+ /** Additional transparent iTerm2-only top padding for sub-cell vertical alignment. */
629
973
  iterm2TopPaddingPx?: number;
630
- /** Transparent iTerm2-only bottom padding inside the two-row canvas. */
974
+ /** Transparent iTerm2-only bottom padding inside the canvas. */
631
975
  iterm2BottomPaddingPx?: number;
632
976
  /** Color skin for the sprite palette (default "red"). */
633
977
  skin?: PetSkinId;
@@ -660,12 +1004,23 @@ export function buildGajaePixelFrames(options: {
660
1004
  const topPaddingPx =
661
1005
  allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0);
662
1006
  const heightPx = visibleHeightPx + topPaddingPx;
1007
+ const kittyYOffsetPx = options.protocol === "kitty" ? Math.max(0, Math.round(options.kittyCellYOffsetPx ?? 0)) : 0;
663
1008
  // Center the square sprite in its (cols * cellWidth) block, which the ceil()
664
1009
  // column rounding can make wider than the sprite itself.
665
1010
  const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx);
666
1011
  const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
667
1012
  const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
668
1013
  const canvasWidthPx = widthPx + leftPaddingPx + rightPaddingPx;
1014
+ // When minimum 1x art is taller than targetRows (only possible with tiny
1015
+ // cells), top-pad iTerm2 to the full reserved row block. Its unitless OSC
1016
+ // height then maps the PNG 1:1 without vertically stretching the authored square sprite.
1017
+ const iterm2TopPaddingPx =
1018
+ allocatedHeightPx - visibleHeightPx + (options.protocol === "iterm2" ? (options.iterm2TopPaddingPx ?? 0) : 0);
1019
+ const protocolHeightPx =
1020
+ options.protocol === "iterm2"
1021
+ ? visibleHeightPx + iterm2TopPaddingPx + (options.iterm2BottomPaddingPx ?? 0)
1022
+ : heightPx;
1023
+ const rasterRows = Math.ceil((protocolHeightPx + kittyYOffsetPx) / options.cellHeightPx);
669
1024
  if (
670
1025
  widthPx > MAX_PET_FRAME_DIMENSION ||
671
1026
  heightPx > MAX_PET_FRAME_DIMENSION ||
@@ -685,8 +1040,12 @@ export function buildGajaePixelFrames(options: {
685
1040
  ? encodeGridIterm2(
686
1041
  grid,
687
1042
  scale,
688
- options.iterm2TopPaddingPx ?? 0,
1043
+ columns,
1044
+ rasterRows,
1045
+ iterm2TopPaddingPx,
689
1046
  options.iterm2BottomPaddingPx ?? 0,
1047
+ leftPaddingPx,
1048
+ rightPaddingPx,
690
1049
  skin.palette,
691
1050
  )
692
1051
  : encodeGridKitty(
@@ -703,10 +1062,6 @@ export function buildGajaePixelFrames(options: {
703
1062
  );
704
1063
  }
705
1064
 
706
- const protocolHeightPx =
707
- options.protocol === "iterm2"
708
- ? visibleHeightPx + (options.iterm2TopPaddingPx ?? 0) + (options.iterm2BottomPaddingPx ?? 0)
709
- : heightPx;
710
1065
  return {
711
1066
  frames,
712
1067
  protocol: options.protocol,
@@ -714,6 +1069,6 @@ export function buildGajaePixelFrames(options: {
714
1069
  heightPx: protocolHeightPx,
715
1070
  columns,
716
1071
  rows,
717
- rasterRows: Math.ceil(protocolHeightPx / options.cellHeightPx),
1072
+ rasterRows,
718
1073
  };
719
1074
  }