devrel-origin 0.2.14__py3-none-any.whl
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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser recorder — manages Playwright browser for screen recording.
|
|
3
|
+
Opens URLs, executes user actions (click, type, scroll, wait),
|
|
4
|
+
and captures screen recordings at 1920x1080 resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
VALID_ACTION_TYPES = {"click", "type", "wait", "scroll", "hover"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class BrowserAction:
|
|
20
|
+
"""A single browser interaction."""
|
|
21
|
+
|
|
22
|
+
action_type: str
|
|
23
|
+
selector: Optional[str] = None
|
|
24
|
+
value: Optional[str] = None
|
|
25
|
+
delay: float = 0.5
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BrowserRecorder:
|
|
29
|
+
"""Records browser sessions using Playwright's native video recording."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
output_dir: Path,
|
|
34
|
+
width: int = 1920,
|
|
35
|
+
height: int = 1080,
|
|
36
|
+
slow_mo: int = 200,
|
|
37
|
+
):
|
|
38
|
+
self.output_dir = Path(output_dir)
|
|
39
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
self.width = width
|
|
41
|
+
self.height = height
|
|
42
|
+
self.slow_mo = slow_mo
|
|
43
|
+
|
|
44
|
+
async def record_step(
|
|
45
|
+
self,
|
|
46
|
+
url: str,
|
|
47
|
+
actions: list[BrowserAction],
|
|
48
|
+
filename_prefix: str,
|
|
49
|
+
duration_hint: float = 10.0,
|
|
50
|
+
) -> Path:
|
|
51
|
+
from playwright.async_api import async_playwright
|
|
52
|
+
|
|
53
|
+
output_path = self.output_dir / f"{filename_prefix}.webm"
|
|
54
|
+
logger.info(f"Recording step: {filename_prefix} -> {url}")
|
|
55
|
+
|
|
56
|
+
async with async_playwright() as p:
|
|
57
|
+
browser = await p.chromium.launch(headless=True, slow_mo=self.slow_mo)
|
|
58
|
+
context = await browser.new_context(
|
|
59
|
+
viewport={"width": self.width, "height": self.height},
|
|
60
|
+
record_video_dir=str(self.output_dir),
|
|
61
|
+
record_video_size={"width": self.width, "height": self.height},
|
|
62
|
+
)
|
|
63
|
+
page = await context.new_page()
|
|
64
|
+
try:
|
|
65
|
+
await page.goto(url, wait_until="networkidle", timeout=30000)
|
|
66
|
+
for action in actions:
|
|
67
|
+
await self._execute_action(page, action)
|
|
68
|
+
remaining = max(0, duration_hint - len(actions) * 0.5)
|
|
69
|
+
if remaining > 0:
|
|
70
|
+
await asyncio.sleep(remaining)
|
|
71
|
+
finally:
|
|
72
|
+
# Capture video path BEFORE closing context — page.video
|
|
73
|
+
# becomes invalid after context.close() finalizes the recording.
|
|
74
|
+
video_path = await page.video.path() if page.video else None
|
|
75
|
+
await context.close()
|
|
76
|
+
await browser.close()
|
|
77
|
+
if video_path and Path(video_path).exists():
|
|
78
|
+
Path(video_path).rename(output_path)
|
|
79
|
+
|
|
80
|
+
logger.info(f"Step recorded: {output_path}")
|
|
81
|
+
return output_path
|
|
82
|
+
|
|
83
|
+
async def _execute_action(self, page, action: BrowserAction) -> None:
|
|
84
|
+
try:
|
|
85
|
+
if action.action_type == "click" and action.selector:
|
|
86
|
+
await page.click(action.selector, timeout=5000)
|
|
87
|
+
elif action.action_type == "type" and action.selector and action.value:
|
|
88
|
+
await page.fill(action.selector, action.value)
|
|
89
|
+
elif action.action_type == "scroll" and action.selector:
|
|
90
|
+
await page.evaluate(
|
|
91
|
+
f"document.querySelector('{action.selector}')"
|
|
92
|
+
f"?.scrollIntoView({{behavior: 'smooth'}})"
|
|
93
|
+
)
|
|
94
|
+
elif action.action_type == "hover" and action.selector:
|
|
95
|
+
await page.hover(action.selector, timeout=5000)
|
|
96
|
+
elif action.action_type == "wait":
|
|
97
|
+
await asyncio.sleep(action.delay)
|
|
98
|
+
if action.action_type != "wait":
|
|
99
|
+
await asyncio.sleep(action.delay)
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
logger.warning(f"Action failed ({action.action_type} {action.selector}): {exc}")
|
|
102
|
+
|
|
103
|
+
def parse_actions(self, action_dicts: list[dict]) -> list[BrowserAction]:
|
|
104
|
+
actions = []
|
|
105
|
+
for d in action_dicts:
|
|
106
|
+
action_type = d.get("type", "")
|
|
107
|
+
if action_type not in VALID_ACTION_TYPES:
|
|
108
|
+
logger.warning(f"Skipping unknown action type: {action_type}")
|
|
109
|
+
continue
|
|
110
|
+
actions.append(
|
|
111
|
+
BrowserAction(
|
|
112
|
+
action_type=action_type,
|
|
113
|
+
selector=d.get("selector"),
|
|
114
|
+
value=d.get("value"),
|
|
115
|
+
delay=d.get("delay", 0.5),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return actions
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Desktop recorder — captures desktop app sessions using FFmpeg screen recording
|
|
3
|
+
and PyAutoGUI for mouse/keyboard automation.
|
|
4
|
+
|
|
5
|
+
Use this when the tutorial target is a native desktop app rather than a browser.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import platform
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
VALID_DESKTOP_ACTIONS = {"click", "type", "wait", "scroll", "move", "hotkey", "screenshot"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DesktopAction:
|
|
24
|
+
"""A single desktop automation action."""
|
|
25
|
+
|
|
26
|
+
action_type: str # "click", "type", "wait", "scroll", "move", "hotkey", "screenshot"
|
|
27
|
+
x: Optional[int] = None # screen x coordinate
|
|
28
|
+
y: Optional[int] = None # screen y coordinate
|
|
29
|
+
value: Optional[str] = None # text to type or hotkey combo (e.g. "command+c")
|
|
30
|
+
delay: float = 0.5 # seconds to wait after action
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_ffmpeg_input_format() -> tuple[str, str]:
|
|
34
|
+
"""Return FFmpeg input format and device for the current platform."""
|
|
35
|
+
system = platform.system()
|
|
36
|
+
if system == "Darwin":
|
|
37
|
+
return "avfoundation", "1:none" # screen:audio — "1" is main display
|
|
38
|
+
elif system == "Linux":
|
|
39
|
+
return "x11grab", ":0.0"
|
|
40
|
+
elif system == "Windows":
|
|
41
|
+
return "gdigrab", "desktop"
|
|
42
|
+
raise RuntimeError(f"Unsupported platform for screen recording: {system}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DesktopRecorder:
|
|
46
|
+
"""Records desktop sessions using FFmpeg screen capture + PyAutoGUI automation."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
output_dir: Path,
|
|
51
|
+
width: int = 1920,
|
|
52
|
+
height: int = 1080,
|
|
53
|
+
framerate: int = 30,
|
|
54
|
+
):
|
|
55
|
+
self.output_dir = Path(output_dir)
|
|
56
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
self.width = width
|
|
58
|
+
self.height = height
|
|
59
|
+
self.framerate = framerate
|
|
60
|
+
self._has_ffmpeg = shutil.which("ffmpeg") is not None
|
|
61
|
+
self._has_pyautogui = self._check_pyautogui()
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _check_pyautogui() -> bool:
|
|
65
|
+
try:
|
|
66
|
+
import pyautogui # noqa: F401
|
|
67
|
+
|
|
68
|
+
return True
|
|
69
|
+
except ImportError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
async def record_step(
|
|
73
|
+
self,
|
|
74
|
+
actions: list[DesktopAction],
|
|
75
|
+
filename_prefix: str,
|
|
76
|
+
duration_hint: float = 10.0,
|
|
77
|
+
app_name: Optional[str] = None,
|
|
78
|
+
) -> Path:
|
|
79
|
+
"""Record a desktop step with FFmpeg screen capture.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
actions: List of desktop actions to perform during recording.
|
|
83
|
+
filename_prefix: Output filename prefix.
|
|
84
|
+
duration_hint: Minimum recording duration in seconds.
|
|
85
|
+
app_name: Optional app name to bring to foreground before recording.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Path to recorded .mp4 file.
|
|
89
|
+
"""
|
|
90
|
+
if not self._has_ffmpeg:
|
|
91
|
+
raise RuntimeError("FFmpeg not found — required for desktop recording")
|
|
92
|
+
|
|
93
|
+
output_path = self.output_dir / f"{filename_prefix}.mp4"
|
|
94
|
+
|
|
95
|
+
# Bring app to foreground if specified
|
|
96
|
+
if app_name:
|
|
97
|
+
await self._activate_app(app_name)
|
|
98
|
+
await asyncio.sleep(1.0) # wait for app to come forward
|
|
99
|
+
|
|
100
|
+
# Start FFmpeg recording in background
|
|
101
|
+
ffmpeg_process = await self._start_recording(output_path, duration_hint)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
# Wait a moment for recording to begin
|
|
105
|
+
await asyncio.sleep(0.5)
|
|
106
|
+
|
|
107
|
+
# Execute actions
|
|
108
|
+
for action in actions:
|
|
109
|
+
await self._execute_action(action)
|
|
110
|
+
|
|
111
|
+
# Hold for remaining duration
|
|
112
|
+
elapsed = sum(a.delay for a in actions) + 0.5
|
|
113
|
+
remaining = max(0, duration_hint - elapsed)
|
|
114
|
+
if remaining > 0:
|
|
115
|
+
await asyncio.sleep(remaining)
|
|
116
|
+
|
|
117
|
+
finally:
|
|
118
|
+
# Stop recording
|
|
119
|
+
ffmpeg_process.terminate()
|
|
120
|
+
_stdout, stderr = await ffmpeg_process.communicate()
|
|
121
|
+
# FFmpeg returns non-zero when terminated mid-encode (rc=255 on SIGTERM
|
|
122
|
+
# is expected); log only when we have a genuine error code with stderr.
|
|
123
|
+
if ffmpeg_process.returncode not in (0, -15, 255) and stderr:
|
|
124
|
+
logger.error(
|
|
125
|
+
"Desktop recorder FFmpeg failed (rc=%d). stderr:\n%s",
|
|
126
|
+
ffmpeg_process.returncode,
|
|
127
|
+
stderr.decode(errors="replace"),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
logger.info(f"Desktop step recorded: {output_path}")
|
|
131
|
+
return output_path
|
|
132
|
+
|
|
133
|
+
async def _start_recording(
|
|
134
|
+
self, output_path: Path, duration: float
|
|
135
|
+
) -> asyncio.subprocess.Process:
|
|
136
|
+
"""Start FFmpeg screen recording as a background process."""
|
|
137
|
+
input_format, input_device = _get_ffmpeg_input_format()
|
|
138
|
+
|
|
139
|
+
cmd = [
|
|
140
|
+
"ffmpeg",
|
|
141
|
+
"-y",
|
|
142
|
+
"-f",
|
|
143
|
+
input_format,
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# Platform-specific options
|
|
147
|
+
if input_format == "avfoundation":
|
|
148
|
+
cmd.extend(["-framerate", str(self.framerate)])
|
|
149
|
+
cmd.extend(["-video_size", f"{self.width}x{self.height}"])
|
|
150
|
+
cmd.extend(["-capture_cursor", "1"])
|
|
151
|
+
elif input_format == "x11grab":
|
|
152
|
+
cmd.extend(["-framerate", str(self.framerate)])
|
|
153
|
+
cmd.extend(["-video_size", f"{self.width}x{self.height}"])
|
|
154
|
+
elif input_format == "gdigrab":
|
|
155
|
+
cmd.extend(["-framerate", str(self.framerate)])
|
|
156
|
+
|
|
157
|
+
cmd.extend(["-i", input_device])
|
|
158
|
+
cmd.extend(["-t", str(duration + 2)]) # extra buffer
|
|
159
|
+
cmd.extend(["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"])
|
|
160
|
+
cmd.extend([str(output_path)])
|
|
161
|
+
|
|
162
|
+
logger.info(f"Starting desktop recording: {output_path.name}")
|
|
163
|
+
process = await asyncio.create_subprocess_exec(
|
|
164
|
+
*cmd,
|
|
165
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
166
|
+
stderr=asyncio.subprocess.PIPE,
|
|
167
|
+
)
|
|
168
|
+
return process
|
|
169
|
+
|
|
170
|
+
async def _execute_action(self, action: DesktopAction) -> None:
|
|
171
|
+
"""Execute a desktop automation action using PyAutoGUI."""
|
|
172
|
+
if not self._has_pyautogui:
|
|
173
|
+
logger.warning("PyAutoGUI not available — skipping action")
|
|
174
|
+
await asyncio.sleep(action.delay)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
import pyautogui
|
|
178
|
+
|
|
179
|
+
pyautogui.PAUSE = 0.1 # small pause between pyautogui calls
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
if action.action_type == "click" and action.x is not None and action.y is not None:
|
|
183
|
+
pyautogui.click(action.x, action.y)
|
|
184
|
+
elif action.action_type == "type" and action.value:
|
|
185
|
+
pyautogui.typewrite(action.value, interval=0.05)
|
|
186
|
+
elif action.action_type == "move" and action.x is not None and action.y is not None:
|
|
187
|
+
pyautogui.moveTo(action.x, action.y, duration=0.3)
|
|
188
|
+
elif action.action_type == "scroll":
|
|
189
|
+
clicks = int(action.value) if action.value else -3
|
|
190
|
+
pyautogui.scroll(clicks)
|
|
191
|
+
elif action.action_type == "hotkey" and action.value:
|
|
192
|
+
keys = action.value.split("+")
|
|
193
|
+
pyautogui.hotkey(*keys)
|
|
194
|
+
elif action.action_type == "wait":
|
|
195
|
+
pass # delay handled below
|
|
196
|
+
elif action.action_type == "screenshot":
|
|
197
|
+
# Take a screenshot (useful for debugging)
|
|
198
|
+
shot_path = self.output_dir / f"debug_{action.value or 'shot'}.png"
|
|
199
|
+
pyautogui.screenshot(str(shot_path))
|
|
200
|
+
|
|
201
|
+
await asyncio.sleep(action.delay)
|
|
202
|
+
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
logger.warning(f"Desktop action failed ({action.action_type}): {exc}")
|
|
205
|
+
await asyncio.sleep(action.delay)
|
|
206
|
+
|
|
207
|
+
async def _activate_app(self, app_name: str) -> None:
|
|
208
|
+
"""Bring a desktop app to the foreground."""
|
|
209
|
+
system = platform.system()
|
|
210
|
+
try:
|
|
211
|
+
if system == "Darwin":
|
|
212
|
+
subprocess.run(
|
|
213
|
+
["osascript", "-e", f'tell application "{app_name}" to activate'],
|
|
214
|
+
capture_output=True,
|
|
215
|
+
timeout=5,
|
|
216
|
+
)
|
|
217
|
+
elif system == "Linux":
|
|
218
|
+
subprocess.run(
|
|
219
|
+
["wmctrl", "-a", app_name],
|
|
220
|
+
capture_output=True,
|
|
221
|
+
timeout=5,
|
|
222
|
+
)
|
|
223
|
+
elif system == "Windows":
|
|
224
|
+
# PowerShell approach
|
|
225
|
+
subprocess.run(
|
|
226
|
+
[
|
|
227
|
+
"powershell",
|
|
228
|
+
"-Command",
|
|
229
|
+
f"(New-Object -ComObject WScript.Shell).AppActivate('{app_name}')",
|
|
230
|
+
],
|
|
231
|
+
capture_output=True,
|
|
232
|
+
timeout=5,
|
|
233
|
+
)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
logger.warning(f"Failed to activate app '{app_name}': {exc}")
|
|
236
|
+
|
|
237
|
+
def parse_actions(self, action_dicts: list[dict]) -> list[DesktopAction]:
|
|
238
|
+
"""Convert raw action dicts to DesktopAction objects."""
|
|
239
|
+
actions = []
|
|
240
|
+
for d in action_dicts:
|
|
241
|
+
action_type = d.get("type", "")
|
|
242
|
+
if action_type not in VALID_DESKTOP_ACTIONS:
|
|
243
|
+
logger.warning(f"Skipping unknown desktop action type: {action_type}")
|
|
244
|
+
continue
|
|
245
|
+
actions.append(
|
|
246
|
+
DesktopAction(
|
|
247
|
+
action_type=action_type,
|
|
248
|
+
x=d.get("x"),
|
|
249
|
+
y=d.get("y"),
|
|
250
|
+
value=d.get("value"),
|
|
251
|
+
delay=d.get("delay", 0.5),
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
return actions
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Overlay renderer — adds visual polish to recorded video segments using FFmpeg.
|
|
3
|
+
Renders: step title bar, callout text boxes, step number indicator.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Hard cap on FFmpeg subprocess wall-clock time (seconds). 5 minutes is
|
|
15
|
+
# generous for normal overlay encoding but stops a stuck encoder from
|
|
16
|
+
# hanging the whole pipeline.
|
|
17
|
+
FFMPEG_TIMEOUT_S = 300
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _communicate_with_timeout(process: asyncio.subprocess.Process):
|
|
21
|
+
"""Run ``process.communicate()`` with a hard timeout; kill on timeout."""
|
|
22
|
+
try:
|
|
23
|
+
return await asyncio.wait_for(process.communicate(), timeout=FFMPEG_TIMEOUT_S)
|
|
24
|
+
except asyncio.TimeoutError as exc:
|
|
25
|
+
process.kill()
|
|
26
|
+
await process.wait()
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
f"FFmpeg subprocess timed out after {FFMPEG_TIMEOUT_S}s; killed"
|
|
29
|
+
) from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class OverlayConfig:
|
|
34
|
+
font_size: int = 32
|
|
35
|
+
title_font_size: int = 48
|
|
36
|
+
font_color: str = "white"
|
|
37
|
+
bg_color: str = "black@0.7"
|
|
38
|
+
padding: int = 20
|
|
39
|
+
title_position: str = "top"
|
|
40
|
+
callout_position: str = "bottom"
|
|
41
|
+
title_display_duration: float = 4.0
|
|
42
|
+
callout_display_duration: float = 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OverlayRenderer:
|
|
46
|
+
def __init__(self, output_dir: Path, config: Optional[OverlayConfig] = None):
|
|
47
|
+
self.output_dir = Path(output_dir)
|
|
48
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
self.config = config or OverlayConfig()
|
|
50
|
+
|
|
51
|
+
async def render_overlays(
|
|
52
|
+
self,
|
|
53
|
+
video_path: Path,
|
|
54
|
+
title: str,
|
|
55
|
+
step_number: int,
|
|
56
|
+
total_steps: int,
|
|
57
|
+
callout_text: str = "",
|
|
58
|
+
filename_prefix: str = "overlay",
|
|
59
|
+
) -> Path:
|
|
60
|
+
output_path = self.output_dir / f"{filename_prefix}_overlaid.mp4"
|
|
61
|
+
filters = []
|
|
62
|
+
filters.append(self._build_title_filter(title, step_number))
|
|
63
|
+
filters.append(self._build_step_indicator(step_number, total_steps))
|
|
64
|
+
if callout_text:
|
|
65
|
+
filters.append(self._build_callout_filter(callout_text))
|
|
66
|
+
filter_chain = ",".join(filters)
|
|
67
|
+
cmd = [
|
|
68
|
+
"ffmpeg",
|
|
69
|
+
"-y",
|
|
70
|
+
"-i",
|
|
71
|
+
str(video_path),
|
|
72
|
+
"-vf",
|
|
73
|
+
filter_chain,
|
|
74
|
+
"-c:v",
|
|
75
|
+
"libx264",
|
|
76
|
+
"-preset",
|
|
77
|
+
"fast",
|
|
78
|
+
"-crf",
|
|
79
|
+
"23",
|
|
80
|
+
"-c:a",
|
|
81
|
+
"copy",
|
|
82
|
+
str(output_path),
|
|
83
|
+
]
|
|
84
|
+
logger.info(f"Rendering overlays for {filename_prefix}")
|
|
85
|
+
process = await asyncio.create_subprocess_exec(
|
|
86
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
87
|
+
)
|
|
88
|
+
_, stderr = await _communicate_with_timeout(process)
|
|
89
|
+
if process.returncode != 0:
|
|
90
|
+
err_text = stderr.decode()
|
|
91
|
+
logger.error(f"FFmpeg overlay failed: {err_text[:500]}")
|
|
92
|
+
raise RuntimeError(f"FFmpeg overlay rendering failed: {err_text[:200]}")
|
|
93
|
+
logger.info(f"Overlays rendered: {output_path}")
|
|
94
|
+
return output_path
|
|
95
|
+
|
|
96
|
+
def _build_title_filter(self, title: str, step_number: int) -> str:
|
|
97
|
+
escaped = self._escape_ffmpeg_text(f"Step {step_number}: {title}")
|
|
98
|
+
c = self.config
|
|
99
|
+
y_pos = str(c.padding) if c.title_position == "top" else f"h-th-{c.padding}"
|
|
100
|
+
return (
|
|
101
|
+
f"drawtext=text='{escaped}'"
|
|
102
|
+
f":fontsize={c.title_font_size}"
|
|
103
|
+
f":fontcolor={c.font_color}"
|
|
104
|
+
f":box=1:boxcolor={c.bg_color}:boxborderw={c.padding}"
|
|
105
|
+
f":x=(w-tw)/2:y={y_pos}"
|
|
106
|
+
f":enable='between(t,0,{c.title_display_duration})'"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _build_callout_filter(self, text: str) -> str:
|
|
110
|
+
escaped = self._escape_ffmpeg_text(text)
|
|
111
|
+
c = self.config
|
|
112
|
+
y_pos = f"h-th-{c.padding * 3}" if c.callout_position == "bottom" else str(c.padding * 3)
|
|
113
|
+
duration_clause = ""
|
|
114
|
+
if c.callout_display_duration > 0:
|
|
115
|
+
duration_clause = f":enable='between(t,1,{c.callout_display_duration + 1})'"
|
|
116
|
+
return (
|
|
117
|
+
f"drawtext=text='{escaped}'"
|
|
118
|
+
f":fontsize={c.font_size}"
|
|
119
|
+
f":fontcolor={c.font_color}"
|
|
120
|
+
f":font=monospace"
|
|
121
|
+
f":box=1:boxcolor={c.bg_color}:boxborderw={c.padding}"
|
|
122
|
+
f":x={c.padding * 2}:y={y_pos}"
|
|
123
|
+
f"{duration_clause}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _build_step_indicator(self, step_number: int, total_steps: int) -> str:
|
|
127
|
+
c = self.config
|
|
128
|
+
return (
|
|
129
|
+
f"drawtext=text='{step_number}/{total_steps}'"
|
|
130
|
+
f":fontsize={c.font_size}"
|
|
131
|
+
f":fontcolor={c.font_color}"
|
|
132
|
+
f":box=1:boxcolor={c.bg_color}:boxborderw=10"
|
|
133
|
+
f":x=w-tw-{c.padding}:y={c.padding}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _escape_ffmpeg_text(text: str) -> str:
|
|
138
|
+
text = text.replace("\\", "\\\\")
|
|
139
|
+
text = text.replace("'", "'\\''")
|
|
140
|
+
text = text.replace(":", "\\:")
|
|
141
|
+
text = text.replace("%", "%%")
|
|
142
|
+
text = text.replace("\n", " ")
|
|
143
|
+
return text
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ScriptParser — Converts markdown scripts and task strings into
|
|
3
|
+
structured TutorialStep sequences for video generation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# --- Regex patterns ---
|
|
13
|
+
STEP_HEADING_RE = re.compile(r"^##\s+(?:Step\s+\d+[:\s]*)?(.+)$", re.MULTILINE)
|
|
14
|
+
CODE_BLOCK_RE = re.compile(r"```[\s\S]*?```")
|
|
15
|
+
|
|
16
|
+
# Headings to skip (lowercased for comparison)
|
|
17
|
+
SKIP_HEADINGS = {
|
|
18
|
+
"prerequisites",
|
|
19
|
+
"introduction",
|
|
20
|
+
"overview",
|
|
21
|
+
"conclusion",
|
|
22
|
+
"summary",
|
|
23
|
+
"next steps",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class TutorialStep:
|
|
29
|
+
"""A single step in a video tutorial."""
|
|
30
|
+
|
|
31
|
+
step_number: int
|
|
32
|
+
title: str
|
|
33
|
+
narration: str
|
|
34
|
+
url: str
|
|
35
|
+
actions: list[dict] = field(default_factory=list)
|
|
36
|
+
overlay_text: str = ""
|
|
37
|
+
duration_hint: float = 5.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class VideoTutorial:
|
|
42
|
+
"""A complete video tutorial definition."""
|
|
43
|
+
|
|
44
|
+
title: str
|
|
45
|
+
steps: list[TutorialStep]
|
|
46
|
+
output_path: str
|
|
47
|
+
source: str
|
|
48
|
+
resolution: tuple[int, int] = (1920, 1080)
|
|
49
|
+
total_duration: float = 0.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ScriptParser:
|
|
53
|
+
"""Parses markdown scripts and task strings into TutorialStep sequences."""
|
|
54
|
+
|
|
55
|
+
def parse_markdown(
|
|
56
|
+
self, markdown: str, base_url: str = "https://example.com"
|
|
57
|
+
) -> list[TutorialStep]:
|
|
58
|
+
"""Split markdown by ## headings into TutorialSteps.
|
|
59
|
+
|
|
60
|
+
Skips prerequisite/conclusion sections. Extracts narration
|
|
61
|
+
(stripped of code blocks) and overlay text from first code block.
|
|
62
|
+
"""
|
|
63
|
+
sections = self._split_by_headings(markdown)
|
|
64
|
+
steps: list[TutorialStep] = []
|
|
65
|
+
step_number = 1
|
|
66
|
+
|
|
67
|
+
for title, body in sections:
|
|
68
|
+
# Skip non-content sections
|
|
69
|
+
if title.strip().lower() in SKIP_HEADINGS:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
narration = self._extract_narration(body)
|
|
73
|
+
if not narration.strip():
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
overlay = self._extract_first_code(body)
|
|
77
|
+
duration = max(5.0, len(narration) / 15)
|
|
78
|
+
|
|
79
|
+
steps.append(
|
|
80
|
+
TutorialStep(
|
|
81
|
+
step_number=step_number,
|
|
82
|
+
title=title.strip(),
|
|
83
|
+
narration=narration,
|
|
84
|
+
url=base_url,
|
|
85
|
+
overlay_text=overlay,
|
|
86
|
+
duration_hint=round(duration, 1),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
step_number += 1
|
|
90
|
+
|
|
91
|
+
return steps
|
|
92
|
+
|
|
93
|
+
def parse_task(self, task: str, base_url: str = "https://example.com") -> list[TutorialStep]:
|
|
94
|
+
"""Create a single TutorialStep from a task string."""
|
|
95
|
+
narration = self._extract_narration(task)
|
|
96
|
+
duration = max(5.0, len(narration) / 15)
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
TutorialStep(
|
|
100
|
+
step_number=1,
|
|
101
|
+
title=task[:80].strip(),
|
|
102
|
+
narration=narration,
|
|
103
|
+
url=base_url,
|
|
104
|
+
duration_hint=round(duration, 1),
|
|
105
|
+
)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
def _split_by_headings(self, markdown: str) -> list[tuple[str, str]]:
|
|
109
|
+
"""Split markdown into (heading, body) tuples by ## headings."""
|
|
110
|
+
matches = list(STEP_HEADING_RE.finditer(markdown))
|
|
111
|
+
if not matches:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
sections: list[tuple[str, str]] = []
|
|
115
|
+
for i, match in enumerate(matches):
|
|
116
|
+
title = match.group(1)
|
|
117
|
+
start = match.end()
|
|
118
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown)
|
|
119
|
+
body = markdown[start:end]
|
|
120
|
+
sections.append((title, body))
|
|
121
|
+
|
|
122
|
+
return sections
|
|
123
|
+
|
|
124
|
+
def _extract_narration(self, text: str) -> str:
|
|
125
|
+
"""Remove code blocks, markdown links, bold/italic, collapse whitespace."""
|
|
126
|
+
# Remove code blocks
|
|
127
|
+
cleaned = CODE_BLOCK_RE.sub("", text)
|
|
128
|
+
# Remove markdown links: [text](url) → text
|
|
129
|
+
cleaned = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", cleaned)
|
|
130
|
+
# Remove bold and italic markers
|
|
131
|
+
cleaned = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", cleaned)
|
|
132
|
+
cleaned = re.sub(r"_{1,2}([^_]+)_{1,2}", r"\1", cleaned)
|
|
133
|
+
# Collapse whitespace
|
|
134
|
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
135
|
+
return cleaned
|
|
136
|
+
|
|
137
|
+
def _extract_first_code(self, text: str) -> str:
|
|
138
|
+
"""Extract content of the first code block, truncated to 5 lines."""
|
|
139
|
+
match = re.search(r"```(?:\w*\n)?([\s\S]*?)```", text)
|
|
140
|
+
if not match:
|
|
141
|
+
return ""
|
|
142
|
+
code = match.group(1).strip()
|
|
143
|
+
lines = code.splitlines()
|
|
144
|
+
if len(lines) > 5:
|
|
145
|
+
lines = lines[:5]
|
|
146
|
+
lines.append("...")
|
|
147
|
+
return "\n".join(lines)
|