@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -2
  3. package/dist/ansi.d.ts +13 -0
  4. package/dist/ansi.js +46 -0
  5. package/dist/browser/player-core.d.ts +23 -0
  6. package/dist/browser/player-core.js +68 -0
  7. package/dist/browser/player.d.ts +25 -0
  8. package/dist/browser/player.js +198 -0
  9. package/dist/cli/commands/list.d.ts +3 -0
  10. package/dist/cli/commands/list.js +21 -0
  11. package/dist/cli/commands/play.d.ts +3 -0
  12. package/dist/cli/commands/play.js +55 -0
  13. package/dist/cli/commands/preview.d.ts +3 -0
  14. package/dist/cli/commands/preview.js +57 -0
  15. package/dist/cli/commands/render.d.ts +3 -0
  16. package/dist/cli/commands/render.js +94 -0
  17. package/dist/cli/live.d.ts +13 -0
  18. package/dist/cli/live.js +116 -0
  19. package/dist/cli/main.d.ts +2 -0
  20. package/dist/cli/main.js +53 -0
  21. package/dist/cli/shared.d.ts +103 -0
  22. package/dist/cli/shared.js +178 -0
  23. package/dist/examples.d.ts +13 -0
  24. package/dist/examples.js +164 -0
  25. package/dist/format.d.ts +57 -0
  26. package/dist/format.js +262 -0
  27. package/dist/frames.d.ts +34 -0
  28. package/dist/frames.js +88 -0
  29. package/dist/index.d.ts +6 -0
  30. package/dist/index.js +6 -0
  31. package/dist/loaders/gltf.d.ts +7 -0
  32. package/dist/loaders/gltf.js +187 -0
  33. package/dist/loaders/index.d.ts +8 -0
  34. package/dist/loaders/index.js +24 -0
  35. package/dist/loaders/obj.d.ts +3 -0
  36. package/dist/loaders/obj.js +52 -0
  37. package/dist/loaders/stl.d.ts +3 -0
  38. package/dist/loaders/stl.js +29 -0
  39. package/dist/mesh.d.ts +35 -0
  40. package/dist/mesh.js +106 -0
  41. package/dist/output.d.ts +18 -0
  42. package/dist/output.js +76 -0
  43. package/dist/render.d.ts +36 -0
  44. package/dist/render.js +305 -0
  45. package/dist/sdf.d.ts +36 -0
  46. package/dist/sdf.js +151 -0
  47. package/dist/style.d.ts +125 -0
  48. package/dist/style.js +130 -0
  49. package/dist/terminal.d.ts +89 -0
  50. package/dist/terminal.js +197 -0
  51. package/dist/textel-player.js +7 -0
  52. 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
- # Temporary Holding Version
1
+ # textel
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 === '<' ? '&lt;' : ch === '>' ? '&gt;' : ch === '&' ? '&amp;' : 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,3 @@
1
+ export declare const summary = "built-in examples and charsets";
2
+ export declare const HELP = "textel list: built-in examples and charsets\n\nusage\n textel list\n";
3
+ export declare function run(argv: string[]): undefined;
@@ -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
+ }