token_reel 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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +35 -0
- data/.gitignore +5 -0
- data/Gemfile +5 -0
- data/README.md +170 -0
- data/Rakefile +59 -0
- data/exe/token_reel +7 -0
- data/lib/token_reel/cli.rb +118 -0
- data/lib/token_reel/config.rb +56 -0
- data/lib/token_reel/errors.rb +7 -0
- data/lib/token_reel/fonts.rb +48 -0
- data/lib/token_reel/generator.rb +71 -0
- data/lib/token_reel/gif_writer.rb +23 -0
- data/lib/token_reel/highlight.rb +62 -0
- data/lib/token_reel/renderer.rb +273 -0
- data/lib/token_reel/script.rb +86 -0
- data/lib/token_reel/theme.rb +38 -0
- data/lib/token_reel/timeline.rb +120 -0
- data/lib/token_reel/tokenizer.rb +24 -0
- data/lib/token_reel/version.rb +5 -0
- data/lib/token_reel.rb +31 -0
- data/token_reel.gemspec +40 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d8ea0cf1ffd5db7548162763c7944b3005683791b0df22a9c56cc03200a2427a
|
|
4
|
+
data.tar.gz: ee2f2160151b7658ab65a13747b4dc84463ecc83b62bff1d659465a95ce99bce
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f2835962949cc430260f3e37b620c2f0c4a9286c661ba64d895d4ea290ed2fc910d8adc7c1b20d3df17a28fbf06e9f3c3c917fbe32edd142d43388c9d53f7992
|
|
7
|
+
data.tar.gz: '087df605401c0cb00fdc2025cfd1d685e0c657ab167c2d31092b508b0136d8956ac650f9864a9209a1d405072e643cfcae8138000b47672289de7e8d9120e051'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
name: Ruby ${{ matrix.ruby }}
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
ruby: ["3.0", "3.1", "3.2", "3.3"]
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Install ImageMagick
|
|
24
|
+
run: sudo apt-get update && sudo apt-get install -y imagemagick
|
|
25
|
+
|
|
26
|
+
- uses: ruby/setup-ruby@v1
|
|
27
|
+
with:
|
|
28
|
+
ruby-version: ${{ matrix.ruby }}
|
|
29
|
+
bundler-cache: true
|
|
30
|
+
|
|
31
|
+
- name: Run specs
|
|
32
|
+
run: bundle exec rake spec
|
|
33
|
+
|
|
34
|
+
- name: Build gem
|
|
35
|
+
run: gem build token_reel.gemspec
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# TokenReel
|
|
2
|
+
|
|
3
|
+
Render a terminal-style animated GIF of a prompt being answered by an
|
|
4
|
+
LLM CLI -- prompt appears, there's a pause for **time to first token**,
|
|
5
|
+
then the response streams in at a chosen **tokens/sec** rate. Useful
|
|
6
|
+
for READMEs, blog posts, and talks that want a realistic-looking demo
|
|
7
|
+
without a real model or a real screen recorder.
|
|
8
|
+
|
|
9
|
+
Everything is driven by ImageMagick's `convert`/`magick` under the
|
|
10
|
+
hood, so that needs to be installed and on `PATH`. Text is always
|
|
11
|
+
rendered in a monospace font -- by default TokenReel auto-detects one
|
|
12
|
+
from whatever ImageMagick has installed (DejaVu Sans Mono, Menlo,
|
|
13
|
+
Consolas, Courier New, ...); pass `--font NAME` with an exact name from
|
|
14
|
+
`magick -list font` to pick a specific one. This matters beyond looks:
|
|
15
|
+
column wrapping, cursor placement, and code syntax highlighting all
|
|
16
|
+
assume every glyph is the same width.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
gem install token_reel
|
|
22
|
+
# or, in a Gemfile:
|
|
23
|
+
gem "token_reel"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires ImageMagick (`brew install imagemagick` / `apt install imagemagick`).
|
|
27
|
+
|
|
28
|
+
## CLI
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
token_reel \
|
|
32
|
+
--prompt "Refactor this method to be tail-recursive" \
|
|
33
|
+
--response $'Sure -- here\'s a tail-recursive version:\n\ndef sum(n, acc = 0)\n return acc if n.zero?\n sum(n - 1, acc + n)\nend' \
|
|
34
|
+
--tps 14 --ttft 0.8 --theme matrix -o demo.gif
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Note the `$'...'` (ANSI-C) quoting -- plain `"..."` leaves `\n` as a literal
|
|
38
|
+
backslash-n instead of a real newline.
|
|
39
|
+
|
|
40
|
+
Read from files instead of inline strings:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
token_reel -P prompt.txt -R response.md --tps 20 -o demo.gif
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or pipe both in, separated by a line of `---`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cat <<'EOF' | token_reel --stdin -o demo.gif
|
|
50
|
+
Explain time-to-first-token in one sentence.
|
|
51
|
+
---
|
|
52
|
+
Time to first token is the delay between sending a prompt and the
|
|
53
|
+
model's first streamed token arriving -- it's latency, not throughput.
|
|
54
|
+
EOF
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or write the whole exchange as one Markdown file and pass `-m`/`--markdown`.
|
|
58
|
+
Get a starter file with `--init-markdown` (writes `exchange.md` by default,
|
|
59
|
+
or pass a path -- it refuses to overwrite an existing file):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
token_reel --init-markdown # writes ./exchange.md
|
|
63
|
+
token_reel --init-markdown demo.md # writes ./demo.md
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
````markdown
|
|
67
|
+
## Prompt
|
|
68
|
+
|
|
69
|
+
Refactor this method to be tail-recursive.
|
|
70
|
+
|
|
71
|
+
## Reasoning
|
|
72
|
+
|
|
73
|
+
The recursive call isn't in tail position because of the addition
|
|
74
|
+
after it returns -- an accumulator fixes that.
|
|
75
|
+
|
|
76
|
+
## Output
|
|
77
|
+
|
|
78
|
+
Sure -- here's a tail-recursive version:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
def sum(n, acc = 0)
|
|
82
|
+
return acc if n.zero?
|
|
83
|
+
sum(n - 1, acc + n)
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
````
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
token_reel -m exchange.md --tps 14 --ttft 0.8 --theme matrix -o demo.gif
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Headings are case-insensitive and also accept `User`/`Input`/`Question` for
|
|
93
|
+
the prompt, `Thinking`/`Thought` for reasoning, and `Response`/`Answer`/
|
|
94
|
+
`Assistant` for the output. A heading is only recognized outside of a
|
|
95
|
+
fenced code block, so a `# Output`-style comment inside example code
|
|
96
|
+
won't be mistaken for a section. The `## Reasoning` section is optional;
|
|
97
|
+
when present it streams first (as if the model were thinking out loud)
|
|
98
|
+
and is then replaced by the response once real output starts. Code
|
|
99
|
+
fences (` ``` `) in any section -- from `-m`, `-r`/`-R`, or `--stdin` --
|
|
100
|
+
are syntax-highlighted and rendered without the literal backtick lines.
|
|
101
|
+
|
|
102
|
+
Run `token_reel --help` for the full flag list. The important ones:
|
|
103
|
+
|
|
104
|
+
| flag | meaning | default |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `--tps N` | response tokens/sec | `8` |
|
|
107
|
+
| `--ttft N` | seconds of "thinking" before the first token | `0.6` |
|
|
108
|
+
| `--prompt-tps N` | prompt typing speed, `0` = appears instantly | `0` |
|
|
109
|
+
| `--reasoning-tps N` | reasoning typing speed, `0` = same as `--tps` | `0` |
|
|
110
|
+
| `--unit word\|char` | stream whole words or single characters | `word` |
|
|
111
|
+
| `--theme dark\|matrix\|light\|solarized` | color scheme | `dark` |
|
|
112
|
+
| `--cols N` | console width, in characters | `80` |
|
|
113
|
+
| `--rows N` | console height, in lines (scrolls once content grows past this) | `25` |
|
|
114
|
+
| `--font NAME` | exact ImageMagick font name | auto-detected monospace |
|
|
115
|
+
| `--fps N` | GIF frame rate | `12` |
|
|
116
|
+
| `--hold N` | seconds to hold on the finished frame | `1.5` |
|
|
117
|
+
| `-o, --out PATH` | output GIF path | `token_reel.gif` |
|
|
118
|
+
|
|
119
|
+
## Ruby API
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
require "token_reel"
|
|
123
|
+
|
|
124
|
+
TokenReel.generate(
|
|
125
|
+
prompt: "What's the airspeed velocity of an unladen swallow?",
|
|
126
|
+
reasoning: "This is the classic Monty Python bridgekeeper question.",
|
|
127
|
+
response: "African or European swallow?",
|
|
128
|
+
tps: 10,
|
|
129
|
+
ttft: 0.4,
|
|
130
|
+
theme: :dark,
|
|
131
|
+
out: "swallow.gif"
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Or build a `TokenReel::Config` for more control and hand it to
|
|
136
|
+
`TokenReel::Generator` directly:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
config = TokenReel::Config.new
|
|
140
|
+
config.prompt = File.read("prompt.txt")
|
|
141
|
+
config.response = File.read("response.md")
|
|
142
|
+
config.tps = 16
|
|
143
|
+
config.ttft = 1.2
|
|
144
|
+
config.theme = :solarized
|
|
145
|
+
config.out = "demo.gif"
|
|
146
|
+
|
|
147
|
+
TokenReel::Generator.new(config).generate!
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## How timing works
|
|
151
|
+
|
|
152
|
+
1. The prompt is shown -- instantly by default, or typed out at
|
|
153
|
+
`--prompt-tps` tokens/sec if you set it.
|
|
154
|
+
2. Once the prompt is fully shown, `--ttft` seconds pass with a
|
|
155
|
+
blinking-cursor "thinking..." indicator -- this is your simulated
|
|
156
|
+
time to first token.
|
|
157
|
+
3. If reasoning text was given, it streams in next at `--reasoning-tps`
|
|
158
|
+
tokens/sec (or `--tps` if that's unset), then disappears, replaced by
|
|
159
|
+
the response -- like a chat UI's collapsible thinking trace.
|
|
160
|
+
4. The response then streams in one token (word or character,
|
|
161
|
+
per `--unit`) every `1 / --tps` seconds.
|
|
162
|
+
5. The final frame holds for `--hold` seconds before the GIF loops.
|
|
163
|
+
|
|
164
|
+
Frames are sampled at `--fps` and identical consecutive frames are
|
|
165
|
+
collapsed into a single frame with a longer delay, so a long `--ttft`
|
|
166
|
+
or a slow `--tps` doesn't blow up the frame count or file size.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core/rake_task"
|
|
4
|
+
|
|
5
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
6
|
+
|
|
7
|
+
task default: :spec
|
|
8
|
+
|
|
9
|
+
VERSION_FILE = File.expand_path("lib/token_reel/version.rb", __dir__)
|
|
10
|
+
|
|
11
|
+
def current_version
|
|
12
|
+
File.read(VERSION_FILE).match(/VERSION = "(.*)"/)[1]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write_version(new_version)
|
|
16
|
+
contents = File.read(VERSION_FILE)
|
|
17
|
+
updated = contents.sub(/VERSION = ".*"/, %(VERSION = "#{new_version}"))
|
|
18
|
+
File.write(VERSION_FILE, updated)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def bumped(part)
|
|
22
|
+
major, minor, patch = current_version.split(".").map(&:to_i)
|
|
23
|
+
case part
|
|
24
|
+
when :major then [major + 1, 0, 0]
|
|
25
|
+
when :minor then [major, minor + 1, 0]
|
|
26
|
+
when :patch then [major, minor, patch + 1]
|
|
27
|
+
end.join(".")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "Print the current version"
|
|
31
|
+
task :version do
|
|
32
|
+
puts current_version
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
namespace :version do
|
|
36
|
+
desc "Bump major version (X.0.0) -- breaking changes"
|
|
37
|
+
task :major do
|
|
38
|
+
old_version = current_version
|
|
39
|
+
new_version = bumped(:major)
|
|
40
|
+
write_version(new_version)
|
|
41
|
+
puts "#{old_version} -> #{new_version} (lib/token_reel/version.rb updated, not committed)"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc "Bump minor version (x.X.0) -- backwards-compatible features"
|
|
45
|
+
task :minor do
|
|
46
|
+
old_version = current_version
|
|
47
|
+
new_version = bumped(:minor)
|
|
48
|
+
write_version(new_version)
|
|
49
|
+
puts "#{old_version} -> #{new_version} (lib/token_reel/version.rb updated, not committed)"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc "Bump patch version (x.x.X) -- backwards-compatible fixes"
|
|
53
|
+
task :patch do
|
|
54
|
+
old_version = current_version
|
|
55
|
+
new_version = bumped(:patch)
|
|
56
|
+
write_version(new_version)
|
|
57
|
+
puts "#{old_version} -> #{new_version} (lib/token_reel/version.rb updated, not committed)"
|
|
58
|
+
end
|
|
59
|
+
end
|
data/exe/token_reel
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module TokenReel
|
|
6
|
+
module CLI
|
|
7
|
+
# Parses ARGV into a Config. Returns nil if the parser already
|
|
8
|
+
# handled the request itself (e.g. --help, --version).
|
|
9
|
+
def self.parse(argv)
|
|
10
|
+
config = Config.new
|
|
11
|
+
prompt_file = nil
|
|
12
|
+
response_file = nil
|
|
13
|
+
reasoning_file = nil
|
|
14
|
+
markdown_file = nil
|
|
15
|
+
stdin_mode = false
|
|
16
|
+
|
|
17
|
+
parser = OptionParser.new do |o|
|
|
18
|
+
o.banner = "Usage: token_reel [options]\n\n" \
|
|
19
|
+
"Renders a terminal-style GIF of a prompt being answered, streamed\n" \
|
|
20
|
+
"token-by-token at a chosen speed with a simulated time-to-first-token.\n\n"
|
|
21
|
+
|
|
22
|
+
o.on("-p", "--prompt TEXT", "Prompt text (shown as typed input)") { |v| config.prompt = v }
|
|
23
|
+
o.on("-P", "--prompt-file PATH", "Read the prompt from a file") { |v| prompt_file = v }
|
|
24
|
+
o.on("-r", "--response TEXT", "Response text (streamed as output)") { |v| config.response = v }
|
|
25
|
+
o.on("-R", "--response-file PATH", "Read the response from a file") { |v| response_file = v }
|
|
26
|
+
o.on("--reasoning TEXT", "Reasoning/thinking text, streamed then replaced by the response") { |v| config.reasoning = v }
|
|
27
|
+
o.on("--reasoning-file PATH", "Read the reasoning text from a file") { |v| reasoning_file = v }
|
|
28
|
+
o.on("-m", "--markdown PATH", "Read prompt/reasoning/response from one Markdown file " \
|
|
29
|
+
"(## Prompt / ## Reasoning / ## Output headings; overrides -p/-P/-r/-R/--reasoning[-file])") { |v| markdown_file = v }
|
|
30
|
+
o.on("--init-markdown [PATH]", "Write a starter Markdown template for -m/--markdown, then exit " \
|
|
31
|
+
"(default path: exchange.md)") do |v|
|
|
32
|
+
path = v || "exchange.md"
|
|
33
|
+
if File.exist?(path)
|
|
34
|
+
warn "token_reel: #{path} already exists, not overwriting"
|
|
35
|
+
exit(1)
|
|
36
|
+
end
|
|
37
|
+
File.write(path, Script.template)
|
|
38
|
+
puts "Wrote #{path}"
|
|
39
|
+
exit(0)
|
|
40
|
+
end
|
|
41
|
+
o.on("--stdin", "Read prompt and response from STDIN, separated by a line of '---'") { stdin_mode = true }
|
|
42
|
+
|
|
43
|
+
o.separator ""
|
|
44
|
+
o.separator "Timing:"
|
|
45
|
+
o.on("--tps N", Float, "Response tokens revealed per second (default: #{config.tps})") { |v| config.tps = v }
|
|
46
|
+
o.on("--ttft N", Float, "Time to first token, in seconds (default: #{config.ttft})") { |v| config.ttft = v }
|
|
47
|
+
o.on("--prompt-tps N", Float, "Prompt typing speed in tokens/sec, 0 = instant (default: #{config.prompt_tps})") { |v| config.prompt_tps = v }
|
|
48
|
+
o.on("--reasoning-tps N", Float, "Reasoning typing speed in tokens/sec, 0 = same as --tps (default: #{config.reasoning_tps})") { |v| config.reasoning_tps = v }
|
|
49
|
+
o.on("--unit UNIT", %w[word char], "Streaming unit: word or char (default: #{config.unit})") { |v| config.unit = v.to_sym }
|
|
50
|
+
o.on("--hold N", Float, "Seconds to hold the final frame (default: #{config.hold})") { |v| config.hold = v }
|
|
51
|
+
o.on("--fps N", Integer, "GIF frame rate (default: #{config.fps})") { |v| config.fps = v }
|
|
52
|
+
o.on("--loop N", Integer, "GIF loop count, 0 = forever (default: #{config.loop_count})") { |v| config.loop_count = v }
|
|
53
|
+
|
|
54
|
+
o.separator ""
|
|
55
|
+
o.separator "Look:"
|
|
56
|
+
o.on("--theme THEME", %w[dark matrix light solarized], "Color theme (default: #{config.theme})") { |v| config.theme = v.to_sym }
|
|
57
|
+
o.on("--cols N", Integer, "Console width, in characters (default: #{config.cols})") { |v| config.cols = v }
|
|
58
|
+
o.on("--rows N", Integer, "Console height, in lines; once content grows past this the window " \
|
|
59
|
+
"scrolls, like a real terminal (default: #{config.rows})") { |v| config.rows = v }
|
|
60
|
+
o.on("--font-size N", Integer, "Font point size (default: #{config.font_size})") { |v| config.font_size = v }
|
|
61
|
+
o.on("--font NAME", "ImageMagick font name (default: auto-detect a monospace font)") { |v| config.font = v }
|
|
62
|
+
o.on("--label STRING", "Prompt prefix (default: #{config.label.inspect})") { |v| config.label = v }
|
|
63
|
+
o.on("--title STRING", "Window title bar text (default: #{config.title.inspect})") { |v| config.title = v }
|
|
64
|
+
o.on("--cursor CHAR", "Cursor glyph (default: #{config.cursor_char.inspect})") { |v| config.cursor_char = v }
|
|
65
|
+
|
|
66
|
+
o.separator ""
|
|
67
|
+
o.separator "Output:"
|
|
68
|
+
o.on("-o", "--out PATH", "Output GIF path (default: #{config.out})") { |v| config.out = v }
|
|
69
|
+
o.on("--keep-frames", "Keep the intermediate PNG frames on disk") { config.keep_frames = true }
|
|
70
|
+
|
|
71
|
+
o.separator ""
|
|
72
|
+
o.on("-v", "--version", "Print the version and exit") do
|
|
73
|
+
puts TokenReel::VERSION
|
|
74
|
+
exit(0)
|
|
75
|
+
end
|
|
76
|
+
o.on("-h", "--help", "Print this help and exit") do
|
|
77
|
+
puts o
|
|
78
|
+
exit(0)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
parser.parse!(argv)
|
|
83
|
+
|
|
84
|
+
config.prompt = File.read(prompt_file) if prompt_file
|
|
85
|
+
config.response = File.read(response_file) if response_file
|
|
86
|
+
config.reasoning = File.read(reasoning_file) if reasoning_file
|
|
87
|
+
|
|
88
|
+
if markdown_file
|
|
89
|
+
sections = Script.parse(File.read(markdown_file))
|
|
90
|
+
config.prompt = sections[:prompt] if sections.key?(:prompt)
|
|
91
|
+
config.reasoning = sections[:reasoning] if sections.key?(:reasoning)
|
|
92
|
+
config.response = sections[:response] if sections.key?(:response)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if stdin_mode
|
|
96
|
+
prompt, response = $stdin.read.split(/^---\s*$/, 2)
|
|
97
|
+
config.prompt = prompt.to_s.strip
|
|
98
|
+
config.response = response.to_s.strip
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
config
|
|
102
|
+
rescue OptionParser::InvalidOption, OptionParser::InvalidArgument, OptionParser::MissingArgument => e
|
|
103
|
+
warn "token_reel: #{e.message}"
|
|
104
|
+
warn parser
|
|
105
|
+
exit(1)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.run(argv)
|
|
109
|
+
config = parse(argv)
|
|
110
|
+
path = Generator.new(config).generate!
|
|
111
|
+
puts "Wrote #{path}"
|
|
112
|
+
0
|
|
113
|
+
rescue Error => e
|
|
114
|
+
warn "token_reel: #{e.message}"
|
|
115
|
+
1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenReel
|
|
4
|
+
# All the knobs that control a render. Build one directly, or via
|
|
5
|
+
# TokenReel::CLI.parse(ARGV).
|
|
6
|
+
class Config
|
|
7
|
+
attr_accessor :prompt, :response, :reasoning,
|
|
8
|
+
:tps, :ttft, :prompt_tps, :reasoning_tps, :unit,
|
|
9
|
+
:cols, :rows, :font, :font_size, :fps,
|
|
10
|
+
:theme, :hold, :loop_count,
|
|
11
|
+
:label, :thinking_label, :title, :cursor_char,
|
|
12
|
+
:out, :keep_frames
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@prompt = ""
|
|
16
|
+
@response = ""
|
|
17
|
+
@reasoning = "" # optional; shown streaming during the "thinking" pause, then replaced by the response
|
|
18
|
+
@tps = 8.0 # response tokens revealed per second
|
|
19
|
+
@ttft = 0.6 # seconds of "thinking" before first token
|
|
20
|
+
@prompt_tps = 0 # 0 = prompt appears instantly, fully typed
|
|
21
|
+
@reasoning_tps = 0 # 0 = same rate as tps
|
|
22
|
+
@unit = :word # :word or :char
|
|
23
|
+
@cols = 80 # console width, in characters (classic terminal default: 80x25)
|
|
24
|
+
@rows = 25 # console height, in lines -- once content grows past this, the
|
|
25
|
+
# window scrolls (oldest lines drop off the top), like a real terminal
|
|
26
|
+
@font = nil # nil = auto-detect an available monospace font, see Fonts
|
|
27
|
+
@font_size = 20
|
|
28
|
+
@fps = 12
|
|
29
|
+
@theme = :dark
|
|
30
|
+
@hold = 1.5 # seconds to hold the final frame
|
|
31
|
+
@loop_count = 0 # 0 = loop forever
|
|
32
|
+
@label = "\u276F " # "❯ "
|
|
33
|
+
@thinking_label = "thinking"
|
|
34
|
+
@title = "assistant"
|
|
35
|
+
@cursor_char = "\u258A" # "▊"
|
|
36
|
+
@out = "token_reel.gif"
|
|
37
|
+
@keep_frames = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate!
|
|
41
|
+
raise ConfigError, "prompt can't be blank" if prompt.to_s.empty?
|
|
42
|
+
raise ConfigError, "response can't be blank" if response.to_s.empty?
|
|
43
|
+
raise ConfigError, "tps must be > 0" unless tps.to_f.positive?
|
|
44
|
+
raise ConfigError, "ttft can't be negative" if ttft.to_f.negative?
|
|
45
|
+
raise ConfigError, "prompt_tps can't be negative" if prompt_tps.to_f.negative?
|
|
46
|
+
raise ConfigError, "reasoning_tps can't be negative" if reasoning_tps.to_f.negative?
|
|
47
|
+
raise ConfigError, "cols must be >= 20" if cols.to_i < 20
|
|
48
|
+
raise ConfigError, "rows must be >= 3" if rows.to_i < 3
|
|
49
|
+
raise ConfigError, "fps must be between 1 and 50" unless (1..50).cover?(fps.to_i)
|
|
50
|
+
raise ConfigError, "font_size must be >= 8" if font_size.to_i < 8
|
|
51
|
+
raise ConfigError, "hold can't be negative" if hold.to_f.negative?
|
|
52
|
+
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenReel
|
|
4
|
+
# Picks and validates the monospace font ImageMagick renders text
|
|
5
|
+
# with. This matters more than it sounds: the whole layout (column
|
|
6
|
+
# wrapping, cursor placement, and the per-span x-offsets syntax
|
|
7
|
+
# highlighting draws at) assumes every glyph is the same width, which
|
|
8
|
+
# is only true if the font actually resolves to a real monospace
|
|
9
|
+
# font. ImageMagick silently falls back to *some* default font (and
|
|
10
|
+
# only warns on stderr, without failing) when asked for a font name
|
|
11
|
+
# it doesn't know -- so guessing a hardcoded name and trusting it
|
|
12
|
+
# works is how you end up with misaligned, overlapping text.
|
|
13
|
+
module Fonts
|
|
14
|
+
# Common monospace font names, in priority order, as ImageMagick
|
|
15
|
+
# tends to expose them across Linux/macOS/Windows installs.
|
|
16
|
+
CANDIDATES = %w[
|
|
17
|
+
DejaVu-Sans-Mono Menlo-Regular Consolas Liberation-Mono
|
|
18
|
+
Courier-New Monaco Andale-Mono PT-Mono Noto-Sans-Mono
|
|
19
|
+
Ubuntu-Mono Cascadia-Mono JetBrains-Mono JetBrainsMono-Regular
|
|
20
|
+
Fira-Code FiraCode-Regular Hack-Regular Courier fixed
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def self.available
|
|
24
|
+
@available ||= `#{Shellwords.escape(Renderer.convert_binary)} -list font 2>/dev/null`
|
|
25
|
+
.scan(/^\s*Font:\s*(\S+)/).flatten
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# name == nil means "pick one for me". A name the caller passed in
|
|
29
|
+
# explicitly is validated against what ImageMagick actually knows,
|
|
30
|
+
# rather than trusted -- see the module comment for why.
|
|
31
|
+
def self.resolve!(name)
|
|
32
|
+
return autodetect if name.nil?
|
|
33
|
+
return name if available.include?(name)
|
|
34
|
+
|
|
35
|
+
raise RenderError,
|
|
36
|
+
"font #{name.inspect} not found via `#{Renderer.convert_binary} -list font` " \
|
|
37
|
+
"(pass --font with an exact name from that list, or omit --font to auto-detect)"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.autodetect
|
|
41
|
+
CANDIDATES.find { |name| available.include?(name) } ||
|
|
42
|
+
raise(RenderError,
|
|
43
|
+
"no monospace font found on this system; install one of " \
|
|
44
|
+
"#{CANDIDATES.first(4).join(', ')}, etc., or pass --font NAME " \
|
|
45
|
+
"with an exact name from `#{Renderer.convert_binary} -list font`")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module TokenReel
|
|
8
|
+
class Generator
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config.validate!
|
|
11
|
+
@timeline = Timeline.new(config)
|
|
12
|
+
@renderer = Renderer.new(config, @timeline)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns the output path on success.
|
|
16
|
+
def generate!
|
|
17
|
+
workdir = @config.keep_frames ? make_persistent_workdir : nil
|
|
18
|
+
Dir.mktmpdir do |tmp|
|
|
19
|
+
dir = workdir || tmp
|
|
20
|
+
frames = sample_and_render(dir)
|
|
21
|
+
GifWriter.assemble(frames, @config.out, @config.loop_count)
|
|
22
|
+
end
|
|
23
|
+
@config.out
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def make_persistent_workdir
|
|
29
|
+
base = File.basename(@config.out.to_s).sub(/\.gif\z/i, "")
|
|
30
|
+
base = "token_reel" if base.strip.empty?
|
|
31
|
+
dir = "#{base}_frames"
|
|
32
|
+
FileUtils.mkdir_p(dir)
|
|
33
|
+
dir
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Walks the timeline at a fixed frame rate. Consecutive samples
|
|
37
|
+
# that render to an identical frame (very common during the
|
|
38
|
+
# "thinking" pause, or whenever fps > tps) are collapsed into one
|
|
39
|
+
# frame with a longer delay instead of being rendered twice.
|
|
40
|
+
def sample_and_render(dir)
|
|
41
|
+
step = 1.0 / @config.fps
|
|
42
|
+
delay_cs = [(step * 100).round, 1].max
|
|
43
|
+
rendered = {} # signature -> path, so repeated (non-consecutive) states reuse a PNG
|
|
44
|
+
|
|
45
|
+
frames = []
|
|
46
|
+
t = 0.0
|
|
47
|
+
index = 0
|
|
48
|
+
loop do
|
|
49
|
+
state = @timeline.state_at(t)
|
|
50
|
+
sig = state.signature
|
|
51
|
+
|
|
52
|
+
if frames.any? && frames.last[:sig] == sig
|
|
53
|
+
frames.last[:delay_cs] += delay_cs
|
|
54
|
+
else
|
|
55
|
+
path = rendered[sig] ||= begin
|
|
56
|
+
p = File.join(dir, format("frame_%05d.png", index += 1))
|
|
57
|
+
@renderer.render(state, p)
|
|
58
|
+
p
|
|
59
|
+
end
|
|
60
|
+
frames << { sig: sig, path: path, delay_cs: delay_cs }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
break if t >= @timeline.duration
|
|
64
|
+
|
|
65
|
+
t = [t + step, @timeline.duration].min
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
frames
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module TokenReel
|
|
6
|
+
module GifWriter
|
|
7
|
+
# frames: [{ path: "...", delay_cs: Integer }, ...] in playback order.
|
|
8
|
+
def self.assemble(frames, out_path, loop_count)
|
|
9
|
+
raise RenderError, "no frames to assemble" if frames.empty?
|
|
10
|
+
|
|
11
|
+
argv = [Renderer.convert_binary, "-loop", loop_count.to_s]
|
|
12
|
+
frames.each do |f|
|
|
13
|
+
argv += ["-delay", f[:delay_cs].to_s, "-dispose", "Background", f[:path]]
|
|
14
|
+
end
|
|
15
|
+
argv << out_path
|
|
16
|
+
|
|
17
|
+
_out, err, status = Open3.capture3(*argv)
|
|
18
|
+
raise RenderError, "ImageMagick failed to assemble GIF: #{err}" unless status.success?
|
|
19
|
+
|
|
20
|
+
out_path
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|