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,152 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Video trimming with spatiotemporal marker detection.
4
+
5
+ Uses visual markers with OCR-readable text for reliable detection
6
+ of start/end points. Markers encode metadata that can be re-extracted.
7
+
8
+ Spatiotemporal Encoding:
9
+ - Temporal: Peak detection finds marker frames (dark frames)
10
+ - Spatial: Text at fixed positions, extracted via OCR
11
+ - Metadata: Version, timestamp embedded and recoverable
12
+
13
+ Usage:
14
+ # In recorder - inject markers
15
+ await inject_start_marker(page, version="0.8.0", timestamp="2025-12-27")
16
+ # ... record content ...
17
+ await inject_end_marker(page, version="0.8.0", timestamp="2025-12-27")
18
+
19
+ # Post-processing - detect and trim
20
+ output, metadata = process_video_with_markers(input_path, output_path)
21
+ """
22
+
23
+ import subprocess
24
+ from pathlib import Path
25
+ from typing import Dict, Optional, Tuple
26
+
27
+ from ._detection import detect_markers
28
+ from ._markers import (
29
+ MARKER_END_ID,
30
+ MARKER_START_ID,
31
+ inject_end_marker,
32
+ inject_start_marker,
33
+ )
34
+
35
+
36
+ def trim_video(
37
+ input_path: Path,
38
+ output_path: Path,
39
+ start_time: Optional[float] = None,
40
+ end_time: Optional[float] = None,
41
+ codec: str = "libx264",
42
+ ) -> bool:
43
+ """Trim video based on timestamps.
44
+
45
+ Parameters
46
+ ----------
47
+ input_path : Path
48
+ Input video file.
49
+ output_path : Path
50
+ Output video file.
51
+ start_time : float, optional
52
+ Start time in seconds (trim before this).
53
+ end_time : float, optional
54
+ End time in seconds (trim after this).
55
+ codec : str
56
+ Video codec (default: libx264 for H.264).
57
+
58
+ Returns
59
+ -------
60
+ bool
61
+ True if successful.
62
+ """
63
+ cmd = ["ffmpeg", "-y"]
64
+
65
+ if start_time is not None:
66
+ cmd.extend(["-ss", str(start_time)])
67
+
68
+ cmd.extend(["-i", str(input_path)])
69
+
70
+ if end_time is not None and start_time is not None:
71
+ duration = end_time - start_time
72
+ if duration > 0:
73
+ cmd.extend(["-t", str(duration)])
74
+ elif end_time is not None:
75
+ cmd.extend(["-t", str(end_time)])
76
+
77
+ cmd.extend(["-c:v", codec, "-preset", "fast", "-crf", "23", str(output_path)])
78
+
79
+ try:
80
+ subprocess.run(cmd, capture_output=True, check=True)
81
+ return True
82
+ except subprocess.CalledProcessError:
83
+ return False
84
+
85
+
86
+ def process_video_with_markers(
87
+ input_path: Path,
88
+ output_path: Path,
89
+ cleanup: bool = True,
90
+ verbose: bool = True,
91
+ ) -> Tuple[Path, Dict]:
92
+ """Full pipeline: detect markers, extract metadata, trim video.
93
+
94
+ Parameters
95
+ ----------
96
+ input_path : Path
97
+ Raw recorded video (with markers).
98
+ output_path : Path
99
+ Trimmed output video.
100
+ cleanup : bool
101
+ Remove input file after success.
102
+ verbose : bool
103
+ Print debug information.
104
+
105
+ Returns
106
+ -------
107
+ Tuple[Path, Dict]
108
+ (output_path, extracted_metadata)
109
+ """
110
+ input_path = Path(input_path)
111
+ output_path = Path(output_path)
112
+
113
+ # Detect markers and extract metadata
114
+ start_time, end_time, metadata = detect_markers(input_path)
115
+
116
+ if verbose:
117
+ print(f" Marker detection: start={start_time}, end={end_time}")
118
+
119
+ if start_time is None:
120
+ print("Warning: No start marker detected, using beginning")
121
+ start_time = 0
122
+
123
+ if end_time is None:
124
+ print("Warning: No end marker detected, using end of video")
125
+
126
+ # Trim video
127
+ success = trim_video(input_path, output_path, start_time, end_time)
128
+
129
+ if not success:
130
+ raise RuntimeError(f"Failed to trim video: {input_path}")
131
+
132
+ if cleanup and input_path != output_path and input_path.exists():
133
+ input_path.unlink()
134
+
135
+ return output_path, metadata
136
+
137
+
138
+ # Aliases for backward compatibility
139
+ trim_video_by_markers = trim_video
140
+
141
+ __all__ = [
142
+ "inject_start_marker",
143
+ "inject_end_marker",
144
+ "detect_markers",
145
+ "trim_video",
146
+ "trim_video_by_markers",
147
+ "process_video_with_markers",
148
+ "MARKER_START_ID",
149
+ "MARKER_END_ID",
150
+ ]
151
+
152
+ # EOF
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Marker detection using spatiotemporal analysis.
4
+
5
+ - Temporal: Peak detection finds dark marker frames
6
+ - Spatial: OCR extracts text at fixed positions
7
+ """
8
+
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ import numpy as np
16
+
17
+
18
+ def extract_frames(video_path: Path, fps: int = 5) -> List[Tuple[float, np.ndarray]]:
19
+ """Extract frames from video with timestamps."""
20
+ frames = []
21
+
22
+ with tempfile.TemporaryDirectory() as tmpdir:
23
+ frame_pattern = Path(tmpdir) / "frame_%06d.png"
24
+ cmd = [
25
+ "ffmpeg",
26
+ "-i",
27
+ str(video_path),
28
+ "-vf",
29
+ f"fps={fps}",
30
+ "-f",
31
+ "image2",
32
+ str(frame_pattern),
33
+ ]
34
+ subprocess.run(cmd, capture_output=True, check=True)
35
+
36
+ for i, frame_file in enumerate(sorted(Path(tmpdir).glob("frame_*.png"))):
37
+ timestamp = i / fps
38
+ try:
39
+ from PIL import Image
40
+
41
+ img = Image.open(frame_file)
42
+ frame = np.array(img)[:, :, :3]
43
+ except ImportError:
44
+ import imageio
45
+
46
+ frame = imageio.imread(frame_file)[:, :, :3]
47
+ frames.append((timestamp, frame))
48
+
49
+ return frames
50
+
51
+
52
+ def frame_brightness(frame: np.ndarray) -> float:
53
+ """Calculate mean brightness (0-255)."""
54
+ return float(np.mean(frame))
55
+
56
+
57
+ def is_dark_frame(frame: np.ndarray, threshold: float = 30) -> bool:
58
+ """Check if frame is dark (marker candidate)."""
59
+ return frame_brightness(frame) < threshold
60
+
61
+
62
+ def detect_dark_frames(
63
+ frames: List[Tuple[float, np.ndarray]],
64
+ ) -> List[float]:
65
+ """Find timestamps of dark frames using peak detection."""
66
+ if len(frames) < 3:
67
+ return []
68
+
69
+ brightnesses = np.array([frame_brightness(f[1]) for f in frames])
70
+ dark_timestamps = []
71
+
72
+ for i in range(len(brightnesses)):
73
+ if brightnesses[i] < 40: # Dark threshold
74
+ dark_timestamps.append(frames[i][0])
75
+
76
+ return dark_timestamps
77
+
78
+
79
+ def ocr_frame(frame: np.ndarray) -> str:
80
+ """Extract text from frame using Tesseract OCR."""
81
+ try:
82
+ import pytesseract
83
+ from PIL import Image
84
+
85
+ img = Image.fromarray(frame)
86
+ text = pytesseract.image_to_string(
87
+ img,
88
+ config="--psm 6",
89
+ )
90
+ return text.strip()
91
+ except ImportError:
92
+ return ""
93
+ except Exception:
94
+ return ""
95
+
96
+
97
+ def extract_marker_metadata(frame: np.ndarray) -> Optional[Dict]:
98
+ """Extract metadata from marker frame via OCR.
99
+
100
+ Detection strategy (OCR is unreliable, so be lenient):
101
+ 1. Accept frames with version string (vX.Y.Z or X.Y.Z pattern)
102
+ 2. OR accept frames with marker keywords (TRIM, START, END)
103
+ 3. Caller determines type by position (first=start, last=end)
104
+ """
105
+ text = ocr_frame(frame)
106
+ if not text:
107
+ return None
108
+
109
+ metadata = {}
110
+ text_upper = text.upper()
111
+
112
+ # Accept if has version string OR marker keywords
113
+ has_version = bool(re.search(r"\d+\.\d+\.\d+", text))
114
+ has_keywords = any(kw in text_upper for kw in ["TRIM", "START", "END"])
115
+
116
+ if not (has_version or has_keywords):
117
+ return None
118
+
119
+ # Try to detect type from text, but this is optional
120
+ if "START" in text_upper:
121
+ metadata["marker_type"] = "start"
122
+ elif "END" in text_upper:
123
+ metadata["marker_type"] = "end"
124
+ else:
125
+ # Type will be determined by caller based on position
126
+ metadata["marker_type"] = "marker"
127
+
128
+ # Extract version (vX.Y.Z pattern)
129
+ version_match = re.search(r"v?(\d+\.\d+\.\d+)", text)
130
+ if version_match:
131
+ metadata["version"] = version_match.group(1)
132
+
133
+ # Extract timestamp (YYYY-MM-DD HH:MM pattern)
134
+ ts_match = re.search(r"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})", text)
135
+ if ts_match:
136
+ metadata["timestamp"] = ts_match.group(1)
137
+
138
+ return metadata
139
+
140
+
141
+ def detect_markers(
142
+ video_path: Path,
143
+ fps: int = 5, # Lower fps for faster processing (500ms markers = 2.5 frames)
144
+ ) -> Tuple[Optional[float], Optional[float], Dict]:
145
+ """Detect start/end markers using spatiotemporal analysis.
146
+
147
+ Strategy:
148
+ 1. Find dark frames (brightness < 40)
149
+ 2. OCR each to find version string (marker identifier)
150
+ 3. First marker = START, last marker = END
151
+
152
+ Returns (start_time, end_time, metadata).
153
+ """
154
+ frames = extract_frames(video_path, fps=fps)
155
+ if not frames:
156
+ return None, None, {}
157
+
158
+ # Stage 1: Find dark frame candidates
159
+ dark_timestamps = detect_dark_frames(frames)
160
+
161
+ # Stage 2: Collect marker groups (consecutive frames = same marker)
162
+ # Group frames that are within 1 second of each other
163
+ marker_groups = [] # List of (first_timestamp, last_timestamp, metadata)
164
+ current_group = None
165
+
166
+ for timestamp in dark_timestamps:
167
+ frame_idx = int(timestamp * fps)
168
+ if 0 <= frame_idx < len(frames):
169
+ frame = frames[frame_idx][1]
170
+ marker_meta = extract_marker_metadata(frame)
171
+
172
+ if marker_meta:
173
+ if current_group is None:
174
+ # Start new group
175
+ current_group = [timestamp, timestamp, marker_meta]
176
+ elif timestamp - current_group[1] < 1.0:
177
+ # Extend current group
178
+ current_group[1] = timestamp
179
+ else:
180
+ # Save current group and start new one
181
+ marker_groups.append(tuple(current_group))
182
+ current_group = [timestamp, timestamp, marker_meta]
183
+
184
+ if current_group:
185
+ marker_groups.append(tuple(current_group))
186
+
187
+ # Stage 3: Determine start/end by position
188
+ start_time = None
189
+ end_time = None
190
+ metadata = {}
191
+
192
+ if marker_groups:
193
+ # First group is START (use first frame of group)
194
+ start_time = marker_groups[0][0]
195
+ metadata["start"] = marker_groups[0][2]
196
+ metadata["start"]["marker_type"] = "start"
197
+
198
+ # Last group is END (use first frame of group, if different from first group)
199
+ if len(marker_groups) > 1:
200
+ end_time = marker_groups[-1][0]
201
+ metadata["end"] = marker_groups[-1][2]
202
+ metadata["end"]["marker_type"] = "end"
203
+
204
+ # Trim margins: drop first 1.5s after start marker, drop last 1s before end marker
205
+ if start_time is not None:
206
+ start_time += 1.5 # Drop first 1.5 seconds
207
+ if end_time is not None:
208
+ end_time -= 1.0 # Drop last 1 second
209
+
210
+ return start_time, end_time, metadata
211
+
212
+
213
+ __all__ = [
214
+ "extract_frames",
215
+ "frame_brightness",
216
+ "is_dark_frame",
217
+ "detect_dark_frames",
218
+ "ocr_frame",
219
+ "extract_marker_metadata",
220
+ "detect_markers",
221
+ ]
222
+
223
+ # EOF
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Marker injection for video trimming.
4
+
5
+ Injects visual markers with OCR-readable metadata text.
6
+ Uses same async pattern as title_screen which is known to work.
7
+ """
8
+
9
+ # Marker identifiers
10
+ MARKER_START_ID = "TRIM_START"
11
+ MARKER_END_ID = "TRIM_END"
12
+
13
+ # JavaScript to create marker overlay with metadata text
14
+ # Uses async function pattern matching show_title_screen
15
+ MARKER_OVERLAY_JS = """
16
+ async (args) => {
17
+ const { marker_id, version, timestamp, duration } = args;
18
+
19
+ const overlay = document.createElement('div');
20
+ overlay.id = 'trim-marker-overlay';
21
+ overlay.style.cssText = `
22
+ position: fixed;
23
+ top: 0;
24
+ left: 0;
25
+ width: 100vw;
26
+ height: 100vh;
27
+ background: #000000;
28
+ z-index: 2147483647;
29
+ display: flex;
30
+ flex-direction: column;
31
+ justify-content: center;
32
+ align-items: center;
33
+ font-family: 'Courier New', monospace;
34
+ color: #FFFFFF;
35
+ `;
36
+
37
+ // Marker ID (top) - OCR-friendly format
38
+ const idText = document.createElement('div');
39
+ idText.textContent = '== ' + marker_id + ' ==';
40
+ idText.style.cssText = `
41
+ font-size: 72px;
42
+ font-weight: bold;
43
+ letter-spacing: 8px;
44
+ margin-bottom: 60px;
45
+ `;
46
+ overlay.appendChild(idText);
47
+
48
+ // Version (center)
49
+ const verText = document.createElement('div');
50
+ verText.textContent = version;
51
+ verText.style.cssText = `
52
+ font-size: 48px;
53
+ margin-bottom: 40px;
54
+ `;
55
+ overlay.appendChild(verText);
56
+
57
+ // Timestamp (bottom)
58
+ const tsText = document.createElement('div');
59
+ tsText.textContent = timestamp;
60
+ tsText.style.cssText = `
61
+ font-size: 36px;
62
+ opacity: 0.9;
63
+ `;
64
+ overlay.appendChild(tsText);
65
+
66
+ document.body.appendChild(overlay);
67
+
68
+ // Wait for duration
69
+ await new Promise(r => setTimeout(r, duration));
70
+
71
+ overlay.remove();
72
+ return true;
73
+ }
74
+ """
75
+
76
+
77
+ async def inject_start_marker(
78
+ page,
79
+ version: str = "",
80
+ timestamp: str = "",
81
+ duration_ms: int = 500,
82
+ ) -> None:
83
+ """Inject start marker with metadata.
84
+
85
+ Parameters
86
+ ----------
87
+ page : playwright.async_api.Page
88
+ Playwright page object.
89
+ version : str
90
+ Version string to display.
91
+ timestamp : str
92
+ Timestamp string to display.
93
+ duration_ms : int
94
+ Duration to show marker in milliseconds (default: 500).
95
+ """
96
+ args = {
97
+ "marker_id": MARKER_START_ID,
98
+ "version": version,
99
+ "timestamp": timestamp,
100
+ "duration": duration_ms,
101
+ }
102
+ await page.evaluate(MARKER_OVERLAY_JS, args)
103
+
104
+
105
+ async def inject_end_marker(
106
+ page,
107
+ version: str = "",
108
+ timestamp: str = "",
109
+ duration_ms: int = 500,
110
+ ) -> None:
111
+ """Inject end marker with metadata.
112
+
113
+ Parameters
114
+ ----------
115
+ page : playwright.async_api.Page
116
+ Playwright page object.
117
+ version : str
118
+ Version string to display.
119
+ timestamp : str
120
+ Timestamp string to display.
121
+ duration_ms : int
122
+ Duration to show marker in milliseconds (default: 500).
123
+ """
124
+ args = {
125
+ "marker_id": MARKER_END_ID,
126
+ "version": version,
127
+ "timestamp": timestamp,
128
+ "duration": duration_ms,
129
+ }
130
+ await page.evaluate(MARKER_OVERLAY_JS, args)
131
+
132
+
133
+ __all__ = [
134
+ "MARKER_START_ID",
135
+ "MARKER_END_ID",
136
+ "inject_start_marker",
137
+ "inject_end_marker",
138
+ ]
139
+
140
+ # EOF
@@ -29,9 +29,11 @@ def edit(
29
29
  source: Optional[Union[RecordingFigure, str, Path]] = None,
30
30
  style: Optional[Union[str, Dict[str, Any]]] = None,
31
31
  port: int = 5050,
32
+ host: str = "127.0.0.1",
32
33
  open_browser: bool = True,
33
34
  hot_reload: bool = False,
34
35
  working_dir: Optional[Union[str, Path]] = None,
36
+ desktop: bool = False,
35
37
  ) -> Dict[str, Any]:
36
38
  """
37
39
  Launch interactive GUI editor for figure styling.
@@ -42,8 +44,13 @@ def edit(
42
44
  Parameters
43
45
  ----------
44
46
  source : RecordingFigure, str, Path, or None
45
- Either a live RecordingFigure object, path to a .yaml/.png file,
46
- or None to create a new blank figure.
47
+ Figure source. Supports multiple formats:
48
+ - RecordingFigure: Live figure object
49
+ - .yaml/.yml: Direct recipe file
50
+ - .png/.jpg/etc: Image with associated .yaml
51
+ - Directory: Bundle containing recipe.yaml
52
+ - .zip: ZIP archive containing recipe.yaml
53
+ - None: Create new blank figure
47
54
  style : str or dict, optional
48
55
  Style preset name (e.g., 'SCITEX', 'SCITEX_DARK') or style dict.
49
56
  If None, uses the currently loaded global style.
@@ -57,6 +64,9 @@ def edit(
57
64
  working_dir : str or Path, optional
58
65
  Working directory for file switching feature (default: current directory).
59
66
  The file switcher will list recipe files from this directory.
67
+ desktop : bool, optional
68
+ Launch as native desktop window using pywebview (default: False).
69
+ Requires: pip install figrecipe[desktop]
60
70
 
61
71
  Returns
62
72
  -------
@@ -127,16 +137,28 @@ def edit(
127
137
  recipe_path=recipe_path,
128
138
  style=style_dict,
129
139
  port=port,
140
+ host=host,
130
141
  static_png_path=static_png_path,
131
142
  hitmap_base64=hitmap_base64,
132
143
  color_map=color_map,
133
144
  hot_reload=hot_reload,
134
145
  working_dir=resolved_working_dir,
146
+ desktop=desktop,
135
147
  )
136
148
 
137
149
  return editor.run(open_browser=open_browser)
138
150
 
139
151
 
152
+ def _check_figure_has_content(fig: RecordingFigure) -> bool:
153
+ """Check if figure has any plot content."""
154
+ for ax_row in fig._axes:
155
+ for ax in ax_row:
156
+ # Check for lines, patches, images, collections
157
+ if ax.lines or ax.patches or ax.images or ax.collections or ax.texts:
158
+ return True
159
+ return False
160
+
161
+
140
162
  def _resolve_source(source: Optional[Union[RecordingFigure, str, Path]]):
141
163
  """
142
164
  Resolve source to figure and optional recipe path.
@@ -144,8 +166,13 @@ def _resolve_source(source: Optional[Union[RecordingFigure, str, Path]]):
144
166
  Parameters
145
167
  ----------
146
168
  source : RecordingFigure, str, Path, or None
147
- Input source. If None, creates a new blank figure.
148
- If PNG path, tries to find associated YAML recipe.
169
+ Input source. Supports:
170
+ - None: Creates new blank figure
171
+ - RecordingFigure: Uses directly
172
+ - .yaml/.yml: Direct recipe file
173
+ - .png/.jpg/etc: Image with associated YAML
174
+ - Directory: Bundle containing recipe.yaml
175
+ - .zip: ZIP archive containing recipe.yaml
149
176
 
150
177
  Returns
151
178
  -------
@@ -158,16 +185,6 @@ def _resolve_source(source: Optional[Union[RecordingFigure, str, Path]]):
158
185
 
159
186
  fig, ax = subplots()
160
187
  ax.set_title("New Figure")
161
- ax.text(
162
- 0.5,
163
- 0.5,
164
- "Add plots using fr.edit(fig)",
165
- ha="center",
166
- va="center",
167
- transform=ax.transAxes,
168
- fontsize=12,
169
- color="gray",
170
- )
171
188
  return fig, None
172
189
 
173
190
  if isinstance(source, RecordingFigure):
@@ -190,28 +207,11 @@ def _resolve_source(source: Optional[Union[RecordingFigure, str, Path]]):
190
207
  )
191
208
  return wrapped_fig, None
192
209
 
193
- # Assume it's a path
194
- path = Path(source)
195
- if not path.exists():
196
- raise FileNotFoundError(f"File not found: {path}")
197
-
198
- # Handle PNG path - find associated YAML
199
- if path.suffix.lower() == ".png":
200
- yaml_path = path.with_suffix(".yaml")
201
- if yaml_path.exists():
202
- path = yaml_path
203
- else:
204
- yml_path = path.with_suffix(".yml")
205
- if yml_path.exists():
206
- path = yml_path
207
- else:
208
- raise FileNotFoundError(
209
- f"No recipe found for {path.name}. "
210
- f"Expected {yaml_path.name} or {yml_path.name}"
211
- )
212
-
213
- if path.suffix.lower() not in (".yaml", ".yml"):
214
- raise ValueError(f"Expected .yaml, .yml, or .png file, got: {path.suffix}")
210
+ # Assume it's a path - use bundle resolution (handles dir, zip, yaml, png)
211
+ from .._utils._bundle import resolve_recipe_path
212
+
213
+ path, _temp_dir = resolve_recipe_path(source)
214
+ # Note: temp_dir cleanup handled by reproduce() if ZIP was extracted
215
215
 
216
216
  # Load recipe and reproduce figure
217
217
  from .._reproducer import reproduce