@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Berk Beken
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,181 @@
|
|
|
1
|
-
#
|
|
1
|
+
# textel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Turn 3D models into animated ASCII art, in the terminal and on the web.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
/-----------------\
|
|
7
|
+
/---/-----------------\----\
|
|
8
|
+
/--//-------------------------\\--\
|
|
9
|
+
////-----%@@@@@@@@@@@@##########----||
|
|
10
|
+
/##%//%%%%@@@@@@@@@@@@@%############\|
|
|
11
|
+
\\***##%@@@@@@@@@@@@@@@@%#####*++++==|
|
|
12
|
+
\\\**##############%%%%+==========//
|
|
13
|
+
\-\\*###############*====://////
|
|
14
|
+
\\\----------------///--///
|
|
15
|
+
\\\----------------//-/
|
|
16
|
+
\--\-----------///
|
|
17
|
+
\-\------//-/
|
|
18
|
+
\-\--///
|
|
19
|
+
\--/
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npx @feelgxxd/textel model.glb # spins in your terminal
|
|
24
|
+
npx @feelgxxd/textel --example gem # built-in scenes: donut, dice, rocket, planet, mug, gem
|
|
25
|
+
npx @feelgxxd/textel model.stl --once > art.txt # one frame as plain text
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Install it once with `npm install -g @feelgxxd/textel` and the command is just `textel`. Needs Node 22 or newer.
|
|
29
|
+
|
|
30
|
+
Reads `.glb`, `.gltf`, `.obj` and `.stl`. Everything runs locally; nothing is uploaded.
|
|
31
|
+
|
|
32
|
+
## Terminal
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
textel <model> [options] spin it in the terminal (same as textel preview)
|
|
36
|
+
textel render <model> [-o f] record a spinning loop into a .textel.json file
|
|
37
|
+
textel play <file> play a recorded file
|
|
38
|
+
textel list built-in examples and charsets
|
|
39
|
+
textel help <command> all options of a command
|
|
40
|
+
|
|
41
|
+
look
|
|
42
|
+
-c, --chars <ramp|name> charset name or a custom ramp, darkest first
|
|
43
|
+
-p, --preset <file.json> style file; flags override it
|
|
44
|
+
--color <#hex> one color; --color #301000,#ffb000 for a gradient
|
|
45
|
+
--edges [depth] outline the silhouette (and depth steps)
|
|
46
|
+
--light <x,y,z> direction toward the light
|
|
47
|
+
camera
|
|
48
|
+
--pitch <rad> --spin <rad/s> --zoom <n> --distance <n>
|
|
49
|
+
-s, --size <WxH> -a, --aspect <n>
|
|
50
|
+
output
|
|
51
|
+
--once --yaw <rad> --fps <n> --no-color
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Keys while it runs: `←→↑↓` rotate, `+/-` zoom, `c` charset, `e` edges, `l` light, `space` pause, `r` reset, `q` quit.
|
|
55
|
+
|
|
56
|
+
### Recording and playing
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
textel render logo.glb --color "#301000,#ffb000" # writes logo.textel.json (120 frames, 112x56)
|
|
60
|
+
textel play logo.textel.json # space pause, ←→ step, +/- speed, q quit
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A frames file is played without the 3D renderer, so it is cheap to show anywhere.
|
|
64
|
+
The format is documented in [docs/format.md](docs/format.md).
|
|
65
|
+
|
|
66
|
+
### In your own CLI
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { playSplash } from '@feelgxxd/textel/terminal';
|
|
70
|
+
|
|
71
|
+
await playSplash('logo.textel.json', { seconds: 2 }); // any key skips it
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`play(recording, options)` from the same module gives full control: looping, keys, an
|
|
75
|
+
`AbortSignal`, the output stream. Both restore the terminal however playback ends, and print a single
|
|
76
|
+
frame when the output is not a terminal.
|
|
77
|
+
|
|
78
|
+
## On a web page
|
|
79
|
+
|
|
80
|
+
Record once, then play the file with `<textel-player>`: about 3 KB, no 3D code in the page.
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
textel render logo.glb --aspect 1.5 -o logo.textel.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```html
|
|
87
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@feelgxxd/textel@0.2/dist/textel-player.js"></script>
|
|
88
|
+
<textel-player src="logo.textel.json"></textel-player>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Or from npm in a bundled app: `npm install @feelgxxd/textel`, then `import '@feelgxxd/textel/player'`.
|
|
92
|
+
|
|
93
|
+
It fills its width and keeps the proportions the file was rendered for, whatever the page font.
|
|
94
|
+
Drag sideways to scrub. It pauses off screen and in background tabs, shows a still frame when the
|
|
95
|
+
visitor prefers reduced motion, and is labeled for screen readers.
|
|
96
|
+
|
|
97
|
+
| attribute | |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `src` | the `.textel.json` file |
|
|
100
|
+
| `fps` | playback rate; default from the file |
|
|
101
|
+
| `paused` | start paused |
|
|
102
|
+
| `nodrag` | turn off drag to scrub |
|
|
103
|
+
| `color` | default color for cells without one; otherwise the element's CSS `color` |
|
|
104
|
+
| `label` | accessible name; default from the file's title |
|
|
105
|
+
|
|
106
|
+
Style it with CSS: `font-family`, `color`, `--textel-glow` (a `text-shadow`), `--textel-dim-opacity`,
|
|
107
|
+
or `::part(screen)`. From JavaScript: `player.play()`, `player.pause()`, `player.frame`,
|
|
108
|
+
`player.recording = json`, and the `load` and `error` events.
|
|
109
|
+
|
|
110
|
+
`npm run demo` serves a page with several players and a drop zone for your own files.
|
|
111
|
+
|
|
112
|
+
## Presets
|
|
113
|
+
|
|
114
|
+
A preset is the whole look in one JSON file. The same file works with the CLI and the library.
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"charset": "hard",
|
|
119
|
+
"color": ["#301000", "#ffb000"],
|
|
120
|
+
"edges": "outline",
|
|
121
|
+
"pitch": 0.2,
|
|
122
|
+
"spin": 0.8,
|
|
123
|
+
"light": [-0.3, 0.35, 0.88],
|
|
124
|
+
"materials": { "Glass": "carved", "Trim": "dim" }
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Materials are matched by the names in the model file:
|
|
129
|
+
|
|
130
|
+
| style | effect |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `"solid"` | the normal ramp |
|
|
133
|
+
| `"carved"` | blank, but still hides what is behind it: holes, sockets, engravings |
|
|
134
|
+
| `"dim"` | lower intensity |
|
|
135
|
+
| `{ "chars": "...", "color": "#hex", "opacity": 0.5 }` | its own ramp and color |
|
|
136
|
+
|
|
137
|
+
Charsets: `classic`, `hard`, `detailed`, `blocks`, `dots`, `minimal`, or any string of your own.
|
|
138
|
+
|
|
139
|
+
## Library
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { parseModel, createRenderer, toANSI, toHTML, toText } from '@feelgxxd/textel';
|
|
143
|
+
|
|
144
|
+
const mesh = parseModel(bytes, 'model.glb');
|
|
145
|
+
const renderer = createRenderer(mesh, { width: 112, height: 56, aspect: 1.5, charset: 'hard' });
|
|
146
|
+
|
|
147
|
+
const frame = renderer.render({ yaw: 0.5 });
|
|
148
|
+
pre.innerHTML = toHTML(frame); // browser
|
|
149
|
+
process.stdout.write(toANSI(frame).join('\n')); // terminal
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Shapes in code
|
|
153
|
+
|
|
154
|
+
`@feelgxxd/textel/sdf` meshes signed distance fields, so a scene can be written as a function instead of modeled:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { sdfToMesh, sdBox, sdSphere } from '@feelgxxd/textel/sdf';
|
|
158
|
+
|
|
159
|
+
const mesh = sdfToMesh(
|
|
160
|
+
(x, y, z) => Math.max(sdBox(x, y, z, 1, 1, 1, 0.1), -sdSphere(x, y, z, 1.25)),
|
|
161
|
+
{ min: [-1.2, -1.2, -1.2], max: [1.2, 1.2, 1.2], step: 0.03 },
|
|
162
|
+
);
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`sdfToMeshSteps` does the same as a generator, so a page can build big shapes in idle time.
|
|
166
|
+
|
|
167
|
+
## How it works
|
|
168
|
+
|
|
169
|
+
A small software rasterizer draws into a character grid. Each cell is sampled several times
|
|
170
|
+
(`supersample`); the cell takes the mean brightness of the samples that hit the model, and that
|
|
171
|
+
picks a glyph from the ramp. The grid is corrected for character cells being taller than wide
|
|
172
|
+
(`aspect`). Edge mode runs a Sobel filter over depth and draws `- / | \` along the outline, at the
|
|
173
|
+
angles those glyphs actually have in a cell of that shape.
|
|
174
|
+
|
|
175
|
+
## Roadmap
|
|
176
|
+
|
|
177
|
+
See [ROADMAP.md](ROADMAP.md).
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT
|
package/dist/ansi.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RGB } from './style.ts';
|
|
2
|
+
export type ColorDepth = 'truecolor' | '256' | 'none';
|
|
3
|
+
export declare const RESET = "\u001B[0m";
|
|
4
|
+
export declare const FAINT = "\u001B[2m";
|
|
5
|
+
/** nearest xterm-256 color: grays get the 24-step ramp, everything else the 6x6x6 cube */
|
|
6
|
+
export declare function rgbTo256([r, g, b]: RGB): number;
|
|
7
|
+
export declare function fg(c: RGB, depth: ColorDepth): string;
|
|
8
|
+
export declare function bg(c: RGB | null, depth: ColorDepth): string;
|
|
9
|
+
/**
|
|
10
|
+
* Builds one line from cells, writing a code only when it changes.
|
|
11
|
+
* `code(i)` gives the code for cell i ('' for the default color); spaces keep the current code.
|
|
12
|
+
*/
|
|
13
|
+
export declare function line(chars: (i: number) => string, code: (i: number) => string, from: number, count: number, background?: string): string;
|
package/dist/ansi.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const RESET = '\x1b[0m';
|
|
2
|
+
export const FAINT = '\x1b[2m';
|
|
3
|
+
/** nearest xterm-256 color: grays get the 24-step ramp, everything else the 6x6x6 cube */
|
|
4
|
+
export function rgbTo256([r, g, b]) {
|
|
5
|
+
if (Math.abs(r - g) < 10 && Math.abs(g - b) < 10) {
|
|
6
|
+
const v = (r + g + b) / 3;
|
|
7
|
+
if (v < 8)
|
|
8
|
+
return 16;
|
|
9
|
+
if (v > 238)
|
|
10
|
+
return 231;
|
|
11
|
+
return 232 + Math.round(((v - 8) / 230) * 23);
|
|
12
|
+
}
|
|
13
|
+
const q = (x) => Math.round((x / 255) * 5);
|
|
14
|
+
return 16 + 36 * q(r) + 6 * q(g) + q(b);
|
|
15
|
+
}
|
|
16
|
+
export function fg(c, depth) {
|
|
17
|
+
if (depth === 'none')
|
|
18
|
+
return '';
|
|
19
|
+
return depth === 'truecolor' ? `\x1b[38;2;${c[0]};${c[1]};${c[2]}m` : `\x1b[38;5;${rgbTo256(c)}m`;
|
|
20
|
+
}
|
|
21
|
+
export function bg(c, depth) {
|
|
22
|
+
if (!c || depth === 'none')
|
|
23
|
+
return '';
|
|
24
|
+
return depth === 'truecolor' ? `\x1b[48;2;${c[0]};${c[1]};${c[2]}m` : `\x1b[48;5;${rgbTo256(c)}m`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Builds one line from cells, writing a code only when it changes.
|
|
28
|
+
* `code(i)` gives the code for cell i ('' for the default color); spaces keep the current code.
|
|
29
|
+
*/
|
|
30
|
+
export function line(chars, code, from, count, background = '') {
|
|
31
|
+
let s = background, cur = '';
|
|
32
|
+
for (let i = from; i < from + count; i++) {
|
|
33
|
+
const ch = chars(i);
|
|
34
|
+
if (ch !== ' ') {
|
|
35
|
+
const c = code(i);
|
|
36
|
+
if (c !== cur) {
|
|
37
|
+
s += cur ? RESET + background + c : c;
|
|
38
|
+
cur = c;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
s += ch;
|
|
42
|
+
}
|
|
43
|
+
if (cur || background)
|
|
44
|
+
s += RESET;
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Recording } from '../format.ts';
|
|
2
|
+
/** what goes into the <pre> for one frame: plain text when the file has no styles, otherwise HTML */
|
|
3
|
+
export interface Markup {
|
|
4
|
+
html: boolean;
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function frameMarkup(rec: Recording, index: number): Markup;
|
|
8
|
+
/** the accessible name: an explicit label, the file's title, or a generic one */
|
|
9
|
+
export declare function accessibleLabel(rec: Recording | null, label: string | null): string;
|
|
10
|
+
/** frame position after `dt` seconds; gaps longer than 0.1 s (tab switches) do not jump ahead */
|
|
11
|
+
export declare function advance(pos: number, dt: number, fps: number, frames: number): number;
|
|
12
|
+
/** dragging across the whole width moves through the whole loop once */
|
|
13
|
+
export declare function scrub(pos: number, dx: number, width: number, frames: number): number;
|
|
14
|
+
/**
|
|
15
|
+
* How to show a grid of `cols`×`rows` cells drawn in a font whose cells measure `cellW`×`cellH`,
|
|
16
|
+
* so it fills `boxW`×`boxH` (boxH 0 = width decides) with the proportions it was rendered for:
|
|
17
|
+
* `stretchY` corrects the font's cell shape to the file's `aspect`, `scale` fits the result.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fit(cols: number, rows: number, aspect: number, cellW: number, cellH: number, boxW: number, boxH: number): {
|
|
20
|
+
scale: number;
|
|
21
|
+
stretchY: number;
|
|
22
|
+
ratio: number;
|
|
23
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const esc = (ch) => (ch === '<' ? '<' : ch === '>' ? '>' : ch === '&' ? '&' : ch);
|
|
2
|
+
export function frameMarkup(rec, index) {
|
|
3
|
+
const { width: W, height: H, glyphs, styles } = rec, fr = rec.frames[index];
|
|
4
|
+
if (!styles || !fr.style) {
|
|
5
|
+
let s = '';
|
|
6
|
+
for (let r = 0; r < H; r++) {
|
|
7
|
+
for (let c = 0; c < W; c++)
|
|
8
|
+
s += glyphs[fr.glyph[r * W + c]];
|
|
9
|
+
if (r < H - 1)
|
|
10
|
+
s += '\n';
|
|
11
|
+
}
|
|
12
|
+
return { html: false, content: s };
|
|
13
|
+
}
|
|
14
|
+
// one span per run of cells with the same style; spaces never open a span
|
|
15
|
+
const open = styles.map((st) => (st === '' ? '' : st === '~' ? '<span class="d">' : `<span style="color:${st}">`));
|
|
16
|
+
let s = '';
|
|
17
|
+
for (let r = 0; r < H; r++) {
|
|
18
|
+
let cur = 0;
|
|
19
|
+
for (let c = 0; c < W; c++) {
|
|
20
|
+
const i = r * W + c, ch = glyphs[fr.glyph[i]];
|
|
21
|
+
if (ch !== ' ') {
|
|
22
|
+
const st = fr.style[i];
|
|
23
|
+
if (st !== cur) {
|
|
24
|
+
if (cur)
|
|
25
|
+
s += '</span>';
|
|
26
|
+
s += open[st];
|
|
27
|
+
cur = st;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
s += esc(ch);
|
|
31
|
+
}
|
|
32
|
+
if (cur)
|
|
33
|
+
s += '</span>';
|
|
34
|
+
if (r < H - 1)
|
|
35
|
+
s += '\n';
|
|
36
|
+
}
|
|
37
|
+
return { html: true, content: s };
|
|
38
|
+
}
|
|
39
|
+
/** the accessible name: an explicit label, the file's title, or a generic one */
|
|
40
|
+
export function accessibleLabel(rec, label) {
|
|
41
|
+
if (label)
|
|
42
|
+
return label;
|
|
43
|
+
const t = rec?.meta.title;
|
|
44
|
+
return typeof t === 'string' && t ? `${t}, ASCII animation` : 'ASCII animation';
|
|
45
|
+
}
|
|
46
|
+
/** frame position after `dt` seconds; gaps longer than 0.1 s (tab switches) do not jump ahead */
|
|
47
|
+
export function advance(pos, dt, fps, frames) {
|
|
48
|
+
return (pos + Math.min(Math.max(dt, 0), 0.1) * fps) % frames;
|
|
49
|
+
}
|
|
50
|
+
/** dragging across the whole width moves through the whole loop once */
|
|
51
|
+
export function scrub(pos, dx, width, frames) {
|
|
52
|
+
if (!(width > 0))
|
|
53
|
+
return pos;
|
|
54
|
+
return (((pos + (dx / width) * frames) % frames) + frames) % frames;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* How to show a grid of `cols`×`rows` cells drawn in a font whose cells measure `cellW`×`cellH`,
|
|
58
|
+
* so it fills `boxW`×`boxH` (boxH 0 = width decides) with the proportions it was rendered for:
|
|
59
|
+
* `stretchY` corrects the font's cell shape to the file's `aspect`, `scale` fits the result.
|
|
60
|
+
*/
|
|
61
|
+
export function fit(cols, rows, aspect, cellW, cellH, boxW, boxH) {
|
|
62
|
+
const stretchY = cellW > 0 && cellH > 0 ? (aspect * cellW) / cellH : 1;
|
|
63
|
+
const w = cols * cellW, h = rows * cellH * stretchY;
|
|
64
|
+
if (!(w > 0 && h > 0))
|
|
65
|
+
return { scale: 1, stretchY, ratio: 1 };
|
|
66
|
+
const scale = boxH > 0 ? Math.min(boxW / w, boxH / h) : boxW / w;
|
|
67
|
+
return { scale, stretchY, ratio: w / h };
|
|
68
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FramesFile, type Recording } from '../format.ts';
|
|
2
|
+
declare const Base: typeof HTMLElement;
|
|
3
|
+
export declare class TextelPlayer extends Base {
|
|
4
|
+
#private;
|
|
5
|
+
static observedAttributes: string[];
|
|
6
|
+
constructor();
|
|
7
|
+
connectedCallback(): void;
|
|
8
|
+
disconnectedCallback(): void;
|
|
9
|
+
attributeChangedCallback(name: string, old: string | null, value: string | null): void;
|
|
10
|
+
/** the loaded recording; set it directly to play one without a file */
|
|
11
|
+
get recording(): Recording | null;
|
|
12
|
+
set recording(rec: Recording | FramesFile | null);
|
|
13
|
+
/** current frame index */
|
|
14
|
+
get frame(): number;
|
|
15
|
+
set frame(i: number);
|
|
16
|
+
get paused(): boolean;
|
|
17
|
+
play(): void;
|
|
18
|
+
pause(): void;
|
|
19
|
+
}
|
|
20
|
+
declare global {
|
|
21
|
+
interface HTMLElementTagNameMap {
|
|
22
|
+
'textel-player': TextelPlayer;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// <textel-player>: plays a .textel.json file. No dependencies; no 3D code.
|
|
2
|
+
//
|
|
3
|
+
// <script type="module" src="https://cdn.jsdelivr.net/npm/@feelgxxd/textel@0.2/dist/textel-player.js"></script>
|
|
4
|
+
// <textel-player src="logo.textel.json"></textel-player>
|
|
5
|
+
import { fromFramesFile } from "../format.js";
|
|
6
|
+
import { accessibleLabel, advance, fit, frameMarkup, scrub } from "./player-core.js";
|
|
7
|
+
const CSS = `: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}
|
|
8
|
+
:host(:not([nodrag])){cursor:grab;touch-action:pan-y}
|
|
9
|
+
:host(:not([nodrag]):active){cursor:grabbing}
|
|
10
|
+
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}
|
|
11
|
+
.d{opacity:var(--textel-dim-opacity,.6)}`;
|
|
12
|
+
// Lets the module load where there is no DOM (server rendering, type imports).
|
|
13
|
+
const Base = (typeof HTMLElement === 'undefined' ? class {
|
|
14
|
+
} : HTMLElement);
|
|
15
|
+
export class TextelPlayer extends Base {
|
|
16
|
+
static observedAttributes = ['src', 'fps', 'paused', 'color', 'label'];
|
|
17
|
+
#rec = null;
|
|
18
|
+
#markup = [];
|
|
19
|
+
#pre;
|
|
20
|
+
#pos = 0;
|
|
21
|
+
#shown = -1;
|
|
22
|
+
#playing = true;
|
|
23
|
+
#visible = true;
|
|
24
|
+
#raf = 0;
|
|
25
|
+
#last = 0;
|
|
26
|
+
#drag = null;
|
|
27
|
+
#load = null;
|
|
28
|
+
#io;
|
|
29
|
+
#ro;
|
|
30
|
+
#reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
31
|
+
constructor() {
|
|
32
|
+
super();
|
|
33
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
34
|
+
const style = document.createElement('style');
|
|
35
|
+
style.textContent = CSS;
|
|
36
|
+
this.#pre = document.createElement('pre');
|
|
37
|
+
this.#pre.setAttribute('part', 'screen');
|
|
38
|
+
root.append(style, this.#pre);
|
|
39
|
+
this.addEventListener('pointerdown', this.#onDown);
|
|
40
|
+
this.addEventListener('pointermove', this.#onMove);
|
|
41
|
+
this.addEventListener('pointerup', this.#onUp);
|
|
42
|
+
this.addEventListener('pointercancel', this.#onUp);
|
|
43
|
+
}
|
|
44
|
+
connectedCallback() {
|
|
45
|
+
this.setAttribute('role', 'img');
|
|
46
|
+
this.#updateLabel();
|
|
47
|
+
this.#playing = !this.hasAttribute('paused') && !this.#reduced;
|
|
48
|
+
// both are optional: without them the player just keeps running and fits once
|
|
49
|
+
if (typeof IntersectionObserver === 'function') {
|
|
50
|
+
this.#io = new IntersectionObserver(([e]) => { this.#visible = e.isIntersecting; this.#kick(); });
|
|
51
|
+
this.#io.observe(this);
|
|
52
|
+
}
|
|
53
|
+
if (typeof ResizeObserver === 'function') {
|
|
54
|
+
this.#ro = new ResizeObserver(() => this.#fit());
|
|
55
|
+
this.#ro.observe(this);
|
|
56
|
+
}
|
|
57
|
+
document.addEventListener('visibilitychange', this.#kick);
|
|
58
|
+
document.fonts?.ready.then(() => this.#fit());
|
|
59
|
+
this.#kick();
|
|
60
|
+
}
|
|
61
|
+
disconnectedCallback() {
|
|
62
|
+
this.#io?.disconnect();
|
|
63
|
+
this.#ro?.disconnect();
|
|
64
|
+
document.removeEventListener('visibilitychange', this.#kick);
|
|
65
|
+
cancelAnimationFrame(this.#raf);
|
|
66
|
+
this.#raf = 0;
|
|
67
|
+
}
|
|
68
|
+
attributeChangedCallback(name, old, value) {
|
|
69
|
+
if (old === value)
|
|
70
|
+
return;
|
|
71
|
+
if (name === 'src') {
|
|
72
|
+
if (value)
|
|
73
|
+
this.#fetch(value);
|
|
74
|
+
}
|
|
75
|
+
else if (name === 'paused') {
|
|
76
|
+
value === null ? this.play() : this.pause();
|
|
77
|
+
}
|
|
78
|
+
else if (name === 'color')
|
|
79
|
+
this.#pre.style.color = value ?? '';
|
|
80
|
+
else if (name === 'label')
|
|
81
|
+
this.#updateLabel();
|
|
82
|
+
}
|
|
83
|
+
/** the loaded recording; set it directly to play one without a file */
|
|
84
|
+
get recording() { return this.#rec; }
|
|
85
|
+
set recording(rec) {
|
|
86
|
+
this.#rec = rec && 'data' in rec ? fromFramesFile(rec) : rec;
|
|
87
|
+
this.#markup = [];
|
|
88
|
+
this.#pos = 0;
|
|
89
|
+
this.#shown = -1;
|
|
90
|
+
this.#updateLabel();
|
|
91
|
+
this.#draw();
|
|
92
|
+
this.#fit();
|
|
93
|
+
this.#kick();
|
|
94
|
+
}
|
|
95
|
+
/** current frame index */
|
|
96
|
+
get frame() { return Math.floor(this.#pos); }
|
|
97
|
+
set frame(i) {
|
|
98
|
+
const n = this.#rec?.frames.length ?? 1;
|
|
99
|
+
this.#pos = ((Math.floor(i) % n) + n) % n;
|
|
100
|
+
this.#draw();
|
|
101
|
+
}
|
|
102
|
+
get paused() { return !this.#playing; }
|
|
103
|
+
play() { this.#playing = true; this.#kick(); }
|
|
104
|
+
pause() { this.#playing = false; this.#kick(); }
|
|
105
|
+
async #fetch(src) {
|
|
106
|
+
this.#load?.abort();
|
|
107
|
+
const ac = (this.#load = new AbortController());
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(src, { signal: ac.signal });
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
throw new Error(`${res.status} ${res.statusText} for ${src}`);
|
|
112
|
+
const file = await res.json();
|
|
113
|
+
if (ac.signal.aborted)
|
|
114
|
+
return;
|
|
115
|
+
this.recording = fromFramesFile(file);
|
|
116
|
+
this.dispatchEvent(new CustomEvent('load', { detail: { frames: this.#rec.frames.length } }));
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (ac.signal.aborted)
|
|
120
|
+
return;
|
|
121
|
+
this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
#updateLabel() {
|
|
125
|
+
if (this.isConnected)
|
|
126
|
+
this.setAttribute('aria-label', accessibleLabel(this.#rec, this.getAttribute('label')));
|
|
127
|
+
}
|
|
128
|
+
#fps() {
|
|
129
|
+
const a = Number(this.getAttribute('fps'));
|
|
130
|
+
return a > 0 ? a : this.#rec?.fps ?? 30;
|
|
131
|
+
}
|
|
132
|
+
#draw() {
|
|
133
|
+
const rec = this.#rec;
|
|
134
|
+
if (!rec)
|
|
135
|
+
return;
|
|
136
|
+
const i = Math.floor(this.#pos) % rec.frames.length;
|
|
137
|
+
if (i === this.#shown)
|
|
138
|
+
return;
|
|
139
|
+
this.#shown = i;
|
|
140
|
+
const m = (this.#markup[i] ??= frameMarkup(rec, i));
|
|
141
|
+
if (m.html)
|
|
142
|
+
this.#pre.innerHTML = m.content;
|
|
143
|
+
else
|
|
144
|
+
this.#pre.textContent = m.content;
|
|
145
|
+
}
|
|
146
|
+
#fit() {
|
|
147
|
+
const rec = this.#rec;
|
|
148
|
+
if (!rec)
|
|
149
|
+
return;
|
|
150
|
+
const pre = this.#pre, cellW = pre.offsetWidth / rec.width, cellH = pre.offsetHeight / rec.height;
|
|
151
|
+
const f = fit(rec.width, rec.height, rec.aspect, cellW, cellH, this.clientWidth, this.clientHeight);
|
|
152
|
+
this.style.setProperty('--textel-ratio', String(f.ratio));
|
|
153
|
+
pre.style.transform = `translate(-50%,-50%) scale(${f.scale},${f.scale * f.stretchY})`;
|
|
154
|
+
}
|
|
155
|
+
/** runs the animation only while it is playing, on screen and in a visible tab */
|
|
156
|
+
#kick = () => {
|
|
157
|
+
const run = this.#playing && this.#visible && !document.hidden && !!this.#rec && !this.#drag && this.isConnected;
|
|
158
|
+
if (run && !this.#raf) {
|
|
159
|
+
// the first tick only sets the clock: time is measured between frame timestamps alone,
|
|
160
|
+
// never mixed with performance.now(), whose reading can be later than the next frame's
|
|
161
|
+
this.#last = -1;
|
|
162
|
+
this.#raf = requestAnimationFrame(this.#tick);
|
|
163
|
+
}
|
|
164
|
+
else if (!run && this.#raf) {
|
|
165
|
+
cancelAnimationFrame(this.#raf);
|
|
166
|
+
this.#raf = 0;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
#tick = (now) => {
|
|
170
|
+
const rec = this.#rec;
|
|
171
|
+
if (this.#last >= 0)
|
|
172
|
+
this.#pos = advance(this.#pos, (now - this.#last) / 1000, this.#fps(), rec.frames.length);
|
|
173
|
+
this.#last = now;
|
|
174
|
+
this.#draw();
|
|
175
|
+
this.#raf = requestAnimationFrame(this.#tick);
|
|
176
|
+
};
|
|
177
|
+
#onDown = (e) => {
|
|
178
|
+
if (this.hasAttribute('nodrag') || !this.#rec || e.button !== 0)
|
|
179
|
+
return;
|
|
180
|
+
this.#drag = { x: e.clientX, pos: this.#pos };
|
|
181
|
+
this.setPointerCapture(e.pointerId);
|
|
182
|
+
this.#kick();
|
|
183
|
+
};
|
|
184
|
+
#onMove = (e) => {
|
|
185
|
+
if (!this.#drag || !this.#rec)
|
|
186
|
+
return;
|
|
187
|
+
this.#pos = scrub(this.#drag.pos, e.clientX - this.#drag.x, this.clientWidth, this.#rec.frames.length);
|
|
188
|
+
this.#draw();
|
|
189
|
+
};
|
|
190
|
+
#onUp = () => {
|
|
191
|
+
if (!this.#drag)
|
|
192
|
+
return;
|
|
193
|
+
this.#drag = null;
|
|
194
|
+
this.#kick();
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (typeof customElements !== 'undefined' && !customElements.get('textel-player'))
|
|
198
|
+
customElements.define('textel-player', TextelPlayer);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { EXAMPLES } from "../../examples.js";
|
|
2
|
+
import { CHARSETS } from "../../style.js";
|
|
3
|
+
import { HELP_OPTIONS, parse } from "../shared.js";
|
|
4
|
+
export const summary = 'built-in examples and charsets';
|
|
5
|
+
export const HELP = `textel list: built-in examples and charsets
|
|
6
|
+
|
|
7
|
+
usage
|
|
8
|
+
textel list
|
|
9
|
+
`;
|
|
10
|
+
export function run(argv) {
|
|
11
|
+
const { values: o } = parse(argv, HELP_OPTIONS);
|
|
12
|
+
if (o.help)
|
|
13
|
+
return void process.stdout.write(HELP);
|
|
14
|
+
const out = ['examples'];
|
|
15
|
+
for (const e of EXAMPLES)
|
|
16
|
+
out.push(` ${e.name.padEnd(10)} ${e.description}`);
|
|
17
|
+
out.push('', 'charsets');
|
|
18
|
+
for (const [k, v] of Object.entries(CHARSETS))
|
|
19
|
+
out.push(` ${k.padEnd(10)} ${JSON.stringify(v.length > 40 ? v.slice(0, 40) + '…' : v)}`);
|
|
20
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
21
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const summary = "play a .textel.json file in the terminal";
|
|
2
|
+
export declare const HELP = "textel play: play a .textel.json file in the terminal\n\nusage\n textel play <file.textel.json> [options]\n\noptions\n --once print one frame and exit (also when output is not a terminal)\n --frame <n> which frame --once prints (default 0)\n --fps <n> playback rate (default: the file's)\n --seconds <n> stop after this long\n --no-color plain characters\n\nkeys space pause \u2190\u2192 step a frame +/- speed q quit\n";
|
|
3
|
+
export declare function run(argv: string[]): Promise<undefined>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { basename, resolve } from 'node:path';
|
|
2
|
+
import { recordingANSI, recordingText } from "../../frames.js";
|
|
3
|
+
import { loadFrames, play } from "../../terminal.js";
|
|
4
|
+
import { colorDepth, fail, HELP_OPTIONS, num, parse } from "../shared.js";
|
|
5
|
+
export const summary = 'play a .textel.json file in the terminal';
|
|
6
|
+
export const HELP = `textel play: play a .textel.json file in the terminal
|
|
7
|
+
|
|
8
|
+
usage
|
|
9
|
+
textel play <file.textel.json> [options]
|
|
10
|
+
|
|
11
|
+
options
|
|
12
|
+
--once print one frame and exit (also when output is not a terminal)
|
|
13
|
+
--frame <n> which frame --once prints (default 0)
|
|
14
|
+
--fps <n> playback rate (default: the file's)
|
|
15
|
+
--seconds <n> stop after this long
|
|
16
|
+
--no-color plain characters
|
|
17
|
+
|
|
18
|
+
keys space pause ←→ step a frame +/- speed q quit
|
|
19
|
+
`;
|
|
20
|
+
const OPTIONS = {
|
|
21
|
+
...HELP_OPTIONS,
|
|
22
|
+
once: { type: 'boolean' },
|
|
23
|
+
frame: { type: 'string' },
|
|
24
|
+
fps: { type: 'string' },
|
|
25
|
+
seconds: { type: 'string' },
|
|
26
|
+
'no-color': { type: 'boolean' },
|
|
27
|
+
};
|
|
28
|
+
export async function run(argv) {
|
|
29
|
+
const { values: o, positionals } = parse(argv, OPTIONS);
|
|
30
|
+
if (o.help || !positionals[0])
|
|
31
|
+
return void process.stdout.write(HELP);
|
|
32
|
+
if (positionals.length > 1)
|
|
33
|
+
fail(`unexpected argument "${positionals[1]}"`);
|
|
34
|
+
const file = resolve(positionals[0]);
|
|
35
|
+
let rec;
|
|
36
|
+
try {
|
|
37
|
+
rec = loadFrames(file);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
const err = e;
|
|
41
|
+
fail(err.code === 'ENOENT' ? `cannot read ${positionals[0]}` : `${basename(file)}: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
const depth = colorDepth(o['no-color']);
|
|
44
|
+
if (o.once || !process.stdout.isTTY || !process.stdin.isTTY) {
|
|
45
|
+
const i = num(o.frame, '--frame') ?? 0;
|
|
46
|
+
if (!(Number.isInteger(i) && i >= 0 && i < rec.frames.length))
|
|
47
|
+
fail(`--frame is 0 to ${rec.frames.length - 1}`);
|
|
48
|
+
process.stdout.write((depth === 'none' ? recordingText(rec, i) : recordingANSI(rec, i, depth).join('\n')) + '\n');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const title = typeof rec.meta.title === 'string' ? rec.meta.title : basename(file);
|
|
52
|
+
const result = await play(rec, { interactive: true, depth, fps: num(o.fps, '--fps'), seconds: num(o.seconds, '--seconds'), title });
|
|
53
|
+
if (result === 'interrupt')
|
|
54
|
+
process.exitCode = 130;
|
|
55
|
+
}
|