storymode-cli 1.0.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.
- package/README.md +65 -0
- package/bin/storymode.mjs +3 -0
- package/package.json +31 -0
- package/src/api.mjs +45 -0
- package/src/browse.mjs +70 -0
- package/src/cli.mjs +92 -0
- package/src/mcp.mjs +150 -0
- package/src/player.mjs +146 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# storymode-cli
|
|
2
|
+
|
|
3
|
+
Play AI-animated pixel art characters in your terminal.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx storymode-cli play 1
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
### `play <id>`
|
|
12
|
+
Play a gallery animation with interactive controls.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
npx storymode-cli play 1
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Controls:**
|
|
19
|
+
- `space` — pause / resume
|
|
20
|
+
- `←` `→` — step frames (when paused)
|
|
21
|
+
- `+` `-` — adjust speed
|
|
22
|
+
- `q` — quit
|
|
23
|
+
|
|
24
|
+
### `show <id>`
|
|
25
|
+
Print the first frame as a static portrait.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
npx storymode-cli show 1
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### `browse`
|
|
32
|
+
Browse the gallery and pick an animation to play.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
npx storymode-cli browse
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `mcp`
|
|
39
|
+
Start a Model Context Protocol server for Claude Code integration.
|
|
40
|
+
|
|
41
|
+
Add to `~/.claude/settings.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"storymode": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["storymode-cli", "mcp"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- Node.js 18+
|
|
57
|
+
- A terminal with truecolor support (iTerm2, Terminal.app, Windows Terminal, Claude Code)
|
|
58
|
+
|
|
59
|
+
## Zero Dependencies
|
|
60
|
+
|
|
61
|
+
Built entirely on Node.js built-ins — no `node_modules` needed.
|
|
62
|
+
|
|
63
|
+
## Gallery
|
|
64
|
+
|
|
65
|
+
Browse characters at [storymode.fixmy.codes](https://storymode.fixmy.codes)
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "storymode-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Play AI-animated pixel art characters in your terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"storymode": "bin/storymode.mjs",
|
|
8
|
+
"storymode-cli": "bin/storymode.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pixel-art",
|
|
20
|
+
"animation",
|
|
21
|
+
"terminal",
|
|
22
|
+
"ansi",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/Yuncun/storymode-cli"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://storymode.fixmy.codes"
|
|
31
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { gunzipSync } from 'node:zlib';
|
|
4
|
+
|
|
5
|
+
const BASE = 'https://storymode.fixmy.codes';
|
|
6
|
+
|
|
7
|
+
function request(url) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const mod = url.startsWith('https') ? https : http;
|
|
10
|
+
mod.get(url, { headers: { 'Accept-Encoding': 'identity' } }, (res) => {
|
|
11
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
12
|
+
return resolve(request(res.headers.location));
|
|
13
|
+
}
|
|
14
|
+
if (res.statusCode !== 200) {
|
|
15
|
+
res.resume();
|
|
16
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
17
|
+
}
|
|
18
|
+
const chunks = [];
|
|
19
|
+
res.on('data', (c) => chunks.push(c));
|
|
20
|
+
res.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: res.headers['content-type'] || '' }));
|
|
21
|
+
res.on('error', reject);
|
|
22
|
+
}).on('error', reject);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function fetchGallery() {
|
|
27
|
+
const { buffer } = await request(`${BASE}/gallery`);
|
|
28
|
+
return JSON.parse(buffer.toString('utf-8'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function fetchFrames(galleryId) {
|
|
32
|
+
const { buffer, contentType } = await request(`${BASE}/gallery/${galleryId}/frames`);
|
|
33
|
+
let json;
|
|
34
|
+
if (contentType.includes('gzip') || buffer[0] === 0x1f) {
|
|
35
|
+
json = gunzipSync(buffer).toString('utf-8');
|
|
36
|
+
} else {
|
|
37
|
+
json = buffer.toString('utf-8');
|
|
38
|
+
}
|
|
39
|
+
return JSON.parse(json);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function fetchCharacter(galleryId) {
|
|
43
|
+
const { buffer } = await request(`${BASE}/gallery/${galleryId}/character`);
|
|
44
|
+
return JSON.parse(buffer.toString('utf-8'));
|
|
45
|
+
}
|
package/src/browse.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { fetchGallery, fetchFrames } from './api.mjs';
|
|
3
|
+
import { playAnimation } from './player.mjs';
|
|
4
|
+
|
|
5
|
+
export async function browse() {
|
|
6
|
+
const spinner = startSpinner('Fetching gallery...');
|
|
7
|
+
let items;
|
|
8
|
+
try {
|
|
9
|
+
items = await fetchGallery();
|
|
10
|
+
} finally {
|
|
11
|
+
stopSpinner(spinner);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!items || items.length === 0) {
|
|
15
|
+
console.log('Gallery is empty.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log('');
|
|
20
|
+
console.log(' storymode gallery');
|
|
21
|
+
console.log('');
|
|
22
|
+
for (let i = 0; i < items.length; i++) {
|
|
23
|
+
const item = items[i];
|
|
24
|
+
const name = item.name || 'untitled';
|
|
25
|
+
const prompt = item.prompt ? ` — ${item.prompt}` : '';
|
|
26
|
+
const author = item.author_name ? ` (by ${item.author_name})` : '';
|
|
27
|
+
console.log(` ${String(i + 1).padStart(3)}. ${name}${prompt}${author}`);
|
|
28
|
+
}
|
|
29
|
+
console.log('');
|
|
30
|
+
|
|
31
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
try {
|
|
33
|
+
while (true) {
|
|
34
|
+
const answer = await rl.question(' Enter number to play (q to quit): ');
|
|
35
|
+
const trimmed = answer.trim().toLowerCase();
|
|
36
|
+
if (trimmed === 'q' || trimmed === 'quit' || trimmed === '') break;
|
|
37
|
+
|
|
38
|
+
const num = parseInt(trimmed, 10);
|
|
39
|
+
if (isNaN(num) || num < 1 || num > items.length) {
|
|
40
|
+
console.log(` Pick 1-${items.length}`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const item = items[num - 1];
|
|
45
|
+
rl.close();
|
|
46
|
+
|
|
47
|
+
console.log(`\n Loading ${item.name || 'animation'}...`);
|
|
48
|
+
const framesData = await fetchFrames(item.id);
|
|
49
|
+
await playAnimation(framesData);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
rl.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function startSpinner(msg) {
|
|
58
|
+
const chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
59
|
+
let i = 0;
|
|
60
|
+
process.stdout.write(` ${chars[0]} ${msg}`);
|
|
61
|
+
return setInterval(() => {
|
|
62
|
+
i = (i + 1) % chars.length;
|
|
63
|
+
process.stdout.write(`\r ${chars[i]} ${msg}`);
|
|
64
|
+
}, 80);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stopSpinner(id) {
|
|
68
|
+
clearInterval(id);
|
|
69
|
+
process.stdout.write('\r\x1b[K');
|
|
70
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { fetchFrames, fetchCharacter } from './api.mjs';
|
|
2
|
+
import { playAnimation, showFrame } from './player.mjs';
|
|
3
|
+
import { browse } from './browse.mjs';
|
|
4
|
+
import { startMcpServer } from './mcp.mjs';
|
|
5
|
+
|
|
6
|
+
const HELP = `
|
|
7
|
+
storymode-cli — play AI-animated pixel art in your terminal
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
storymode play <gallery_id> Play an animation
|
|
11
|
+
storymode show <gallery_id> Show first frame as static portrait
|
|
12
|
+
storymode browse Browse the gallery interactively
|
|
13
|
+
storymode mcp Start MCP server (for Claude Code)
|
|
14
|
+
|
|
15
|
+
Controls (during playback):
|
|
16
|
+
space pause / resume
|
|
17
|
+
< / > step frames (when paused)
|
|
18
|
+
+ / - increase / decrease speed
|
|
19
|
+
q quit
|
|
20
|
+
|
|
21
|
+
https://storymode.fixmy.codes
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export async function run(args) {
|
|
25
|
+
const cmd = args[0];
|
|
26
|
+
const id = args[1];
|
|
27
|
+
|
|
28
|
+
switch (cmd) {
|
|
29
|
+
case 'play': {
|
|
30
|
+
if (!id) {
|
|
31
|
+
console.error('Usage: storymode play <gallery_id>');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
process.stderr.write(' Loading animation...\n');
|
|
36
|
+
const framesData = await fetchFrames(id);
|
|
37
|
+
await playAnimation(framesData);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(`Error: ${err.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
case 'show': {
|
|
46
|
+
if (!id) {
|
|
47
|
+
console.error('Usage: storymode show <gallery_id>');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
process.stderr.write(' Loading...\n');
|
|
52
|
+
const [framesData, character] = await Promise.all([
|
|
53
|
+
fetchFrames(id),
|
|
54
|
+
fetchCharacter(id).catch(() => null),
|
|
55
|
+
]);
|
|
56
|
+
const info = character
|
|
57
|
+
? { ...character, galleryId: id }
|
|
58
|
+
: { galleryId: id };
|
|
59
|
+
showFrame(framesData, info);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`Error: ${err.message}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'browse':
|
|
68
|
+
try {
|
|
69
|
+
await browse();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(`Error: ${err.message}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 'mcp':
|
|
77
|
+
startMcpServer();
|
|
78
|
+
break;
|
|
79
|
+
|
|
80
|
+
case '--help':
|
|
81
|
+
case '-h':
|
|
82
|
+
case 'help':
|
|
83
|
+
case undefined:
|
|
84
|
+
console.log(HELP);
|
|
85
|
+
break;
|
|
86
|
+
|
|
87
|
+
default:
|
|
88
|
+
console.error(`Unknown command: ${cmd}`);
|
|
89
|
+
console.log(HELP);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { fetchGallery, fetchFrames, fetchCharacter } from './api.mjs';
|
|
3
|
+
|
|
4
|
+
const TOOLS = [
|
|
5
|
+
{
|
|
6
|
+
name: 'list_characters',
|
|
7
|
+
description: 'List all characters in the Storymode gallery',
|
|
8
|
+
inputSchema: { type: 'object', properties: {} },
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: 'show_character',
|
|
12
|
+
description: 'Show character info and portrait (first frame as ANSI art)',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: { gallery_id: { type: 'string', description: 'Gallery item ID' } },
|
|
16
|
+
required: ['gallery_id'],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'play_animation',
|
|
21
|
+
description: 'Get animation data for a gallery character (returns first frame as ANSI art + metadata)',
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: { gallery_id: { type: 'string', description: 'Gallery item ID' } },
|
|
25
|
+
required: ['gallery_id'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
async function handleToolCall(name, args) {
|
|
31
|
+
switch (name) {
|
|
32
|
+
case 'list_characters': {
|
|
33
|
+
const items = await fetchGallery();
|
|
34
|
+
const lines = items.map((item, i) =>
|
|
35
|
+
`${i + 1}. ${item.name || 'untitled'}${item.prompt ? ' — ' + item.prompt : ''} (id: ${item.id})`
|
|
36
|
+
);
|
|
37
|
+
return lines.join('\n') || 'Gallery is empty.';
|
|
38
|
+
}
|
|
39
|
+
case 'show_character': {
|
|
40
|
+
const id = args.gallery_id;
|
|
41
|
+
const [character, framesData] = await Promise.all([
|
|
42
|
+
fetchCharacter(id).catch(() => null),
|
|
43
|
+
fetchFrames(id),
|
|
44
|
+
]);
|
|
45
|
+
let text = '';
|
|
46
|
+
if (character) {
|
|
47
|
+
text += `Name: ${character.name || 'untitled'}\n`;
|
|
48
|
+
if (character.backstory) text += `Backstory: ${character.backstory}\n`;
|
|
49
|
+
if (character.animations?.length) {
|
|
50
|
+
text += `Animations: ${character.animations.map(a => a.name || a.prompt).join(', ')}\n`;
|
|
51
|
+
}
|
|
52
|
+
text += '\n';
|
|
53
|
+
}
|
|
54
|
+
// First frame as ANSI
|
|
55
|
+
if (framesData.frames?.length > 0) {
|
|
56
|
+
text += 'Portrait (ANSI):\n';
|
|
57
|
+
text += framesData.frames[0].join('\n');
|
|
58
|
+
}
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
case 'play_animation': {
|
|
62
|
+
const id = args.gallery_id;
|
|
63
|
+
const framesData = await fetchFrames(id);
|
|
64
|
+
let text = '';
|
|
65
|
+
if (framesData.frames?.length > 0) {
|
|
66
|
+
text += framesData.frames[0].join('\n');
|
|
67
|
+
text += `\n\n${framesData.frames.length} frames @ ${framesData.fps || 16} fps`;
|
|
68
|
+
text += `\n\nTo see the full animation, run:\n npx storymode-cli play ${id}`;
|
|
69
|
+
} else {
|
|
70
|
+
text = 'No frames available.';
|
|
71
|
+
}
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function send(obj) {
|
|
80
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleMessage(msg) {
|
|
84
|
+
const { id, method, params } = msg;
|
|
85
|
+
|
|
86
|
+
switch (method) {
|
|
87
|
+
case 'initialize':
|
|
88
|
+
send({
|
|
89
|
+
jsonrpc: '2.0',
|
|
90
|
+
id,
|
|
91
|
+
result: {
|
|
92
|
+
protocolVersion: '2024-11-05',
|
|
93
|
+
capabilities: { tools: {} },
|
|
94
|
+
serverInfo: { name: 'storymode', version: '1.0.0' },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case 'notifications/initialized':
|
|
100
|
+
// No response needed
|
|
101
|
+
break;
|
|
102
|
+
|
|
103
|
+
case 'tools/list':
|
|
104
|
+
send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
105
|
+
break;
|
|
106
|
+
|
|
107
|
+
case 'tools/call':
|
|
108
|
+
handleToolCall(params.name, params.arguments || {})
|
|
109
|
+
.then((text) => {
|
|
110
|
+
send({
|
|
111
|
+
jsonrpc: '2.0',
|
|
112
|
+
id,
|
|
113
|
+
result: { content: [{ type: 'text', text }] },
|
|
114
|
+
});
|
|
115
|
+
})
|
|
116
|
+
.catch((err) => {
|
|
117
|
+
send({
|
|
118
|
+
jsonrpc: '2.0',
|
|
119
|
+
id,
|
|
120
|
+
result: {
|
|
121
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
122
|
+
isError: true,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
break;
|
|
127
|
+
|
|
128
|
+
default:
|
|
129
|
+
if (id) {
|
|
130
|
+
send({
|
|
131
|
+
jsonrpc: '2.0',
|
|
132
|
+
id,
|
|
133
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function startMcpServer() {
|
|
140
|
+
const rl = createInterface({ input: process.stdin });
|
|
141
|
+
rl.on('line', (line) => {
|
|
142
|
+
try {
|
|
143
|
+
const msg = JSON.parse(line);
|
|
144
|
+
handleMessage(msg);
|
|
145
|
+
} catch {
|
|
146
|
+
// Ignore malformed input
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
rl.on('close', () => process.exit(0));
|
|
150
|
+
}
|
package/src/player.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const { stdout, stdin } = process;
|
|
2
|
+
|
|
3
|
+
export function playAnimation(framesData) {
|
|
4
|
+
const frames = framesData.frames;
|
|
5
|
+
const baseFps = framesData.fps || 16;
|
|
6
|
+
const nFrames = frames.length;
|
|
7
|
+
|
|
8
|
+
if (nFrames === 0) {
|
|
9
|
+
console.log('No frames to play.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const maxLines = Math.max(...frames.map(f => f.length));
|
|
14
|
+
let frameIdx = 0;
|
|
15
|
+
let frameCount = 0;
|
|
16
|
+
let loopCount = 0;
|
|
17
|
+
let paused = false;
|
|
18
|
+
let currentFps = baseFps;
|
|
19
|
+
let interval = 1000 / currentFps;
|
|
20
|
+
let quit = false;
|
|
21
|
+
|
|
22
|
+
// Enter alt screen, hide cursor, clear
|
|
23
|
+
stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H');
|
|
24
|
+
|
|
25
|
+
function drawFrame(idx) {
|
|
26
|
+
let out = '\x1b[H';
|
|
27
|
+
const frameLines = frames[idx];
|
|
28
|
+
for (let i = 0; i < maxLines; i++) {
|
|
29
|
+
if (i < frameLines.length) {
|
|
30
|
+
out += frameLines[i];
|
|
31
|
+
}
|
|
32
|
+
out += '\x1b[K\n';
|
|
33
|
+
}
|
|
34
|
+
const status = paused ? 'PAUSED' : 'playing';
|
|
35
|
+
out += `\x1b[0m\x1b[K [${status}] frame ${idx + 1}/${nFrames} `;
|
|
36
|
+
out += `${currentFps} fps loop ${loopCount + 1} `;
|
|
37
|
+
out += '[space]=pause [</>]=step [+/-]=speed [q]=quit\n';
|
|
38
|
+
stdout.write(out);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cleanup() {
|
|
42
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
43
|
+
stdin.removeAllListeners('data');
|
|
44
|
+
stdin.pause();
|
|
45
|
+
// Exit alt screen, show cursor
|
|
46
|
+
stdout.write('\x1b[?1049l\x1b[?25h');
|
|
47
|
+
stdout.write(
|
|
48
|
+
`\x1b[0m${frameCount} frames displayed (${nFrames} unique, ${loopCount} loops)\n`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function handleKey(data) {
|
|
53
|
+
const key = data.toString();
|
|
54
|
+
if (key === 'q' || key === '\x03') {
|
|
55
|
+
quit = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (key === ' ') {
|
|
59
|
+
paused = !paused;
|
|
60
|
+
drawFrame(frameIdx);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (key === '+' || key === '=') {
|
|
64
|
+
currentFps = Math.min(currentFps + 2, 60);
|
|
65
|
+
interval = 1000 / currentFps;
|
|
66
|
+
drawFrame(frameIdx);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (key === '-' || key === '_') {
|
|
70
|
+
currentFps = Math.max(currentFps - 2, 1);
|
|
71
|
+
interval = 1000 / currentFps;
|
|
72
|
+
drawFrame(frameIdx);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Arrow keys: ESC [ A/B/C/D
|
|
76
|
+
if (key === '\x1b[C' && paused) {
|
|
77
|
+
// right arrow - next frame
|
|
78
|
+
frameIdx = (frameIdx + 1) % nFrames;
|
|
79
|
+
frameCount++;
|
|
80
|
+
drawFrame(frameIdx);
|
|
81
|
+
} else if (key === '\x1b[D' && paused) {
|
|
82
|
+
// left arrow - prev frame
|
|
83
|
+
frameIdx = (frameIdx - 1 + nFrames) % nFrames;
|
|
84
|
+
frameCount++;
|
|
85
|
+
drawFrame(frameIdx);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Set raw mode for key input
|
|
90
|
+
if (stdin.isTTY) {
|
|
91
|
+
stdin.setRawMode(true);
|
|
92
|
+
stdin.resume();
|
|
93
|
+
stdin.on('data', handleKey);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
function tick() {
|
|
98
|
+
if (quit) {
|
|
99
|
+
cleanup();
|
|
100
|
+
resolve();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!paused) {
|
|
104
|
+
drawFrame(frameIdx);
|
|
105
|
+
frameCount++;
|
|
106
|
+
frameIdx++;
|
|
107
|
+
if (frameIdx >= nFrames) {
|
|
108
|
+
loopCount++;
|
|
109
|
+
frameIdx = 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
setTimeout(tick, paused ? 50 : interval);
|
|
113
|
+
}
|
|
114
|
+
tick();
|
|
115
|
+
|
|
116
|
+
// Handle SIGINT gracefully
|
|
117
|
+
process.on('SIGINT', () => {
|
|
118
|
+
quit = true;
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function showFrame(framesData, info) {
|
|
124
|
+
const frames = framesData.frames;
|
|
125
|
+
if (!frames || frames.length === 0) {
|
|
126
|
+
console.log('No frames available.');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Print first frame
|
|
130
|
+
const frame = frames[0];
|
|
131
|
+
for (const line of frame) {
|
|
132
|
+
stdout.write(line + '\x1b[K\n');
|
|
133
|
+
}
|
|
134
|
+
stdout.write('\x1b[0m');
|
|
135
|
+
// Print character info below if provided
|
|
136
|
+
if (info) {
|
|
137
|
+
console.log('');
|
|
138
|
+
if (info.name) console.log(` ${info.name}`);
|
|
139
|
+
if (info.author_name) console.log(` by ${info.author_name}`);
|
|
140
|
+
if (info.backstory) console.log(` ${info.backstory}`);
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log(` ${frames.length} frames @ ${framesData.fps || 16} fps`);
|
|
143
|
+
console.log(` npx storymode-cli play ${info.galleryId || '?'}`);
|
|
144
|
+
}
|
|
145
|
+
console.log('');
|
|
146
|
+
}
|