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.
Files changed (98) hide show
  1. devrel_origin/__init__.py +15 -0
  2. devrel_origin/cli/__init__.py +92 -0
  3. devrel_origin/cli/_common.py +243 -0
  4. devrel_origin/cli/analytics.py +28 -0
  5. devrel_origin/cli/argus.py +497 -0
  6. devrel_origin/cli/auth.py +227 -0
  7. devrel_origin/cli/config.py +108 -0
  8. devrel_origin/cli/content.py +259 -0
  9. devrel_origin/cli/cost.py +108 -0
  10. devrel_origin/cli/cro.py +298 -0
  11. devrel_origin/cli/deliverables.py +65 -0
  12. devrel_origin/cli/docs.py +91 -0
  13. devrel_origin/cli/doctor.py +178 -0
  14. devrel_origin/cli/experiment.py +29 -0
  15. devrel_origin/cli/growth.py +97 -0
  16. devrel_origin/cli/init.py +472 -0
  17. devrel_origin/cli/intel.py +27 -0
  18. devrel_origin/cli/kb.py +96 -0
  19. devrel_origin/cli/listen.py +31 -0
  20. devrel_origin/cli/marketing.py +66 -0
  21. devrel_origin/cli/migrate.py +45 -0
  22. devrel_origin/cli/run.py +46 -0
  23. devrel_origin/cli/sales.py +57 -0
  24. devrel_origin/cli/schedule.py +62 -0
  25. devrel_origin/cli/synthesize.py +28 -0
  26. devrel_origin/cli/triage.py +29 -0
  27. devrel_origin/cli/video.py +35 -0
  28. devrel_origin/core/__init__.py +58 -0
  29. devrel_origin/core/agent_config.py +75 -0
  30. devrel_origin/core/argus.py +964 -0
  31. devrel_origin/core/atlas.py +1450 -0
  32. devrel_origin/core/base.py +372 -0
  33. devrel_origin/core/cyra.py +563 -0
  34. devrel_origin/core/dex.py +708 -0
  35. devrel_origin/core/echo.py +614 -0
  36. devrel_origin/core/growth/__init__.py +27 -0
  37. devrel_origin/core/growth/recommendations.py +219 -0
  38. devrel_origin/core/growth/target_kinds.py +51 -0
  39. devrel_origin/core/iris.py +513 -0
  40. devrel_origin/core/kai.py +1367 -0
  41. devrel_origin/core/llm.py +542 -0
  42. devrel_origin/core/llm_backends.py +274 -0
  43. devrel_origin/core/mox.py +514 -0
  44. devrel_origin/core/nova.py +349 -0
  45. devrel_origin/core/pax.py +1205 -0
  46. devrel_origin/core/rex.py +532 -0
  47. devrel_origin/core/sage.py +486 -0
  48. devrel_origin/core/sentinel.py +385 -0
  49. devrel_origin/core/types.py +98 -0
  50. devrel_origin/core/video/__init__.py +22 -0
  51. devrel_origin/core/video/assembler.py +131 -0
  52. devrel_origin/core/video/browser_recorder.py +118 -0
  53. devrel_origin/core/video/desktop_recorder.py +254 -0
  54. devrel_origin/core/video/overlay_renderer.py +143 -0
  55. devrel_origin/core/video/script_parser.py +147 -0
  56. devrel_origin/core/video/tts_engine.py +82 -0
  57. devrel_origin/core/vox.py +268 -0
  58. devrel_origin/core/watchdog.py +321 -0
  59. devrel_origin/project/__init__.py +1 -0
  60. devrel_origin/project/config.py +75 -0
  61. devrel_origin/project/cost_sink.py +61 -0
  62. devrel_origin/project/init.py +104 -0
  63. devrel_origin/project/paths.py +75 -0
  64. devrel_origin/project/state.py +241 -0
  65. devrel_origin/project/templates/__init__.py +4 -0
  66. devrel_origin/project/templates/config.toml +24 -0
  67. devrel_origin/project/templates/devrel.gitignore +10 -0
  68. devrel_origin/project/templates/slop-blocklist.md +45 -0
  69. devrel_origin/project/templates/style.md +24 -0
  70. devrel_origin/project/templates/voice.md +29 -0
  71. devrel_origin/quality/__init__.py +66 -0
  72. devrel_origin/quality/editorial.py +357 -0
  73. devrel_origin/quality/persona.py +84 -0
  74. devrel_origin/quality/readability.py +148 -0
  75. devrel_origin/quality/slop.py +167 -0
  76. devrel_origin/quality/style.py +110 -0
  77. devrel_origin/quality/voice.py +15 -0
  78. devrel_origin/tools/__init__.py +9 -0
  79. devrel_origin/tools/analytics.py +304 -0
  80. devrel_origin/tools/api_client.py +393 -0
  81. devrel_origin/tools/apollo_client.py +305 -0
  82. devrel_origin/tools/code_validator.py +428 -0
  83. devrel_origin/tools/github_tools.py +297 -0
  84. devrel_origin/tools/instantly_client.py +412 -0
  85. devrel_origin/tools/kb_harvester.py +340 -0
  86. devrel_origin/tools/mcp_server.py +578 -0
  87. devrel_origin/tools/notifications.py +245 -0
  88. devrel_origin/tools/run_report.py +193 -0
  89. devrel_origin/tools/scheduler.py +231 -0
  90. devrel_origin/tools/search_tools.py +321 -0
  91. devrel_origin/tools/self_improve.py +168 -0
  92. devrel_origin/tools/sheets.py +236 -0
  93. devrel_origin-0.2.14.dist-info/METADATA +354 -0
  94. devrel_origin-0.2.14.dist-info/RECORD +98 -0
  95. devrel_origin-0.2.14.dist-info/WHEEL +5 -0
  96. devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
  97. devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
  98. 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)