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,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
|
figrecipe/_editor/__init__.py
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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.
|
|
148
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|