hexastack-cli 0.10.0__tar.gz → 0.11.0__tar.gz
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.
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/PKG-INFO +5 -4
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/README.md +3 -2
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/pyproject.toml +2 -2
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/pyproject.toml.orig +2 -2
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/infra/app.py +2 -2
- hexastack_cli-0.10.0/src/hexastack_cli/testing/__init__.py +0 -9
- hexastack_cli-0.10.0/src/hexastack_cli/testing/events.py +0 -16
- hexastack_cli-0.10.0/src/hexastack_cli/testing/narrator.py +0 -323
- hexastack_cli-0.10.0/src/hexastack_cli/testing/terminal.py +0 -329
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/__init__.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/adapters/__init__.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/adapters/presenter.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/adapters/routing.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/adapters/suggest.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/domain/__init__.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/domain/config.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/domain/options.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/infra/__init__.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/infra/autodiscovery.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/infra/bootstrap.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/infra/config.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/infra/decorators.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/infra/options.py +0 -0
- {hexastack_cli-0.10.0 → hexastack_cli-0.11.0}/src/hexastack_cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexastack-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Hexastack CLI presentation adapter with Typer and Rich
|
|
5
5
|
Author: Richard West
|
|
6
6
|
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
@@ -11,7 +11,7 @@ Requires-Dist: questionary>=2.1.0
|
|
|
11
11
|
Requires-Dist: rich>=13.8.0
|
|
12
12
|
Requires-Dist: typer>=0.27.1
|
|
13
13
|
Requires-Dist: playwright>=1.49.0 ; extra == 'testing'
|
|
14
|
-
Requires-Python: >=3.
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
15
|
Provides-Extra: testing
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
|
|
@@ -51,7 +51,6 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
hexastack_cli/
|
|
52
52
|
├── domain/ # CliContext, OutputFormat enum
|
|
53
53
|
├── adapters/ # Rich presenters, Typer command runners, TyperSuggest
|
|
54
|
-
├── testing/ # CliNarrator, TerminalEvent (demo video & VTT recorder)
|
|
55
54
|
└── infra/ # create_cli_app, CliBootstrapper (order=40), @cli_command, @cli_query, @cli_group, @feature_flag_command
|
|
56
55
|
```
|
|
57
56
|
|
|
@@ -189,8 +188,10 @@ In automated CI environments or redirected streams (`not sys.stdin.isatty()`), `
|
|
|
189
188
|
|
|
190
189
|
## 7. Automated Demo Recording & Video Slicing (`CliNarrator`)
|
|
191
190
|
|
|
191
|
+
> Automated narrative terminal recording and Playwright demo video rendering is provided by [`hexaqual.adapters.testing`](https://github.com/TheTrueSCU/hexaqual).
|
|
192
|
+
|
|
192
193
|
```python
|
|
193
|
-
from
|
|
194
|
+
from hexaqual.adapters.testing import CliNarrator
|
|
194
195
|
|
|
195
196
|
narrator = CliNarrator(record_video=True)
|
|
196
197
|
narrator.say("Scaffolding microservice...")
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
hexastack_cli/
|
|
35
35
|
├── domain/ # CliContext, OutputFormat enum
|
|
36
36
|
├── adapters/ # Rich presenters, Typer command runners, TyperSuggest
|
|
37
|
-
├── testing/ # CliNarrator, TerminalEvent (demo video & VTT recorder)
|
|
38
37
|
└── infra/ # create_cli_app, CliBootstrapper (order=40), @cli_command, @cli_query, @cli_group, @feature_flag_command
|
|
39
38
|
```
|
|
40
39
|
|
|
@@ -172,8 +171,10 @@ In automated CI environments or redirected streams (`not sys.stdin.isatty()`), `
|
|
|
172
171
|
|
|
173
172
|
## 7. Automated Demo Recording & Video Slicing (`CliNarrator`)
|
|
174
173
|
|
|
174
|
+
> Automated narrative terminal recording and Playwright demo video rendering is provided by [`hexaqual.adapters.testing`](https://github.com/TheTrueSCU/hexaqual).
|
|
175
|
+
|
|
175
176
|
```python
|
|
176
|
-
from
|
|
177
|
+
from hexaqual.adapters.testing import CliNarrator
|
|
177
178
|
|
|
178
179
|
narrator = CliNarrator(record_video=True)
|
|
179
180
|
narrator.say("Scaffolding microservice...")
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexastack-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.0"
|
|
4
4
|
description = "Hexastack CLI presentation adapter with Typer and Rich"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
7
|
-
requires-python = ">=3.
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
8
|
dependencies = [
|
|
9
9
|
"hexastack-core",
|
|
10
10
|
"hexastack-cqrs",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexastack-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.0"
|
|
4
4
|
description = "Hexastack CLI presentation adapter with Typer and Rich"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
7
7
|
authors = [
|
|
8
8
|
{ name = "Richard West", email = "dopplereffect.us@gmail.com" }
|
|
9
9
|
]
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"hexastack-core",
|
|
13
13
|
"hexastack-cqrs",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import typer
|
|
2
2
|
from rich.console import Console
|
|
3
|
-
from rodi import Container
|
|
4
3
|
|
|
5
4
|
from hexastack_cli.adapters.presenter import RichTerminalPresenter
|
|
6
5
|
from hexastack_cli.adapters.suggest import TyperSuggest
|
|
7
6
|
from hexastack_cli.infra.config import HexastackCliConfig
|
|
8
7
|
from hexastack_core.domain import Generic
|
|
8
|
+
from hexastack_core.ports.container import ContainerPort
|
|
9
9
|
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
10
10
|
from hexastack_cqrs.infra.registries.presenter import PresenterRegistry
|
|
11
11
|
|
|
@@ -16,7 +16,7 @@ __all__ = [
|
|
|
16
16
|
|
|
17
17
|
def create_cli_app(
|
|
18
18
|
config: HexastackCliConfig | None = None,
|
|
19
|
-
container:
|
|
19
|
+
container: ContainerPort | None = None,
|
|
20
20
|
pipeline: ExecutionPipeline | None = None,
|
|
21
21
|
console: Console | None = None,
|
|
22
22
|
) -> typer.Typer:
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
"""Domain models and data contracts for CLI testing and narrative demonstration."""
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
|
|
5
|
-
__all__ = [
|
|
6
|
-
"TerminalEvent",
|
|
7
|
-
]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass(frozen=True, slots=True)
|
|
11
|
-
class TerminalEvent:
|
|
12
|
-
"""A discrete timestamped event within a CLI demo recording session."""
|
|
13
|
-
|
|
14
|
-
event_type: str # "step" | "input" | "output"
|
|
15
|
-
payload: str
|
|
16
|
-
timestamp: float
|
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
"""Feature demo narrator and Playwright terminal video recorder for CLI commands."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import os
|
|
7
|
-
import time
|
|
8
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
from typer.testing import CliRunner, Result
|
|
13
|
-
|
|
14
|
-
from hexastack_cli.testing.events import TerminalEvent
|
|
15
|
-
from hexastack_core.utils.fs import atomic_write_text
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class CliNarrator:
|
|
19
|
-
"""Orchestrates structured narrative CLI execution, step recording, and video rendering.
|
|
20
|
-
|
|
21
|
-
Notes/Architectural Intent:
|
|
22
|
-
1. In standard CI/unit test mode, runs instantly with zero delay via in-memory CliRunner.
|
|
23
|
-
2. In demo recording mode (`RECORD_DEMO=1`), captures keystrokes, output tokens, and narrative steps,
|
|
24
|
-
and directly renders a fixed-window, auto-scrolling .webm video via Playwright.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
def __init__(
|
|
28
|
-
self,
|
|
29
|
-
app: Any,
|
|
30
|
-
output_name: str | None = None,
|
|
31
|
-
output_dir: Path | None = None,
|
|
32
|
-
width: int = 100,
|
|
33
|
-
height: int = 24,
|
|
34
|
-
bin_name: str | None = None,
|
|
35
|
-
) -> None:
|
|
36
|
-
"""Initialize CLI narrator attached to a Typer or Click application.
|
|
37
|
-
|
|
38
|
-
Args:
|
|
39
|
-
app: Target Typer application instance.
|
|
40
|
-
output_name: Base filename (without extension) for saving recordings.
|
|
41
|
-
output_dir: Target output directory for generated demo artifacts.
|
|
42
|
-
width: Terminal column width.
|
|
43
|
-
height: Terminal row height.
|
|
44
|
-
bin_name: Display binary name for recorded command lines (defaults to app name or 'hexastack').
|
|
45
|
-
|
|
46
|
-
Notes/Architectural Intent:
|
|
47
|
-
Configurable bin_name allows narrating commands from across the hexa- family of tools
|
|
48
|
-
(e.g., hexaflow, hexaqual, hq) with accurate terminal prompts in generated demo videos.
|
|
49
|
-
"""
|
|
50
|
-
self.app = app
|
|
51
|
-
self.output_name = output_name
|
|
52
|
-
self.output_dir = output_dir or Path("docs/assets/demos")
|
|
53
|
-
self.width = width
|
|
54
|
-
self.height = height
|
|
55
|
-
app_name = getattr(getattr(app, "info", None), "name", None)
|
|
56
|
-
self.bin_name = bin_name or app_name or "hexastack"
|
|
57
|
-
self.record_mode = os.environ.get("RECORD_DEMO") in ("1", "true", "True")
|
|
58
|
-
self.runner = CliRunner()
|
|
59
|
-
self.start_time: float = time.time()
|
|
60
|
-
self.events: list[TerminalEvent] = []
|
|
61
|
-
self.captions: list[tuple[float, float, str]] = []
|
|
62
|
-
self._current_step_start: float | None = None
|
|
63
|
-
self._current_step_text: str | None = None
|
|
64
|
-
self._segment_count: int = 0
|
|
65
|
-
self.segments: dict[str, dict[str, Path]] = {}
|
|
66
|
-
|
|
67
|
-
def step(self, title: str, *, record: bool = True) -> None:
|
|
68
|
-
"""Mark a new narrative step in the CLI demo recording.
|
|
69
|
-
|
|
70
|
-
Args:
|
|
71
|
-
title: Human-readable narrative explanation shown in subtitle banner.
|
|
72
|
-
record: Whether to emit the step subtitle and recording marker (defaults to True).
|
|
73
|
-
"""
|
|
74
|
-
if not record:
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
now = time.time() - self.start_time
|
|
78
|
-
if self._current_step_start is not None and self._current_step_text is not None:
|
|
79
|
-
self.captions.append(
|
|
80
|
-
(self._current_step_start, now, self._current_step_text)
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
self._current_step_start = now
|
|
84
|
-
self._current_step_text = title
|
|
85
|
-
|
|
86
|
-
if self.record_mode:
|
|
87
|
-
self.events.append(
|
|
88
|
-
TerminalEvent(event_type="step", payload=title, timestamp=now)
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
def run_command(
|
|
92
|
-
self,
|
|
93
|
-
args: list[str],
|
|
94
|
-
env: dict[str, str] | None = None,
|
|
95
|
-
input: str | None = None,
|
|
96
|
-
record: bool = True,
|
|
97
|
-
) -> Result:
|
|
98
|
-
"""Execute a CLI command within the active Typer application.
|
|
99
|
-
|
|
100
|
-
Args:
|
|
101
|
-
args: Command-line argument strings.
|
|
102
|
-
env: Optional environment variables.
|
|
103
|
-
input: Optional stdin input string.
|
|
104
|
-
record: Whether to record this command invocation and output (defaults to True).
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
CliRunner Result containing exit code, stdout, and stderr.
|
|
108
|
-
"""
|
|
109
|
-
cmd_str = f"{self.bin_name} {' '.join(args)}"
|
|
110
|
-
|
|
111
|
-
if self.record_mode and record:
|
|
112
|
-
# 1. Record input command string
|
|
113
|
-
now = time.time() - self.start_time
|
|
114
|
-
self.events.append(
|
|
115
|
-
TerminalEvent(
|
|
116
|
-
event_type="input",
|
|
117
|
-
payload=f"$ {cmd_str}\n",
|
|
118
|
-
timestamp=now,
|
|
119
|
-
)
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
# Execute command in-memory
|
|
123
|
-
# If an event loop is running in the active thread, offload execution to a thread pool
|
|
124
|
-
# to ensure commands invoking asyncio.run() (e.g. Hexaflow simulate/run) succeed without RuntimeError.
|
|
125
|
-
try:
|
|
126
|
-
asyncio.get_running_loop()
|
|
127
|
-
is_loop_running = True
|
|
128
|
-
except RuntimeError:
|
|
129
|
-
is_loop_running = False
|
|
130
|
-
|
|
131
|
-
if is_loop_running:
|
|
132
|
-
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
133
|
-
res = pool.submit(
|
|
134
|
-
self.runner.invoke, self.app, args, input=input, env=env
|
|
135
|
-
).result()
|
|
136
|
-
else:
|
|
137
|
-
res = self.runner.invoke(self.app, args, input=input, env=env)
|
|
138
|
-
|
|
139
|
-
if self.record_mode and record:
|
|
140
|
-
# 2. Record command output text
|
|
141
|
-
now = time.time() - self.start_time
|
|
142
|
-
output_text = res.stdout if res.stdout else ""
|
|
143
|
-
if output_text:
|
|
144
|
-
self.events.append(
|
|
145
|
-
TerminalEvent(
|
|
146
|
-
event_type="output",
|
|
147
|
-
payload=output_text,
|
|
148
|
-
timestamp=now,
|
|
149
|
-
)
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
return res
|
|
153
|
-
|
|
154
|
-
def checkpoint_segment(self, name: str | None = None) -> dict[str, Path]:
|
|
155
|
-
"""Flush the current recording segment to video and subtitle artifacts.
|
|
156
|
-
|
|
157
|
-
Slices recorded terminal events and narration captions since the last checkpoint,
|
|
158
|
-
renders the segment video (.webm) and WebVTT subtitle track (.vtt), and resets
|
|
159
|
-
the segment event buffer while preserving the underlying execution state
|
|
160
|
-
(Typer application context, filesystem, and databases).
|
|
161
|
-
|
|
162
|
-
Args:
|
|
163
|
-
name: Suffix or name for this segment (e.g. 'part1-scaffolding'). If omitted,
|
|
164
|
-
defaults to 'segment-<N>'.
|
|
165
|
-
|
|
166
|
-
Returns:
|
|
167
|
-
Dictionary containing paths to generated segment artifacts ('webm', 'vtt').
|
|
168
|
-
|
|
169
|
-
Notes/Architectural Intent:
|
|
170
|
-
Enables dogfooding multi-step tutorials (e.g. Hexaflow stages) to produce digestible
|
|
171
|
-
micro-demo videos per stage without incurring the overhead of running separate,
|
|
172
|
-
disconnected test harnesses.
|
|
173
|
-
"""
|
|
174
|
-
self._segment_count += 1
|
|
175
|
-
if name:
|
|
176
|
-
segment_name = (
|
|
177
|
-
f"{self.output_name}-{name}"
|
|
178
|
-
if self.output_name and not name.startswith(f"{self.output_name}-")
|
|
179
|
-
else name
|
|
180
|
-
)
|
|
181
|
-
else:
|
|
182
|
-
base = self.output_name or "cli-demo"
|
|
183
|
-
segment_name = f"{base}-segment-{self._segment_count}"
|
|
184
|
-
|
|
185
|
-
if not self.record_mode:
|
|
186
|
-
self.events = []
|
|
187
|
-
self.captions = []
|
|
188
|
-
self.start_time = time.time()
|
|
189
|
-
self._current_step_start = None
|
|
190
|
-
self._current_step_text = None
|
|
191
|
-
return {}
|
|
192
|
-
|
|
193
|
-
now = time.time() - self.start_time
|
|
194
|
-
if self._current_step_start is not None and self._current_step_text is not None:
|
|
195
|
-
self.captions.append(
|
|
196
|
-
(self._current_step_start, now, self._current_step_text)
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
out_dir = self.output_dir
|
|
200
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
-
artifacts: dict[str, Path] = {}
|
|
202
|
-
|
|
203
|
-
# 1. Normalize segment event timestamps so the segment video begins at 0.0s
|
|
204
|
-
if self.events:
|
|
205
|
-
base_ts = self.events[0].timestamp
|
|
206
|
-
segment_events = [
|
|
207
|
-
TerminalEvent(
|
|
208
|
-
event_type=e.event_type,
|
|
209
|
-
payload=e.payload,
|
|
210
|
-
timestamp=max(0.0, e.timestamp - base_ts),
|
|
211
|
-
)
|
|
212
|
-
for e in self.events
|
|
213
|
-
]
|
|
214
|
-
else:
|
|
215
|
-
segment_events = []
|
|
216
|
-
|
|
217
|
-
# 2. Render segment video and true-timing VTT via Playwright Rich Terminal
|
|
218
|
-
from hexastack_cli.testing.terminal import render_cli_demo_video
|
|
219
|
-
|
|
220
|
-
video_path = out_dir / f"{segment_name}.webm"
|
|
221
|
-
video_file, vtt_file = render_cli_demo_video(
|
|
222
|
-
events=segment_events,
|
|
223
|
-
output_path=video_path,
|
|
224
|
-
title=f"{self.bin_name} CLI — {segment_name}",
|
|
225
|
-
)
|
|
226
|
-
artifacts["webm"] = video_file
|
|
227
|
-
artifacts["vtt"] = vtt_file
|
|
228
|
-
self.segments[segment_name] = artifacts
|
|
229
|
-
|
|
230
|
-
# 3. Reset segment event buffer and start time for the next segment
|
|
231
|
-
self.events = []
|
|
232
|
-
self.captions = []
|
|
233
|
-
self.start_time = time.time()
|
|
234
|
-
self._current_step_start = None
|
|
235
|
-
self._current_step_text = None
|
|
236
|
-
|
|
237
|
-
return artifacts
|
|
238
|
-
|
|
239
|
-
def finish(self) -> dict[str, Path]:
|
|
240
|
-
"""Finalize the recording and render video and caption artifacts.
|
|
241
|
-
|
|
242
|
-
Returns:
|
|
243
|
-
Dictionary containing paths to generated artifact files (e.g. 'webm', 'vtt').
|
|
244
|
-
"""
|
|
245
|
-
if not self.record_mode:
|
|
246
|
-
return {}
|
|
247
|
-
|
|
248
|
-
if self.events:
|
|
249
|
-
if self._segment_count > 0:
|
|
250
|
-
self.checkpoint_segment("final")
|
|
251
|
-
all_artifacts: dict[str, Path] = {}
|
|
252
|
-
for seg_name, arts in self.segments.items():
|
|
253
|
-
for k, v in arts.items():
|
|
254
|
-
all_artifacts[f"{seg_name}_{k}"] = v
|
|
255
|
-
if self.segments:
|
|
256
|
-
latest_seg = list(self.segments.values())[-1]
|
|
257
|
-
all_artifacts.update(latest_seg)
|
|
258
|
-
return all_artifacts
|
|
259
|
-
|
|
260
|
-
now = time.time() - self.start_time
|
|
261
|
-
if (
|
|
262
|
-
self._current_step_start is not None
|
|
263
|
-
and self._current_step_text is not None
|
|
264
|
-
):
|
|
265
|
-
self.captions.append(
|
|
266
|
-
(self._current_step_start, now, self._current_step_text)
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
out_dir = self.output_dir
|
|
270
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
271
|
-
base_name = self.output_name or "cli-demo"
|
|
272
|
-
artifacts: dict[str, Path] = {}
|
|
273
|
-
|
|
274
|
-
# 1. Export WebVTT (.vtt) synchronized narration subtitles
|
|
275
|
-
vtt_lines = ["WEBVTT", ""]
|
|
276
|
-
for idx, (start_s, end_s, text) in enumerate(self.captions, start=1):
|
|
277
|
-
vtt_lines.append(str(idx))
|
|
278
|
-
vtt_lines.append(
|
|
279
|
-
f"{self._format_vtt_time(start_s)} --> {self._format_vtt_time(end_s)}"
|
|
280
|
-
)
|
|
281
|
-
vtt_lines.append(text)
|
|
282
|
-
vtt_lines.append("")
|
|
283
|
-
|
|
284
|
-
vtt_path = out_dir / f"{base_name}.vtt"
|
|
285
|
-
atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
|
|
286
|
-
artifacts["vtt"] = vtt_path
|
|
287
|
-
|
|
288
|
-
# 2. Render directly to .webm video via Playwright Rich Terminal
|
|
289
|
-
from hexastack_cli.testing.terminal import render_cli_demo_video
|
|
290
|
-
|
|
291
|
-
video_path = out_dir / f"{base_name}.webm"
|
|
292
|
-
render_cli_demo_video(
|
|
293
|
-
events=self.events,
|
|
294
|
-
output_path=video_path,
|
|
295
|
-
title=f"{self.bin_name} CLI — {base_name}",
|
|
296
|
-
)
|
|
297
|
-
artifacts["webm"] = video_path
|
|
298
|
-
|
|
299
|
-
return artifacts
|
|
300
|
-
|
|
301
|
-
all_artifacts: dict[str, Path] = {}
|
|
302
|
-
for seg_name, arts in self.segments.items():
|
|
303
|
-
for k, v in arts.items():
|
|
304
|
-
all_artifacts[f"{seg_name}_{k}"] = v
|
|
305
|
-
if self.segments:
|
|
306
|
-
latest_seg = list(self.segments.values())[-1]
|
|
307
|
-
all_artifacts.update(latest_seg)
|
|
308
|
-
return all_artifacts
|
|
309
|
-
|
|
310
|
-
@staticmethod
|
|
311
|
-
def _format_vtt_time(seconds: float) -> str:
|
|
312
|
-
"""Format seconds float into standard WebVTT timestamp (HH:MM:SS.mmm)."""
|
|
313
|
-
hrs = int(seconds // 3600)
|
|
314
|
-
mins = int((seconds % 3600) // 60)
|
|
315
|
-
secs = int(seconds % 60)
|
|
316
|
-
millis = int(round((seconds - int(seconds)) * 1000))
|
|
317
|
-
return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
__all__ = [
|
|
321
|
-
"CliNarrator",
|
|
322
|
-
"TerminalEvent",
|
|
323
|
-
]
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
"""Rich HTML Terminal view and Playwright video renderer for CLI demo recordings."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import concurrent.futures
|
|
6
|
-
import html
|
|
7
|
-
import json
|
|
8
|
-
import re
|
|
9
|
-
import shutil
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
from hexastack_cli.testing.events import TerminalEvent
|
|
13
|
-
from hexastack_core.domain.exceptions import MissingDependencyError
|
|
14
|
-
from hexastack_core.utils.fs import atomic_write_text
|
|
15
|
-
|
|
16
|
-
TERMINAL_HTML_TEMPLATE = """<!DOCTYPE html>
|
|
17
|
-
<html lang="en">
|
|
18
|
-
<head>
|
|
19
|
-
<meta charset="UTF-8">
|
|
20
|
-
<title>{title}</title>
|
|
21
|
-
<style>
|
|
22
|
-
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
23
|
-
body {{
|
|
24
|
-
background: #0d1117;
|
|
25
|
-
color: #e6edf3;
|
|
26
|
-
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
|
|
27
|
-
display: flex;
|
|
28
|
-
align-items: center;
|
|
29
|
-
justify-content: center;
|
|
30
|
-
height: 100vh;
|
|
31
|
-
overflow: hidden;
|
|
32
|
-
padding: 40px;
|
|
33
|
-
}}
|
|
34
|
-
.window {{
|
|
35
|
-
background: #161b22;
|
|
36
|
-
border-radius: 12px;
|
|
37
|
-
box-shadow: 0 20px 50px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1);
|
|
38
|
-
width: 1080px;
|
|
39
|
-
height: 560px;
|
|
40
|
-
max-height: 560px;
|
|
41
|
-
display: flex;
|
|
42
|
-
flex-direction: column;
|
|
43
|
-
overflow: hidden;
|
|
44
|
-
}}
|
|
45
|
-
.titlebar {{
|
|
46
|
-
background: #21262d;
|
|
47
|
-
padding: 12px 16px;
|
|
48
|
-
display: flex;
|
|
49
|
-
align-items: center;
|
|
50
|
-
border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
51
|
-
flex-shrink: 0;
|
|
52
|
-
}}
|
|
53
|
-
.dots {{
|
|
54
|
-
display: flex;
|
|
55
|
-
gap: 8px;
|
|
56
|
-
}}
|
|
57
|
-
.dot {{
|
|
58
|
-
width: 12px;
|
|
59
|
-
height: 12px;
|
|
60
|
-
border-radius: 50%;
|
|
61
|
-
}}
|
|
62
|
-
.dot-red {{ background: #ff5f56; }}
|
|
63
|
-
.dot-yellow {{ background: #ffbd2e; }}
|
|
64
|
-
.dot-green {{ background: #27c93f; }}
|
|
65
|
-
.title {{
|
|
66
|
-
flex: 1;
|
|
67
|
-
text-align: center;
|
|
68
|
-
color: #8b949e;
|
|
69
|
-
font-size: 13px;
|
|
70
|
-
font-weight: 500;
|
|
71
|
-
margin-right: 48px;
|
|
72
|
-
}}
|
|
73
|
-
.content {{
|
|
74
|
-
padding: 24px;
|
|
75
|
-
font-size: 15px;
|
|
76
|
-
line-height: 1.6;
|
|
77
|
-
flex: 1;
|
|
78
|
-
overflow-y: auto;
|
|
79
|
-
white-space: pre-wrap;
|
|
80
|
-
word-break: break-word;
|
|
81
|
-
scroll-behavior: smooth;
|
|
82
|
-
}}
|
|
83
|
-
.content::-webkit-scrollbar {{
|
|
84
|
-
display: none;
|
|
85
|
-
}}
|
|
86
|
-
.content {{
|
|
87
|
-
-ms-overflow-style: none;
|
|
88
|
-
scrollbar-width: none;
|
|
89
|
-
}}
|
|
90
|
-
.prompt {{ color: #7ee787; font-weight: bold; }}
|
|
91
|
-
.command {{ color: #79c0ff; font-weight: bold; }}
|
|
92
|
-
.step-banner {{
|
|
93
|
-
position: fixed;
|
|
94
|
-
bottom: 32px;
|
|
95
|
-
left: 50%;
|
|
96
|
-
transform: translateX(-50%);
|
|
97
|
-
background: rgba(15, 23, 42, 0.92);
|
|
98
|
-
color: #ffffff;
|
|
99
|
-
padding: 12px 24px;
|
|
100
|
-
border-radius: 8px;
|
|
101
|
-
font-size: 17px;
|
|
102
|
-
font-family: system-ui, -apple-system, sans-serif;
|
|
103
|
-
font-weight: 500;
|
|
104
|
-
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
|
105
|
-
border: 1px solid rgba(255,255,255,0.15);
|
|
106
|
-
backdrop-filter: blur(8px);
|
|
107
|
-
z-index: 9999;
|
|
108
|
-
transition: opacity 0.2s ease-in-out;
|
|
109
|
-
text-align: center;
|
|
110
|
-
max-width: 80%;
|
|
111
|
-
}}
|
|
112
|
-
.cursor {{
|
|
113
|
-
display: inline-block;
|
|
114
|
-
width: 8px;
|
|
115
|
-
height: 16px;
|
|
116
|
-
background: #58a6ff;
|
|
117
|
-
vertical-align: text-bottom;
|
|
118
|
-
animation: blink 1s step-end infinite;
|
|
119
|
-
}}
|
|
120
|
-
@keyframes blink {{ 50% {{ opacity: 0; }} }}
|
|
121
|
-
</style>
|
|
122
|
-
</head>
|
|
123
|
-
<body>
|
|
124
|
-
<div class="window">
|
|
125
|
-
<div class="titlebar">
|
|
126
|
-
<div class="dots">
|
|
127
|
-
<div class="dot dot-red"></div>
|
|
128
|
-
<div class="dot dot-yellow"></div>
|
|
129
|
-
<div class="dot dot-green"></div>
|
|
130
|
-
</div>
|
|
131
|
-
<div class="title">{title} — bash</div>
|
|
132
|
-
</div>
|
|
133
|
-
<div class="content" id="terminal-content"><span class="cursor"></span></div>
|
|
134
|
-
</div>
|
|
135
|
-
<div class="step-banner" id="banner" style="display: none;"></div>
|
|
136
|
-
</body>
|
|
137
|
-
</html>
|
|
138
|
-
"""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _strip_ansi(text: str) -> str:
|
|
142
|
-
"""Remove ANSI color and escape sequences from text."""
|
|
143
|
-
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
144
|
-
return ansi_escape.sub("", text)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def _render_in_clean_thread(
|
|
148
|
-
events: list[TerminalEvent],
|
|
149
|
-
output_path: Path,
|
|
150
|
-
title: str,
|
|
151
|
-
width: int,
|
|
152
|
-
height: int,
|
|
153
|
-
) -> tuple[Path, Path]:
|
|
154
|
-
"""Execute Playwright video recording in a fresh thread with its own event loop."""
|
|
155
|
-
try:
|
|
156
|
-
from playwright.sync_api import sync_playwright
|
|
157
|
-
except ImportError as e:
|
|
158
|
-
raise MissingDependencyError(
|
|
159
|
-
"Playwright is required to record CLI demos into .webm video. "
|
|
160
|
-
"Install with 'pip install hexastack-cli[testing]' or 'pip install playwright'."
|
|
161
|
-
) from e
|
|
162
|
-
|
|
163
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
-
temp_dir = output_path.parent / ".temp_video"
|
|
165
|
-
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
166
|
-
|
|
167
|
-
html_content = TERMINAL_HTML_TEMPLATE.format(title=html.escape(title))
|
|
168
|
-
vtt_captions: list[tuple[float, float, str]] = []
|
|
169
|
-
current_step_start: float | None = None
|
|
170
|
-
current_step_text: str | None = None
|
|
171
|
-
|
|
172
|
-
with sync_playwright() as p:
|
|
173
|
-
try:
|
|
174
|
-
browser = p.chromium.launch(headless=True)
|
|
175
|
-
except Exception:
|
|
176
|
-
# If Playwright browser binaries are not installed, create placeholder video artifact
|
|
177
|
-
output_path.write_bytes(b"")
|
|
178
|
-
return output_path, output_path.with_suffix(".vtt")
|
|
179
|
-
|
|
180
|
-
context = browser.new_context(
|
|
181
|
-
viewport={"width": width, "height": height},
|
|
182
|
-
record_video_dir=str(temp_dir),
|
|
183
|
-
record_video_size={"width": width, "height": height},
|
|
184
|
-
)
|
|
185
|
-
page = context.new_page()
|
|
186
|
-
page.set_content(html_content)
|
|
187
|
-
page.wait_for_timeout(500)
|
|
188
|
-
start_playback_time = 0.5
|
|
189
|
-
|
|
190
|
-
current_time = start_playback_time
|
|
191
|
-
|
|
192
|
-
for ev in events:
|
|
193
|
-
if ev.event_type == "step":
|
|
194
|
-
if current_step_start is not None and current_step_text is not None:
|
|
195
|
-
vtt_captions.append(
|
|
196
|
-
(current_step_start, current_time, current_step_text)
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
current_step_start = current_time
|
|
200
|
-
current_step_text = ev.payload
|
|
201
|
-
|
|
202
|
-
json_text = json.dumps(ev.payload)
|
|
203
|
-
page.evaluate(
|
|
204
|
-
f"""(() => {{
|
|
205
|
-
const banner = document.getElementById('banner');
|
|
206
|
-
if (banner) {{
|
|
207
|
-
banner.innerText = {json_text};
|
|
208
|
-
banner.style.display = 'block';
|
|
209
|
-
}}
|
|
210
|
-
}})();"""
|
|
211
|
-
)
|
|
212
|
-
page.wait_for_timeout(600)
|
|
213
|
-
current_time += 0.6
|
|
214
|
-
|
|
215
|
-
elif ev.event_type == "input":
|
|
216
|
-
char_str = html.escape(ev.payload)
|
|
217
|
-
if char_str == "\n":
|
|
218
|
-
char_str = "<br/>"
|
|
219
|
-
json_html = json.dumps(char_str)
|
|
220
|
-
page.evaluate(
|
|
221
|
-
f"""(() => {{
|
|
222
|
-
const terminal = document.getElementById('terminal-content');
|
|
223
|
-
const cursor = terminal.querySelector('.cursor');
|
|
224
|
-
const span = document.createElement('span');
|
|
225
|
-
span.className = 'command';
|
|
226
|
-
span.innerHTML = {json_html};
|
|
227
|
-
terminal.insertBefore(span, cursor);
|
|
228
|
-
terminal.scrollTop = terminal.scrollHeight;
|
|
229
|
-
}})();"""
|
|
230
|
-
)
|
|
231
|
-
page.wait_for_timeout(35)
|
|
232
|
-
current_time += 0.035
|
|
233
|
-
|
|
234
|
-
elif ev.event_type == "output":
|
|
235
|
-
clean_text = _strip_ansi(ev.payload)
|
|
236
|
-
escaped_output = html.escape(clean_text).replace("\n", "<br/>")
|
|
237
|
-
json_output = json.dumps(escaped_output)
|
|
238
|
-
page.evaluate(
|
|
239
|
-
f"""(() => {{
|
|
240
|
-
const terminal = document.getElementById('terminal-content');
|
|
241
|
-
const cursor = terminal.querySelector('.cursor');
|
|
242
|
-
const div = document.createElement('div');
|
|
243
|
-
div.style.color = '#c9d1d9';
|
|
244
|
-
div.style.margin = '4px 0 12px 0';
|
|
245
|
-
div.innerHTML = {json_output};
|
|
246
|
-
terminal.insertBefore(div, cursor);
|
|
247
|
-
terminal.scrollTop = terminal.scrollHeight;
|
|
248
|
-
}})();"""
|
|
249
|
-
)
|
|
250
|
-
page.wait_for_timeout(1000)
|
|
251
|
-
current_time += 1.0
|
|
252
|
-
|
|
253
|
-
if current_step_start is not None and current_step_text is not None:
|
|
254
|
-
vtt_captions.append(
|
|
255
|
-
(current_step_start, current_time + 1.5, current_step_text)
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
page.wait_for_timeout(1500)
|
|
259
|
-
video = page.video
|
|
260
|
-
page.close()
|
|
261
|
-
|
|
262
|
-
if video:
|
|
263
|
-
video.save_as(output_path)
|
|
264
|
-
|
|
265
|
-
context.close()
|
|
266
|
-
browser.close()
|
|
267
|
-
|
|
268
|
-
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
269
|
-
|
|
270
|
-
# Export synchronized WebVTT subtitles with true video timestamps
|
|
271
|
-
vtt_lines = ["WEBVTT", ""]
|
|
272
|
-
for idx, (start_s, end_s, text) in enumerate(vtt_captions, start=1):
|
|
273
|
-
vtt_lines.append(str(idx))
|
|
274
|
-
vtt_lines.append(f"{_format_vtt_time(start_s)} --> {_format_vtt_time(end_s)}")
|
|
275
|
-
vtt_lines.append(text)
|
|
276
|
-
vtt_lines.append("")
|
|
277
|
-
|
|
278
|
-
vtt_path = output_path.with_suffix(".vtt")
|
|
279
|
-
atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
|
|
280
|
-
|
|
281
|
-
return output_path, vtt_path
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def render_cli_demo_video(
|
|
285
|
-
events: list[TerminalEvent],
|
|
286
|
-
output_path: Path,
|
|
287
|
-
title: str = "Hexastack CLI",
|
|
288
|
-
width: int = 1280,
|
|
289
|
-
height: int = 720,
|
|
290
|
-
) -> tuple[Path, Path]:
|
|
291
|
-
"""Render a sequence of TerminalEvents into synchronized .webm video and .vtt subtitle track.
|
|
292
|
-
|
|
293
|
-
Args:
|
|
294
|
-
events: Chronological sequence of input, output, and step narrative events.
|
|
295
|
-
output_path: Destination Path for .webm video file.
|
|
296
|
-
title: Window title text for terminal banner.
|
|
297
|
-
width: Video frame width in pixels.
|
|
298
|
-
height: Video frame height in pixels.
|
|
299
|
-
|
|
300
|
-
Returns:
|
|
301
|
-
Tuple of (video_path, vtt_path).
|
|
302
|
-
|
|
303
|
-
Raises:
|
|
304
|
-
MissingDependencyError: If `playwright` is not installed.
|
|
305
|
-
"""
|
|
306
|
-
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
307
|
-
future = executor.submit(
|
|
308
|
-
_render_in_clean_thread,
|
|
309
|
-
events=events,
|
|
310
|
-
output_path=output_path,
|
|
311
|
-
title=title,
|
|
312
|
-
width=width,
|
|
313
|
-
height=height,
|
|
314
|
-
)
|
|
315
|
-
return future.result()
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def _format_vtt_time(seconds: float) -> str:
|
|
319
|
-
"""Format seconds into WebVTT timestamp (HH:MM:SS.mmm)."""
|
|
320
|
-
hrs = int(seconds // 3600)
|
|
321
|
-
mins = int((seconds % 3600) // 60)
|
|
322
|
-
secs = int(seconds % 60)
|
|
323
|
-
millis = int(round((seconds - int(seconds)) * 1000))
|
|
324
|
-
return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
__all__ = [
|
|
328
|
-
"render_cli_demo_video",
|
|
329
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|