@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -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/browser/viewer-core.d.ts +42 -0
  10. package/dist/browser/viewer-core.js +67 -0
  11. package/dist/browser/viewer.d.ts +29 -0
  12. package/dist/browser/viewer.js +338 -0
  13. package/dist/cli/commands/list.d.ts +3 -0
  14. package/dist/cli/commands/list.js +21 -0
  15. package/dist/cli/commands/play.d.ts +3 -0
  16. package/dist/cli/commands/play.js +55 -0
  17. package/dist/cli/commands/preview.d.ts +3 -0
  18. package/dist/cli/commands/preview.js +57 -0
  19. package/dist/cli/commands/render.d.ts +3 -0
  20. package/dist/cli/commands/render.js +94 -0
  21. package/dist/cli/live.d.ts +13 -0
  22. package/dist/cli/live.js +116 -0
  23. package/dist/cli/main.d.ts +2 -0
  24. package/dist/cli/main.js +53 -0
  25. package/dist/cli/shared.d.ts +103 -0
  26. package/dist/cli/shared.js +178 -0
  27. package/dist/examples.d.ts +13 -0
  28. package/dist/examples.js +164 -0
  29. package/dist/format.d.ts +57 -0
  30. package/dist/format.js +262 -0
  31. package/dist/frames.d.ts +34 -0
  32. package/dist/frames.js +88 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +6 -0
  35. package/dist/loaders/gltf.d.ts +7 -0
  36. package/dist/loaders/gltf.js +187 -0
  37. package/dist/loaders/index.d.ts +18 -0
  38. package/dist/loaders/index.js +58 -0
  39. package/dist/loaders/obj.d.ts +8 -0
  40. package/dist/loaders/obj.js +69 -0
  41. package/dist/loaders/stl.d.ts +3 -0
  42. package/dist/loaders/stl.js +29 -0
  43. package/dist/mesh.d.ts +35 -0
  44. package/dist/mesh.js +106 -0
  45. package/dist/output.d.ts +18 -0
  46. package/dist/output.js +88 -0
  47. package/dist/render.d.ts +36 -0
  48. package/dist/render.js +309 -0
  49. package/dist/sdf.d.ts +36 -0
  50. package/dist/sdf.js +151 -0
  51. package/dist/style.d.ts +138 -0
  52. package/dist/style.js +132 -0
  53. package/dist/terminal.d.ts +89 -0
  54. package/dist/terminal.js +197 -0
  55. package/dist/textel-player.js +7 -0
  56. package/dist/textel-viewer.js +10 -0
  57. 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
- # 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
+ 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 === '<' ? '&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
+ 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>;