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.
- figrecipe/__init__.py +74 -76
- figrecipe/__main__.py +12 -0
- figrecipe/_api/_panel.py +67 -0
- figrecipe/_api/_save.py +100 -4
- 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 +2 -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/_editor/__init__.py +36 -36
- figrecipe/_editor/_bbox/_extract.py +155 -9
- figrecipe/_editor/_bbox/_extract_text.py +124 -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 +157 -16
- figrecipe/_editor/_helpers.py +17 -8
- figrecipe/_editor/_hitmap/_detect.py +89 -32
- figrecipe/_editor/_hitmap_main.py +4 -4
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_render_overrides.py +38 -11
- figrecipe/_editor/_renderer.py +46 -1
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +35 -6
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +15 -173
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +37 -19
- 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 +28 -8
- figrecipe/_editor/_templates/__init__.py +40 -2
- figrecipe/_editor/_templates/_html.py +97 -103
- 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 +58 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +1 -1
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +94 -37
- 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/_element_editor.py +17 -2
- figrecipe/_editor/_templates/_scripts/_files.py +274 -40
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
- 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 +8 -1
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
- figrecipe/_editor/_templates/_styles/__init__.py +9 -0
- figrecipe/_editor/_templates/_styles/_base.py +47 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +168 -3
- 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 +5 -5
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +98 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
- figrecipe/_editor/_templates/_styles/_modals.py +29 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
- figrecipe/_editor/_templates/_styles/_preview.py +213 -8
- 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 +2 -0
- figrecipe/_recorder.py +28 -3
- figrecipe/_reproducer/_core.py +60 -49
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +150 -2
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +26 -1
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/styles/_style_applier.py +10 -2
- figrecipe/styles/presets/SCITEX.yaml +11 -4
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe-0.7.4.dist-info/RECORD +0 -188
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Import axes from external recipes into existing figures."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Tuple, Union
|
|
7
|
+
|
|
8
|
+
from .._recorder import FigureRecord
|
|
9
|
+
from .._serializer import load_recipe
|
|
10
|
+
from .._wrappers import RecordingAxes, RecordingFigure
|
|
11
|
+
from ._compose import _replay_axes_record
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def import_axes(
|
|
15
|
+
fig: RecordingFigure,
|
|
16
|
+
target_position: Tuple[int, int],
|
|
17
|
+
source: Union[str, Path, FigureRecord],
|
|
18
|
+
source_axes: str = "ax_0_0",
|
|
19
|
+
) -> RecordingAxes:
|
|
20
|
+
"""Import axes from another recipe into an existing figure.
|
|
21
|
+
|
|
22
|
+
This function copies all plotting calls and decorations from a source
|
|
23
|
+
axes (in a recipe file or FigureRecord) to a target position in an
|
|
24
|
+
existing figure. The target axes is cleared before import.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
fig : RecordingFigure
|
|
29
|
+
Target figure to import into.
|
|
30
|
+
target_position : tuple
|
|
31
|
+
(row, col) position in target figure.
|
|
32
|
+
source : str, Path, or FigureRecord
|
|
33
|
+
Source recipe file path or FigureRecord object.
|
|
34
|
+
source_axes : str, optional
|
|
35
|
+
Key of axes to import from source (default: "ax_0_0").
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
RecordingAxes
|
|
40
|
+
The target axes after import.
|
|
41
|
+
|
|
42
|
+
Raises
|
|
43
|
+
------
|
|
44
|
+
ValueError
|
|
45
|
+
If source_axes key not found in source.
|
|
46
|
+
TypeError
|
|
47
|
+
If source is not a valid type.
|
|
48
|
+
|
|
49
|
+
Examples
|
|
50
|
+
--------
|
|
51
|
+
>>> import figrecipe as fr
|
|
52
|
+
>>> fig, axes = fr.subplots(1, 2)
|
|
53
|
+
>>> axes[0].plot([1, 2, 3], [1, 4, 9])
|
|
54
|
+
>>> fr.import_axes(fig, (0, 1), "analysis.yaml")
|
|
55
|
+
"""
|
|
56
|
+
# Load source if path
|
|
57
|
+
if isinstance(source, (str, Path)):
|
|
58
|
+
source_record = load_recipe(source)
|
|
59
|
+
elif isinstance(source, FigureRecord):
|
|
60
|
+
source_record = source
|
|
61
|
+
else:
|
|
62
|
+
raise TypeError(f"Invalid source type: {type(source)}")
|
|
63
|
+
|
|
64
|
+
# Get source axes record
|
|
65
|
+
ax_record = source_record.axes.get(source_axes)
|
|
66
|
+
if ax_record is None:
|
|
67
|
+
available = list(source_record.axes.keys())
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Axes '{source_axes}' not found in source. Available: {available}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Get target axes
|
|
73
|
+
row, col = target_position
|
|
74
|
+
target_ax = _get_target_axes(fig, row, col)
|
|
75
|
+
|
|
76
|
+
# Clear existing content
|
|
77
|
+
mpl_ax = target_ax._ax if hasattr(target_ax, "_ax") else target_ax
|
|
78
|
+
mpl_ax.clear()
|
|
79
|
+
|
|
80
|
+
# Replay source calls onto target
|
|
81
|
+
_replay_axes_record(target_ax, ax_record, fig.record, row, col)
|
|
82
|
+
|
|
83
|
+
return target_ax
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_target_axes(
|
|
87
|
+
fig: RecordingFigure,
|
|
88
|
+
row: int,
|
|
89
|
+
col: int,
|
|
90
|
+
) -> RecordingAxes:
|
|
91
|
+
"""Get target axes from figure at position.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
fig : RecordingFigure
|
|
96
|
+
The figure.
|
|
97
|
+
row, col : int
|
|
98
|
+
Target position.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
RecordingAxes
|
|
103
|
+
Axes at position.
|
|
104
|
+
|
|
105
|
+
Raises
|
|
106
|
+
------
|
|
107
|
+
IndexError
|
|
108
|
+
If position is out of range.
|
|
109
|
+
"""
|
|
110
|
+
if not hasattr(fig, "_axes"):
|
|
111
|
+
raise ValueError("Figure must have _axes attribute")
|
|
112
|
+
|
|
113
|
+
axes = fig._axes
|
|
114
|
+
try:
|
|
115
|
+
# Handle different axes array structures
|
|
116
|
+
if isinstance(axes, list):
|
|
117
|
+
if isinstance(axes[0], list):
|
|
118
|
+
return axes[row][col]
|
|
119
|
+
else:
|
|
120
|
+
return axes[max(row, col)]
|
|
121
|
+
else:
|
|
122
|
+
return axes[row, col]
|
|
123
|
+
except (IndexError, KeyError) as e:
|
|
124
|
+
raise IndexError(f"Position ({row}, {col}) out of range for figure axes") from e
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = ["import_axes"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Panel visibility management for composition feature."""
|
|
4
|
+
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
from .._wrappers import RecordingFigure
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def hide_panel(fig: RecordingFigure, position: Tuple[int, int]) -> None:
|
|
11
|
+
"""Hide a panel (visually drop it without deleting data).
|
|
12
|
+
|
|
13
|
+
The panel data is preserved in the recipe but not rendered.
|
|
14
|
+
Use show_panel() to restore visibility.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
fig : RecordingFigure
|
|
19
|
+
The figure containing the panel.
|
|
20
|
+
position : tuple
|
|
21
|
+
(row, col) position of the panel to hide.
|
|
22
|
+
|
|
23
|
+
Examples
|
|
24
|
+
--------
|
|
25
|
+
>>> import figrecipe as fr
|
|
26
|
+
>>> fig, axes = fr.subplots(1, 2)
|
|
27
|
+
>>> axes[0].plot([1, 2], [1, 2])
|
|
28
|
+
>>> fr.hide_panel(fig, (0, 1)) # Hide empty second panel
|
|
29
|
+
"""
|
|
30
|
+
ax_key = f"ax_{position[0]}_{position[1]}"
|
|
31
|
+
if ax_key in fig.record.axes:
|
|
32
|
+
fig.record.axes[ax_key].visible = False
|
|
33
|
+
_set_axes_visible(fig, position, False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def show_panel(fig: RecordingFigure, position: Tuple[int, int]) -> None:
|
|
37
|
+
"""Show a previously hidden panel.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
fig : RecordingFigure
|
|
42
|
+
The figure containing the panel.
|
|
43
|
+
position : tuple
|
|
44
|
+
(row, col) position of the panel to show.
|
|
45
|
+
|
|
46
|
+
Examples
|
|
47
|
+
--------
|
|
48
|
+
>>> import figrecipe as fr
|
|
49
|
+
>>> fig, axes = fr.subplots(1, 2)
|
|
50
|
+
>>> fr.hide_panel(fig, (0, 1))
|
|
51
|
+
>>> fr.show_panel(fig, (0, 1)) # Restore visibility
|
|
52
|
+
"""
|
|
53
|
+
ax_key = f"ax_{position[0]}_{position[1]}"
|
|
54
|
+
if ax_key in fig.record.axes:
|
|
55
|
+
fig.record.axes[ax_key].visible = True
|
|
56
|
+
_set_axes_visible(fig, position, True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def toggle_panel(fig: RecordingFigure, position: Tuple[int, int]) -> bool:
|
|
60
|
+
"""Toggle panel visibility.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
fig : RecordingFigure
|
|
65
|
+
The figure containing the panel.
|
|
66
|
+
position : tuple
|
|
67
|
+
(row, col) position of the panel.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
bool
|
|
72
|
+
New visibility state (True = visible, False = hidden).
|
|
73
|
+
|
|
74
|
+
Examples
|
|
75
|
+
--------
|
|
76
|
+
>>> import figrecipe as fr
|
|
77
|
+
>>> fig, ax = fr.subplots()
|
|
78
|
+
>>> fr.toggle_panel(fig, (0, 0)) # Returns False (now hidden)
|
|
79
|
+
>>> fr.toggle_panel(fig, (0, 0)) # Returns True (now visible)
|
|
80
|
+
"""
|
|
81
|
+
ax_key = f"ax_{position[0]}_{position[1]}"
|
|
82
|
+
if ax_key in fig.record.axes:
|
|
83
|
+
current = fig.record.axes[ax_key].visible
|
|
84
|
+
if current:
|
|
85
|
+
hide_panel(fig, position)
|
|
86
|
+
else:
|
|
87
|
+
show_panel(fig, position)
|
|
88
|
+
return not current
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _set_axes_visible(
|
|
93
|
+
fig: RecordingFigure,
|
|
94
|
+
position: Tuple[int, int],
|
|
95
|
+
visible: bool,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Set matplotlib axes visibility.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
fig : RecordingFigure
|
|
102
|
+
The figure.
|
|
103
|
+
position : tuple
|
|
104
|
+
(row, col) position.
|
|
105
|
+
visible : bool
|
|
106
|
+
Whether to make visible or hidden.
|
|
107
|
+
"""
|
|
108
|
+
row, col = position
|
|
109
|
+
try:
|
|
110
|
+
axes = fig._axes
|
|
111
|
+
if isinstance(axes, list):
|
|
112
|
+
if isinstance(axes[0], list):
|
|
113
|
+
ax = axes[row][col]
|
|
114
|
+
else:
|
|
115
|
+
ax = axes[max(row, col)]
|
|
116
|
+
else:
|
|
117
|
+
ax = axes[row, col]
|
|
118
|
+
|
|
119
|
+
mpl_ax = ax._ax if hasattr(ax, "_ax") else ax
|
|
120
|
+
mpl_ax.set_visible(visible)
|
|
121
|
+
except (IndexError, AttributeError, KeyError):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["hide_panel", "show_panel", "toggle_panel"]
|
figrecipe/_dev/__init__.py
CHANGED
|
@@ -16,6 +16,7 @@ Usage:
|
|
|
16
16
|
results = run_all_demos(fr, output_dir="./outputs")
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
+
from . import browser
|
|
19
20
|
from ._plotters import PLOTTERS, get_plotter, list_plotters
|
|
20
21
|
from ._run_demos import run_all_demos
|
|
21
22
|
|
|
@@ -24,6 +25,7 @@ __all__ = [
|
|
|
24
25
|
"list_plotters",
|
|
25
26
|
"get_plotter",
|
|
26
27
|
"run_all_demos",
|
|
28
|
+
"browser",
|
|
27
29
|
]
|
|
28
30
|
|
|
29
31
|
# EOF
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Browser automation utilities for demo generation.
|
|
4
|
+
|
|
5
|
+
This module provides tools for creating visual demos of figrecipe features
|
|
6
|
+
using Playwright for browser automation.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Mouse cursor visualization
|
|
10
|
+
- Click effect animations
|
|
11
|
+
- Caption overlays
|
|
12
|
+
- Element highlighting
|
|
13
|
+
- Video/GIF recording
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._audio import generate_tts_segments, mix_narration_with_bgm
|
|
17
|
+
from ._caption import hide_caption, show_caption, show_title_screen
|
|
18
|
+
from ._click_effect import inject_click_effect, remove_click_effect
|
|
19
|
+
from ._cursor import (
|
|
20
|
+
inject_cursor,
|
|
21
|
+
move_cursor_to,
|
|
22
|
+
move_cursor_to_element,
|
|
23
|
+
remove_cursor,
|
|
24
|
+
)
|
|
25
|
+
from ._highlight import highlight_element
|
|
26
|
+
from ._narration import (
|
|
27
|
+
add_narration_to_video,
|
|
28
|
+
estimate_caption_times,
|
|
29
|
+
extract_captions_from_script,
|
|
30
|
+
get_video_duration,
|
|
31
|
+
)
|
|
32
|
+
from ._recorder import DemoRecorder
|
|
33
|
+
from ._utils import concatenate_videos, convert_to_gif
|
|
34
|
+
from ._video_trim import (
|
|
35
|
+
detect_markers,
|
|
36
|
+
inject_end_marker,
|
|
37
|
+
inject_start_marker,
|
|
38
|
+
process_video_with_markers,
|
|
39
|
+
trim_video_by_markers,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"inject_cursor",
|
|
44
|
+
"remove_cursor",
|
|
45
|
+
"move_cursor_to",
|
|
46
|
+
"move_cursor_to_element",
|
|
47
|
+
"inject_click_effect",
|
|
48
|
+
"remove_click_effect",
|
|
49
|
+
"show_caption",
|
|
50
|
+
"hide_caption",
|
|
51
|
+
"show_title_screen",
|
|
52
|
+
"highlight_element",
|
|
53
|
+
"DemoRecorder",
|
|
54
|
+
"convert_to_gif",
|
|
55
|
+
"concatenate_videos",
|
|
56
|
+
"inject_start_marker",
|
|
57
|
+
"inject_end_marker",
|
|
58
|
+
"detect_markers",
|
|
59
|
+
"trim_video_by_markers",
|
|
60
|
+
"process_video_with_markers",
|
|
61
|
+
"generate_tts_segments",
|
|
62
|
+
"mix_narration_with_bgm",
|
|
63
|
+
"extract_captions_from_script",
|
|
64
|
+
"get_video_duration",
|
|
65
|
+
"estimate_caption_times",
|
|
66
|
+
"add_narration_to_video",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# EOF
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Audio processing for demo videos.
|
|
4
|
+
|
|
5
|
+
Provides TTS generation and audio mixing for demo narration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Tuple
|
|
14
|
+
|
|
15
|
+
# Check for ElevenLabs API key
|
|
16
|
+
ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sanitize_filename(text: str, max_length: int = 50) -> str:
|
|
20
|
+
"""Convert text to a safe filename prefix.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
text : str
|
|
25
|
+
Text to convert.
|
|
26
|
+
max_length : int
|
|
27
|
+
Maximum length of the result.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
str
|
|
32
|
+
Sanitized filename-safe string.
|
|
33
|
+
"""
|
|
34
|
+
# Remove special characters, keep alphanumeric and spaces
|
|
35
|
+
clean = re.sub(r"[^a-zA-Z0-9\s]", "", text)
|
|
36
|
+
# Replace spaces with underscores
|
|
37
|
+
clean = re.sub(r"\s+", "_", clean.strip())
|
|
38
|
+
# Truncate and lowercase
|
|
39
|
+
return clean[:max_length].lower().rstrip("_")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_cache_path(text: str, cache_dir: Path) -> Path:
|
|
43
|
+
"""Get cache file path using transcription-based naming.
|
|
44
|
+
|
|
45
|
+
Format: {sanitized_text}_{hash}.mp3
|
|
46
|
+
Example: enable_dark_mode_demo_a1b2c3d4.mp3
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
text : str
|
|
51
|
+
Text content for TTS.
|
|
52
|
+
cache_dir : Path
|
|
53
|
+
Cache directory.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
Path
|
|
58
|
+
Cache file path.
|
|
59
|
+
"""
|
|
60
|
+
# Create short hash for uniqueness
|
|
61
|
+
text_hash = hashlib.md5(text.encode()).hexdigest()[:8]
|
|
62
|
+
# Sanitize text for filename
|
|
63
|
+
sanitized = _sanitize_filename(text)
|
|
64
|
+
# Combine: readable prefix + hash for uniqueness
|
|
65
|
+
filename = f"{sanitized}_{text_hash}.mp3"
|
|
66
|
+
return cache_dir / filename
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def generate_tts_segments(
|
|
70
|
+
narrations: List[Tuple[str, str]],
|
|
71
|
+
output_dir: Path,
|
|
72
|
+
) -> List[Path]:
|
|
73
|
+
"""Generate TTS audio files for narrations.
|
|
74
|
+
|
|
75
|
+
Uses ElevenLabs if API key is available, falls back to gTTS.
|
|
76
|
+
Cache files are named using transcription text for easy identification.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
narrations : List[Tuple[str, str]]
|
|
81
|
+
List of (name, text) tuples.
|
|
82
|
+
output_dir : Path
|
|
83
|
+
Output directory for audio files.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
List[Path]
|
|
88
|
+
List of generated audio file paths.
|
|
89
|
+
"""
|
|
90
|
+
output_dir = Path(output_dir)
|
|
91
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
audio_files = []
|
|
94
|
+
|
|
95
|
+
if ELEVENLABS_API_KEY:
|
|
96
|
+
print("Using ElevenLabs TTS (high quality)")
|
|
97
|
+
try:
|
|
98
|
+
from elevenlabs import ElevenLabs
|
|
99
|
+
|
|
100
|
+
client = ElevenLabs(api_key=ELEVENLABS_API_KEY)
|
|
101
|
+
|
|
102
|
+
for name, text in narrations:
|
|
103
|
+
cache_path = _get_cache_path(text, output_dir)
|
|
104
|
+
|
|
105
|
+
# Check cache
|
|
106
|
+
if cache_path.exists():
|
|
107
|
+
print(f" [cache] {cache_path.name}")
|
|
108
|
+
audio_files.append(cache_path)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# Generate TTS
|
|
112
|
+
audio = client.text_to_speech.convert(
|
|
113
|
+
voice_id="21m00Tcm4TlvDq8ikWAM", # Rachel
|
|
114
|
+
text=text,
|
|
115
|
+
model_id="eleven_monolingual_v1",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Save audio
|
|
119
|
+
with open(cache_path, "wb") as f:
|
|
120
|
+
for chunk in audio:
|
|
121
|
+
f.write(chunk)
|
|
122
|
+
|
|
123
|
+
print(f" ✓ ElevenLabs: {cache_path.name} - '{text[:40]}...'")
|
|
124
|
+
audio_files.append(cache_path)
|
|
125
|
+
|
|
126
|
+
return audio_files
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
print(f" ElevenLabs failed: {e}, falling back to gTTS")
|
|
130
|
+
|
|
131
|
+
# Fallback to gTTS
|
|
132
|
+
print("Using gTTS (fallback)")
|
|
133
|
+
try:
|
|
134
|
+
from gtts import gTTS
|
|
135
|
+
|
|
136
|
+
for name, text in narrations:
|
|
137
|
+
cache_path = _get_cache_path(text, output_dir)
|
|
138
|
+
|
|
139
|
+
# Check cache
|
|
140
|
+
if cache_path.exists():
|
|
141
|
+
print(f" [cache] {cache_path.name}")
|
|
142
|
+
audio_files.append(cache_path)
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Generate TTS
|
|
146
|
+
tts = gTTS(text=text, lang="en")
|
|
147
|
+
tts.save(str(cache_path))
|
|
148
|
+
|
|
149
|
+
print(f" ✓ gTTS: {cache_path.name} - '{text[:40]}...'")
|
|
150
|
+
audio_files.append(cache_path)
|
|
151
|
+
|
|
152
|
+
return audio_files
|
|
153
|
+
|
|
154
|
+
except ImportError:
|
|
155
|
+
raise RuntimeError("Neither ElevenLabs nor gTTS available")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def mix_narration_with_bgm(
|
|
159
|
+
narration_files: List[Path],
|
|
160
|
+
narration_times: List[float],
|
|
161
|
+
bgm_path: Path,
|
|
162
|
+
output_path: Path,
|
|
163
|
+
duration: float,
|
|
164
|
+
bgm_volume: float = 0.10,
|
|
165
|
+
narration_delay: float = 0.3,
|
|
166
|
+
fade_in_duration: float = 0.5,
|
|
167
|
+
fade_out_duration: float = 1.0,
|
|
168
|
+
) -> Path:
|
|
169
|
+
"""Mix narration audio with background music.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
narration_files : List[Path]
|
|
174
|
+
List of narration audio files.
|
|
175
|
+
narration_times : List[float]
|
|
176
|
+
Start times for each narration.
|
|
177
|
+
bgm_path : Path
|
|
178
|
+
Path to background music file.
|
|
179
|
+
output_path : Path
|
|
180
|
+
Output path for mixed audio.
|
|
181
|
+
duration : float
|
|
182
|
+
Total duration in seconds.
|
|
183
|
+
bgm_volume : float
|
|
184
|
+
Background music volume (0.0-1.0).
|
|
185
|
+
narration_delay : float
|
|
186
|
+
Delay before narration starts.
|
|
187
|
+
fade_in_duration : float
|
|
188
|
+
BGM fade-in duration.
|
|
189
|
+
fade_out_duration : float
|
|
190
|
+
BGM fade-out duration.
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
Path
|
|
195
|
+
Path to mixed audio file.
|
|
196
|
+
"""
|
|
197
|
+
# Build ffmpeg filter for mixing
|
|
198
|
+
inputs = ["-i", str(bgm_path)]
|
|
199
|
+
for f in narration_files:
|
|
200
|
+
inputs.extend(["-i", str(f)])
|
|
201
|
+
|
|
202
|
+
# BGM filter: loop, trim, volume, fade
|
|
203
|
+
bgm_filter = (
|
|
204
|
+
f"[0:a]aloop=loop=-1:size=2e+09,atrim=0:{duration},"
|
|
205
|
+
f"volume={bgm_volume},"
|
|
206
|
+
f"afade=t=in:st=0:d={fade_in_duration},"
|
|
207
|
+
f"afade=t=out:st={duration - fade_out_duration}:d={fade_out_duration}[bgm]"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Narration filters with delays
|
|
211
|
+
narration_filters = []
|
|
212
|
+
mix_inputs = "[bgm]"
|
|
213
|
+
|
|
214
|
+
for i, (f, t) in enumerate(zip(narration_files, narration_times)):
|
|
215
|
+
delay_ms = int((t + narration_delay) * 1000)
|
|
216
|
+
narration_filters.append(f"[{i + 1}:a]adelay={delay_ms}|{delay_ms}[n{i}]")
|
|
217
|
+
mix_inputs += f"[n{i}]"
|
|
218
|
+
|
|
219
|
+
# Combine all filters
|
|
220
|
+
all_filters = [bgm_filter] + narration_filters
|
|
221
|
+
mix_filter = (
|
|
222
|
+
f"{mix_inputs}amix=inputs={len(narration_files) + 1}:duration=first[out]"
|
|
223
|
+
)
|
|
224
|
+
all_filters.append(mix_filter)
|
|
225
|
+
|
|
226
|
+
filter_complex = ";".join(all_filters)
|
|
227
|
+
|
|
228
|
+
# Run ffmpeg
|
|
229
|
+
cmd = (
|
|
230
|
+
["ffmpeg", "-y"]
|
|
231
|
+
+ inputs
|
|
232
|
+
+ ["-filter_complex", filter_complex, "-map", "[out]", str(output_path)]
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
236
|
+
|
|
237
|
+
return output_path
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
__all__ = ["generate_tts_segments", "mix_narration_with_bgm"]
|