@feelgxxd/textel 0.0.0-stage → 0.3.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 +224 -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/browser/viewer-core.d.ts +42 -0
- package/dist/browser/viewer-core.js +67 -0
- package/dist/browser/viewer.d.ts +29 -0
- package/dist/browser/viewer.js +338 -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 +18 -0
- package/dist/loaders/index.js +58 -0
- package/dist/loaders/obj.d.ts +8 -0
- package/dist/loaders/obj.js +69 -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 +88 -0
- package/dist/render.d.ts +36 -0
- package/dist/render.js +309 -0
- package/dist/sdf.d.ts +36 -0
- package/dist/sdf.js +151 -0
- package/dist/style.d.ts +138 -0
- package/dist/style.js +132 -0
- package/dist/terminal.d.ts +89 -0
- package/dist/terminal.js +197 -0
- package/dist/textel-player.js +7 -0
- package/dist/textel-viewer.js +10 -0
- package/package.json +90 -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,225 @@
|
|
|
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
|
+
Two elements, both without dependencies and both loaded with one script tag:
|
|
81
|
+
|
|
82
|
+
| | `<textel-player>` | `<textel-viewer>` |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| shows | a recorded loop (`.textel.json`) | the model itself, rendered live |
|
|
85
|
+
| size | about 3 KB | about 11 KB |
|
|
86
|
+
| dragging | scrubs through the loop | turns the model any way, with a flick |
|
|
87
|
+
| best for | a spinning logo | something people play with |
|
|
88
|
+
|
|
89
|
+
### `<textel-player>`
|
|
90
|
+
|
|
91
|
+
Record once, then play the file: no 3D code in the page.
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
textel render logo.glb --aspect 1.5 -o logo.textel.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@feelgxxd/textel@0.2/dist/textel-player.js"></script>
|
|
99
|
+
<textel-player src="logo.textel.json"></textel-player>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or from npm in a bundled app: `npm install @feelgxxd/textel`, then `import '@feelgxxd/textel/player'`.
|
|
103
|
+
|
|
104
|
+
It fills its width and keeps the proportions the file was rendered for, whatever the page font.
|
|
105
|
+
Drag sideways to scrub. It pauses off screen and in background tabs, shows a still frame when the
|
|
106
|
+
visitor prefers reduced motion, and is labeled for screen readers.
|
|
107
|
+
|
|
108
|
+
| attribute | |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `src` | the `.textel.json` file |
|
|
111
|
+
| `fps` | playback rate; default from the file |
|
|
112
|
+
| `paused` | start paused |
|
|
113
|
+
| `nodrag` | turn off drag to scrub |
|
|
114
|
+
| `color` | default color for cells without one; otherwise the element's CSS `color` |
|
|
115
|
+
| `label` | accessible name; default from the file's title |
|
|
116
|
+
|
|
117
|
+
Style it with CSS: `font-family`, `color`, `--textel-glow` (a `text-shadow`), `--textel-dim-opacity`,
|
|
118
|
+
or `::part(screen)`. From JavaScript: `player.play()`, `player.pause()`, `player.frame`,
|
|
119
|
+
`player.recording = json`, and the `load` and `error` events.
|
|
120
|
+
|
|
121
|
+
### `<textel-viewer>`
|
|
122
|
+
|
|
123
|
+
```html
|
|
124
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@feelgxxd/textel@0.3/dist/textel-viewer.js"></script>
|
|
125
|
+
<textel-viewer src="model.glb" charset="hard" color="#301000,#ffb000" edges></textel-viewer>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Or `import '@feelgxxd/textel/viewer'` in a bundled app.
|
|
129
|
+
|
|
130
|
+
It measures the page font and renders for its real cell shape, so the model is never squashed. The box
|
|
131
|
+
is square unless you give it a height. Drag in any direction and let go to flick it; it eases back to
|
|
132
|
+
its resting spin. When focused, the arrow keys turn it and space pauses. Large `.obj` files are parsed
|
|
133
|
+
in slices so the page keeps responding while they load. Like the player, it pauses off screen and in
|
|
134
|
+
background tabs and respects reduced motion.
|
|
135
|
+
|
|
136
|
+
| attribute | |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `src` | a `.glb`, `.gltf` (external buffers are fetched too), `.obj` or `.stl` |
|
|
139
|
+
| `preset` | a preset JSON file (see [Presets](#presets)) |
|
|
140
|
+
| `charset`, `chars`, `color`, `edges`, `spin`, `pitch`, `zoom`, `distance`, `ambient` | the same as the preset fields; `color` takes a comma separated gradient |
|
|
141
|
+
| `cols` | grid width in characters (default 96) |
|
|
142
|
+
| `paused` | start without spinning |
|
|
143
|
+
| `nodrag` | turn off dragging |
|
|
144
|
+
| `zoomable` | wheel and `+` / `-` zoom |
|
|
145
|
+
| `label` | accessible name |
|
|
146
|
+
|
|
147
|
+
A preset is applied first, then the attributes, then `viewer.look = { ... }` from JavaScript.
|
|
148
|
+
Also from JavaScript: `viewer.load(urlOrFileOrBytes)`, `viewer.mesh = mesh` (for example from
|
|
149
|
+
`@feelgxxd/textel/sdf`), `play()`, `pause()`, and the `load` and `error` events.
|
|
150
|
+
|
|
151
|
+
`npm run demo` serves both demo pages with drop zones for your own files, and an output benchmark.
|
|
152
|
+
|
|
153
|
+
## Presets
|
|
154
|
+
|
|
155
|
+
A preset is the whole look in one JSON file. The same file works with the CLI and the library.
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"charset": "hard",
|
|
160
|
+
"color": ["#301000", "#ffb000"],
|
|
161
|
+
"edges": "outline",
|
|
162
|
+
"pitch": 0.2,
|
|
163
|
+
"spin": 0.8,
|
|
164
|
+
"light": [-0.3, 0.35, 0.88],
|
|
165
|
+
"materials": { "Glass": "carved", "Trim": "dim" }
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Materials are matched by the names in the model file:
|
|
170
|
+
|
|
171
|
+
| style | effect |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `"solid"` | the normal ramp |
|
|
174
|
+
| `"carved"` | blank, but still hides what is behind it: holes, sockets, engravings |
|
|
175
|
+
| `"dim"` | lower intensity |
|
|
176
|
+
| `{ "chars": "...", "color": "#hex", "opacity": 0.5 }` | its own ramp and color |
|
|
177
|
+
|
|
178
|
+
Charsets: `classic`, `hard`, `detailed`, `blocks`, `dots`, `minimal`, or any string of your own.
|
|
179
|
+
|
|
180
|
+
A gradient gives each glyph of the ramp one color, so colors change only where glyphs do; that keeps
|
|
181
|
+
terminal output and web pages light. `"colorSteps": 0` makes it smooth instead, or set a number of steps.
|
|
182
|
+
|
|
183
|
+
## Library
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { parseModel, createRenderer, toANSI, toHTML, toText } from '@feelgxxd/textel';
|
|
187
|
+
|
|
188
|
+
const mesh = parseModel(bytes, 'model.glb');
|
|
189
|
+
const renderer = createRenderer(mesh, { width: 112, height: 56, aspect: 1.5, charset: 'hard' });
|
|
190
|
+
|
|
191
|
+
const frame = renderer.render({ yaw: 0.5 });
|
|
192
|
+
pre.innerHTML = toHTML(frame); // browser
|
|
193
|
+
process.stdout.write(toANSI(frame).join('\n')); // terminal
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Shapes in code
|
|
197
|
+
|
|
198
|
+
`@feelgxxd/textel/sdf` meshes signed distance fields, so a scene can be written as a function instead of modeled:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { sdfToMesh, sdBox, sdSphere } from '@feelgxxd/textel/sdf';
|
|
202
|
+
|
|
203
|
+
const mesh = sdfToMesh(
|
|
204
|
+
(x, y, z) => Math.max(sdBox(x, y, z, 1, 1, 1, 0.1), -sdSphere(x, y, z, 1.25)),
|
|
205
|
+
{ min: [-1.2, -1.2, -1.2], max: [1.2, 1.2, 1.2], step: 0.03 },
|
|
206
|
+
);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`sdfToMeshSteps` does the same as a generator, so a page can build big shapes in idle time.
|
|
210
|
+
|
|
211
|
+
## How it works
|
|
212
|
+
|
|
213
|
+
A small software rasterizer draws into a character grid. Each cell is sampled several times
|
|
214
|
+
(`supersample`); the cell takes the mean brightness of the samples that hit the model, and that
|
|
215
|
+
picks a glyph from the ramp. The grid is corrected for character cells being taller than wide
|
|
216
|
+
(`aspect`). Edge mode runs a Sobel filter over depth and draws `- / | \` along the outline, at the
|
|
217
|
+
angles those glyphs actually have in a cell of that shape.
|
|
218
|
+
|
|
219
|
+
## Roadmap
|
|
220
|
+
|
|
221
|
+
See [ROADMAP.md](ROADMAP.md).
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
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
|
+
this.ownerDocument.addEventListener('visibilitychange', this.#kick);
|
|
58
|
+
this.ownerDocument.fonts?.ready.then(() => this.#fit());
|
|
59
|
+
this.#kick();
|
|
60
|
+
}
|
|
61
|
+
disconnectedCallback() {
|
|
62
|
+
this.#io?.disconnect();
|
|
63
|
+
this.#ro?.disconnect();
|
|
64
|
+
this.ownerDocument.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 && !this.ownerDocument.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,42 @@
|
|
|
1
|
+
export interface Grid {
|
|
2
|
+
cols: number;
|
|
3
|
+
rows: number;
|
|
4
|
+
/** cell height / width of the page font, handed to the renderer so nothing has to be stretched */
|
|
5
|
+
aspect: number;
|
|
6
|
+
/** transform scale that makes the grid fill the box width */
|
|
7
|
+
scale: number;
|
|
8
|
+
/** width / height of the drawn grid, for an auto-height box */
|
|
9
|
+
ratio: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The grid for a box: `cols` is fixed, rows follow from the box shape in this font.
|
|
13
|
+
* A box without a height gets a square picture.
|
|
14
|
+
*/
|
|
15
|
+
export declare function gridFor(cols: number, cellW: number, cellH: number, boxW: number, boxH: number): Grid;
|
|
16
|
+
export interface Motion {
|
|
17
|
+
yaw: number;
|
|
18
|
+
pitch: number;
|
|
19
|
+
/** radians per second */
|
|
20
|
+
vyaw: number;
|
|
21
|
+
vpitch: number;
|
|
22
|
+
}
|
|
23
|
+
export interface MotionTarget {
|
|
24
|
+
/** resting spin, radians per second (0 when paused or reduced motion) */
|
|
25
|
+
spin: number;
|
|
26
|
+
/** resting pitch */
|
|
27
|
+
pitch: number;
|
|
28
|
+
/** how far people may tilt it either way */
|
|
29
|
+
maxPitch: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Advances the turntable by `dt` seconds. After a drag the flick keeps going and eases back to the
|
|
33
|
+
* resting spin, and the tilt drifts back to the resting pitch.
|
|
34
|
+
*/
|
|
35
|
+
export declare function step(m: Motion, t: MotionTarget, dt: number): Motion;
|
|
36
|
+
/**
|
|
37
|
+
* A pointer moved by (dx, dy) pixels over `dt` seconds on a box `width` wide: a drag across the
|
|
38
|
+
* whole width turns the model once. The speed is kept so a release turns into a flick.
|
|
39
|
+
*/
|
|
40
|
+
export declare function drag(m: Motion, dx: number, dy: number, dt: number, width: number, maxPitch: number): Motion;
|
|
41
|
+
/** attribute values to style fields; unknown or empty values are left out */
|
|
42
|
+
export declare function styleFromAttributes(get: (name: string) => string | null): Record<string, unknown>;
|