figrecipe 0.7.4__py3-none-any.whl → 0.9.0__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 (143) hide show
  1. figrecipe/__init__.py +74 -76
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/_panel.py +67 -0
  4. figrecipe/_api/_save.py +100 -4
  5. figrecipe/_cli/__init__.py +7 -0
  6. figrecipe/_cli/_compose.py +87 -0
  7. figrecipe/_cli/_convert.py +117 -0
  8. figrecipe/_cli/_crop.py +82 -0
  9. figrecipe/_cli/_edit.py +70 -0
  10. figrecipe/_cli/_extract.py +128 -0
  11. figrecipe/_cli/_fonts.py +47 -0
  12. figrecipe/_cli/_info.py +67 -0
  13. figrecipe/_cli/_main.py +58 -0
  14. figrecipe/_cli/_reproduce.py +79 -0
  15. figrecipe/_cli/_style.py +77 -0
  16. figrecipe/_cli/_validate.py +66 -0
  17. figrecipe/_cli/_version.py +50 -0
  18. figrecipe/_composition/__init__.py +32 -0
  19. figrecipe/_composition/_alignment.py +452 -0
  20. figrecipe/_composition/_compose.py +179 -0
  21. figrecipe/_composition/_import_axes.py +127 -0
  22. figrecipe/_composition/_visibility.py +125 -0
  23. figrecipe/_dev/__init__.py +2 -0
  24. figrecipe/_dev/browser/__init__.py +69 -0
  25. figrecipe/_dev/browser/_audio.py +240 -0
  26. figrecipe/_dev/browser/_caption.py +356 -0
  27. figrecipe/_dev/browser/_click_effect.py +146 -0
  28. figrecipe/_dev/browser/_cursor.py +196 -0
  29. figrecipe/_dev/browser/_highlight.py +105 -0
  30. figrecipe/_dev/browser/_narration.py +237 -0
  31. figrecipe/_dev/browser/_recorder.py +446 -0
  32. figrecipe/_dev/browser/_utils.py +178 -0
  33. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  34. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  35. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  36. figrecipe/_editor/__init__.py +36 -36
  37. figrecipe/_editor/_bbox/_extract.py +155 -9
  38. figrecipe/_editor/_bbox/_extract_text.py +124 -0
  39. figrecipe/_editor/_call_overrides.py +183 -0
  40. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  41. figrecipe/_editor/_figure_layout.py +211 -0
  42. figrecipe/_editor/_flask_app.py +157 -16
  43. figrecipe/_editor/_helpers.py +17 -8
  44. figrecipe/_editor/_hitmap/_detect.py +89 -32
  45. figrecipe/_editor/_hitmap_main.py +4 -4
  46. figrecipe/_editor/_overrides.py +4 -1
  47. figrecipe/_editor/_plot_types_registry.py +190 -0
  48. figrecipe/_editor/_render_overrides.py +38 -11
  49. figrecipe/_editor/_renderer.py +46 -1
  50. figrecipe/_editor/_routes_annotation.py +114 -0
  51. figrecipe/_editor/_routes_axis.py +35 -6
  52. figrecipe/_editor/_routes_captions.py +130 -0
  53. figrecipe/_editor/_routes_composition.py +270 -0
  54. figrecipe/_editor/_routes_core.py +15 -173
  55. figrecipe/_editor/_routes_datatable.py +364 -0
  56. figrecipe/_editor/_routes_element.py +37 -19
  57. figrecipe/_editor/_routes_files.py +443 -0
  58. figrecipe/_editor/_routes_image.py +200 -0
  59. figrecipe/_editor/_routes_snapshot.py +94 -0
  60. figrecipe/_editor/_routes_style.py +28 -8
  61. figrecipe/_editor/_templates/__init__.py +40 -2
  62. figrecipe/_editor/_templates/_html.py +97 -103
  63. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  64. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  65. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  66. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  67. figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
  68. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  69. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  70. figrecipe/_editor/_templates/_scripts/_api.py +1 -1
  71. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  72. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  73. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  74. figrecipe/_editor/_templates/_scripts/_core.py +94 -37
  75. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  76. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  77. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  78. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  79. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  80. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  81. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  82. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  83. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  84. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  85. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  86. figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
  87. figrecipe/_editor/_templates/_scripts/_files.py +274 -40
  88. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  89. figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
  90. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  91. figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
  92. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  93. figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
  94. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  95. figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
  96. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  97. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  98. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  99. figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
  100. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  101. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  102. figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
  103. figrecipe/_editor/_templates/_styles/__init__.py +9 -0
  104. figrecipe/_editor/_templates/_styles/_base.py +47 -0
  105. figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
  106. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  107. figrecipe/_editor/_templates/_styles/_controls.py +168 -3
  108. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  109. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  110. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  111. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  112. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  113. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  114. figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
  115. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  116. figrecipe/_editor/_templates/_styles/_forms.py +98 -0
  117. figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
  118. figrecipe/_editor/_templates/_styles/_modals.py +29 -0
  119. figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
  120. figrecipe/_editor/_templates/_styles/_preview.py +213 -8
  121. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  122. figrecipe/_editor/static/audio/click.mp3 +0 -0
  123. figrecipe/_editor/static/click.mp3 +0 -0
  124. figrecipe/_editor/static/icons/favicon.ico +0 -0
  125. figrecipe/_integrations/__init__.py +17 -0
  126. figrecipe/_integrations/_scitex_stats.py +298 -0
  127. figrecipe/_params/_DECORATION_METHODS.py +2 -0
  128. figrecipe/_recorder.py +28 -3
  129. figrecipe/_reproducer/_core.py +60 -49
  130. figrecipe/_utils/__init__.py +3 -0
  131. figrecipe/_utils/_bundle.py +205 -0
  132. figrecipe/_wrappers/_axes.py +150 -2
  133. figrecipe/_wrappers/_caption_generator.py +218 -0
  134. figrecipe/_wrappers/_figure.py +26 -1
  135. figrecipe/_wrappers/_stat_annotation.py +274 -0
  136. figrecipe/styles/_style_applier.py +10 -2
  137. figrecipe/styles/presets/SCITEX.yaml +11 -4
  138. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
  139. figrecipe-0.9.0.dist-info/RECORD +277 -0
  140. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  141. figrecipe-0.7.4.dist-info/RECORD +0 -188
  142. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  143. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """DemoRecorder base class for creating demo videos.
4
+
5
+ Provides a framework for recording browser demos with:
6
+ - Video recording via Playwright (no audio)
7
+ - Cursor and click visualization
8
+ - Caption overlays
9
+ - Title screen with blur effect
10
+ - GIF conversion
11
+ """
12
+
13
+ import asyncio
14
+ from abc import ABC, abstractmethod
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from ._caption import hide_caption, show_caption
19
+ from ._click_effect import inject_click_effect, remove_click_effect
20
+ from ._cursor import inject_cursor, remove_cursor
21
+ from ._highlight import highlight_element
22
+ from ._utils import convert_to_gif
23
+
24
+
25
+ class DemoRecorder(ABC):
26
+ """Base class for creating demo recordings.
27
+
28
+ Subclass this and implement the `run` method to define
29
+ your demo actions.
30
+
31
+ Attributes
32
+ ----------
33
+ title : str
34
+ Demo title (used for captions and filenames).
35
+ duration_target : int
36
+ Target duration in seconds.
37
+ url : str
38
+ URL to navigate to (default: http://127.0.0.1:5050).
39
+ output_dir : Path
40
+ Directory for output files.
41
+
42
+ Example
43
+ -------
44
+ ```python
45
+ class ChangeColorDemo(DemoRecorder):
46
+ title = "Change Element Color"
47
+ duration_target = 8
48
+
49
+ async def run(self, page):
50
+ await self.caption("Click on bar chart element")
51
+ await page.click('[data-key="bar_0"]')
52
+ await self.wait(1)
53
+ await self.caption("Select red color")
54
+ await page.click('#color-select')
55
+ ```
56
+ """
57
+
58
+ title: str = "Demo"
59
+ demo_id: str = "" # e.g., "01" for numbered output filenames
60
+ duration_target: int = 10
61
+ url: str = "http://127.0.0.1:5050"
62
+ output_dir: Path = Path("examples/demo_movie/outputs")
63
+
64
+ def __init__(
65
+ self,
66
+ url: Optional[str] = None,
67
+ output_dir: Optional[Path] = None,
68
+ headless: bool = True,
69
+ ):
70
+ """Initialize DemoRecorder.
71
+
72
+ Parameters
73
+ ----------
74
+ url : str, optional
75
+ Override default URL.
76
+ output_dir : Path, optional
77
+ Override default output directory.
78
+ headless : bool, optional
79
+ Run browser in headless mode (default: True).
80
+ """
81
+ if url:
82
+ self.url = url
83
+ if output_dir:
84
+ self.output_dir = Path(output_dir)
85
+ self.headless = headless
86
+ self._page = None
87
+
88
+ @abstractmethod
89
+ async def run(self, page) -> None:
90
+ """Define demo actions.
91
+
92
+ Override this method to define the demo sequence.
93
+
94
+ Parameters
95
+ ----------
96
+ page : playwright.async_api.Page
97
+ Playwright page object.
98
+ """
99
+ pass
100
+
101
+ async def caption(self, text: str, duration: float = 2.0) -> None:
102
+ """Show caption overlay.
103
+
104
+ Parameters
105
+ ----------
106
+ text : str
107
+ Caption text to display.
108
+ duration : float, optional
109
+ Duration to show caption in seconds (default: 2.0).
110
+ """
111
+ if self._page:
112
+ await show_caption(self._page, text)
113
+ if duration > 0:
114
+ await asyncio.sleep(duration)
115
+
116
+ async def hide_caption_now(self) -> None:
117
+ """Hide current caption immediately."""
118
+ if self._page:
119
+ await hide_caption(self._page)
120
+
121
+ async def highlight(
122
+ self,
123
+ selector: str,
124
+ duration: float = 1.0,
125
+ color: str = "#FF4444",
126
+ ) -> None:
127
+ """Highlight an element.
128
+
129
+ Parameters
130
+ ----------
131
+ selector : str
132
+ CSS selector for element.
133
+ duration : float, optional
134
+ Duration to highlight in seconds (default: 1.0).
135
+ color : str, optional
136
+ Highlight color (default: "#FF4444").
137
+ """
138
+ if self._page:
139
+ await highlight_element(self._page, selector, int(duration * 1000), color)
140
+
141
+ async def wait(self, seconds: float) -> None:
142
+ """Wait for specified duration.
143
+
144
+ Parameters
145
+ ----------
146
+ seconds : float
147
+ Time to wait in seconds.
148
+ """
149
+ await asyncio.sleep(seconds)
150
+
151
+ async def move_to(self, locator, duration: float = 0.5) -> bool:
152
+ """Move cursor to an element naturally.
153
+
154
+ Parameters
155
+ ----------
156
+ locator : playwright.async_api.Locator
157
+ Playwright locator for target element.
158
+ duration : float, optional
159
+ Animation duration in seconds (default: 0.5).
160
+
161
+ Returns
162
+ -------
163
+ bool
164
+ True if successful.
165
+ """
166
+ if self._page:
167
+ from ._cursor import move_cursor_to_element
168
+
169
+ return await move_cursor_to_element(
170
+ self._page, locator, int(duration * 1000)
171
+ )
172
+ return False
173
+
174
+ async def title_screen(
175
+ self,
176
+ title: str,
177
+ subtitle: str = "",
178
+ timestamp: str = "",
179
+ duration: float = 2.0,
180
+ ) -> None:
181
+ """Show title screen with blur overlay.
182
+
183
+ Parameters
184
+ ----------
185
+ title : str
186
+ Main title text.
187
+ subtitle : str, optional
188
+ Subtitle text.
189
+ timestamp : str, optional
190
+ Timestamp to display.
191
+ duration : float, optional
192
+ Duration in seconds (default: 2.0).
193
+ """
194
+ if self._page:
195
+ from ._caption import show_title_screen
196
+
197
+ await show_title_screen(
198
+ self._page, title, subtitle, timestamp, int(duration * 1000)
199
+ )
200
+
201
+ async def closing_screen(self, duration: float = 2.5) -> None:
202
+ """Show closing branding screen.
203
+
204
+ Parameters
205
+ ----------
206
+ duration : float, optional
207
+ Duration in seconds (default: 2.5).
208
+ """
209
+ if self._page:
210
+ from ._caption import show_closing_screen
211
+
212
+ await show_closing_screen(self._page, int(duration * 1000))
213
+
214
+ def _get_output_paths(self) -> tuple:
215
+ """Get output file paths.
216
+
217
+ Returns
218
+ -------
219
+ tuple
220
+ (mp4_path, gif_path)
221
+ """
222
+ self.output_dir.mkdir(parents=True, exist_ok=True)
223
+ safe_name = self.title.lower().replace(" ", "_").replace("-", "_")
224
+ # Add demo_id prefix if provided (e.g., "01_enable_dark_mode")
225
+ if self.demo_id:
226
+ filename = f"{self.demo_id}_{safe_name}"
227
+ else:
228
+ filename = safe_name
229
+ mp4_path = self.output_dir / f"{filename}.mp4"
230
+ gif_path = self.output_dir / f"{filename}.gif"
231
+ return mp4_path, gif_path
232
+
233
+ def _get_version_from_pyproject(self) -> str:
234
+ """Get version from pyproject.toml instead of installed package.
235
+
236
+ Returns
237
+ -------
238
+ str
239
+ Version string (e.g., "0.8.0").
240
+ """
241
+ import re
242
+
243
+ # Find pyproject.toml by traversing up from this file
244
+ current = Path(__file__).resolve()
245
+ for _ in range(10): # Max 10 levels up
246
+ pyproject = current / "pyproject.toml"
247
+ if pyproject.exists():
248
+ content = pyproject.read_text()
249
+ match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
250
+ if match:
251
+ return match.group(1)
252
+ current = current.parent
253
+ return "0.0.0" # Fallback
254
+
255
+ async def _setup_page(self, page) -> None:
256
+ """Setup page with visual effects.
257
+
258
+ Parameters
259
+ ----------
260
+ page : playwright.async_api.Page
261
+ Playwright page object.
262
+ """
263
+ self._page = page
264
+ await inject_cursor(page)
265
+ await inject_click_effect(page)
266
+
267
+ async def _cleanup_page(self, page) -> None:
268
+ """Cleanup visual effects from page.
269
+
270
+ Parameters
271
+ ----------
272
+ page : playwright.async_api.Page
273
+ Playwright page object.
274
+ """
275
+ await remove_cursor(page)
276
+ await remove_click_effect(page)
277
+ await hide_caption(page)
278
+ self._page = None
279
+
280
+ async def record(self) -> Path:
281
+ """Record the demo and save video.
282
+
283
+ Uses visual markers (yellow flash) to reliably detect
284
+ content start/end for trimming, regardless of page load time.
285
+
286
+ Returns
287
+ -------
288
+ Path
289
+ Path to output MP4 file.
290
+ """
291
+ try:
292
+ from playwright.async_api import async_playwright
293
+ except ImportError:
294
+ raise RuntimeError(
295
+ "Playwright not installed. Install with: pip install playwright && playwright install chromium"
296
+ )
297
+
298
+ from datetime import datetime
299
+
300
+ from ._video_trim import (
301
+ inject_end_marker,
302
+ inject_start_marker,
303
+ process_video_with_markers,
304
+ )
305
+
306
+ mp4_path, _ = self._get_output_paths()
307
+
308
+ async with async_playwright() as p:
309
+ browser = await p.chromium.launch(headless=self.headless)
310
+ context = await browser.new_context(
311
+ viewport={"width": 1920, "height": 1080},
312
+ record_video_dir=str(self.output_dir),
313
+ record_video_size={"width": 1920, "height": 1080},
314
+ )
315
+ page = await context.new_page()
316
+
317
+ try:
318
+ # Navigate to URL (fresh load resets state)
319
+ await page.goto(self.url, wait_until="networkidle")
320
+
321
+ # Wait for preview image to load
322
+ try:
323
+ await page.wait_for_selector("#preview-image", timeout=10000)
324
+ except Exception:
325
+ pass # Continue even if selector not found
326
+
327
+ # Wait for video recording to stabilize
328
+ # Playwright needs time to start capturing frames
329
+ await asyncio.sleep(1.0)
330
+
331
+ # Get version and timestamp for markers
332
+ version = self._get_version_from_pyproject()
333
+ timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M")
334
+
335
+ # === START MARKER === (dark frame with OCR metadata)
336
+ # Duration 500ms = ~8 frames at 15fps for reliable detection
337
+ await inject_start_marker(
338
+ page,
339
+ version=f"figrecipe v{version}",
340
+ timestamp=timestamp_str,
341
+ duration_ms=500,
342
+ )
343
+ # Brief pause after marker
344
+ await asyncio.sleep(0.2)
345
+ self._page = page
346
+ await self.title_screen(
347
+ title=self.title,
348
+ subtitle=f"figrecipe v{version}",
349
+ timestamp=timestamp_str,
350
+ duration=2.0,
351
+ )
352
+
353
+ # Setup cursor AFTER title screen
354
+ await self._setup_page(page)
355
+
356
+ # Run demo actions
357
+ await self.run(page)
358
+
359
+ # Show closing branding screen
360
+ await self.closing_screen(duration=2.0)
361
+ await asyncio.sleep(0.3)
362
+
363
+ # === END MARKER === (dark frame with OCR metadata)
364
+ # Duration 500ms = ~5 frames at 10fps for reliable detection
365
+ await inject_end_marker(
366
+ page,
367
+ version=f"figrecipe v{version}",
368
+ timestamp=timestamp_str,
369
+ duration_ms=500,
370
+ )
371
+ await asyncio.sleep(0.1)
372
+
373
+ # Cleanup
374
+ await self._cleanup_page(page)
375
+
376
+ finally:
377
+ await context.close()
378
+ await browser.close()
379
+
380
+ # Get recorded video and process with marker detection
381
+ video_path = await page.video.path()
382
+ if video_path and Path(video_path).exists():
383
+ webm_path = Path(video_path)
384
+ try:
385
+ # Detect markers and trim automatically
386
+ process_video_with_markers(webm_path, mp4_path, cleanup=True)
387
+ except Exception as e:
388
+ print(f"Warning: Marker-based trim failed ({e}), using fallback")
389
+ # Fallback: simple conversion without trim
390
+ import subprocess
391
+
392
+ subprocess.run(
393
+ [
394
+ "ffmpeg",
395
+ "-y",
396
+ "-i",
397
+ str(webm_path),
398
+ "-c:v",
399
+ "libx264",
400
+ "-preset",
401
+ "fast",
402
+ "-crf",
403
+ "23",
404
+ str(mp4_path),
405
+ ],
406
+ capture_output=True,
407
+ )
408
+ webm_path.unlink(missing_ok=True)
409
+
410
+ print(f"Recorded: {mp4_path}")
411
+ return mp4_path
412
+
413
+ async def record_and_convert(self) -> tuple:
414
+ """Record demo and convert to GIF.
415
+
416
+ Returns
417
+ -------
418
+ tuple
419
+ (mp4_path, gif_path)
420
+ """
421
+ mp4_path = await self.record()
422
+ _, gif_path = self._get_output_paths()
423
+
424
+ try:
425
+ convert_to_gif(mp4_path, gif_path)
426
+ print(f"Converted: {gif_path}")
427
+ except Exception as e:
428
+ print(f"GIF conversion failed: {e}")
429
+ gif_path = None
430
+
431
+ return mp4_path, gif_path
432
+
433
+ def execute(self) -> tuple:
434
+ """Execute recording (synchronous wrapper).
435
+
436
+ Returns
437
+ -------
438
+ tuple
439
+ (mp4_path, gif_path)
440
+ """
441
+ return asyncio.run(self.record_and_convert())
442
+
443
+
444
+ __all__ = ["DemoRecorder"]
445
+
446
+ # EOF
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Utility functions for demo video processing.
4
+
5
+ Provides video format conversion (MP4 to GIF) and
6
+ video concatenation using ffmpeg.
7
+ """
8
+
9
+ import shutil
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+
15
+ def check_ffmpeg() -> bool:
16
+ """Check if ffmpeg is available.
17
+
18
+ Returns
19
+ -------
20
+ bool
21
+ True if ffmpeg is available.
22
+ """
23
+ return shutil.which("ffmpeg") is not None
24
+
25
+
26
+ def convert_to_gif(
27
+ input_path: Path,
28
+ output_path: Optional[Path] = None,
29
+ fps: int = 10,
30
+ width: int = 800,
31
+ optimize: bool = True,
32
+ ) -> Path:
33
+ """Convert MP4 video to GIF.
34
+
35
+ Parameters
36
+ ----------
37
+ input_path : Path
38
+ Path to input MP4 file.
39
+ output_path : Path, optional
40
+ Path for output GIF. If None, uses input path with .gif extension.
41
+ fps : int, optional
42
+ Frame rate for GIF (default: 10).
43
+ width : int, optional
44
+ Width of output GIF in pixels (default: 800).
45
+ optimize : bool, optional
46
+ Whether to optimize GIF palette (default: True).
47
+
48
+ Returns
49
+ -------
50
+ Path
51
+ Path to output GIF file.
52
+
53
+ Raises
54
+ ------
55
+ RuntimeError
56
+ If ffmpeg is not available or conversion fails.
57
+ """
58
+ if not check_ffmpeg():
59
+ raise RuntimeError("ffmpeg is not installed or not in PATH")
60
+
61
+ input_path = Path(input_path)
62
+ if output_path is None:
63
+ output_path = input_path.with_suffix(".gif")
64
+ output_path = Path(output_path)
65
+
66
+ if optimize:
67
+ # Two-pass conversion for better quality
68
+ palette_path = input_path.parent / f"{input_path.stem}_palette.png"
69
+
70
+ # Generate palette
71
+ palette_cmd = [
72
+ "ffmpeg",
73
+ "-y",
74
+ "-i",
75
+ str(input_path),
76
+ "-vf",
77
+ f"fps={fps},scale={width}:-1:flags=lanczos,palettegen=stats_mode=diff",
78
+ str(palette_path),
79
+ ]
80
+ subprocess.run(palette_cmd, capture_output=True, check=True)
81
+
82
+ # Generate GIF using palette
83
+ gif_cmd = [
84
+ "ffmpeg",
85
+ "-y",
86
+ "-i",
87
+ str(input_path),
88
+ "-i",
89
+ str(palette_path),
90
+ "-lavfi",
91
+ f"fps={fps},scale={width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
92
+ str(output_path),
93
+ ]
94
+ subprocess.run(gif_cmd, capture_output=True, check=True)
95
+
96
+ # Clean up palette
97
+ palette_path.unlink(missing_ok=True)
98
+ else:
99
+ # Simple single-pass conversion
100
+ cmd = [
101
+ "ffmpeg",
102
+ "-y",
103
+ "-i",
104
+ str(input_path),
105
+ "-vf",
106
+ f"fps={fps},scale={width}:-1:flags=lanczos",
107
+ str(output_path),
108
+ ]
109
+ subprocess.run(cmd, capture_output=True, check=True)
110
+
111
+ return output_path
112
+
113
+
114
+ def concatenate_videos(
115
+ input_paths: List[Path],
116
+ output_path: Path,
117
+ transition_frames: int = 0,
118
+ ) -> Path:
119
+ """Concatenate multiple videos into one.
120
+
121
+ Parameters
122
+ ----------
123
+ input_paths : List[Path]
124
+ List of paths to input video files.
125
+ output_path : Path
126
+ Path for output concatenated video.
127
+ transition_frames : int, optional
128
+ Number of black frames between videos (default: 0).
129
+
130
+ Returns
131
+ -------
132
+ Path
133
+ Path to output video file.
134
+
135
+ Raises
136
+ ------
137
+ RuntimeError
138
+ If ffmpeg is not available or concatenation fails.
139
+ """
140
+ if not check_ffmpeg():
141
+ raise RuntimeError("ffmpeg is not installed or not in PATH")
142
+
143
+ if not input_paths:
144
+ raise ValueError("No input paths provided")
145
+
146
+ output_path = Path(output_path)
147
+
148
+ # Create concat file list
149
+ concat_list = output_path.parent / "concat_list.txt"
150
+ with open(concat_list, "w") as f:
151
+ for path in input_paths:
152
+ f.write(f"file '{Path(path).resolve()}'\n")
153
+
154
+ # Concatenate videos
155
+ cmd = [
156
+ "ffmpeg",
157
+ "-y",
158
+ "-f",
159
+ "concat",
160
+ "-safe",
161
+ "0",
162
+ "-i",
163
+ str(concat_list),
164
+ "-c",
165
+ "copy",
166
+ str(output_path),
167
+ ]
168
+ subprocess.run(cmd, capture_output=True, check=True)
169
+
170
+ # Clean up
171
+ concat_list.unlink(missing_ok=True)
172
+
173
+ return output_path
174
+
175
+
176
+ __all__ = ["convert_to_gif", "concatenate_videos", "check_ffmpeg"]
177
+
178
+ # EOF