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 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
@@ -0,0 +1,5 @@
1
+ /pkg/
2
+ *.gem
3
+ *_frames/
4
+ /spec/tmp/
5
+ /Gemfile.lock
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "token_reel"
6
+
7
+ exit(TokenReel::CLI.run(ARGV))
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenReel
4
+ class Error < StandardError; end
5
+ class RenderError < Error; end
6
+ class ConfigError < Error; end
7
+ 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