storymode-cli 1.2.2 → 1.2.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymode-cli",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Play AI-animated pixel art characters in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -49,3 +49,26 @@ export async function fetchCharacter(galleryId) {
49
49
  const { buffer } = await request(`${BASE}/gallery/${galleryId}/character`);
50
50
  return JSON.parse(buffer.toString('utf-8'));
51
51
  }
52
+
53
+ /**
54
+ * Fetch raw PNG frames (base64) for a gallery animation.
55
+ * Returns { fps, frame_count, frames: [Buffer, ...] }
56
+ */
57
+ export async function fetchPngFrames(galleryId, { animId } = {}) {
58
+ let url = `${BASE}/gallery/${galleryId}/png-frames`;
59
+ if (animId) url += `?anim_id=${animId}`;
60
+ const { buffer, contentType } = await request(url);
61
+ let json;
62
+ if (contentType.includes('gzip') || buffer[0] === 0x1f) {
63
+ json = gunzipSync(buffer).toString('utf-8');
64
+ } else {
65
+ json = buffer.toString('utf-8');
66
+ }
67
+ const data = JSON.parse(json);
68
+ // Decode base64 frames to Buffers
69
+ return {
70
+ fps: data.fps,
71
+ frame_count: data.frame_count,
72
+ frames: data.frames.map(b64 => Buffer.from(b64, 'base64')),
73
+ };
74
+ }
package/src/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
- import { fetchFrames, fetchCharacter } from './api.mjs';
4
- import { playAnimation, playCompanion, playReactiveCompanion, showFrame } from './player.mjs';
3
+ import { fetchFrames, fetchCharacter, fetchPngFrames } from './api.mjs';
4
+ import { playAnimation, playCompanion, playReactiveCompanion, showFrame, playHdAnimation, detectGraphicsProtocol } from './player.mjs';
5
5
  import { browse } from './browse.mjs';
6
6
  import { startMcpServer } from './mcp.mjs';
7
7
  import { loadAnimations, clearCache } from './cache.mjs';
@@ -86,6 +86,7 @@ const HELP = `
86
86
  --tiny Tiny companion pane
87
87
  --no-ai Disable AI personality (preset speech only)
88
88
  --model=ID Anthropic model for AI personality
89
+ --hd Pixel-perfect rendering (iTerm2/Kitty/Ghostty)
89
90
  --classic Full-screen animation viewer (non-reactive)
90
91
  --detach Return control immediately
91
92
 
@@ -137,6 +138,26 @@ export async function run(args) {
137
138
  break;
138
139
  }
139
140
 
141
+ // --hd: pixel-perfect rendering via graphics protocol (iTerm2/Kitty/Ghostty)
142
+ if (flags.hd) {
143
+ const protocol = detectGraphicsProtocol();
144
+ if (!protocol) {
145
+ console.error(' --hd requires a terminal with graphics protocol support');
146
+ console.error(' (iTerm2, Kitty, Ghostty, or WezTerm)');
147
+ process.exit(1);
148
+ }
149
+ try {
150
+ process.stderr.write(' Loading HD frames...\n');
151
+ const pngData = await fetchPngFrames(id);
152
+ process.stderr.write(` ${pngData.frame_count} frames @ ${pngData.fps}fps (${protocol} protocol)\n`);
153
+ await playHdAnimation(pngData.frames, { fps: pngData.fps });
154
+ } catch (err) {
155
+ console.error(`Error: ${err.message}`);
156
+ process.exit(1);
157
+ }
158
+ break;
159
+ }
160
+
140
161
  // Determine size: default full, --compact, --tiny, or explicit --size=
141
162
  const size = flags.tiny ? 'tiny' : flags.compact ? 'compact' : (flags.size || 'full');
142
163
  const detach = !!flags.detach;
package/src/player.mjs CHANGED
@@ -661,3 +661,110 @@ export function showFrame(framesData, info) {
661
661
  }
662
662
  console.log('');
663
663
  }
664
+
665
+ // --- Graphics Protocol Rendering (HD mode) ---
666
+
667
+ const CHUNK_SIZE = 4096;
668
+
669
+ /** Detect terminal graphics protocol support */
670
+ export function detectGraphicsProtocol() {
671
+ const termProgram = process.env.TERM_PROGRAM || '';
672
+ const term = process.env.TERM || '';
673
+ if (process.env.KITTY_WINDOW_ID) return 'kitty';
674
+ if (term === 'xterm-ghostty' || process.env.GHOSTTY_RESOURCES_DIR) return 'kitty';
675
+ if (termProgram === 'WezTerm') return 'kitty';
676
+ if (termProgram === 'iTerm.app') return 'iterm2';
677
+ return null;
678
+ }
679
+
680
+ /** Display PNG via iTerm2 inline images protocol */
681
+ function writeIterm2(pngBuf, cols, rows) {
682
+ const b64 = pngBuf.toString('base64');
683
+ let params = `inline=1;size=${pngBuf.length}`;
684
+ if (cols) params += `;width=${cols}`;
685
+ if (rows) params += `;height=${rows}`;
686
+ params += `;preserveAspectRatio=1`;
687
+ stdout.write(`\x1b]1337;File=${params}:${b64}\x07`);
688
+ }
689
+
690
+ /** Display PNG via Kitty graphics protocol */
691
+ function writeKitty(pngBuf, cols, rows) {
692
+ const b64 = pngBuf.toString('base64');
693
+ if (b64.length <= CHUNK_SIZE) {
694
+ let params = `a=T,f=100`;
695
+ if (cols) params += `,c=${cols}`;
696
+ if (rows) params += `,r=${rows}`;
697
+ stdout.write(`\x1b_G${params};${b64}\x1b\\`);
698
+ return;
699
+ }
700
+ for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
701
+ const chunk = b64.slice(i, i + CHUNK_SIZE);
702
+ const isFirst = i === 0;
703
+ const isLast = i + CHUNK_SIZE >= b64.length;
704
+ let params = `m=${isLast ? 0 : 1}`;
705
+ if (isFirst) {
706
+ params = `a=T,f=100`;
707
+ if (cols) params += `,c=${cols}`;
708
+ if (rows) params += `,r=${rows}`;
709
+ params += `,m=${isLast ? 0 : 1}`;
710
+ }
711
+ stdout.write(`\x1b_G${params};${chunk}\x1b\\`);
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Play PNG frames using native graphics protocol.
717
+ * @param {Buffer[]} frames - Array of PNG buffers
718
+ * @param {object} opts - { fps, cols, rows }
719
+ */
720
+ export function playHdAnimation(frames, opts = {}) {
721
+ const { fps = 16, cols, rows } = opts;
722
+ const interval = 1000 / fps;
723
+ const protocol = detectGraphicsProtocol();
724
+
725
+ if (!protocol) {
726
+ console.error(' No graphics protocol supported. Use without --hd.');
727
+ process.exit(1);
728
+ }
729
+
730
+ let frameIdx = 0;
731
+ let quit = false;
732
+
733
+ // Alt screen, hide cursor, clear
734
+ stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J');
735
+
736
+ function drawFrame() {
737
+ stdout.write('\x1b[H');
738
+ if (protocol === 'kitty') {
739
+ stdout.write(`\x1b_Ga=d,d=a\x1b\\`);
740
+ writeKitty(frames[frameIdx], cols, rows);
741
+ } else {
742
+ writeIterm2(frames[frameIdx], cols, rows);
743
+ }
744
+ }
745
+
746
+ if (stdin.isTTY) {
747
+ stdin.setRawMode(true);
748
+ stdin.resume();
749
+ stdin.on('data', (data) => {
750
+ const key = data.toString();
751
+ if (key === 'q' || key === '\x03') quit = true;
752
+ });
753
+ }
754
+
755
+ return new Promise((resolve) => {
756
+ function tick() {
757
+ if (quit) {
758
+ stdout.write('\x1b[?1049l\x1b[?25h');
759
+ if (stdin.isTTY) stdin.setRawMode(false);
760
+ stdin.pause();
761
+ resolve();
762
+ return;
763
+ }
764
+ drawFrame();
765
+ frameIdx = (frameIdx + 1) % frames.length;
766
+ setTimeout(tick, interval);
767
+ }
768
+ tick();
769
+ });
770
+ }