@feelgxxd/textel 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +180 -2
- package/dist/ansi.d.ts +13 -0
- package/dist/ansi.js +46 -0
- package/dist/browser/player-core.d.ts +23 -0
- package/dist/browser/player-core.js +68 -0
- package/dist/browser/player.d.ts +25 -0
- package/dist/browser/player.js +198 -0
- package/dist/cli/commands/list.d.ts +3 -0
- package/dist/cli/commands/list.js +21 -0
- package/dist/cli/commands/play.d.ts +3 -0
- package/dist/cli/commands/play.js +55 -0
- package/dist/cli/commands/preview.d.ts +3 -0
- package/dist/cli/commands/preview.js +57 -0
- package/dist/cli/commands/render.d.ts +3 -0
- package/dist/cli/commands/render.js +94 -0
- package/dist/cli/live.d.ts +13 -0
- package/dist/cli/live.js +116 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +53 -0
- package/dist/cli/shared.d.ts +103 -0
- package/dist/cli/shared.js +178 -0
- package/dist/examples.d.ts +13 -0
- package/dist/examples.js +164 -0
- package/dist/format.d.ts +57 -0
- package/dist/format.js +262 -0
- package/dist/frames.d.ts +34 -0
- package/dist/frames.js +88 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/loaders/gltf.d.ts +7 -0
- package/dist/loaders/gltf.js +187 -0
- package/dist/loaders/index.d.ts +8 -0
- package/dist/loaders/index.js +24 -0
- package/dist/loaders/obj.d.ts +3 -0
- package/dist/loaders/obj.js +52 -0
- package/dist/loaders/stl.d.ts +3 -0
- package/dist/loaders/stl.js +29 -0
- package/dist/mesh.d.ts +35 -0
- package/dist/mesh.js +106 -0
- package/dist/output.d.ts +18 -0
- package/dist/output.js +76 -0
- package/dist/render.d.ts +36 -0
- package/dist/render.js +305 -0
- package/dist/sdf.d.ts +36 -0
- package/dist/sdf.js +151 -0
- package/dist/style.d.ts +125 -0
- package/dist/style.js +130 -0
- package/dist/terminal.d.ts +89 -0
- package/dist/terminal.js +197 -0
- package/dist/textel-player.js +7 -0
- package/package.json +84 -4
package/dist/style.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Paul Bourke's 70-level ramp, flipped so it runs dark to bright like the others
|
|
2
|
+
const DETAILED = [...'$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,"^`\'. '].reverse().join('');
|
|
3
|
+
/**
|
|
4
|
+
* Character ramps, darkest first. A ramp that starts with a space lets shadows go empty
|
|
5
|
+
* (crisp on flat faces); one that does not keeps the whole silhouette visible.
|
|
6
|
+
*/
|
|
7
|
+
export const CHARSETS = {
|
|
8
|
+
classic: '.,-~:;=!*#$@',
|
|
9
|
+
hard: ' .:-=+*#%@',
|
|
10
|
+
detailed: DETAILED,
|
|
11
|
+
blocks: ' ░▒▓█',
|
|
12
|
+
dots: ' ·•●',
|
|
13
|
+
minimal: ' .:-=',
|
|
14
|
+
};
|
|
15
|
+
export const DEFAULTS = {
|
|
16
|
+
width: 80,
|
|
17
|
+
height: 40,
|
|
18
|
+
aspect: 2,
|
|
19
|
+
charset: 'classic',
|
|
20
|
+
pitch: 0.3,
|
|
21
|
+
spin: 0.66,
|
|
22
|
+
distance: 5,
|
|
23
|
+
zoom: 1,
|
|
24
|
+
light: [-0.45, 0.6, 0.65],
|
|
25
|
+
ambient: 0,
|
|
26
|
+
shading: 'auto',
|
|
27
|
+
supersample: 2,
|
|
28
|
+
coverage: 0.5,
|
|
29
|
+
edgeChars: '-/|\\',
|
|
30
|
+
edgeThreshold: 0.03,
|
|
31
|
+
};
|
|
32
|
+
/** Later inputs win; materials are merged by name. */
|
|
33
|
+
export function mergeStyles(...inputs) {
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const s of inputs) {
|
|
36
|
+
if (!s)
|
|
37
|
+
continue;
|
|
38
|
+
const { materials, ...rest } = s;
|
|
39
|
+
for (const [k, v] of Object.entries(rest))
|
|
40
|
+
if (v !== undefined)
|
|
41
|
+
out[k] = v;
|
|
42
|
+
if (materials)
|
|
43
|
+
out.materials = { ...out.materials, ...materials };
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export function resolveRamp(chars, charset) {
|
|
48
|
+
if (chars !== undefined && chars.length)
|
|
49
|
+
return [...chars];
|
|
50
|
+
const name = charset ?? DEFAULTS.charset;
|
|
51
|
+
const r = CHARSETS[name];
|
|
52
|
+
if (!r)
|
|
53
|
+
throw new Error(`unknown charset "${name}" (have: ${Object.keys(CHARSETS).join(', ')})`);
|
|
54
|
+
return [...r];
|
|
55
|
+
}
|
|
56
|
+
export function resolveStyle(input = {}) {
|
|
57
|
+
const num = (v, d, min = -Infinity, max = Infinity) => {
|
|
58
|
+
const n = v ?? d;
|
|
59
|
+
if (!Number.isFinite(n))
|
|
60
|
+
throw new Error(`not a number: ${v}`);
|
|
61
|
+
return Math.min(max, Math.max(min, n));
|
|
62
|
+
};
|
|
63
|
+
const l = input.light ?? DEFAULTS.light;
|
|
64
|
+
const ll = Math.hypot(l[0], l[1], l[2]) || 1;
|
|
65
|
+
const edgeChars = [...(input.edgeChars ?? DEFAULTS.edgeChars)];
|
|
66
|
+
if (edgeChars.length !== 4)
|
|
67
|
+
throw new Error('edgeChars needs exactly 4 glyphs: horizontal, rising, vertical, falling');
|
|
68
|
+
return {
|
|
69
|
+
width: Math.round(num(input.width, DEFAULTS.width, 1, 2000)),
|
|
70
|
+
height: Math.round(num(input.height, DEFAULTS.height, 1, 2000)),
|
|
71
|
+
aspect: num(input.aspect, DEFAULTS.aspect, 0.25, 8),
|
|
72
|
+
chars: resolveRamp(input.chars, input.charset),
|
|
73
|
+
pitch: num(input.pitch, DEFAULTS.pitch),
|
|
74
|
+
spin: num(input.spin, DEFAULTS.spin),
|
|
75
|
+
distance: num(input.distance, DEFAULTS.distance, 1.5, 1000),
|
|
76
|
+
zoom: num(input.zoom, DEFAULTS.zoom, 0.01, 100),
|
|
77
|
+
light: [l[0] / ll, l[1] / ll, l[2] / ll],
|
|
78
|
+
ambient: num(input.ambient, DEFAULTS.ambient, 0, 1),
|
|
79
|
+
shading: input.shading ?? DEFAULTS.shading,
|
|
80
|
+
supersample: Math.round(num(input.supersample, DEFAULTS.supersample, 1, 4)),
|
|
81
|
+
coverage: num(input.coverage, DEFAULTS.coverage, 0, 1),
|
|
82
|
+
edges: input.edges === true ? 'outline' : input.edges || false,
|
|
83
|
+
edgeChars,
|
|
84
|
+
edgeThreshold: num(input.edgeThreshold, DEFAULTS.edgeThreshold, 0),
|
|
85
|
+
color: resolveColor(input.color),
|
|
86
|
+
materials: input.materials ?? {},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function parseHex(hex) {
|
|
90
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
91
|
+
if (!m)
|
|
92
|
+
throw new Error(`not a hex color: ${hex}`);
|
|
93
|
+
let h = m[1];
|
|
94
|
+
if (h.length === 3)
|
|
95
|
+
h = h.replace(/./g, (c) => c + c);
|
|
96
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
97
|
+
}
|
|
98
|
+
export function resolveColor(c) {
|
|
99
|
+
const white = [255, 255, 255];
|
|
100
|
+
if (c === undefined || c === 'none')
|
|
101
|
+
return { mode: 'none', fg: white, stops: [], bg: null };
|
|
102
|
+
if (typeof c === 'string')
|
|
103
|
+
return { mode: 'mono', fg: parseHex(c), stops: [], bg: null };
|
|
104
|
+
if (Array.isArray(c)) {
|
|
105
|
+
if (c.length === 1)
|
|
106
|
+
return { mode: 'mono', fg: parseHex(c[0]), stops: [], bg: null };
|
|
107
|
+
const stops = c.map(parseHex);
|
|
108
|
+
return { mode: 'gradient', fg: stops[stops.length - 1], stops, bg: null };
|
|
109
|
+
}
|
|
110
|
+
const stops = (c.stops ?? []).map(parseHex);
|
|
111
|
+
const mode = c.mode ?? (stops.length > 1 ? 'gradient' : c.fg ? 'mono' : 'none');
|
|
112
|
+
return { mode, fg: c.fg ? parseHex(c.fg) : stops[stops.length - 1] ?? white, stops, bg: c.bg ? parseHex(c.bg) : null };
|
|
113
|
+
}
|
|
114
|
+
export function resolveMaterial(name, input) {
|
|
115
|
+
const m = { name, chars: null, color: null, opacity: 1, carved: false };
|
|
116
|
+
if (input === undefined || input === 'solid')
|
|
117
|
+
return m;
|
|
118
|
+
if (input === 'carved')
|
|
119
|
+
return { ...m, carved: true };
|
|
120
|
+
if (input === 'dim')
|
|
121
|
+
return { ...m, opacity: 0.6 };
|
|
122
|
+
if (input.chars !== undefined || input.charset !== undefined)
|
|
123
|
+
m.chars = resolveRamp(input.chars, input.charset);
|
|
124
|
+
if (input.color)
|
|
125
|
+
m.color = parseHex(input.color);
|
|
126
|
+
if (input.opacity !== undefined)
|
|
127
|
+
m.opacity = Math.min(1, Math.max(0, input.opacity));
|
|
128
|
+
m.carved = !!input.carved;
|
|
129
|
+
return m;
|
|
130
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type ColorDepth } from './ansi.ts';
|
|
2
|
+
import { type Recording } from './frames.ts';
|
|
3
|
+
/** What the players need from an output stream; `process.stdout` is one. */
|
|
4
|
+
export interface TerminalOutput {
|
|
5
|
+
write(s: string): unknown;
|
|
6
|
+
on(event: 'resize', listener: () => void): unknown;
|
|
7
|
+
off(event: 'resize', listener: () => void): unknown;
|
|
8
|
+
isTTY?: boolean;
|
|
9
|
+
columns?: number;
|
|
10
|
+
rows?: number;
|
|
11
|
+
}
|
|
12
|
+
/** What the players need from an input stream; `process.stdin` is one. */
|
|
13
|
+
export interface TerminalInput {
|
|
14
|
+
on(event: 'data', listener: (key: string) => void): unknown;
|
|
15
|
+
off(event: 'data', listener: (key: string) => void): unknown;
|
|
16
|
+
resume(): unknown;
|
|
17
|
+
pause(): unknown;
|
|
18
|
+
setEncoding(encoding: 'utf8'): unknown;
|
|
19
|
+
isTTY?: boolean;
|
|
20
|
+
setRawMode?(on: boolean): unknown;
|
|
21
|
+
}
|
|
22
|
+
type Out = TerminalOutput;
|
|
23
|
+
type In = TerminalInput;
|
|
24
|
+
/** What a stream can show, honoring NO_COLOR, FORCE_COLOR and COLORTERM. */
|
|
25
|
+
export declare function detectColorDepth(stream?: Out, env?: NodeJS.ProcessEnv): ColorDepth;
|
|
26
|
+
export interface ScreenOptions {
|
|
27
|
+
output?: Out;
|
|
28
|
+
/** keys arrive here when given; the input is put in raw mode */
|
|
29
|
+
input?: In | null;
|
|
30
|
+
onKey?: (key: string) => void;
|
|
31
|
+
onResize?: () => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A full-screen session: alternate screen, hidden cursor, raw keys. `close()` puts everything back,
|
|
35
|
+
* and so does process exit, so a crash never leaves the terminal broken.
|
|
36
|
+
*/
|
|
37
|
+
export declare class Screen {
|
|
38
|
+
readonly output: Out;
|
|
39
|
+
private input;
|
|
40
|
+
private closed;
|
|
41
|
+
private keyHandler?;
|
|
42
|
+
private resizeHandler?;
|
|
43
|
+
private exitHandler;
|
|
44
|
+
constructor(opts?: ScreenOptions);
|
|
45
|
+
get columns(): number;
|
|
46
|
+
get rows(): number;
|
|
47
|
+
/** draws lines from the top left; `status` goes on the last row, dimmed */
|
|
48
|
+
draw(lines: string[], status?: string): void;
|
|
49
|
+
private restore;
|
|
50
|
+
close(): void;
|
|
51
|
+
}
|
|
52
|
+
export type PlayResult = 'end' | 'quit' | 'interrupt' | 'abort';
|
|
53
|
+
export interface PlayOptions {
|
|
54
|
+
output?: Out;
|
|
55
|
+
input?: In | null;
|
|
56
|
+
/** keys: space pause, ←/→ step a frame, +/- speed, q quit (default false) */
|
|
57
|
+
interactive?: boolean;
|
|
58
|
+
/** keep looping until stopped (default true) */
|
|
59
|
+
loop?: boolean;
|
|
60
|
+
/** stop after this long */
|
|
61
|
+
seconds?: number;
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
/** default: detected from the output */
|
|
64
|
+
depth?: ColorDepth;
|
|
65
|
+
/** playback rate; default the file's */
|
|
66
|
+
fps?: number;
|
|
67
|
+
/** a status line at the bottom (default: on when interactive) */
|
|
68
|
+
status?: boolean;
|
|
69
|
+
/** shown in the status line */
|
|
70
|
+
title?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Plays a recording full screen. Resolves when it ends, the user quits, Ctrl+C is pressed or the
|
|
74
|
+
* signal aborts; the terminal is restored in every case. On output that is not a terminal it prints
|
|
75
|
+
* the first frame and returns.
|
|
76
|
+
*/
|
|
77
|
+
export declare function play(rec: Recording, opts?: PlayOptions): Promise<PlayResult>;
|
|
78
|
+
/** Reads a `.textel.json` file. */
|
|
79
|
+
export declare function loadFrames(file: string): Recording;
|
|
80
|
+
/**
|
|
81
|
+
* A spinning logo for a few seconds, then back to the normal screen: for the start of another CLI.
|
|
82
|
+
* Any key skips it. Does nothing visible when output is not a terminal.
|
|
83
|
+
*/
|
|
84
|
+
export declare function playSplash(source: string | Recording, opts?: {
|
|
85
|
+
seconds?: number;
|
|
86
|
+
output?: Out;
|
|
87
|
+
input?: In | null;
|
|
88
|
+
}): Promise<PlayResult>;
|
|
89
|
+
export {};
|
package/dist/terminal.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Terminal playback for Node: a full-screen session with clean restore, and players for frames files.
|
|
2
|
+
// `@feelgxxd/textel/terminal` in the package.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { RESET } from "./ansi.js";
|
|
5
|
+
import { parseFrames, recordingANSI, recordingText } from "./frames.js";
|
|
6
|
+
const CSI = '\x1b[';
|
|
7
|
+
/** What a stream can show, honoring NO_COLOR, FORCE_COLOR and COLORTERM. */
|
|
8
|
+
export function detectColorDepth(stream = process.stdout, env = process.env) {
|
|
9
|
+
if (env.NO_COLOR)
|
|
10
|
+
return 'none';
|
|
11
|
+
if (!stream.isTTY && !env.FORCE_COLOR)
|
|
12
|
+
return 'none';
|
|
13
|
+
if (env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || env.WT_SESSION || env.TERM_PROGRAM === 'vscode' || env.TERM_PROGRAM === 'iTerm.app')
|
|
14
|
+
return 'truecolor';
|
|
15
|
+
return '256';
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A full-screen session: alternate screen, hidden cursor, raw keys. `close()` puts everything back,
|
|
19
|
+
* and so does process exit, so a crash never leaves the terminal broken.
|
|
20
|
+
*/
|
|
21
|
+
export class Screen {
|
|
22
|
+
output;
|
|
23
|
+
input;
|
|
24
|
+
closed = false;
|
|
25
|
+
keyHandler;
|
|
26
|
+
resizeHandler;
|
|
27
|
+
exitHandler = () => this.restore();
|
|
28
|
+
constructor(opts = {}) {
|
|
29
|
+
this.output = opts.output ?? process.stdout;
|
|
30
|
+
this.input = opts.onKey ? opts.input ?? process.stdin : null;
|
|
31
|
+
this.output.write(`${CSI}?1049h${CSI}?25l${CSI}2J`);
|
|
32
|
+
process.on('exit', this.exitHandler);
|
|
33
|
+
if (opts.onResize) {
|
|
34
|
+
this.resizeHandler = () => { this.output.write(`${CSI}2J`); opts.onResize(); };
|
|
35
|
+
this.output.on('resize', this.resizeHandler);
|
|
36
|
+
}
|
|
37
|
+
if (this.input && opts.onKey) {
|
|
38
|
+
this.keyHandler = opts.onKey;
|
|
39
|
+
this.input.setRawMode?.(true);
|
|
40
|
+
this.input.setEncoding('utf8');
|
|
41
|
+
this.input.on('data', this.keyHandler);
|
|
42
|
+
this.input.resume();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
get columns() { return this.output.columns ?? 80; }
|
|
46
|
+
get rows() { return this.output.rows ?? 24; }
|
|
47
|
+
/** draws lines from the top left; `status` goes on the last row, dimmed */
|
|
48
|
+
draw(lines, status) {
|
|
49
|
+
let s = `${CSI}H${lines.join(`${CSI}K\n`)}${CSI}K`;
|
|
50
|
+
if (status !== undefined) {
|
|
51
|
+
const w = this.columns;
|
|
52
|
+
s += `${CSI}${this.rows};1H${CSI}2m${status.slice(0, w).padEnd(w)}${RESET}`;
|
|
53
|
+
}
|
|
54
|
+
this.output.write(s);
|
|
55
|
+
}
|
|
56
|
+
restore() {
|
|
57
|
+
if (this.closed)
|
|
58
|
+
return;
|
|
59
|
+
this.closed = true;
|
|
60
|
+
this.output.write(`${RESET}${CSI}?25h${CSI}?1049l`);
|
|
61
|
+
if (this.input) {
|
|
62
|
+
if (this.keyHandler)
|
|
63
|
+
this.input.off('data', this.keyHandler);
|
|
64
|
+
this.input.setRawMode?.(false);
|
|
65
|
+
this.input.pause();
|
|
66
|
+
}
|
|
67
|
+
if (this.resizeHandler)
|
|
68
|
+
this.output.off('resize', this.resizeHandler);
|
|
69
|
+
}
|
|
70
|
+
close() {
|
|
71
|
+
this.restore();
|
|
72
|
+
process.off('exit', this.exitHandler);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Plays a recording full screen. Resolves when it ends, the user quits, Ctrl+C is pressed or the
|
|
77
|
+
* signal aborts; the terminal is restored in every case. On output that is not a terminal it prints
|
|
78
|
+
* the first frame and returns.
|
|
79
|
+
*/
|
|
80
|
+
export function play(rec, opts = {}) {
|
|
81
|
+
const output = opts.output ?? process.stdout;
|
|
82
|
+
const depth = opts.depth ?? detectColorDepth(output);
|
|
83
|
+
if (!output.isTTY) {
|
|
84
|
+
output.write((depth === 'none' ? recordingText(rec, 0) : recordingANSI(rec, 0, depth).join('\n')) + '\n');
|
|
85
|
+
return Promise.resolve('end');
|
|
86
|
+
}
|
|
87
|
+
const n = rec.frames.length, baseFps = opts.fps ?? rec.fps, loop = opts.loop ?? true;
|
|
88
|
+
const interactive = !!opts.interactive, showStatus = opts.status ?? interactive;
|
|
89
|
+
return new Promise((resolvePlay) => {
|
|
90
|
+
let pos = 0, speed = 1, paused = false, last = performance.now(), elapsed = 0, done = false;
|
|
91
|
+
let timer;
|
|
92
|
+
const finish = (r) => {
|
|
93
|
+
if (done)
|
|
94
|
+
return;
|
|
95
|
+
done = true;
|
|
96
|
+
clearInterval(timer);
|
|
97
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
98
|
+
process.off('SIGINT', onSigint);
|
|
99
|
+
screen.close();
|
|
100
|
+
resolvePlay(r);
|
|
101
|
+
};
|
|
102
|
+
const onAbort = () => finish('abort');
|
|
103
|
+
const onSigint = () => finish('interrupt');
|
|
104
|
+
const screen = new Screen({
|
|
105
|
+
output,
|
|
106
|
+
input: opts.input,
|
|
107
|
+
onResize: () => draw(),
|
|
108
|
+
onKey: interactive || opts.input ? (key) => {
|
|
109
|
+
// without controls, any key ends playback (a splash screen can be skipped)
|
|
110
|
+
if (!interactive)
|
|
111
|
+
return finish(key === '\x03' ? 'interrupt' : 'quit');
|
|
112
|
+
switch (key) {
|
|
113
|
+
case '\x03':
|
|
114
|
+
finish('interrupt');
|
|
115
|
+
break;
|
|
116
|
+
case 'q':
|
|
117
|
+
case 'Q':
|
|
118
|
+
case '\x1b':
|
|
119
|
+
finish('quit');
|
|
120
|
+
break;
|
|
121
|
+
case ' ':
|
|
122
|
+
paused = !paused;
|
|
123
|
+
break;
|
|
124
|
+
case '\x1b[C':
|
|
125
|
+
paused = true;
|
|
126
|
+
pos = Math.floor(pos) + 1;
|
|
127
|
+
draw();
|
|
128
|
+
break;
|
|
129
|
+
case '\x1b[D':
|
|
130
|
+
paused = true;
|
|
131
|
+
pos = Math.floor(pos) - 1 + n;
|
|
132
|
+
draw();
|
|
133
|
+
break;
|
|
134
|
+
case '+':
|
|
135
|
+
case '=':
|
|
136
|
+
speed = Math.min(8, speed * 1.25);
|
|
137
|
+
break;
|
|
138
|
+
case '-':
|
|
139
|
+
case '_':
|
|
140
|
+
speed = Math.max(0.125, speed / 1.25);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
} : undefined,
|
|
144
|
+
});
|
|
145
|
+
if (opts.signal?.aborted)
|
|
146
|
+
return finish('abort');
|
|
147
|
+
opts.signal?.addEventListener('abort', onAbort);
|
|
148
|
+
process.on('SIGINT', onSigint);
|
|
149
|
+
function draw() {
|
|
150
|
+
const i = Math.floor(pos) % n;
|
|
151
|
+
const lines = depth === 'none' ? recordingText(rec, i).split('\n') : recordingANSI(rec, i, depth);
|
|
152
|
+
// center the grid; crop when the terminal is smaller
|
|
153
|
+
const rows = screen.rows - (showStatus ? 1 : 0), cols = screen.columns;
|
|
154
|
+
const top = Math.max(0, Math.floor((rows - rec.height) / 2)), left = Math.max(0, Math.floor((cols - rec.width) / 2));
|
|
155
|
+
const pad = ' '.repeat(left);
|
|
156
|
+
const shown = lines.slice(0, Math.max(0, rows - top)).map((l) => pad + l);
|
|
157
|
+
const out = [...new Array(top).fill(''), ...shown];
|
|
158
|
+
let status;
|
|
159
|
+
if (showStatus) {
|
|
160
|
+
const small = rec.width > cols || rec.height > rows ? ' · terminal too small, cropped' : '';
|
|
161
|
+
status = ` ${opts.title ?? 'textel'} · frame ${i + 1}/${n} · ${(speed * baseFps).toFixed(0)}fps${paused ? ' · paused' : ''}${small} space pause ←→ step +/- speed q quit`;
|
|
162
|
+
}
|
|
163
|
+
screen.draw(out, status);
|
|
164
|
+
}
|
|
165
|
+
const tick = () => {
|
|
166
|
+
const now = performance.now(), dt = Math.min(now - last, 100) / 1000;
|
|
167
|
+
last = now;
|
|
168
|
+
elapsed += dt;
|
|
169
|
+
if (opts.seconds !== undefined && elapsed >= opts.seconds)
|
|
170
|
+
return finish('end');
|
|
171
|
+
if (!paused) {
|
|
172
|
+
pos += dt * baseFps * speed;
|
|
173
|
+
if (!loop && pos >= n)
|
|
174
|
+
return finish('end');
|
|
175
|
+
}
|
|
176
|
+
draw();
|
|
177
|
+
};
|
|
178
|
+
draw();
|
|
179
|
+
timer = setInterval(tick, 1000 / Math.max(1, Math.min(120, baseFps)));
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** Reads a `.textel.json` file. */
|
|
183
|
+
export function loadFrames(file) {
|
|
184
|
+
return parseFrames(readFileSync(file, 'utf8'));
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* A spinning logo for a few seconds, then back to the normal screen: for the start of another CLI.
|
|
188
|
+
* Any key skips it. Does nothing visible when output is not a terminal.
|
|
189
|
+
*/
|
|
190
|
+
export async function playSplash(source, opts = {}) {
|
|
191
|
+
const output = opts.output ?? process.stdout;
|
|
192
|
+
if (!output.isTTY)
|
|
193
|
+
return 'end';
|
|
194
|
+
const rec = typeof source === 'string' ? loadFrames(source) : source;
|
|
195
|
+
const input = opts.input === undefined ? (process.stdin.isTTY ? process.stdin : null) : opts.input;
|
|
196
|
+
return play(rec, { output, input, seconds: opts.seconds ?? 2, interactive: false, status: false });
|
|
197
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
var L="rle-delta-1";var y=class{pos=0;buf;constructor(t){this.buf=t}byte(){if(this.pos>=this.buf.length)throw new Error("frames data ends early");return this.buf[this.pos++]}varint(){let t=0,e=0,r;do if(r=this.byte(),t+=(r&127)*2**e,e+=7,e>35)throw new Error("bad varint in frames data");while(r&128);return t}get done(){return this.pos>=this.buf.length}},U=0,I=1;function C(n,t,e,r,h){let s=r,u=r+h;if(u>t.length)throw new Error("frames data runs past the grid");for(;s<u;){let a=n.varint(),o=n.varint(),i=e?n.varint():0;if(a===0||s+a>u)throw new Error("bad run in frames data");t.fill(o,s,s+a),e&&e.fill(i,s,s+a),s+=a}}function T(n,t,e,r,h,s=65536,u=65536){let a=s<=256?Uint8Array:Uint16Array,o=u<=256?Uint8Array:Uint16Array,i=t*e,l=new y(n),c=[];for(let m=0;m<r;m++){let p=l.byte(),f=c[m-1],g=new a(i),d=h?new o(i):null;if(p===U)C(l,g,d,0,i);else if(p===I){if(!f)throw new Error("first frame cannot be a delta");g.set(f.glyph),d&&d.set(f.style);let O=l.varint(),A=0;for(let v=0;v<O;v++){let E=l.varint(),b=l.varint();if(b===0)throw new Error("empty span in frames data");C(l,g,d,A+E,b),A+=E+b}}else throw new Error(`unknown frame type ${p}`);c.push({glyph:g,style:d})}if(!l.done)throw new Error("extra bytes after the last frame");return c}function w(n){if(!n||typeof n!="object")throw new Error("not a textel frames file");if(n.textel!==1)throw new Error(`frames format ${n.textel} is not supported (this version reads 1)`);if(n.encoding!==L)throw new Error(`unknown frames encoding "${n.encoding}"`);let{width:t,height:e,frameCount:r}=n;if(!(t>0&&e>0&&r>0))throw new Error("bad frames header");if(!Array.isArray(n.glyphs)||!n.glyphs.length)throw new Error("bad frames header");let h=n.glyphs.length,s=n.styles?.length??1,u=T(N(n.data),t,e,r,!!n.styles,h,s);for(let a of u)for(let o=0;o<a.glyph.length;o++)if(a.glyph[o]>=h||a.style&&a.style[o]>=s)throw new Error("frames data points outside its palette");return{width:t,height:e,aspect:n.aspect,fps:n.fps,glyphs:n.glyphs,styles:n.styles??null,bg:n.bg??null,frames:u,meta:n.meta??{}}}function N(n){let t=atob(n),e=new Uint8Array(t.length);for(let r=0;r<t.length;r++)e[r]=t.charCodeAt(r);return e}var $=n=>n==="<"?"<":n===">"?">":n==="&"?"&":n;function R(n,t){let{width:e,height:r,glyphs:h,styles:s}=n,u=n.frames[t];if(!s||!u.style){let i="";for(let l=0;l<r;l++){for(let c=0;c<e;c++)i+=h[u.glyph[l*e+c]];l<r-1&&(i+=`
|
|
2
|
+
`)}return{html:!1,content:i}}let a=s.map(i=>i===""?"":i==="~"?'<span class="d">':`<span style="color:${i}">`),o="";for(let i=0;i<r;i++){let l=0;for(let c=0;c<e;c++){let m=i*e+c,p=h[u.glyph[m]];if(p!==" "){let f=u.style[m];f!==l&&(l&&(o+="</span>"),o+=a[f],l=f)}o+=$(p)}l&&(o+="</span>"),i<r-1&&(o+=`
|
|
3
|
+
`)}return{html:!0,content:o}}function F(n,t){if(t)return t;let e=n?.meta.title;return typeof e=="string"&&e?`${e}, ASCII animation`:"ASCII animation"}function M(n,t,e,r){return(n+Math.min(Math.max(t,0),.1)*e)%r}function k(n,t,e,r){return e>0?((n+t/e*r)%r+r)%r:n}function S(n,t,e,r,h,s,u){let a=r>0&&h>0?e*r/h:1,o=n*r,i=t*h*a;return o>0&&i>0?{scale:u>0?Math.min(s/o,u/i):s/o,stretchY:a,ratio:o/i}:{scale:1,stretchY:a,ratio:1}}var j=`:host{display:block;position:relative;overflow:hidden;aspect-ratio:var(--textel-ratio,auto);min-height:1px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
4
|
+
:host(:not([nodrag])){cursor:grab;touch-action:pan-y}
|
|
5
|
+
:host(:not([nodrag]):active){cursor:grabbing}
|
|
6
|
+
pre{position:absolute;left:50%;top:50%;margin:0;transform-origin:50% 50%;font:inherit;font-size:16px;line-height:1;letter-spacing:0;white-space:pre;color:inherit;text-shadow:var(--textel-glow,none);-webkit-text-size-adjust:none;text-size-adjust:none;user-select:none;pointer-events:none}
|
|
7
|
+
.d{opacity:var(--textel-dim-opacity,.6)}`,H=typeof HTMLElement>"u"?class{}:HTMLElement,x=class extends H{static observedAttributes=["src","fps","paused","color","label"];#t=null;#p=[];#s;#r=0;#h=-1;#o=!0;#d=!0;#n=0;#a=0;#i=null;#g=null;#u;#c;#w=typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches;constructor(){super();let t=this.attachShadow({mode:"open"}),e=document.createElement("style");e.textContent=j,this.#s=document.createElement("pre"),this.#s.setAttribute("part","screen"),t.append(e,this.#s),this.addEventListener("pointerdown",this.#v),this.addEventListener("pointermove",this.#E),this.addEventListener("pointerup",this.#y),this.addEventListener("pointercancel",this.#y)}connectedCallback(){this.setAttribute("role","img"),this.#f(),this.#o=!this.hasAttribute("paused")&&!this.#w,typeof IntersectionObserver=="function"&&(this.#u=new IntersectionObserver(([t])=>{this.#d=t.isIntersecting,this.#e()}),this.#u.observe(this)),typeof ResizeObserver=="function"&&(this.#c=new ResizeObserver(()=>this.#m()),this.#c.observe(this)),document.addEventListener("visibilitychange",this.#e),document.fonts?.ready.then(()=>this.#m()),this.#e()}disconnectedCallback(){this.#u?.disconnect(),this.#c?.disconnect(),document.removeEventListener("visibilitychange",this.#e),cancelAnimationFrame(this.#n),this.#n=0}attributeChangedCallback(t,e,r){e!==r&&(t==="src"?r&&this.#x(r):t==="paused"?r===null?this.play():this.pause():t==="color"?this.#s.style.color=r??"":t==="label"&&this.#f())}get recording(){return this.#t}set recording(t){this.#t=t&&"data"in t?w(t):t,this.#p=[],this.#r=0,this.#h=-1,this.#f(),this.#l(),this.#m(),this.#e()}get frame(){return Math.floor(this.#r)}set frame(t){let e=this.#t?.frames.length??1;this.#r=(Math.floor(t)%e+e)%e,this.#l()}get paused(){return!this.#o}play(){this.#o=!0,this.#e()}pause(){this.#o=!1,this.#e()}async#x(t){this.#g?.abort();let e=this.#g=new AbortController;try{let r=await fetch(t,{signal:e.signal});if(!r.ok)throw new Error(`${r.status} ${r.statusText} for ${t}`);let h=await r.json();if(e.signal.aborted)return;this.recording=w(h),this.dispatchEvent(new CustomEvent("load",{detail:{frames:this.#t.frames.length}}))}catch(r){if(e.signal.aborted)return;this.dispatchEvent(new CustomEvent("error",{detail:{error:r}}))}}#f(){this.isConnected&&this.setAttribute("aria-label",F(this.#t,this.getAttribute("label")))}#A(){let t=Number(this.getAttribute("fps"));return t>0?t:this.#t?.fps??30}#l(){let t=this.#t;if(!t)return;let e=Math.floor(this.#r)%t.frames.length;if(e===this.#h)return;this.#h=e;let r=this.#p[e]??=R(t,e);r.html?this.#s.innerHTML=r.content:this.#s.textContent=r.content}#m(){let t=this.#t;if(!t)return;let e=this.#s,r=e.offsetWidth/t.width,h=e.offsetHeight/t.height,s=S(t.width,t.height,t.aspect,r,h,this.clientWidth,this.clientHeight);this.style.setProperty("--textel-ratio",String(s.ratio)),e.style.transform=`translate(-50%,-50%) scale(${s.scale},${s.scale*s.stretchY})`}#e=()=>{let t=this.#o&&this.#d&&!document.hidden&&!!this.#t&&!this.#i&&this.isConnected;t&&!this.#n?(this.#a=-1,this.#n=requestAnimationFrame(this.#b)):!t&&this.#n&&(cancelAnimationFrame(this.#n),this.#n=0)};#b=t=>{let e=this.#t;this.#a>=0&&(this.#r=M(this.#r,(t-this.#a)/1e3,this.#A(),e.frames.length)),this.#a=t,this.#l(),this.#n=requestAnimationFrame(this.#b)};#v=t=>{this.hasAttribute("nodrag")||!this.#t||t.button!==0||(this.#i={x:t.clientX,pos:this.#r},this.setPointerCapture(t.pointerId),this.#e())};#E=t=>{!this.#i||!this.#t||(this.#r=k(this.#i.pos,t.clientX-this.#i.x,this.clientWidth,this.#t.frames.length),this.#l())};#y=()=>{this.#i&&(this.#i=null,this.#e())}};typeof customElements<"u"&&!customElements.get("textel-player")&&customElements.define("textel-player",x);export{x as TextelPlayer};
|
package/package.json
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feelgxxd/textel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Turn 3D models into animated ASCII art for the terminal and the web.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ascii",
|
|
7
|
+
"ascii-art",
|
|
8
|
+
"3d",
|
|
9
|
+
"terminal",
|
|
10
|
+
"renderer",
|
|
11
|
+
"gltf",
|
|
12
|
+
"obj",
|
|
13
|
+
"stl",
|
|
14
|
+
"sdf"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/feelgxxd/textel#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/feelgxxd/textel/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/feelgxxd/textel.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Berk Beken",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"textel": "dist/cli/main.js"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./sdf": {
|
|
36
|
+
"types": "./dist/sdf.d.ts",
|
|
37
|
+
"default": "./dist/sdf.js"
|
|
38
|
+
},
|
|
39
|
+
"./examples": {
|
|
40
|
+
"types": "./dist/examples.d.ts",
|
|
41
|
+
"default": "./dist/examples.js"
|
|
42
|
+
},
|
|
43
|
+
"./terminal": {
|
|
44
|
+
"types": "./dist/terminal.d.ts",
|
|
45
|
+
"default": "./dist/terminal.js"
|
|
46
|
+
},
|
|
47
|
+
"./frames": {
|
|
48
|
+
"types": "./dist/frames.d.ts",
|
|
49
|
+
"default": "./dist/frames.js"
|
|
50
|
+
},
|
|
51
|
+
"./player": {
|
|
52
|
+
"types": "./dist/browser/player.d.ts",
|
|
53
|
+
"default": "./dist/textel-player.js"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"sideEffects": [
|
|
57
|
+
"./dist/browser/player.js",
|
|
58
|
+
"./dist/textel-player.js"
|
|
59
|
+
],
|
|
60
|
+
"files": [
|
|
61
|
+
"dist"
|
|
62
|
+
],
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=22"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"dev": "node src/cli/main.ts",
|
|
68
|
+
"build": "tsc -p tsconfig.build.json && node scripts/build-browser.ts",
|
|
69
|
+
"typecheck": "tsc",
|
|
70
|
+
"test": "node --test \"test/*.test.ts\"",
|
|
71
|
+
"snapshots": "node test/update-snapshots.ts",
|
|
72
|
+
"size": "node scripts/build-browser.ts --check",
|
|
73
|
+
"demo": "node scripts/build-browser.ts && node scripts/demo.ts",
|
|
74
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@happy-dom/global-registrator": "^21.0.4",
|
|
78
|
+
"@types/node": "^24.0.0",
|
|
79
|
+
"esbuild": "^0.28.2",
|
|
80
|
+
"happy-dom": "^21.0.4",
|
|
81
|
+
"typescript": "^5.9.0"
|
|
82
|
+
},
|
|
83
|
+
"publishConfig": {
|
|
84
|
+
"access": "public"
|
|
85
|
+
}
|
|
86
|
+
}
|