stereo-charuco-pipeline 0.1.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.
- recorder/__init__.py +90 -0
- recorder/auto_calibrate.py +493 -0
- recorder/calibration_ui.py +1106 -0
- recorder/calibration_ui_advanced.py +1013 -0
- recorder/camera.py +51 -0
- recorder/cli.py +122 -0
- recorder/config.py +75 -0
- recorder/configs/default.yaml +38 -0
- recorder/ffmpeg.py +137 -0
- recorder/paths.py +87 -0
- recorder/pipeline_ui.py +1838 -0
- recorder/project_manager.py +329 -0
- recorder/smart_recorder.py +478 -0
- recorder/ui.py +136 -0
- recorder/viz_3d.py +220 -0
- stereo_charuco_pipeline-0.1.0.dist-info/METADATA +10 -0
- stereo_charuco_pipeline-0.1.0.dist-info/RECORD +19 -0
- stereo_charuco_pipeline-0.1.0.dist-info/WHEEL +4 -0
- stereo_charuco_pipeline-0.1.0.dist-info/entry_points.txt +4 -0
recorder/camera.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from .ffmpeg import FFmpegRunner
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def list_dshow_devices() -> List[str]:
|
|
10
|
+
"""
|
|
11
|
+
List DirectShow video devices on Windows using ffmpeg.
|
|
12
|
+
|
|
13
|
+
Returns a list of device names that you can pass to:
|
|
14
|
+
ffmpeg -f dshow -i video="<NAME>"
|
|
15
|
+
"""
|
|
16
|
+
ffmpeg = FFmpegRunner.resolve_ffmpeg()
|
|
17
|
+
runner = FFmpegRunner()
|
|
18
|
+
|
|
19
|
+
cmd = [ffmpeg, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"]
|
|
20
|
+
res = runner.run_capture(cmd)
|
|
21
|
+
|
|
22
|
+
# ffmpeg prints devices to stderr in many builds
|
|
23
|
+
text = (res.stdout or "") + "\n" + (res.stderr or "")
|
|
24
|
+
|
|
25
|
+
devices: List[str] = []
|
|
26
|
+
in_video_section = False
|
|
27
|
+
|
|
28
|
+
# Typical lines:
|
|
29
|
+
# [dshow @ ...] DirectShow video devices
|
|
30
|
+
# [dshow @ ...] "3D USB Camera"
|
|
31
|
+
for line in text.splitlines():
|
|
32
|
+
if "DirectShow video devices" in line:
|
|
33
|
+
in_video_section = True
|
|
34
|
+
continue
|
|
35
|
+
if "DirectShow audio devices" in line:
|
|
36
|
+
in_video_section = False
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
if in_video_section:
|
|
40
|
+
m = re.search(r'"([^"]+)"', line)
|
|
41
|
+
if m:
|
|
42
|
+
devices.append(m.group(1))
|
|
43
|
+
|
|
44
|
+
# de-dup preserve order
|
|
45
|
+
seen = set()
|
|
46
|
+
out = []
|
|
47
|
+
for d in devices:
|
|
48
|
+
if d not in seen:
|
|
49
|
+
out.append(d)
|
|
50
|
+
seen.add(d)
|
|
51
|
+
return out
|
recorder/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line entry points for stereo-charuco-pipeline.
|
|
3
|
+
|
|
4
|
+
After `pip install .`, these commands are available:
|
|
5
|
+
stereo-pipeline Full workflow (Project Manager -> Calibration -> Pipeline)
|
|
6
|
+
stereo-calibrate Calibration UI only
|
|
7
|
+
stereo-record Pipeline UI only (record + reconstruct)
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .paths import resolve_default_config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
"""Full workflow: Project Manager -> Calibration UI -> Pipeline UI."""
|
|
19
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
20
|
+
|
|
21
|
+
parser = argparse.ArgumentParser(description="Unified Stereo Pipeline")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--config", type=str, default=None,
|
|
24
|
+
help="Path to YAML config file (default: built-in default.yaml)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--projects-base", type=str, default=None,
|
|
28
|
+
help="Base directory for projects (default: ./projects)",
|
|
29
|
+
)
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
config_path = _resolve_config(args.config)
|
|
33
|
+
projects_base = Path(args.projects_base) if args.projects_base else Path.cwd() / "projects"
|
|
34
|
+
|
|
35
|
+
# Stage 1: Project Manager
|
|
36
|
+
from .project_manager import ProjectManagerUI
|
|
37
|
+
|
|
38
|
+
pm = ProjectManagerUI(projects_base)
|
|
39
|
+
pm.mainloop()
|
|
40
|
+
|
|
41
|
+
context = pm.result
|
|
42
|
+
if context is None:
|
|
43
|
+
print("No project selected. Exiting.")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
print(f"Project: {context.project_dir}")
|
|
47
|
+
print(f" New: {context.is_new}")
|
|
48
|
+
print(f" Needs calibration: {context.needs_calibration}")
|
|
49
|
+
|
|
50
|
+
# Stage 2: Calibration UI (if needed)
|
|
51
|
+
if context.needs_calibration:
|
|
52
|
+
print("\nOpening Calibration Recording UI...")
|
|
53
|
+
from .calibration_ui import CalibrationUI
|
|
54
|
+
|
|
55
|
+
calib_app = CalibrationUI(
|
|
56
|
+
config_path=config_path,
|
|
57
|
+
project_dir=context.project_dir,
|
|
58
|
+
on_complete=lambda: None,
|
|
59
|
+
)
|
|
60
|
+
calib_app.mainloop()
|
|
61
|
+
|
|
62
|
+
# Stage 3: Pipeline UI
|
|
63
|
+
print("\nOpening Pipeline UI...")
|
|
64
|
+
from .pipeline_ui import PipelineUI
|
|
65
|
+
|
|
66
|
+
pipeline_app = PipelineUI(
|
|
67
|
+
config_path=config_path,
|
|
68
|
+
project_dir=context.project_dir,
|
|
69
|
+
)
|
|
70
|
+
pipeline_app.mainloop()
|
|
71
|
+
|
|
72
|
+
print("\nPipeline closed.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def calibrate_only():
|
|
76
|
+
"""Calibration UI standalone."""
|
|
77
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
78
|
+
|
|
79
|
+
parser = argparse.ArgumentParser(description="Stereo Calibration UI")
|
|
80
|
+
parser.add_argument("--config", type=str, default=None)
|
|
81
|
+
parser.add_argument("--project-dir", type=str, default=None)
|
|
82
|
+
args = parser.parse_args()
|
|
83
|
+
|
|
84
|
+
config_path = _resolve_config(args.config)
|
|
85
|
+
|
|
86
|
+
from .calibration_ui import CalibrationUI
|
|
87
|
+
|
|
88
|
+
app = CalibrationUI(
|
|
89
|
+
config_path=config_path,
|
|
90
|
+
project_dir=Path(args.project_dir) if args.project_dir else None,
|
|
91
|
+
)
|
|
92
|
+
app.mainloop()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pipeline_only():
|
|
96
|
+
"""Pipeline UI standalone."""
|
|
97
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
98
|
+
|
|
99
|
+
parser = argparse.ArgumentParser(description="Stereo Pipeline UI")
|
|
100
|
+
parser.add_argument("--config", type=str, default=None)
|
|
101
|
+
parser.add_argument("--project-dir", type=str, default=None)
|
|
102
|
+
args = parser.parse_args()
|
|
103
|
+
|
|
104
|
+
config_path = _resolve_config(args.config)
|
|
105
|
+
|
|
106
|
+
from .pipeline_ui import PipelineUI
|
|
107
|
+
|
|
108
|
+
app = PipelineUI(
|
|
109
|
+
config_path=config_path,
|
|
110
|
+
project_dir=Path(args.project_dir) if args.project_dir else None,
|
|
111
|
+
)
|
|
112
|
+
app.mainloop()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_config(user_config):
|
|
116
|
+
"""Resolve config path from user arg or built-in default."""
|
|
117
|
+
if user_config:
|
|
118
|
+
p = Path(user_config)
|
|
119
|
+
if not p.is_absolute():
|
|
120
|
+
p = Path.cwd() / p
|
|
121
|
+
return p
|
|
122
|
+
return resolve_default_config()
|
recorder/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, asdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RecorderConfig:
|
|
10
|
+
# capture
|
|
11
|
+
device: str = "3D USB Camera"
|
|
12
|
+
size: str = "3200x1200"
|
|
13
|
+
fps: int = 60
|
|
14
|
+
seconds: int = 120
|
|
15
|
+
|
|
16
|
+
# output
|
|
17
|
+
outdir: str = "."
|
|
18
|
+
outroot: str = "" # if empty: script decides session folder
|
|
19
|
+
|
|
20
|
+
# optional: pass-through (future)
|
|
21
|
+
extra_args: Optional[list[str]] = None
|
|
22
|
+
|
|
23
|
+
def to_args(self) -> list[str]:
|
|
24
|
+
"""Convert config to CLI args that match scripts/record_and_split.py."""
|
|
25
|
+
args = [
|
|
26
|
+
"--device", self.device,
|
|
27
|
+
"--size", self.size,
|
|
28
|
+
"--fps", str(int(self.fps)),
|
|
29
|
+
"--seconds", str(int(self.seconds)),
|
|
30
|
+
"--outdir", str(self.outdir),
|
|
31
|
+
]
|
|
32
|
+
if self.outroot:
|
|
33
|
+
args += ["--outroot", str(self.outroot)]
|
|
34
|
+
if self.extra_args:
|
|
35
|
+
args += list(self.extra_args)
|
|
36
|
+
return args
|
|
37
|
+
|
|
38
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_yaml_config(path: str | Path) -> Dict[str, Any]:
|
|
43
|
+
"""
|
|
44
|
+
Load YAML into dict. If PyYAML isn't installed, raise a clear error.
|
|
45
|
+
"""
|
|
46
|
+
path = Path(path)
|
|
47
|
+
try:
|
|
48
|
+
import yaml # type: ignore
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"PyYAML is required to load YAML config. Please `pip install pyyaml`."
|
|
52
|
+
) from e
|
|
53
|
+
|
|
54
|
+
if not path.exists():
|
|
55
|
+
raise FileNotFoundError(f"Config not found: {path}")
|
|
56
|
+
|
|
57
|
+
with path.open("r", encoding="utf-8") as f:
|
|
58
|
+
data = yaml.safe_load(f) or {}
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
raise ValueError(f"YAML root must be a mapping/dict. Got: {type(data)}")
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def config_from_yaml(path: str | Path) -> RecorderConfig:
|
|
65
|
+
"""
|
|
66
|
+
Create RecorderConfig from YAML mapping. Unknown keys are ignored (safe for future expansion).
|
|
67
|
+
"""
|
|
68
|
+
data = load_yaml_config(path)
|
|
69
|
+
cfg = RecorderConfig()
|
|
70
|
+
|
|
71
|
+
for k, v in data.items():
|
|
72
|
+
if hasattr(cfg, k):
|
|
73
|
+
setattr(cfg, k, v)
|
|
74
|
+
|
|
75
|
+
return cfg
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
camera:
|
|
2
|
+
device_name: "3D USB Camera"
|
|
3
|
+
backend: "dshow"
|
|
4
|
+
video_size: "3200x1200"
|
|
5
|
+
fps: 60
|
|
6
|
+
|
|
7
|
+
split:
|
|
8
|
+
full_w: 3200
|
|
9
|
+
full_h: 1200
|
|
10
|
+
xsplit: 1600
|
|
11
|
+
|
|
12
|
+
recording:
|
|
13
|
+
min_seconds: 30
|
|
14
|
+
target_seconds: 120
|
|
15
|
+
default_seconds: 120
|
|
16
|
+
|
|
17
|
+
output:
|
|
18
|
+
base_dir: ""
|
|
19
|
+
keep_avi: true
|
|
20
|
+
|
|
21
|
+
ffmpeg:
|
|
22
|
+
executable: ""
|
|
23
|
+
|
|
24
|
+
charuco:
|
|
25
|
+
columns: 10
|
|
26
|
+
rows: 16
|
|
27
|
+
board_height_cm: 80.0
|
|
28
|
+
board_width_cm: 50.0
|
|
29
|
+
dictionary: "DICT_4X4_100"
|
|
30
|
+
aruco_scale: 0.7
|
|
31
|
+
square_size_cm: 5.0
|
|
32
|
+
|
|
33
|
+
mp4:
|
|
34
|
+
preset: "veryfast"
|
|
35
|
+
crf: 18
|
|
36
|
+
|
|
37
|
+
project:
|
|
38
|
+
project_dir: ""
|
recorder/ffmpeg.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LogCallback = Callable[[str], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RunResult:
|
|
17
|
+
returncode: int
|
|
18
|
+
stdout: str
|
|
19
|
+
stderr: str
|
|
20
|
+
cmd: list[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FFmpegRunner:
|
|
24
|
+
"""
|
|
25
|
+
Minimal runner for subprocess-based tools (ffmpeg, python scripts, etc.)
|
|
26
|
+
- supports live log streaming
|
|
27
|
+
- supports cancellation
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, executable: Optional[str] = None):
|
|
31
|
+
self.executable = executable
|
|
32
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def resolve_ffmpeg(cls) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Resolve ffmpeg path:
|
|
38
|
+
1) System PATH (cross-platform)
|
|
39
|
+
2) Repo-local bin/ directory (development fallback)
|
|
40
|
+
"""
|
|
41
|
+
from .paths import resolve_ffmpeg
|
|
42
|
+
return resolve_ffmpeg()
|
|
43
|
+
|
|
44
|
+
def run_capture(
|
|
45
|
+
self,
|
|
46
|
+
cmd: list[str],
|
|
47
|
+
cwd: Optional[str | Path] = None,
|
|
48
|
+
env: Optional[dict[str, str]] = None,
|
|
49
|
+
timeout: Optional[int] = None,
|
|
50
|
+
) -> RunResult:
|
|
51
|
+
"""
|
|
52
|
+
Run synchronously and capture stdout/stderr fully.
|
|
53
|
+
"""
|
|
54
|
+
p = subprocess.run(
|
|
55
|
+
cmd,
|
|
56
|
+
cwd=str(cwd) if cwd else None,
|
|
57
|
+
env=env,
|
|
58
|
+
text=True,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
)
|
|
62
|
+
return RunResult(
|
|
63
|
+
returncode=p.returncode,
|
|
64
|
+
stdout=p.stdout or "",
|
|
65
|
+
stderr=p.stderr or "",
|
|
66
|
+
cmd=cmd,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def run_stream(
|
|
70
|
+
self,
|
|
71
|
+
cmd: list[str],
|
|
72
|
+
on_log: Optional[LogCallback] = None,
|
|
73
|
+
cwd: Optional[str | Path] = None,
|
|
74
|
+
env: Optional[dict[str, str]] = None,
|
|
75
|
+
) -> int:
|
|
76
|
+
"""
|
|
77
|
+
Run and stream combined stdout/stderr line-by-line to on_log.
|
|
78
|
+
Returns process return code.
|
|
79
|
+
"""
|
|
80
|
+
# Merge env
|
|
81
|
+
merged_env = os.environ.copy()
|
|
82
|
+
if env:
|
|
83
|
+
merged_env.update(env)
|
|
84
|
+
|
|
85
|
+
self._proc = subprocess.Popen(
|
|
86
|
+
cmd,
|
|
87
|
+
cwd=str(cwd) if cwd else None,
|
|
88
|
+
env=merged_env,
|
|
89
|
+
stdout=subprocess.PIPE,
|
|
90
|
+
stderr=subprocess.STDOUT,
|
|
91
|
+
text=True,
|
|
92
|
+
bufsize=1,
|
|
93
|
+
universal_newlines=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
assert self._proc.stdout is not None
|
|
97
|
+
for line in self._proc.stdout:
|
|
98
|
+
line = line.rstrip("\n")
|
|
99
|
+
if on_log:
|
|
100
|
+
on_log(line)
|
|
101
|
+
|
|
102
|
+
rc = self._proc.wait()
|
|
103
|
+
self._proc = None
|
|
104
|
+
return rc
|
|
105
|
+
|
|
106
|
+
def cancel(self) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Best-effort termination of the running process.
|
|
109
|
+
"""
|
|
110
|
+
if not self._proc:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
self._proc.terminate()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
self._proc.wait(timeout=2)
|
|
120
|
+
self._proc = None
|
|
121
|
+
return
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
self._proc.kill()
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
finally:
|
|
130
|
+
self._proc = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def python_executable() -> str:
|
|
134
|
+
"""
|
|
135
|
+
Always use the current interpreter (e.g. conda env) to run scripts.
|
|
136
|
+
"""
|
|
137
|
+
return sys.executable
|
recorder/paths.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized path resolution for the recorder package.
|
|
3
|
+
|
|
4
|
+
Handles finding configs and tools in both development mode
|
|
5
|
+
(running from repo clone) and installed mode (pip install).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tool_root() -> Path:
|
|
15
|
+
"""Return the calib_record_tool root directory.
|
|
16
|
+
|
|
17
|
+
In dev mode (repo clone), returns the actual calib_record_tool/ dir.
|
|
18
|
+
After pip install, returns the current working directory.
|
|
19
|
+
"""
|
|
20
|
+
dev = _dev_root()
|
|
21
|
+
return dev if dev else Path.cwd()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _dev_root() -> Path | None:
|
|
25
|
+
"""Return calib_record_tool/ root if running from a repo clone."""
|
|
26
|
+
# __file__ = .../calib_record_tool/src/recorder/paths.py
|
|
27
|
+
candidate = Path(__file__).resolve().parents[2]
|
|
28
|
+
if (candidate / "configs" / "default.yaml").exists():
|
|
29
|
+
return candidate
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_default_config() -> Path:
|
|
34
|
+
"""Find the default.yaml config file.
|
|
35
|
+
|
|
36
|
+
Search order:
|
|
37
|
+
1. Package data (recorder/configs/default.yaml) — works after pip install
|
|
38
|
+
2. Repo layout (calib_record_tool/configs/default.yaml) — works in dev
|
|
39
|
+
"""
|
|
40
|
+
# Try package data first (works after pip install from wheel)
|
|
41
|
+
try:
|
|
42
|
+
pkg_configs = resources.files("recorder") / "configs" / "default.yaml"
|
|
43
|
+
p = Path(str(pkg_configs))
|
|
44
|
+
if p.exists():
|
|
45
|
+
return p
|
|
46
|
+
except (TypeError, FileNotFoundError, AttributeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
# Fallback: repo layout
|
|
50
|
+
dev = _dev_root()
|
|
51
|
+
if dev:
|
|
52
|
+
p = dev / "configs" / "default.yaml"
|
|
53
|
+
if p.exists():
|
|
54
|
+
return p
|
|
55
|
+
|
|
56
|
+
raise FileNotFoundError(
|
|
57
|
+
"Cannot find default.yaml config. "
|
|
58
|
+
"Pass --config explicitly or run from the repo directory."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_ffmpeg() -> str:
|
|
63
|
+
"""Find ffmpeg executable.
|
|
64
|
+
|
|
65
|
+
Search order:
|
|
66
|
+
1. System PATH (preferred — works cross-platform)
|
|
67
|
+
2. Repo-local bin/ directory (development fallback)
|
|
68
|
+
"""
|
|
69
|
+
# System PATH first
|
|
70
|
+
which = shutil.which("ffmpeg")
|
|
71
|
+
if which:
|
|
72
|
+
return which
|
|
73
|
+
|
|
74
|
+
# Dev fallback: bin/ffmpeg/ffmpeg.exe
|
|
75
|
+
dev = _dev_root()
|
|
76
|
+
if dev:
|
|
77
|
+
local = dev / "bin" / "ffmpeg" / "ffmpeg.exe"
|
|
78
|
+
if local.exists():
|
|
79
|
+
return str(local)
|
|
80
|
+
|
|
81
|
+
raise FileNotFoundError(
|
|
82
|
+
"ffmpeg not found. Install ffmpeg and ensure it is on your PATH.\n"
|
|
83
|
+
" Windows: winget install ffmpeg OR choco install ffmpeg\n"
|
|
84
|
+
" Linux: sudo apt install ffmpeg\n"
|
|
85
|
+
" macOS: brew install ffmpeg\n"
|
|
86
|
+
" Conda: conda install -c conda-forge ffmpeg"
|
|
87
|
+
)
|