tui-td 0.2.19 → 0.2.21
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 +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +167 -5
- data/lib/tui_td/cli.rb +77 -0
- data/lib/tui_td/configuration.rb +4 -1
- data/lib/tui_td/driver.rb +28 -0
- data/lib/tui_td/matchers.rb +31 -0
- data/lib/tui_td/mcp/server.rb +78 -0
- data/lib/tui_td/minitest/assertions.rb +44 -2
- data/lib/tui_td/screenshot.rb +14 -5
- data/lib/tui_td/test_runner.rb +30 -0
- data/lib/tui_td/version.rb +1 -1
- data/lib/tui_td/video_recorder.rb +153 -0
- data/lib/tui_td.rb +1 -1
- metadata +18 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcd0909e5284e4bdd97bd38154ba64108c9c6e95903ca067126b8fb16215ae28
|
|
4
|
+
data.tar.gz: 9b2bf1430a37e58a72c8f185adbbfb500a70f78a3a80bb66e083dee3e9124aa8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c084254da02d47fe56471eab086e89f35025c875153e0987b8399de324894a270a7488992c64ad78aa888c7886810898b0365b1a288667caef4c17f68096b82
|
|
7
|
+
data.tar.gz: 97aafa9d1056b7db02f84cb9b378edfb869e0651bfebaeceedfcb0f75d05ad76f97994a2986af80e84d8d8cbf77c9e0641600860247e771bb58b404b4aabd492
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.2.20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Video recording via ffmpeg: `VideoRecorder` class pipes PNG frames from the
|
|
8
|
+
existing Screenshot pipeline directly to ffmpeg for incremental encoding
|
|
9
|
+
- Driver methods: `start_recording`, `stop_recording`, `recording?`
|
|
10
|
+
- CLI flags: `--record <path>`, `--framerate <N>`, `--codec <name>`
|
|
11
|
+
- MCP tools: `tui_record_start`, `tui_record_stop`, `tui_record_status`
|
|
12
|
+
- JSON test steps: `start_recording`, `stop_recording`, `assert_recording`
|
|
13
|
+
- Minitest assertions: `assert_record_start`, `assert_record_stop`,
|
|
14
|
+
`assert_recording`, `refute_recording`
|
|
15
|
+
- RSpec matchers: `be_recording`, `have_recorded_video`
|
|
16
|
+
- Configuration: `ffmpeg_path`, `record_default_fps`, `record_default_codec`
|
|
17
|
+
- `Screenshot#to_blob` for in-memory PNG rendering (no temp files)
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `Driver#close` auto-stops recording if active
|
|
22
|
+
- `TestRunner#run` auto-stops recording on `close` step
|
|
23
|
+
|
|
24
|
+
## 0.2.19
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Minitest integration: `TUITD::Minitest::Assertions` module with 34 assertion methods
|
|
29
|
+
(17 assert + 17 refute) covering text, regex, colors, styles, exit status, all 9
|
|
30
|
+
element roles, and named snapshots with region/ignore_rows support
|
|
31
|
+
- Auto-wait for Minitest: Driver assertions wait up to 3s, State checks immediately
|
|
32
|
+
- `tui-td help minitest` — complete assertion reference
|
|
33
|
+
- Example: `examples/minitest_example_test.rb` with 7 test cases
|
|
34
|
+
- Smoke test: `test/minitest_smoke_test.rb` (10 runs, 19 assertions)
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- Updated gemspec metadata (summary, description) for rubygems.org
|
|
39
|
+
|
|
40
|
+
## 0.2.18
|
|
41
|
+
|
|
42
|
+
(yanked — same as 0.2.19 but with outdated gemspec metadata)
|
|
43
|
+
|
|
3
44
|
## 0.2.17
|
|
4
45
|
|
|
5
46
|
### Added
|
data/README.md
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
[](https://rubygems.org/gems/tui-td)
|
|
4
4
|
[](LICENSE.txt)
|
|
5
5
|
|
|
6
|
-
Testing framework
|
|
6
|
+
Testing framework and general-purpose TUI driver. Start a TUI in a PTY, send input, analyze output — as structured JSON, plain text, PNG screenshots, or HTML renders. Use it for testing, automation, or as a bridge between AI agents and terminal apps.
|
|
7
|
+
|
|
8
|
+
**Language-agnostic:** JSON tests + CLI + MCP let you drive TUIs from any language (Python, JavaScript, Rust, Go, …). Write a `.json` test plan, script via `tui-td drive`, or let an AI agent control a TUI through `tui-td serve`.
|
|
9
|
+
|
|
10
|
+
**Ruby-native:** In the Ruby ecosystem, tui-td integrates with RSpec and Minitest — auto-wait matchers and assertions included.
|
|
7
11
|
|
|
8
12
|
> New to tui-td? Jump to [Quick Start](docs/quick_start.md).
|
|
9
13
|
|
|
@@ -13,10 +17,12 @@ Testing framework for Terminal User Interfaces (TUIs). Start a TUI in a PTY, sen
|
|
|
13
17
|
2. **Auto-wait assertions** — matchers automatically retry until the condition is met or timeout
|
|
14
18
|
3. **Semantic selectors** — `get_by_role(:button)`, `get_by_role(:dialog)`, `within { }` scoping
|
|
15
19
|
4. **Multiple output formats** — structured JSON, plain text, PNG screenshots, HTML renders
|
|
16
|
-
5. **JSON test runner** — language-agnostic,
|
|
20
|
+
5. **JSON test runner** — language-agnostic, 23+ step types, CI-friendly
|
|
17
21
|
6. **RSpec matchers** — `have_text`, `have_fg`, `have_button`, `have_dialog`, and more
|
|
18
|
-
7. **
|
|
19
|
-
8. **
|
|
22
|
+
7. **Minitest assertions** — `assert_text`, `assert_button`, `assert_snapshot`, and more
|
|
23
|
+
8. **MCP server** — AI agents can drive TUIs via JSON-RPC over stdio
|
|
24
|
+
9. **Pure Ruby rendering** — embedded Spleen font + 2766 Unifont glyphs, no native deps required
|
|
25
|
+
10. **Video recording** — record TUI sessions as MP4 via ffmpeg for demos, debugging, and AI agent playback
|
|
20
26
|
|
|
21
27
|
## Installation
|
|
22
28
|
|
|
@@ -53,6 +59,9 @@ tui-td --html output.html capture "htop"
|
|
|
53
59
|
# Save as a PNG screenshot
|
|
54
60
|
tui-td --screenshot output.png capture "htop"
|
|
55
61
|
|
|
62
|
+
# Record a session as video (requires ffmpeg)
|
|
63
|
+
tui-td --record demo.mp4 capture "htop" --timeout 10
|
|
64
|
+
|
|
56
65
|
# Drive a TUI interactively
|
|
57
66
|
tui-td drive "htop"
|
|
58
67
|
# At the > prompt:
|
|
@@ -106,6 +115,9 @@ Global options:
|
|
|
106
115
|
-C, --chdir PATH Working directory for the command
|
|
107
116
|
--screenshot PATH Save screenshot (e.g., output.png)
|
|
108
117
|
--html PATH Save HTML render (e.g., output.html)
|
|
118
|
+
--record PATH Record session as video (MP4/WebM, requires ffmpeg)
|
|
119
|
+
--framerate N Recording framerate (default: 30)
|
|
120
|
+
--codec NAME Video codec: libx264, libx265, libvpx-vp9 (default: libx264)
|
|
109
121
|
--json Output state as compact JSON
|
|
110
122
|
--pretty Output state as pretty JSON
|
|
111
123
|
--text Output state as plain text table
|
|
@@ -117,7 +129,7 @@ Global options:
|
|
|
117
129
|
```
|
|
118
130
|
|
|
119
131
|
`tui-td --help` serves as the full CLI reference. `tui-td help test` shows all JSON test
|
|
120
|
-
step types
|
|
132
|
+
step types, `tui-td help rspec` shows all RSpec matchers, and `tui-td help minitest` shows all Minitest assertions — no need to consult the docs.
|
|
121
133
|
|
|
122
134
|
## Demo
|
|
123
135
|
|
|
@@ -161,6 +173,11 @@ driver.screenshot("screenshot.png") # PNG renderer
|
|
|
161
173
|
TUITD::HtmlRenderer.new(driver.state_data).render("output.html") # HTML renderer
|
|
162
174
|
html_string = TUITD::HtmlRenderer.new(driver.state_data).to_html # HTML string
|
|
163
175
|
|
|
176
|
+
# Video recording (requires ffmpeg)
|
|
177
|
+
driver.start_recording("session.mp4", framerate: 30, codec: "libx264")
|
|
178
|
+
driver.recording? # => true
|
|
179
|
+
driver.stop_recording # => "session.mp4"
|
|
180
|
+
|
|
164
181
|
driver.close
|
|
165
182
|
```
|
|
166
183
|
|
|
@@ -293,6 +310,9 @@ tui-td test examples/echo_test.json
|
|
|
293
310
|
| `assert_tab` | `"text"` | Assert a tab (`[Tab1]`) is visible |
|
|
294
311
|
| `assert_statusbar` | — | Assert a status bar (bottom row with background) is visible |
|
|
295
312
|
| `assert_progress_bar` | `"text"` (optional) | Assert a progress bar (`[####]`) is visible |
|
|
313
|
+
| `start_recording` | `"path", "framerate": 30` | Start video recording (requires ffmpeg) |
|
|
314
|
+
| `stop_recording` | — | Stop recording and finalize video |
|
|
315
|
+
| `assert_recording` | `true` / `false` | Assert recording is active / not active |
|
|
296
316
|
| `close` | — | Close the TUI |
|
|
297
317
|
|
|
298
318
|
Example with `html` step for before/after snapshots:
|
|
@@ -378,6 +398,58 @@ end
|
|
|
378
398
|
| `have_statusbar` | Assert a status bar (bottom row with background) is visible |
|
|
379
399
|
| `have_progress_bar("50%")` | Assert a progress bar (`[####]`) is visible |
|
|
380
400
|
| `have_exit_status(N)` | Assert the driver process exit status equals N |
|
|
401
|
+
| `be_recording` | Assert that the driver is currently recording video |
|
|
402
|
+
| `have_recorded_video("path.mp4")` | Assert that a video file was created and has content |
|
|
403
|
+
|
|
404
|
+
## Minitest Assertions
|
|
405
|
+
|
|
406
|
+
Include the assertions module for native Minitest support (auto-wait included):
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
require "tui_td/minitest/assertions"
|
|
410
|
+
|
|
411
|
+
class MyTUITest < Minitest::Test
|
|
412
|
+
include TUITD::Minitest::Assertions
|
|
413
|
+
|
|
414
|
+
def test_login
|
|
415
|
+
driver = TUITD::Driver.new("my_tui", rows: 24, cols: 80)
|
|
416
|
+
driver.start
|
|
417
|
+
assert_text(driver, "Welcome")
|
|
418
|
+
assert_button(driver, "OK")
|
|
419
|
+
refute_text(driver, "Error")
|
|
420
|
+
assert_snapshot(driver, "login", type: :text, region: 0..6)
|
|
421
|
+
ensure
|
|
422
|
+
driver&.close
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
| Assertion | Usage |
|
|
428
|
+
|-----------|-------|
|
|
429
|
+
| `assert_text(driver, "...")` | Assert text is present |
|
|
430
|
+
| `refute_text(driver, "...")` | Assert text is NOT present |
|
|
431
|
+
| `assert_regex(driver, /pattern/)` | Assert regex matches |
|
|
432
|
+
| `assert_fg(driver, "cyan", row:, col:)` | Assert foreground color |
|
|
433
|
+
| `assert_bg(driver, "blue", row:, col:)` | Assert background color |
|
|
434
|
+
| `assert_style(driver, row:, col:, bold: true)` | Assert cell style |
|
|
435
|
+
| `assert_exit_status(driver, 0)` | Assert process exit status |
|
|
436
|
+
| `assert_button(driver, "OK")` | Assert button with given text |
|
|
437
|
+
| `assert_dialog(driver)` | Assert a dialog is visible |
|
|
438
|
+
| `assert_checkbox(driver, "Label", checked: true)` | Assert checkbox with state |
|
|
439
|
+
| `assert_role(driver, :button, text: "OK")` | Generic role assertion |
|
|
440
|
+
| `assert_input(driver)` | Assert an input field |
|
|
441
|
+
| `assert_label(driver, "Name")` | Assert a label |
|
|
442
|
+
| `assert_menu(driver)` | Assert a menu |
|
|
443
|
+
| `assert_tab(driver, "File")` | Assert a tab |
|
|
444
|
+
| `assert_statusbar(driver)` | Assert a status bar |
|
|
445
|
+
| `assert_progress_bar(driver)` | Assert a progress bar |
|
|
446
|
+
| `assert_snapshot(driver, "name", type:, region:, ignore_rows:)` | Named snapshot comparison |
|
|
447
|
+
| `assert_record_start(driver, "path", framerate:)` | Start video recording |
|
|
448
|
+
| `assert_record_stop(driver)` | Stop recording and finalize video |
|
|
449
|
+
| `assert_recording(driver)` | Assert recording is active |
|
|
450
|
+
| `refute_recording(driver)` | Assert recording is NOT active |
|
|
451
|
+
|
|
452
|
+
See `tui-td help minitest` for the full reference.
|
|
381
453
|
|
|
382
454
|
## Snapshot Testing
|
|
383
455
|
|
|
@@ -459,6 +531,9 @@ tui-td serve
|
|
|
459
531
|
| `tui_annotate_element` | Manually register a UI element annotation. Picked up by tui_find_elements. |
|
|
460
532
|
| `tui_save_snapshot` | Save the current terminal state as a named snapshot to disk. |
|
|
461
533
|
| `tui_assert_snapshot` | Assert current state matches a saved named snapshot. Creates on first run. |
|
|
534
|
+
| `tui_record_start` | Start video recording with path, framerate, codec, and quality options. |
|
|
535
|
+
| `tui_record_stop` | Stop recording and finalize the video file. |
|
|
536
|
+
| `tui_record_status` | Check if recording is currently active. |
|
|
462
537
|
| `tui_close` | Close the TUI and clean up. |
|
|
463
538
|
|
|
464
539
|
### MCP configuration
|
|
@@ -604,6 +679,93 @@ html = TUITD::HtmlRenderer.new(driver.state_data).to_html
|
|
|
604
679
|
{"html": "/tmp/snapshot.html"}
|
|
605
680
|
```
|
|
606
681
|
|
|
682
|
+
## Video Recording
|
|
683
|
+
|
|
684
|
+
Record TUI sessions as MP4 video using ffmpeg. Frames are captured via the same Screenshot pipeline and piped directly to ffmpeg for incremental encoding — no temporary frame files.
|
|
685
|
+
|
|
686
|
+
**Requires ffmpeg** (`brew install ffmpeg` on macOS, `apt install ffmpeg` on Debian).
|
|
687
|
+
|
|
688
|
+
```bash
|
|
689
|
+
# Record a capture session
|
|
690
|
+
tui-td --record demo.mp4 capture "htop" --timeout 10
|
|
691
|
+
|
|
692
|
+
# High-framerate recording
|
|
693
|
+
tui-td --record session.mp4 --framerate 60 drive "vim file.txt"
|
|
694
|
+
|
|
695
|
+
# Custom codec and quality
|
|
696
|
+
tui-td --record output.mp4 --codec libx265 --framerate 30 capture "glow README.md"
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
### Ruby API
|
|
700
|
+
|
|
701
|
+
```ruby
|
|
702
|
+
driver = TUITD::Driver.new("htop", rows: 40, cols: 120)
|
|
703
|
+
driver.start
|
|
704
|
+
|
|
705
|
+
# Start recording
|
|
706
|
+
driver.start_recording("session.mp4", framerate: 30, codec: "libx264", quality: "high")
|
|
707
|
+
|
|
708
|
+
# Check recording status
|
|
709
|
+
driver.recording? # => true
|
|
710
|
+
|
|
711
|
+
# ... interact with TUI ...
|
|
712
|
+
|
|
713
|
+
# Stop recording
|
|
714
|
+
driver.stop_recording # => "session.mp4"
|
|
715
|
+
driver.close # auto-stops recording if active
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
### Configuration
|
|
719
|
+
|
|
720
|
+
```ruby
|
|
721
|
+
TUITD.configure do |c|
|
|
722
|
+
c.ffmpeg_path = "/usr/local/bin/ffmpeg" # default: auto-detect
|
|
723
|
+
c.record_default_fps = 30
|
|
724
|
+
c.record_default_codec = "libx264"
|
|
725
|
+
end
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
### RSpec
|
|
729
|
+
|
|
730
|
+
```ruby
|
|
731
|
+
require "tui_td/matchers"
|
|
732
|
+
|
|
733
|
+
driver.start_recording("test.mp4", framerate: 30)
|
|
734
|
+
expect(driver).to be_recording
|
|
735
|
+
# ... interact ...
|
|
736
|
+
driver.stop_recording
|
|
737
|
+
expect(driver).not_to be_recording
|
|
738
|
+
expect(driver).to have_recorded_video("test.mp4")
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
### Minitest
|
|
742
|
+
|
|
743
|
+
```ruby
|
|
744
|
+
require "tui_td/minitest/assertions"
|
|
745
|
+
|
|
746
|
+
assert_record_start(driver, "test.mp4", framerate: 30)
|
|
747
|
+
assert_recording(driver)
|
|
748
|
+
# ... interact ...
|
|
749
|
+
assert_record_stop(driver)
|
|
750
|
+
refute_recording(driver)
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
### JSON Test Steps
|
|
754
|
+
|
|
755
|
+
```json
|
|
756
|
+
{"start_recording": "output.mp4", "framerate": 30, "codec": "libx264"}
|
|
757
|
+
{"assert_recording": true}
|
|
758
|
+
{"stop_recording": true}
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
### MCP Tools
|
|
762
|
+
|
|
763
|
+
| Tool | Description |
|
|
764
|
+
|------|-------------|
|
|
765
|
+
| `tui_record_start` | Start recording with path, framerate, codec, quality |
|
|
766
|
+
| `tui_record_stop` | Stop recording and finalize video file |
|
|
767
|
+
| `tui_record_status` | Check if recording is currently active |
|
|
768
|
+
|
|
607
769
|
## License
|
|
608
770
|
|
|
609
771
|
MIT
|
data/lib/tui_td/cli.rb
CHANGED
|
@@ -71,6 +71,15 @@ module TUITD
|
|
|
71
71
|
opts.on("--html PATH", "Save HTML render (e.g., output.html)") do |p|
|
|
72
72
|
global_opts[:html] = p
|
|
73
73
|
end
|
|
74
|
+
opts.on("--record PATH", "Record session as video (MP4/WebM, requires ffmpeg)") do |p|
|
|
75
|
+
global_opts[:record] = p
|
|
76
|
+
end
|
|
77
|
+
opts.on("--framerate N", Integer, "Recording framerate (default: 30)") do |f|
|
|
78
|
+
global_opts[:record_framerate] = f
|
|
79
|
+
end
|
|
80
|
+
opts.on("--codec NAME", "Video codec: libx264, libx265, libvpx-vp9 (default: libx264)") do |c|
|
|
81
|
+
global_opts[:record_codec] = c
|
|
82
|
+
end
|
|
74
83
|
opts.on("--json", "Output state as compact JSON") do |_|
|
|
75
84
|
global_opts[:format] = :json
|
|
76
85
|
end
|
|
@@ -136,6 +145,23 @@ module TUITD
|
|
|
136
145
|
|
|
137
146
|
private
|
|
138
147
|
|
|
148
|
+
def _start_recording_if(driver, globals)
|
|
149
|
+
return unless globals[:record]
|
|
150
|
+
|
|
151
|
+
framerate = globals[:record_framerate] || 30
|
|
152
|
+
codec = globals[:record_codec] || "libx264"
|
|
153
|
+
path = driver.start_recording(globals[:record], framerate: framerate, codec: codec)
|
|
154
|
+
puts "Recording to: #{path}"
|
|
155
|
+
path
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def _stop_recording(driver)
|
|
159
|
+
return unless driver.recording?
|
|
160
|
+
|
|
161
|
+
path = driver.stop_recording
|
|
162
|
+
puts "Recording saved: #{path}" if path
|
|
163
|
+
end
|
|
164
|
+
|
|
139
165
|
def cmd_serve(globals)
|
|
140
166
|
server = MCP::Server.new(
|
|
141
167
|
rows: globals[:rows] || 40,
|
|
@@ -155,6 +181,8 @@ module TUITD
|
|
|
155
181
|
puts "─" * (globals[:cols] || 80)
|
|
156
182
|
driver.start
|
|
157
183
|
|
|
184
|
+
_start_recording_if(driver, globals)
|
|
185
|
+
|
|
158
186
|
driver.wait_for_stable
|
|
159
187
|
|
|
160
188
|
if %i[json pretty_json].include?(globals[:format])
|
|
@@ -172,6 +200,7 @@ module TUITD
|
|
|
172
200
|
puts "HTML saved: #{path}"
|
|
173
201
|
end
|
|
174
202
|
|
|
203
|
+
_stop_recording(driver)
|
|
175
204
|
driver.close
|
|
176
205
|
end
|
|
177
206
|
|
|
@@ -185,6 +214,8 @@ module TUITD
|
|
|
185
214
|
puts "Type commands to send. Exit with Ctrl+C."
|
|
186
215
|
driver.start
|
|
187
216
|
|
|
217
|
+
_start_recording_if(driver, globals)
|
|
218
|
+
|
|
188
219
|
begin
|
|
189
220
|
loop do
|
|
190
221
|
driver.wait_for_stable
|
|
@@ -264,6 +295,7 @@ module TUITD
|
|
|
264
295
|
rescue Interrupt
|
|
265
296
|
puts "\nDone."
|
|
266
297
|
ensure
|
|
298
|
+
_stop_recording(driver)
|
|
267
299
|
driver.close
|
|
268
300
|
end
|
|
269
301
|
end
|
|
@@ -281,6 +313,9 @@ module TUITD
|
|
|
281
313
|
# Proceed with whatever was rendered before the timeout.
|
|
282
314
|
driver.refresh
|
|
283
315
|
end
|
|
316
|
+
|
|
317
|
+
_start_recording_if(driver, globals)
|
|
318
|
+
|
|
284
319
|
begin
|
|
285
320
|
driver.wait_for_stable
|
|
286
321
|
rescue TimeoutError
|
|
@@ -305,6 +340,7 @@ module TUITD
|
|
|
305
340
|
puts "HTML saved: #{path}"
|
|
306
341
|
end
|
|
307
342
|
|
|
343
|
+
_stop_recording(driver)
|
|
308
344
|
driver.close
|
|
309
345
|
end
|
|
310
346
|
|
|
@@ -462,6 +498,16 @@ module TUITD
|
|
|
462
498
|
{"html": "<path>"}
|
|
463
499
|
Save an HTML render. Path defaults to /tmp/tui_td_<ts>.html.
|
|
464
500
|
|
|
501
|
+
{"start_recording": "<path>", "framerate": 30, "codec": "libx264"}
|
|
502
|
+
Start recording the TUI session as a video (requires ffmpeg).
|
|
503
|
+
framerate defaults to 30, codec defaults to libx264.
|
|
504
|
+
|
|
505
|
+
{"stop_recording": true}
|
|
506
|
+
Stop video recording and finalize the video file.
|
|
507
|
+
|
|
508
|
+
{"assert_recording": true}
|
|
509
|
+
Assert that recording is active. Use false to assert NOT recording.
|
|
510
|
+
|
|
465
511
|
{"wait_for_exit": true}
|
|
466
512
|
Wait until the process exits naturally.
|
|
467
513
|
|
|
@@ -650,6 +696,22 @@ module TUITD
|
|
|
650
696
|
have_exit_status(expected)
|
|
651
697
|
Assert the process exit status matches expected.
|
|
652
698
|
Usage: expect(driver).to have_exit_status(0)
|
|
699
|
+
|
|
700
|
+
Video Recording
|
|
701
|
+
---------------
|
|
702
|
+
|
|
703
|
+
Start/stop recording via Driver methods, then verify with matchers:
|
|
704
|
+
|
|
705
|
+
driver.start_recording("test.mp4", framerate: 30, codec: "libx264")
|
|
706
|
+
expect(driver).to be_recording
|
|
707
|
+
# ... interact with TUI ...
|
|
708
|
+
driver.stop_recording
|
|
709
|
+
expect(driver).not_to be_recording
|
|
710
|
+
expect(driver).to have_recorded_video("test.mp4")
|
|
711
|
+
|
|
712
|
+
Options for start_recording: framerate (default 30),
|
|
713
|
+
codec (libx264, libx265, libvpx-vp9), quality (high/medium/low).
|
|
714
|
+
Recording stops automatically when the driver is closed.
|
|
653
715
|
HELP
|
|
654
716
|
exit 0
|
|
655
717
|
end
|
|
@@ -752,6 +814,21 @@ module TUITD
|
|
|
752
814
|
|
|
753
815
|
assert_snapshot(driver, "main", ignore_rows: [5])
|
|
754
816
|
Skip volatile rows with ignore_rows:.
|
|
817
|
+
|
|
818
|
+
Video Recording
|
|
819
|
+
---------------
|
|
820
|
+
|
|
821
|
+
assert_record_start(driver, "test.mp4", framerate: 30, codec: "libx264")
|
|
822
|
+
Start recording the TUI session as a video (requires ffmpeg).
|
|
823
|
+
|
|
824
|
+
assert_record_stop(driver)
|
|
825
|
+
Stop recording and finalize the video file.
|
|
826
|
+
|
|
827
|
+
assert_recording(driver)
|
|
828
|
+
Verify that recording is currently active.
|
|
829
|
+
|
|
830
|
+
refute_recording(driver)
|
|
831
|
+
Verify that recording is NOT active.
|
|
755
832
|
HELP
|
|
756
833
|
exit 0
|
|
757
834
|
end
|
data/lib/tui_td/configuration.rb
CHANGED
|
@@ -9,10 +9,13 @@ module TUITD
|
|
|
9
9
|
# end
|
|
10
10
|
#
|
|
11
11
|
class Configuration
|
|
12
|
-
attr_accessor :snapshot_dir
|
|
12
|
+
attr_accessor :snapshot_dir, :ffmpeg_path, :record_default_fps, :record_default_codec
|
|
13
13
|
|
|
14
14
|
def initialize
|
|
15
15
|
@snapshot_dir = nil
|
|
16
|
+
@ffmpeg_path = nil
|
|
17
|
+
@record_default_fps = 30
|
|
18
|
+
@record_default_codec = "libx264"
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
# Check if UPDATE_SNAPSHOTS env var is set to update mode.
|
data/lib/tui_td/driver.rb
CHANGED
|
@@ -224,8 +224,36 @@ module TUITD
|
|
|
224
224
|
TUITD::State.new(state_data)
|
|
225
225
|
end
|
|
226
226
|
|
|
227
|
+
# Start video recording (requires ffmpeg).
|
|
228
|
+
# Options: framerate (default 30), codec (default "libx264"), quality ("high"/"medium"/"low").
|
|
229
|
+
def start_recording(path, framerate: 30, codec: "libx264", quality: "high")
|
|
230
|
+
raise Error, "Recording already in progress" if recording?
|
|
231
|
+
|
|
232
|
+
require_relative "video_recorder"
|
|
233
|
+
@recorder = VideoRecorder.new(path, driver: self, framerate: framerate,
|
|
234
|
+
codec: codec, quality: quality,)
|
|
235
|
+
@recorder.start
|
|
236
|
+
path
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Stop video recording and finalize the video file.
|
|
240
|
+
# Returns the output path, or nil if not recording.
|
|
241
|
+
def stop_recording
|
|
242
|
+
return nil unless @recorder
|
|
243
|
+
|
|
244
|
+
path = @recorder.stop
|
|
245
|
+
@recorder = nil
|
|
246
|
+
path
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Is video recording currently active?
|
|
250
|
+
def recording?
|
|
251
|
+
@recorder&.recording? || false
|
|
252
|
+
end
|
|
253
|
+
|
|
227
254
|
# Close the driver and clean up
|
|
228
255
|
def close
|
|
256
|
+
stop_recording
|
|
229
257
|
_stop_reader_thread
|
|
230
258
|
|
|
231
259
|
# Kill the process if still running
|
data/lib/tui_td/matchers.rb
CHANGED
|
@@ -134,6 +134,37 @@ module TUITD
|
|
|
134
134
|
end
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
# Video recording matchers — work on Driver instances
|
|
138
|
+
|
|
139
|
+
RSpec::Matchers.define :be_recording do
|
|
140
|
+
match do |driver|
|
|
141
|
+
@actual = driver.recording?
|
|
142
|
+
@actual
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
description { "be recording video" }
|
|
146
|
+
failure_message do |_driver|
|
|
147
|
+
"expected driver to be recording, but it is not"
|
|
148
|
+
end
|
|
149
|
+
failure_message_when_negated do |_driver|
|
|
150
|
+
"expected driver NOT to be recording, but it is"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
RSpec::Matchers.define :have_recorded_video do |path|
|
|
155
|
+
match do |_actual|
|
|
156
|
+
File.exist?(File.expand_path(path)) && File.size(File.expand_path(path)).positive?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
description { "have recorded video at #{path}" }
|
|
160
|
+
failure_message do |_actual|
|
|
161
|
+
"expected video file to exist at #{path}"
|
|
162
|
+
end
|
|
163
|
+
failure_message_when_negated do |_actual|
|
|
164
|
+
"expected video file NOT to exist at #{path}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
137
168
|
# Selector-based matchers — work with both State and Driver (auto-wait)
|
|
138
169
|
|
|
139
170
|
RSpec::Matchers.define :have_button do |expected|
|
data/lib/tui_td/mcp/server.rb
CHANGED
|
@@ -424,6 +424,52 @@ module TUITD
|
|
|
424
424
|
required: ["name"],
|
|
425
425
|
},
|
|
426
426
|
},
|
|
427
|
+
{
|
|
428
|
+
name: "tui_record_start",
|
|
429
|
+
description: "Start recording the current TUI session as a video file (requires ffmpeg). Call before interacting with the TUI. Frames are captured via the screenshot pipeline and encoded incrementally.",
|
|
430
|
+
inputSchema: {
|
|
431
|
+
type: "object",
|
|
432
|
+
properties: {
|
|
433
|
+
path: {
|
|
434
|
+
type: "string",
|
|
435
|
+
description: "Output file path for the video (e.g., /tmp/session.mp4). Required.",
|
|
436
|
+
},
|
|
437
|
+
framerate: {
|
|
438
|
+
type: "integer",
|
|
439
|
+
description: "Frames per second (default: 30).",
|
|
440
|
+
default: 30,
|
|
441
|
+
},
|
|
442
|
+
codec: {
|
|
443
|
+
type: "string",
|
|
444
|
+
description: "Video codec: libx264, libx265, libvpx-vp9 (default: libx264).",
|
|
445
|
+
default: "libx264",
|
|
446
|
+
},
|
|
447
|
+
quality: {
|
|
448
|
+
type: "string",
|
|
449
|
+
enum: %w[high medium low],
|
|
450
|
+
description: "Quality preset: high (best), medium, low (smaller files). Default: high.",
|
|
451
|
+
default: "high",
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
required: ["path"],
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: "tui_record_stop",
|
|
459
|
+
description: "Stop video recording and finalize the video file. Returns the output path.",
|
|
460
|
+
inputSchema: {
|
|
461
|
+
type: "object",
|
|
462
|
+
properties: {},
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
name: "tui_record_status",
|
|
467
|
+
description: "Check whether video recording is currently active.",
|
|
468
|
+
inputSchema: {
|
|
469
|
+
type: "object",
|
|
470
|
+
properties: {},
|
|
471
|
+
},
|
|
472
|
+
},
|
|
427
473
|
{
|
|
428
474
|
name: "tui_close",
|
|
429
475
|
description: "Close the TUI application and clean up the PTY session. Call this when finished.",
|
|
@@ -461,6 +507,9 @@ module TUITD
|
|
|
461
507
|
when "tui_annotate_element" then call_tui_annotate_element(args)
|
|
462
508
|
when "tui_save_snapshot" then call_tui_save_snapshot(args)
|
|
463
509
|
when "tui_assert_snapshot" then call_tui_assert_snapshot(args)
|
|
510
|
+
when "tui_record_start" then call_tui_record_start(args)
|
|
511
|
+
when "tui_record_stop" then call_tui_record_stop
|
|
512
|
+
when "tui_record_status" then call_tui_record_status
|
|
464
513
|
when "tui_close" then call_tui_close
|
|
465
514
|
else
|
|
466
515
|
return error_response(id, -32_602, "Unknown tool: #{tool_name}")
|
|
@@ -783,6 +832,35 @@ module TUITD
|
|
|
783
832
|
end
|
|
784
833
|
end
|
|
785
834
|
|
|
835
|
+
def call_tui_record_start(args)
|
|
836
|
+
ensure_driver!
|
|
837
|
+
path = args["path"] or return "ERROR: 'path' argument is required"
|
|
838
|
+
framerate = args["framerate"] || 30
|
|
839
|
+
codec = args["codec"] || "libx264"
|
|
840
|
+
quality = args["quality"] || "high"
|
|
841
|
+
|
|
842
|
+
safe = safe_path(path, ext: "mp4")
|
|
843
|
+
@driver.start_recording(safe, framerate: framerate, codec: codec, quality: quality)
|
|
844
|
+
"OK: Recording started to #{safe} (#{framerate} fps, #{codec})"
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def call_tui_record_stop
|
|
848
|
+
ensure_driver!
|
|
849
|
+
return "No recording in progress" unless @driver.recording?
|
|
850
|
+
|
|
851
|
+
path = @driver.stop_recording
|
|
852
|
+
"OK: Recording saved to #{path}"
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def call_tui_record_status
|
|
856
|
+
ensure_driver!
|
|
857
|
+
if @driver.recording?
|
|
858
|
+
"Recording is active"
|
|
859
|
+
else
|
|
860
|
+
"Not recording"
|
|
861
|
+
end
|
|
862
|
+
end
|
|
863
|
+
|
|
786
864
|
def call_tui_close
|
|
787
865
|
@driver&.close
|
|
788
866
|
@driver = nil
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
3
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists, Metrics/ModuleLength
|
|
4
4
|
|
|
5
5
|
require "minitest"
|
|
6
6
|
|
|
@@ -234,6 +234,48 @@ module TUITD
|
|
|
234
234
|
assert(result, msg)
|
|
235
235
|
end
|
|
236
236
|
|
|
237
|
+
# --- Video Recording ---
|
|
238
|
+
|
|
239
|
+
# Start recording the TUI session as a video (requires ffmpeg).
|
|
240
|
+
# +actual+ must be a Driver.
|
|
241
|
+
def assert_record_start(actual, path, framerate: 30, codec: "libx264", quality: "high")
|
|
242
|
+
unless actual.respond_to?(:start_recording)
|
|
243
|
+
raise ArgumentError, "assert_record_start requires a Driver, got #{actual.class}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
result = actual.start_recording(path, framerate: framerate, codec: codec, quality: quality)
|
|
247
|
+
assert(result, "Recording failed to start")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Stop video recording and finalize the video file.
|
|
251
|
+
# +actual+ must be a Driver.
|
|
252
|
+
def assert_record_stop(actual)
|
|
253
|
+
unless actual.respond_to?(:stop_recording)
|
|
254
|
+
raise ArgumentError, "assert_record_stop requires a Driver, got #{actual.class}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
path = actual.stop_recording
|
|
258
|
+
assert(path, "No recording in progress")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Verify that recording is currently active.
|
|
262
|
+
def assert_recording(actual)
|
|
263
|
+
unless actual.respond_to?(:recording?)
|
|
264
|
+
raise ArgumentError, "assert_recording requires a Driver, got #{actual.class}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
assert(actual.recording?, "Expected recording to be active")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Verify that recording is NOT active.
|
|
271
|
+
def refute_recording(actual)
|
|
272
|
+
unless actual.respond_to?(:recording?)
|
|
273
|
+
raise ArgumentError, "refute_recording requires a Driver, got #{actual.class}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
refute(actual.recording?, "Expected recording NOT to be active")
|
|
277
|
+
end
|
|
278
|
+
|
|
237
279
|
# --- Snapshot ---
|
|
238
280
|
|
|
239
281
|
def assert_snapshot(actual, name, type: :text, wait: false, region: nil, ignore_rows: nil)
|
|
@@ -260,4 +302,4 @@ module TUITD
|
|
|
260
302
|
end
|
|
261
303
|
end
|
|
262
304
|
end
|
|
263
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
305
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists, Metrics/ModuleLength
|
data/lib/tui_td/screenshot.rb
CHANGED
|
@@ -215,6 +215,19 @@ module TUITD
|
|
|
215
215
|
end
|
|
216
216
|
|
|
217
217
|
def render(output_path)
|
|
218
|
+
image = build_image
|
|
219
|
+
image.save(output_path)
|
|
220
|
+
output_path
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Return the rendered PNG as a binary string (for in-memory use, e.g. video recording).
|
|
224
|
+
def to_blob
|
|
225
|
+
build_image.to_blob
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def build_image
|
|
218
231
|
width = @cols * CELL_W
|
|
219
232
|
height = @rows * CELL_H
|
|
220
233
|
image = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::BLACK)
|
|
@@ -230,13 +243,9 @@ module TUITD
|
|
|
230
243
|
end
|
|
231
244
|
|
|
232
245
|
draw_cursor(image)
|
|
233
|
-
|
|
234
|
-
image.save(output_path)
|
|
235
|
-
output_path
|
|
246
|
+
image
|
|
236
247
|
end
|
|
237
248
|
|
|
238
|
-
private
|
|
239
|
-
|
|
240
249
|
def render_cell(image, ri, ci, cell)
|
|
241
250
|
char = cell[:char] || cell["char"] || " "
|
|
242
251
|
fg = cell[:fg] || cell["fg"] || "default"
|
data/lib/tui_td/test_runner.rb
CHANGED
|
@@ -227,7 +227,37 @@ module TUITD
|
|
|
227
227
|
Result.new(step: action, passed: result.passed?, message: msg)
|
|
228
228
|
end
|
|
229
229
|
|
|
230
|
+
when "start_recording"
|
|
231
|
+
ensure_driver!(driver)
|
|
232
|
+
framerate = step[:framerate] || 30
|
|
233
|
+
codec = step[:codec] || "libx264"
|
|
234
|
+
path = safe_output_path(value.to_s, "mp4")
|
|
235
|
+
driver.start_recording(path, framerate: framerate, codec: codec)
|
|
236
|
+
Result.new(step: action, passed: true, message: "Recording started: #{path}")
|
|
237
|
+
|
|
238
|
+
when "stop_recording"
|
|
239
|
+
ensure_driver!(driver)
|
|
240
|
+
if driver.recording?
|
|
241
|
+
path = driver.stop_recording
|
|
242
|
+
Result.new(step: action, passed: true, message: "Recording saved: #{path}")
|
|
243
|
+
else
|
|
244
|
+
Result.new(step: action, passed: false, message: "No recording in progress")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
when "assert_recording"
|
|
248
|
+
ensure_driver!(driver)
|
|
249
|
+
expected = value == true || value.nil? # true/nil → assert active; false → assert NOT active
|
|
250
|
+
is_recording = driver.recording?
|
|
251
|
+
if is_recording == expected
|
|
252
|
+
state = expected ? "is active" : "is NOT active"
|
|
253
|
+
Result.new(step: action, passed: true, message: "Recording #{state} (as expected)")
|
|
254
|
+
else
|
|
255
|
+
state = is_recording ? "IS active (expected not)" : "is NOT active (expected active)"
|
|
256
|
+
Result.new(step: action, passed: false, message: "Recording #{state}")
|
|
257
|
+
end
|
|
258
|
+
|
|
230
259
|
when "close"
|
|
260
|
+
driver&.stop_recording if driver&.recording?
|
|
231
261
|
driver&.close
|
|
232
262
|
driver = nil
|
|
233
263
|
Result.new(step: action, passed: true, message: "Closed")
|
data/lib/tui_td/version.rb
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Naming/PredicateMethod
|
|
4
|
+
|
|
5
|
+
require "English"
|
|
6
|
+
|
|
7
|
+
module TUITD
|
|
8
|
+
# Records TUI sessions as video using ffmpeg.
|
|
9
|
+
#
|
|
10
|
+
# Frames are captured via the existing Screenshot pipeline and piped
|
|
11
|
+
# directly to ffmpeg's stdin as PNG images — no temporary frame files.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# recorder = VideoRecorder.new("session.mp4", driver: driver, framerate: 30)
|
|
15
|
+
# recorder.start
|
|
16
|
+
# # ... interact with TUI ...
|
|
17
|
+
# recorder.stop # => "session.mp4"
|
|
18
|
+
#
|
|
19
|
+
class VideoRecorder
|
|
20
|
+
QUALITY_CRF = {
|
|
21
|
+
"high" => 18,
|
|
22
|
+
"medium" => 23,
|
|
23
|
+
"low" => 28,
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
DEFAULT_QUALITY = "high"
|
|
27
|
+
DEFAULT_FRAMERATE = 30
|
|
28
|
+
DEFAULT_CODEC = "libx264"
|
|
29
|
+
|
|
30
|
+
attr_reader :output_path, :framerate, :codec, :quality
|
|
31
|
+
|
|
32
|
+
def initialize(output_path, driver:, framerate: DEFAULT_FRAMERATE,
|
|
33
|
+
codec: DEFAULT_CODEC, quality: DEFAULT_QUALITY)
|
|
34
|
+
raise Error, "ffmpeg not found. Install ffmpeg to use video recording." unless self.class.available?
|
|
35
|
+
|
|
36
|
+
@output_path = File.expand_path(output_path)
|
|
37
|
+
@driver = driver
|
|
38
|
+
@framerate = framerate
|
|
39
|
+
@codec = codec
|
|
40
|
+
@quality = quality
|
|
41
|
+
@ffmpeg_io = nil
|
|
42
|
+
@capture_thread = nil
|
|
43
|
+
@running = false
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@frame_interval = 1.0 / framerate
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check whether ffmpeg is available on the system.
|
|
49
|
+
def self.available?
|
|
50
|
+
system("which ffmpeg > /dev/null 2>&1")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Start recording. Spawns ffmpeg and begins frame capture.
|
|
54
|
+
def start
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
raise Error, "Recording already in progress" if @running
|
|
57
|
+
|
|
58
|
+
@ffmpeg_io = IO.popen(ffmpeg_command, "w", err: File::NULL)
|
|
59
|
+
@running = true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@capture_thread = Thread.new { capture_loop }
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Stop recording. Waits for ffmpeg to finalize and returns the output path.
|
|
67
|
+
def stop
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
return nil unless @running
|
|
70
|
+
|
|
71
|
+
@running = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@capture_thread&.join(5)
|
|
75
|
+
begin
|
|
76
|
+
@capture_thread&.kill
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
@capture_thread = nil
|
|
81
|
+
|
|
82
|
+
begin
|
|
83
|
+
@ffmpeg_io&.close_write
|
|
84
|
+
@ffmpeg_io&.close
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
@ffmpeg_io = nil
|
|
89
|
+
|
|
90
|
+
@output_path
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Is recording currently active?
|
|
94
|
+
def recording?
|
|
95
|
+
@mutex.synchronize { @running }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def ffmpeg_command
|
|
101
|
+
crf = QUALITY_CRF.fetch(@quality, QUALITY_CRF[DEFAULT_QUALITY])
|
|
102
|
+
ffmpeg_bin = TUITD.configuration.ffmpeg_path || "ffmpeg"
|
|
103
|
+
|
|
104
|
+
[
|
|
105
|
+
ffmpeg_bin,
|
|
106
|
+
"-y", # overwrite output
|
|
107
|
+
"-f", "image2pipe", # input format: pipe of images
|
|
108
|
+
"-vcodec", "png", # input codec
|
|
109
|
+
"-r", @framerate.to_s, # input framerate
|
|
110
|
+
"-i", "-", # read from stdin
|
|
111
|
+
"-vcodec", @codec, # output codec
|
|
112
|
+
"-crf", crf.to_s, # quality (lower = better)
|
|
113
|
+
"-pix_fmt", "yuv420p", # wide compatibility
|
|
114
|
+
@output_path,
|
|
115
|
+
]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def capture_loop
|
|
119
|
+
last_frame = nil
|
|
120
|
+
|
|
121
|
+
while recording?
|
|
122
|
+
loop_start = monotonic
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
state = @driver.state_data
|
|
126
|
+
screenshot = Screenshot.new(state)
|
|
127
|
+
png_blob = screenshot.to_blob
|
|
128
|
+
|
|
129
|
+
# Only write frames that differ from the last one (delta compression)
|
|
130
|
+
if last_frame.nil? || png_blob != last_frame
|
|
131
|
+
@ffmpeg_io&.write(png_blob)
|
|
132
|
+
@ffmpeg_io&.flush
|
|
133
|
+
last_frame = png_blob
|
|
134
|
+
end
|
|
135
|
+
rescue IOError, Errno::EPIPE
|
|
136
|
+
break # ffmpeg pipe closed
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
warn "[tui-td VideoRecorder] Frame capture error: #{e.class}: #{e.message}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
elapsed = monotonic - loop_start
|
|
142
|
+
sleep_time = @frame_interval - elapsed
|
|
143
|
+
sleep(sleep_time) if sleep_time.positive?
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def monotonic
|
|
148
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Naming/PredicateMethod
|
data/lib/tui_td.rb
CHANGED
|
@@ -16,10 +16,10 @@ require_relative "tui_td/state"
|
|
|
16
16
|
require_relative "tui_td/configuration"
|
|
17
17
|
require_relative "tui_td/snapshot"
|
|
18
18
|
require_relative "tui_td/screenshot"
|
|
19
|
+
require_relative "tui_td/video_recorder"
|
|
19
20
|
require_relative "tui_td/html_renderer"
|
|
20
21
|
require_relative "tui_td/test_runner"
|
|
21
22
|
require_relative "tui_td/selector"
|
|
22
|
-
require_relative "tui_td/minitest/assertions"
|
|
23
23
|
require_relative "tui_td/mcp/server"
|
|
24
24
|
require_relative "tui_td/cli"
|
|
25
25
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tui-td
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.21
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Haluk Durmus
|
|
@@ -71,14 +71,14 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 0.1.
|
|
74
|
+
version: 0.1.4
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 0.1.
|
|
81
|
+
version: 0.1.4
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: bundler-audit
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -93,6 +93,20 @@ dependencies:
|
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '0.9'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: minitest
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '5.15'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '5.15'
|
|
96
110
|
- !ruby/object:Gem::Dependency
|
|
97
111
|
name: pry
|
|
98
112
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -182,6 +196,7 @@ files:
|
|
|
182
196
|
- lib/tui_td/test_runner.rb
|
|
183
197
|
- lib/tui_td/unifont_glyphs.rb
|
|
184
198
|
- lib/tui_td/version.rb
|
|
199
|
+
- lib/tui_td/video_recorder.rb
|
|
185
200
|
homepage: https://github.com/vurte/tui-td
|
|
186
201
|
licenses:
|
|
187
202
|
- MIT
|