figrecipe 0.6.0__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.
- figrecipe/__init__.py +161 -1030
- figrecipe/__main__.py +12 -0
- figrecipe/_api/__init__.py +48 -0
- figrecipe/_api/_extract.py +108 -0
- figrecipe/_api/_notebook.py +61 -0
- figrecipe/_api/_panel.py +113 -0
- figrecipe/_api/_save.py +287 -0
- figrecipe/_api/_seaborn_proxy.py +34 -0
- figrecipe/_api/_style_manager.py +153 -0
- figrecipe/_api/_subplots.py +333 -0
- figrecipe/_api/_validate.py +82 -0
- figrecipe/_cli/__init__.py +7 -0
- figrecipe/_cli/_compose.py +87 -0
- figrecipe/_cli/_convert.py +117 -0
- figrecipe/_cli/_crop.py +82 -0
- figrecipe/_cli/_edit.py +70 -0
- figrecipe/_cli/_extract.py +128 -0
- figrecipe/_cli/_fonts.py +47 -0
- figrecipe/_cli/_info.py +67 -0
- figrecipe/_cli/_main.py +58 -0
- figrecipe/_cli/_reproduce.py +79 -0
- figrecipe/_cli/_style.py +77 -0
- figrecipe/_cli/_validate.py +66 -0
- figrecipe/_cli/_version.py +50 -0
- figrecipe/_composition/__init__.py +32 -0
- figrecipe/_composition/_alignment.py +452 -0
- figrecipe/_composition/_compose.py +179 -0
- figrecipe/_composition/_import_axes.py +127 -0
- figrecipe/_composition/_visibility.py +125 -0
- figrecipe/_dev/__init__.py +4 -93
- figrecipe/_dev/_plotters.py +76 -0
- figrecipe/_dev/_run_demos.py +56 -0
- figrecipe/_dev/browser/__init__.py +69 -0
- figrecipe/_dev/browser/_audio.py +240 -0
- figrecipe/_dev/browser/_caption.py +356 -0
- figrecipe/_dev/browser/_click_effect.py +146 -0
- figrecipe/_dev/browser/_cursor.py +196 -0
- figrecipe/_dev/browser/_highlight.py +105 -0
- figrecipe/_dev/browser/_narration.py +237 -0
- figrecipe/_dev/browser/_recorder.py +446 -0
- figrecipe/_dev/browser/_utils.py +178 -0
- figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
- figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
- figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
- figrecipe/_dev/demo_plotters/__init__.py +35 -166
- figrecipe/_dev/demo_plotters/_categories.py +81 -0
- figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
- figrecipe/_dev/demo_plotters/_helpers.py +31 -0
- figrecipe/_dev/demo_plotters/_registry.py +50 -0
- figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
- figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
- figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
- figrecipe/_editor/__init__.py +61 -13
- figrecipe/_editor/_bbox/__init__.py +43 -0
- figrecipe/_editor/_bbox/_collections.py +177 -0
- figrecipe/_editor/_bbox/_elements.py +159 -0
- figrecipe/_editor/_bbox/_extract.py +402 -0
- figrecipe/_editor/_bbox/_extract_axes.py +370 -0
- figrecipe/_editor/_bbox/_extract_text.py +466 -0
- figrecipe/_editor/_bbox/_lines.py +173 -0
- figrecipe/_editor/_bbox/_transforms.py +146 -0
- figrecipe/_editor/_call_overrides.py +183 -0
- figrecipe/_editor/_datatable_plot_handlers.py +249 -0
- figrecipe/_editor/_figure_layout.py +211 -0
- figrecipe/_editor/_flask_app.py +200 -1030
- figrecipe/_editor/_helpers.py +251 -0
- figrecipe/_editor/_hitmap/__init__.py +76 -0
- figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
- figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
- figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
- figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
- figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
- figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
- figrecipe/_editor/_hitmap/_colors.py +181 -0
- figrecipe/_editor/_hitmap/_detect.py +194 -0
- figrecipe/_editor/_hitmap/_restore.py +154 -0
- figrecipe/_editor/_hitmap_main.py +182 -0
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_preferences.py +135 -0
- figrecipe/_editor/_render_overrides.py +507 -0
- figrecipe/_editor/_renderer.py +81 -186
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +482 -0
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +126 -0
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +335 -0
- figrecipe/_editor/_routes_files.py +443 -0
- figrecipe/_editor/_routes_image.py +200 -0
- figrecipe/_editor/_routes_snapshot.py +94 -0
- figrecipe/_editor/_routes_style.py +243 -0
- figrecipe/_editor/_templates/__init__.py +116 -1
- figrecipe/_editor/_templates/_html.py +154 -64
- figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
- figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
- figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
- figrecipe/_editor/_templates/_html_datatable.py +92 -0
- figrecipe/_editor/_templates/_scripts/__init__.py +178 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +228 -0
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +493 -0
- figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
- figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +325 -0
- figrecipe/_editor/_templates/_scripts/_files.py +429 -0
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +512 -0
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
- figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +270 -0
- figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +505 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +463 -0
- figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
- figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
- figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +244 -0
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +212 -0
- figrecipe/_editor/_templates/_styles/__init__.py +78 -0
- figrecipe/_editor/_templates/_styles/_base.py +111 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +327 -0
- figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +430 -0
- figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
- figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
- figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
- figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
- figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
- figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +224 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +191 -0
- figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
- figrecipe/_editor/_templates/_styles/_labels.py +118 -0
- figrecipe/_editor/_templates/_styles/_modals.py +127 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
- figrecipe/_editor/_templates/_styles/_preview.py +430 -0
- figrecipe/_editor/_templates/_styles/_selection.py +73 -0
- figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
- figrecipe/_editor/static/audio/click.mp3 +0 -0
- figrecipe/_editor/static/click.mp3 +0 -0
- figrecipe/_editor/static/icons/favicon.ico +0 -0
- figrecipe/_integrations/__init__.py +17 -0
- figrecipe/_integrations/_scitex_stats.py +298 -0
- figrecipe/_params/_DECORATION_METHODS.py +8 -0
- figrecipe/_recorder.py +63 -109
- figrecipe/_recorder_utils.py +124 -0
- figrecipe/_reproducer/__init__.py +18 -0
- figrecipe/_reproducer/_core.py +509 -0
- figrecipe/_reproducer/_custom_plots.py +279 -0
- figrecipe/_reproducer/_seaborn.py +100 -0
- figrecipe/_reproducer/_violin.py +186 -0
- figrecipe/_signatures/_kwargs.py +273 -0
- figrecipe/_signatures/_loader.py +21 -423
- figrecipe/_signatures/_parsing.py +147 -0
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +252 -895
- figrecipe/_wrappers/_axes_helpers.py +136 -0
- figrecipe/_wrappers/_axes_plots.py +418 -0
- figrecipe/_wrappers/_axes_seaborn.py +157 -0
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +188 -1
- figrecipe/_wrappers/_panel_labels.py +127 -0
- figrecipe/_wrappers/_plot_helpers.py +143 -0
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/_wrappers/_violin_helpers.py +180 -0
- figrecipe/styles/__init__.py +8 -6
- figrecipe/styles/_dotdict.py +72 -0
- figrecipe/styles/_finalize.py +134 -0
- figrecipe/styles/_fonts.py +77 -0
- figrecipe/styles/_kwargs_converter.py +178 -0
- figrecipe/styles/_plot_styles.py +209 -0
- figrecipe/styles/_style_applier.py +42 -480
- figrecipe/styles/_style_loader.py +16 -192
- figrecipe/styles/_themes.py +151 -0
- figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
- figrecipe/styles/presets/SCITEX.yaml +40 -28
- figrecipe-0.9.0.dist-info/METADATA +427 -0
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe/_editor/_bbox.py +0 -978
- figrecipe/_editor/_hitmap.py +0 -937
- figrecipe/_editor/_templates/_scripts.py +0 -2778
- figrecipe/_editor/_templates/_styles.py +0 -1326
- figrecipe/_reproducer.py +0 -975
- figrecipe-0.6.0.dist-info/METADATA +0 -394
- figrecipe-0.6.0.dist-info/RECORD +0 -90
- /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
- {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.6.0.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
|