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,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"]
@@ -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"]