autoneat 0.2.2__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.
- autoneat/__init__.py +5 -0
- autoneat/__main__.py +3 -0
- autoneat/api.py +56 -0
- autoneat/cli.py +101 -0
- autoneat/core/__init__.py +1 -0
- autoneat/core/batch.py +526 -0
- autoneat/core/click.py +31 -0
- autoneat/core/neat_ofx.py +195 -0
- autoneat/core/ocr.py +337 -0
- autoneat/core/recorder.py +39 -0
- autoneat/core/resolve_client.py +84 -0
- autoneat/core/shotid.py +82 -0
- autoneat/core/subprocess_utils.py +61 -0
- autoneat/core/ui_driver.py +229 -0
- autoneat/core/windows.py +143 -0
- autoneat/doctor.py +46 -0
- autoneat/resolve.py +74 -0
- autoneat/resources/vision_ocr.swift +69 -0
- autoneat-0.2.2.dist-info/METADATA +102 -0
- autoneat-0.2.2.dist-info/RECORD +24 -0
- autoneat-0.2.2.dist-info/WHEEL +5 -0
- autoneat-0.2.2.dist-info/entry_points.txt +2 -0
- autoneat-0.2.2.dist-info/licenses/LICENSE +21 -0
- autoneat-0.2.2.dist-info/top_level.txt +1 -0
autoneat/__init__.py
ADDED
autoneat/__main__.py
ADDED
autoneat/api.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Public Python API for standalone autoneat runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
from autoneat.core.batch import BatchSettings, run_batch
|
|
9
|
+
from autoneat.resolve import connect_resolve
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ProfileOptions(BatchSettings):
|
|
14
|
+
"""Options for a standalone Neat Video Auto Profile batch."""
|
|
15
|
+
|
|
16
|
+
project_name: Optional[str] = None
|
|
17
|
+
timeline_name: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_profile(
|
|
21
|
+
options: ProfileOptions,
|
|
22
|
+
*,
|
|
23
|
+
resolve: Any = None,
|
|
24
|
+
project: Any = None,
|
|
25
|
+
timeline: Any = None,
|
|
26
|
+
sink: Optional[Callable[[str], None]] = None,
|
|
27
|
+
cancel_event: Any = None,
|
|
28
|
+
) -> dict:
|
|
29
|
+
"""Run Auto Profile against a Resolve project/timeline.
|
|
30
|
+
|
|
31
|
+
Tests and embedding callers may pass explicit Resolve handles. Normal CLI
|
|
32
|
+
usage lets autoneat connect through ``dvr`` and select the requested
|
|
33
|
+
project/timeline.
|
|
34
|
+
"""
|
|
35
|
+
if resolve is not None and project is not None and timeline is not None:
|
|
36
|
+
return run_batch(
|
|
37
|
+
resolve,
|
|
38
|
+
project,
|
|
39
|
+
timeline,
|
|
40
|
+
options,
|
|
41
|
+
sink=sink,
|
|
42
|
+
cancel_event=cancel_event,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
with connect_resolve(
|
|
46
|
+
project_name=options.project_name,
|
|
47
|
+
timeline_name=options.timeline_name,
|
|
48
|
+
) as session:
|
|
49
|
+
return run_batch(
|
|
50
|
+
session.resolve,
|
|
51
|
+
session.project,
|
|
52
|
+
session.timeline,
|
|
53
|
+
options,
|
|
54
|
+
sink=sink,
|
|
55
|
+
cancel_event=cancel_event,
|
|
56
|
+
)
|
autoneat/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Command-line interface for autoneat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from autoneat.api import ProfileOptions, run_profile
|
|
11
|
+
from autoneat.doctor import check_environment, print_report
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_shot_ids(value: str | None) -> list[str]:
|
|
15
|
+
if not value:
|
|
16
|
+
return []
|
|
17
|
+
return [part.strip() for part in value.replace(",", " ").split() if part.strip()]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _profile(args: argparse.Namespace) -> int:
|
|
21
|
+
options = ProfileOptions(
|
|
22
|
+
project_name=args.project,
|
|
23
|
+
timeline_name=args.timeline,
|
|
24
|
+
track=args.track,
|
|
25
|
+
all_video_tracks=args.all_tracks,
|
|
26
|
+
shot_ids=_parse_shot_ids(args.shot_ids),
|
|
27
|
+
start_from=args.start_from,
|
|
28
|
+
limit=args.limit,
|
|
29
|
+
continue_run=args.continue_run,
|
|
30
|
+
retry_failed=args.retry_failed,
|
|
31
|
+
reuse_existing_neat=not args.no_reuse_existing,
|
|
32
|
+
no_color_wrap=args.no_color_wrap,
|
|
33
|
+
open_timeout=args.open_timeout,
|
|
34
|
+
editor_timeout=args.editor_timeout,
|
|
35
|
+
prepare_timeout=args.prepare_timeout,
|
|
36
|
+
profile_wait=args.profile_wait,
|
|
37
|
+
ready_timeout=args.ready_timeout,
|
|
38
|
+
apply_delay=args.apply_delay,
|
|
39
|
+
close_timeout=args.close_timeout,
|
|
40
|
+
step_delay=args.step_delay,
|
|
41
|
+
sidecar_path=Path(args.state).expanduser() if args.state else None,
|
|
42
|
+
)
|
|
43
|
+
result = run_profile(options, sink=lambda line: print(line, flush=True))
|
|
44
|
+
if args.json:
|
|
45
|
+
print(json.dumps(result, indent=2, sort_keys=True), flush=True)
|
|
46
|
+
return 0 if result.get("ok") else 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _doctor(_args: argparse.Namespace) -> int:
|
|
50
|
+
return print_report(check_environment())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = argparse.ArgumentParser(prog="autoneat")
|
|
55
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
doctor = sub.add_parser("doctor", help="Check macOS/Resolve automation prerequisites")
|
|
58
|
+
doctor.set_defaults(func=_doctor)
|
|
59
|
+
|
|
60
|
+
profile = sub.add_parser("profile", help="Run Neat Video Auto Profile over timeline clips")
|
|
61
|
+
profile.add_argument("--project", help="Resolve project name (default: current project)")
|
|
62
|
+
profile.add_argument("--timeline", help="Resolve timeline name (default: current timeline)")
|
|
63
|
+
profile.add_argument("--track", type=int, default=1, help="Video track to process")
|
|
64
|
+
profile.add_argument("--all-tracks", action="store_true", help="Process all video tracks")
|
|
65
|
+
profile.add_argument("--shot-ids", help="Comma/space-separated shot ids to include")
|
|
66
|
+
profile.add_argument("--start-from", type=int, default=1, help="1-based clip offset after filters")
|
|
67
|
+
profile.add_argument("--limit", type=int, default=0, help="Maximum clips to process")
|
|
68
|
+
profile.add_argument("--continue", dest="continue_run", action="store_true", help="Resume from state")
|
|
69
|
+
profile.add_argument("--retry-failed", action="store_true", help="Retry failed clips on resume")
|
|
70
|
+
profile.add_argument("--no-reuse-existing", action="store_true", help="Add a fresh Neat node")
|
|
71
|
+
profile.add_argument("--no-color-wrap", action="store_true", help="Skip ACES/HDR CST wrapping")
|
|
72
|
+
profile.add_argument("--state", help="Path to run state JSON")
|
|
73
|
+
profile.add_argument("--open-timeout", type=float, default=18.0)
|
|
74
|
+
profile.add_argument("--editor-timeout", type=float, default=60.0)
|
|
75
|
+
profile.add_argument("--prepare-timeout", type=float, default=1800.0)
|
|
76
|
+
profile.add_argument("--profile-wait", type=float, default=3.0)
|
|
77
|
+
profile.add_argument("--ready-timeout", type=float, default=90.0)
|
|
78
|
+
profile.add_argument("--apply-delay", type=float, default=5.0)
|
|
79
|
+
profile.add_argument("--close-timeout", type=float, default=20.0)
|
|
80
|
+
profile.add_argument("--step-delay", type=float, default=1.0)
|
|
81
|
+
profile.add_argument("--json", action="store_true", help="Print final summary JSON")
|
|
82
|
+
profile.set_defaults(func=_profile)
|
|
83
|
+
|
|
84
|
+
return parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
parser = build_parser()
|
|
89
|
+
args = parser.parse_args(argv)
|
|
90
|
+
try:
|
|
91
|
+
return int(args.func(args) or 0)
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
print("Interrupted", file=sys.stderr)
|
|
94
|
+
return 130
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Engine layer — pure Python, no UI imports."""
|
autoneat/core/batch.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Per-clip processing flow + batch runner.
|
|
2
|
+
|
|
3
|
+
The flow for one clip is:
|
|
4
|
+
|
|
5
|
+
1. Move the playhead to the clip on the Edit page.
|
|
6
|
+
2. Add (or find) a Neat OFX node on the clip's Fusion comp, optionally
|
|
7
|
+
wrapped in CST tools for ACES + HDR projects, and fire the OFX
|
|
8
|
+
``Prepare Profile___`` ButtonControl to open Neat's main window.
|
|
9
|
+
3. Wait for Neat to advance past splash / info-dialog / preparing-input
|
|
10
|
+
into the editor state.
|
|
11
|
+
4. Click "Auto Profile", wait for the profile to be ready, click "Apply".
|
|
12
|
+
5. Wait for the Neat window to close.
|
|
13
|
+
|
|
14
|
+
There are no fallbacks at any step. If a state machine times out or an OCR
|
|
15
|
+
locate misses, the clip is reported as failed with the actual cause.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
import traceback
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
28
|
+
|
|
29
|
+
from autoneat.core import neat_ofx, resolve_client, ui_driver, windows
|
|
30
|
+
from autoneat.core.ocr import cache_base
|
|
31
|
+
from autoneat.core.recorder import StepRecorder
|
|
32
|
+
from autoneat.core.shotid import filter_clips, shot_id_from_name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Settings dataclass — used by both CLI and GUI
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class BatchSettings:
|
|
42
|
+
track: int = 1
|
|
43
|
+
all_video_tracks: bool = False
|
|
44
|
+
shot_ids: List[str] = field(default_factory=list)
|
|
45
|
+
start_from: int = 1
|
|
46
|
+
limit: int = 0
|
|
47
|
+
continue_run: bool = False
|
|
48
|
+
retry_failed: bool = False
|
|
49
|
+
reuse_existing_neat: bool = True
|
|
50
|
+
no_color_wrap: bool = False
|
|
51
|
+
open_timeout: float = 18.0
|
|
52
|
+
editor_timeout: float = 60.0
|
|
53
|
+
prepare_timeout: float = 1800.0
|
|
54
|
+
profile_wait: float = 3.0
|
|
55
|
+
ready_timeout: float = 90.0
|
|
56
|
+
apply_delay: float = 5.0
|
|
57
|
+
close_timeout: float = 20.0
|
|
58
|
+
step_delay: float = 1.0
|
|
59
|
+
sidecar_path: Optional[Path] = None
|
|
60
|
+
|
|
61
|
+
def sidecar(self) -> Path:
|
|
62
|
+
if self.sidecar_path is not None:
|
|
63
|
+
return self.sidecar_path
|
|
64
|
+
env_override = os.environ.get("AUTONEAT_RESULTS_JSON")
|
|
65
|
+
if env_override:
|
|
66
|
+
return Path(env_override)
|
|
67
|
+
return Path.home() / ".cache" / "autoneat" / "last-run.json"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Per-clip state machine
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _state(work_dir: Path) -> tuple:
|
|
76
|
+
windows.activate_resolve(settle=0)
|
|
77
|
+
time.sleep(0.15)
|
|
78
|
+
return ui_driver.read_screen_state(work_dir)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _open_neat_state(work_dir: Path) -> tuple:
|
|
82
|
+
state, text, rows = _state(work_dir)
|
|
83
|
+
if state in {
|
|
84
|
+
"demo-splash",
|
|
85
|
+
"information-dialog",
|
|
86
|
+
"preparing-input",
|
|
87
|
+
"editor-unprofiled",
|
|
88
|
+
"editor-profiled",
|
|
89
|
+
"editor",
|
|
90
|
+
}:
|
|
91
|
+
return state, text, rows
|
|
92
|
+
return "unknown", text, rows
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dismiss_information_dialog(
|
|
96
|
+
work_dir: Path,
|
|
97
|
+
calibration: ui_driver.UiCalibration,
|
|
98
|
+
rec: StepRecorder,
|
|
99
|
+
*,
|
|
100
|
+
prefix: str,
|
|
101
|
+
step_delay: float,
|
|
102
|
+
) -> None:
|
|
103
|
+
point, method = ui_driver.click_control("ok", work_dir, calibration)
|
|
104
|
+
rec.add(f"{prefix}-ok:{method}:{round(point[0])},{round(point[1])}")
|
|
105
|
+
time.sleep(step_delay)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _advance_to_editor(
|
|
109
|
+
work_dir: Path,
|
|
110
|
+
calibration: ui_driver.UiCalibration,
|
|
111
|
+
rec: StepRecorder,
|
|
112
|
+
*,
|
|
113
|
+
timeout: float,
|
|
114
|
+
prepare_timeout: float,
|
|
115
|
+
step_delay: float,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Advance Neat past splash / info / preparing-input → editor state.
|
|
118
|
+
|
|
119
|
+
Two timeout budgets:
|
|
120
|
+
* ``timeout`` — how long *non*-prepare states can stick.
|
|
121
|
+
* ``prepare_timeout`` — how long the "Resolve is preparing input
|
|
122
|
+
frames…" phase may take. 4K HDR EXR sequences legitimately take
|
|
123
|
+
minutes; we must NOT switch clips while Neat is open or Resolve
|
|
124
|
+
will hang.
|
|
125
|
+
"""
|
|
126
|
+
deadline = time.time() + timeout
|
|
127
|
+
prepare_deadline: Optional[float] = None
|
|
128
|
+
last_text = ""
|
|
129
|
+
acted_states: set = set()
|
|
130
|
+
last_polled_state: Optional[str] = None
|
|
131
|
+
while True:
|
|
132
|
+
state, text, _rows = _state(work_dir)
|
|
133
|
+
last_text = text
|
|
134
|
+
if state in {"editor-unprofiled", "editor-profiled", "editor"}:
|
|
135
|
+
rec.add(f"editor:{state}")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if state == "preparing-input":
|
|
139
|
+
if prepare_deadline is None:
|
|
140
|
+
prepare_deadline = time.time() + prepare_timeout
|
|
141
|
+
rec.add(f"preparing-input:wait-up-to-{prepare_timeout:.0f}s")
|
|
142
|
+
elif time.time() > prepare_deadline:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"Frame prep exceeded {prepare_timeout:.0f}s; last_text={last_text!r}"
|
|
145
|
+
)
|
|
146
|
+
deadline = max(deadline, time.time() + timeout)
|
|
147
|
+
else:
|
|
148
|
+
prepare_deadline = None
|
|
149
|
+
if time.time() > deadline:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"Timed out waiting for Neat editor (last_state={state}, last_text={last_text!r})"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if state != last_polled_state:
|
|
155
|
+
rec.add(f"poll-state:{state}")
|
|
156
|
+
last_polled_state = state
|
|
157
|
+
if state == "inspector-prepare":
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
"Resolve Inspector is showing 'Prepare Noise Profile' — the OFX "
|
|
160
|
+
"`Prepare Profile___` SetInput did not open Neat's window. Fix "
|
|
161
|
+
"the open path; do not OCR the Inspector."
|
|
162
|
+
)
|
|
163
|
+
if state == "demo-splash":
|
|
164
|
+
if state not in acted_states:
|
|
165
|
+
point, method = ui_driver.click_control("continue", work_dir, calibration)
|
|
166
|
+
rec.add(f"continue:{method}:{round(point[0])},{round(point[1])}")
|
|
167
|
+
acted_states.add(state)
|
|
168
|
+
time.sleep(step_delay)
|
|
169
|
+
continue
|
|
170
|
+
if state == "information-dialog":
|
|
171
|
+
if state not in acted_states:
|
|
172
|
+
_dismiss_information_dialog(work_dir, calibration, rec, prefix="info", step_delay=step_delay)
|
|
173
|
+
acted_states.add(state)
|
|
174
|
+
continue
|
|
175
|
+
time.sleep(step_delay)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _profile_and_apply(
|
|
179
|
+
work_dir: Path,
|
|
180
|
+
calibration: ui_driver.UiCalibration,
|
|
181
|
+
rec: StepRecorder,
|
|
182
|
+
*,
|
|
183
|
+
profile_wait: float,
|
|
184
|
+
ready_timeout: float,
|
|
185
|
+
step_delay: float,
|
|
186
|
+
apply_delay: float,
|
|
187
|
+
close_timeout: float,
|
|
188
|
+
) -> None:
|
|
189
|
+
state, _text, _rows = _state(work_dir)
|
|
190
|
+
if state == "editor-unprofiled":
|
|
191
|
+
point, method = ui_driver.click_control("auto-profile", work_dir, calibration)
|
|
192
|
+
rec.add(f"auto-profile:{method}:{round(point[0])},{round(point[1])}")
|
|
193
|
+
time.sleep(max(profile_wait, step_delay))
|
|
194
|
+
|
|
195
|
+
rec.add(f"wait-profile-ready:up-to-{ready_timeout:.0f}s")
|
|
196
|
+
deadline = time.time() + ready_timeout
|
|
197
|
+
while time.time() < deadline:
|
|
198
|
+
state, _text, _rows = _state(work_dir)
|
|
199
|
+
if state in {"editor-profiled", "editor"}:
|
|
200
|
+
rec.add(f"profile-ready:{state}")
|
|
201
|
+
break
|
|
202
|
+
if state == "information-dialog":
|
|
203
|
+
_dismiss_information_dialog(work_dir, calibration, rec, prefix="warning", step_delay=step_delay)
|
|
204
|
+
continue
|
|
205
|
+
time.sleep(step_delay)
|
|
206
|
+
else:
|
|
207
|
+
state, text, _rows = _state(work_dir)
|
|
208
|
+
if state not in {"editor-profiled", "editor"}:
|
|
209
|
+
raise RuntimeError(f"Timed out waiting for Neat profile readiness (last_state={state}, last_text={text!r})")
|
|
210
|
+
|
|
211
|
+
if apply_delay > 0:
|
|
212
|
+
rec.add(f"apply-delay:{apply_delay:.1f}s")
|
|
213
|
+
time.sleep(apply_delay)
|
|
214
|
+
|
|
215
|
+
point, method = ui_driver.click_control("apply", work_dir, calibration)
|
|
216
|
+
rec.add(f"apply:{method}:{round(point[0])},{round(point[1])}")
|
|
217
|
+
time.sleep(step_delay)
|
|
218
|
+
|
|
219
|
+
rec.add(f"wait-neat-close:up-to-{close_timeout:.0f}s")
|
|
220
|
+
close_deadline = time.time() + close_timeout
|
|
221
|
+
closed = False
|
|
222
|
+
while time.time() < close_deadline:
|
|
223
|
+
try:
|
|
224
|
+
win_list = windows.list_resolve_windows(activate=False)
|
|
225
|
+
except Exception:
|
|
226
|
+
win_list = []
|
|
227
|
+
if windows.find_neat_window(windows=win_list) is None:
|
|
228
|
+
closed = True
|
|
229
|
+
break
|
|
230
|
+
time.sleep(step_delay)
|
|
231
|
+
if not closed:
|
|
232
|
+
raise RuntimeError(f"Neat window did not close within {close_timeout:.1f}s after Apply")
|
|
233
|
+
rec.add("neat-window-closed")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _drive_open_neat(
|
|
237
|
+
calibration: ui_driver.UiCalibration,
|
|
238
|
+
rec: StepRecorder,
|
|
239
|
+
settings: BatchSettings,
|
|
240
|
+
*,
|
|
241
|
+
label: str,
|
|
242
|
+
) -> Optional[str]:
|
|
243
|
+
"""Drive whatever Neat window is currently open to Apply + close.
|
|
244
|
+
Used as a recovery action when a stale Neat window is detected at the
|
|
245
|
+
start of a per-clip run.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
with tempfile.TemporaryDirectory(prefix="neat-resume-", dir=str(cache_base())) as tmp:
|
|
249
|
+
work_dir = Path(tmp)
|
|
250
|
+
state, text, _rows = _open_neat_state(work_dir)
|
|
251
|
+
if state == "unknown":
|
|
252
|
+
rec.add(f"{label}:already-closed text={text[:60]!r}")
|
|
253
|
+
return None
|
|
254
|
+
rec.add(f"{label}:state={state}")
|
|
255
|
+
if state not in {"editor-unprofiled", "editor-profiled", "editor"}:
|
|
256
|
+
_advance_to_editor(
|
|
257
|
+
work_dir,
|
|
258
|
+
calibration,
|
|
259
|
+
rec,
|
|
260
|
+
timeout=settings.editor_timeout,
|
|
261
|
+
prepare_timeout=settings.prepare_timeout,
|
|
262
|
+
step_delay=settings.step_delay,
|
|
263
|
+
)
|
|
264
|
+
_profile_and_apply(
|
|
265
|
+
work_dir,
|
|
266
|
+
calibration,
|
|
267
|
+
rec,
|
|
268
|
+
profile_wait=settings.profile_wait,
|
|
269
|
+
ready_timeout=settings.ready_timeout,
|
|
270
|
+
step_delay=settings.step_delay,
|
|
271
|
+
apply_delay=settings.apply_delay,
|
|
272
|
+
close_timeout=settings.close_timeout,
|
|
273
|
+
)
|
|
274
|
+
return None
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
rec.add(f"{label}:FAILED {exc}")
|
|
277
|
+
return str(exc)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def process_clip(
|
|
281
|
+
resolve: Any,
|
|
282
|
+
timeline: Any,
|
|
283
|
+
project: Any,
|
|
284
|
+
clip: Any,
|
|
285
|
+
calibration: ui_driver.UiCalibration,
|
|
286
|
+
settings: BatchSettings,
|
|
287
|
+
sink: Optional[Callable[[str], None]] = None,
|
|
288
|
+
) -> Dict[str, Any]:
|
|
289
|
+
name = resolve_client.clip_name(clip)
|
|
290
|
+
rec = StepRecorder(sink=sink)
|
|
291
|
+
|
|
292
|
+
# If a stale Neat window is open from a prior run, drive it to apply
|
|
293
|
+
# and close before touching the new target. (User-requested behavior:
|
|
294
|
+
# don't panic and refuse — finish what's open first.)
|
|
295
|
+
try:
|
|
296
|
+
win_list = windows.list_resolve_windows(activate=False)
|
|
297
|
+
except Exception:
|
|
298
|
+
win_list = []
|
|
299
|
+
stale = windows.find_neat_window(windows=win_list)
|
|
300
|
+
if stale is not None:
|
|
301
|
+
rec.add(f"stale-neat-detected:{stale.get('name')!r}")
|
|
302
|
+
_drive_open_neat(calibration, rec, settings, label="resume-stale")
|
|
303
|
+
|
|
304
|
+
with tempfile.TemporaryDirectory(prefix="neat-batch-", dir=str(cache_base())) as tmp:
|
|
305
|
+
work_dir = Path(tmp)
|
|
306
|
+
|
|
307
|
+
playhead = resolve_client.set_playhead_to_clip(resolve, timeline, clip)
|
|
308
|
+
rec.add(f"playhead:tc={playhead.get('timecode')} matched={playhead.get('matched')}")
|
|
309
|
+
time.sleep(settings.step_delay)
|
|
310
|
+
|
|
311
|
+
rec.add("attach-neat:add-or-select-node")
|
|
312
|
+
opened = neat_ofx.attach_neat_to_clip(
|
|
313
|
+
clip,
|
|
314
|
+
project,
|
|
315
|
+
reuse_existing=settings.reuse_existing_neat,
|
|
316
|
+
no_color_wrap=settings.no_color_wrap,
|
|
317
|
+
)
|
|
318
|
+
rec.add(f"attach-neat:OK tool={opened.get('tool')}")
|
|
319
|
+
wrap = opened.get("color_wrap") or {}
|
|
320
|
+
if wrap.get("applied"):
|
|
321
|
+
rec.add(
|
|
322
|
+
f"color-wrap:applied {wrap.get('in_cs')}/{wrap.get('in_gamma')} "
|
|
323
|
+
f"\u2194 {wrap.get('out_cs')}/{wrap.get('out_gamma')} (nits={wrap.get('nits')})"
|
|
324
|
+
)
|
|
325
|
+
elif wrap:
|
|
326
|
+
rec.add(f"color-wrap:skipped reason={wrap.get('skip_reason') or 'unknown'}")
|
|
327
|
+
time.sleep(settings.step_delay)
|
|
328
|
+
|
|
329
|
+
_advance_to_editor(
|
|
330
|
+
work_dir,
|
|
331
|
+
calibration,
|
|
332
|
+
rec,
|
|
333
|
+
timeout=settings.editor_timeout,
|
|
334
|
+
prepare_timeout=settings.prepare_timeout,
|
|
335
|
+
step_delay=settings.step_delay,
|
|
336
|
+
)
|
|
337
|
+
_profile_and_apply(
|
|
338
|
+
work_dir,
|
|
339
|
+
calibration,
|
|
340
|
+
rec,
|
|
341
|
+
profile_wait=settings.profile_wait,
|
|
342
|
+
ready_timeout=settings.ready_timeout,
|
|
343
|
+
step_delay=settings.step_delay,
|
|
344
|
+
apply_delay=settings.apply_delay,
|
|
345
|
+
close_timeout=settings.close_timeout,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
"ok": True,
|
|
350
|
+
"clip": name,
|
|
351
|
+
"steps": rec.steps,
|
|
352
|
+
"playhead": playhead,
|
|
353
|
+
"open": opened,
|
|
354
|
+
"elapsed_seconds": round(rec.elapsed(), 1),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# Sidecar helpers (resume / continue support)
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def load_sidecar(path: Path) -> Optional[Dict[str, Any]]:
|
|
364
|
+
try:
|
|
365
|
+
if not path.is_file():
|
|
366
|
+
return None
|
|
367
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
368
|
+
return data if isinstance(data, dict) else None
|
|
369
|
+
except Exception:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def write_sidecar(
|
|
374
|
+
path: Path,
|
|
375
|
+
results: Sequence[Dict[str, Any]],
|
|
376
|
+
*,
|
|
377
|
+
partial: bool,
|
|
378
|
+
skipped_via_continue: Sequence[str],
|
|
379
|
+
calibration: ui_driver.UiCalibration,
|
|
380
|
+
) -> None:
|
|
381
|
+
succ = [r for r in results if r.get("ok")]
|
|
382
|
+
fail = [r for r in results if not r.get("ok")]
|
|
383
|
+
snap = {
|
|
384
|
+
"ok": (not fail) and not partial,
|
|
385
|
+
"partial": partial,
|
|
386
|
+
"processed": len(succ),
|
|
387
|
+
"failed_count": len(fail),
|
|
388
|
+
"succeeded": [r.get("clip", "") for r in succ],
|
|
389
|
+
"failed": [r.get("clip", "") for r in fail],
|
|
390
|
+
"succeeded_ids": [shot_id_from_name(r.get("clip", "")) for r in succ],
|
|
391
|
+
"failed_ids": [shot_id_from_name(r.get("clip", "")) for r in fail],
|
|
392
|
+
"skipped_via_continue": list(skipped_via_continue),
|
|
393
|
+
"calibration": calibration.as_dict(),
|
|
394
|
+
"results": list(results),
|
|
395
|
+
}
|
|
396
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
path.write_text(json.dumps(snap, indent=2, sort_keys=True))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Top-level batch runner
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def run_batch(
|
|
406
|
+
resolve: Any,
|
|
407
|
+
project: Any,
|
|
408
|
+
timeline: Any,
|
|
409
|
+
settings: BatchSettings,
|
|
410
|
+
*,
|
|
411
|
+
sink: Optional[Callable[[str], None]] = None,
|
|
412
|
+
cancel_event: Optional[Any] = None,
|
|
413
|
+
) -> Dict[str, Any]:
|
|
414
|
+
"""Run the batch end-to-end against a Resolve handle that's already
|
|
415
|
+
been resolved by the caller (the WFI script's pre-injected globals).
|
|
416
|
+
|
|
417
|
+
``sink`` receives every step description (one line at a time) and every
|
|
418
|
+
summary line. ``cancel_event`` is any object with an ``is_set()`` method
|
|
419
|
+
(e.g. ``threading.Event``); the runner checks it between clips.
|
|
420
|
+
"""
|
|
421
|
+
log = sink or (lambda s: print(s, flush=True))
|
|
422
|
+
log(f"Connected to Resolve: project={project.GetName()!r} timeline={timeline.GetName()!r}")
|
|
423
|
+
|
|
424
|
+
clips = filter_clips(
|
|
425
|
+
resolve_client.timeline_clips(
|
|
426
|
+
timeline,
|
|
427
|
+
track=settings.track,
|
|
428
|
+
all_video_tracks=settings.all_video_tracks,
|
|
429
|
+
),
|
|
430
|
+
settings.shot_ids,
|
|
431
|
+
name_of=resolve_client.clip_name,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
sidecar = settings.sidecar()
|
|
435
|
+
skipped_continue: List[str] = []
|
|
436
|
+
if settings.continue_run:
|
|
437
|
+
prev = load_sidecar(sidecar)
|
|
438
|
+
if prev is None:
|
|
439
|
+
log(f" warning: continue requested but no readable sidecar at {sidecar}")
|
|
440
|
+
else:
|
|
441
|
+
skip_names = set(prev.get("succeeded") or [])
|
|
442
|
+
if not settings.retry_failed:
|
|
443
|
+
skip_names.update(prev.get("failed") or [])
|
|
444
|
+
kept: List[Any] = []
|
|
445
|
+
for clip in clips:
|
|
446
|
+
name = resolve_client.clip_name(clip)
|
|
447
|
+
if name in skip_names:
|
|
448
|
+
skipped_continue.append(name)
|
|
449
|
+
else:
|
|
450
|
+
kept.append(clip)
|
|
451
|
+
clips = kept
|
|
452
|
+
extra = " (failed clips will be retried)" if settings.retry_failed else ""
|
|
453
|
+
log(
|
|
454
|
+
f" continue: skipping {len(skipped_continue)} previously-processed clip(s); "
|
|
455
|
+
f"{len(clips)} remain{extra}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
if settings.start_from > 1:
|
|
459
|
+
clips = clips[settings.start_from - 1 :]
|
|
460
|
+
if settings.limit:
|
|
461
|
+
clips = clips[: settings.limit]
|
|
462
|
+
|
|
463
|
+
calibration = ui_driver.UiCalibration()
|
|
464
|
+
results: List[Dict[str, Any]] = []
|
|
465
|
+
batch_start = time.time()
|
|
466
|
+
|
|
467
|
+
for idx, clip in enumerate(clips, 1):
|
|
468
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
469
|
+
log(f" cancel requested — stopping after {idx-1} clip(s)")
|
|
470
|
+
break
|
|
471
|
+
|
|
472
|
+
name = resolve_client.clip_name(clip)
|
|
473
|
+
sid = shot_id_from_name(name)
|
|
474
|
+
log(
|
|
475
|
+
f"\n[{idx}/{len(clips)}] {sid} (clip={name}, "
|
|
476
|
+
f"track={resolve_client.clip_track_index(clip)}, "
|
|
477
|
+
f"start={int(clip.GetStart())}, dur={int(clip.GetDuration())})"
|
|
478
|
+
)
|
|
479
|
+
clip_start = time.time()
|
|
480
|
+
try:
|
|
481
|
+
result = process_clip(resolve, timeline, project, clip, calibration, settings, sink=log)
|
|
482
|
+
except Exception as exc:
|
|
483
|
+
result = {
|
|
484
|
+
"ok": False,
|
|
485
|
+
"clip": name,
|
|
486
|
+
"error": str(exc),
|
|
487
|
+
"traceback": traceback.format_exc(),
|
|
488
|
+
"elapsed_seconds": round(time.time() - clip_start, 1),
|
|
489
|
+
}
|
|
490
|
+
results.append(result)
|
|
491
|
+
elapsed = result.get("elapsed_seconds", round(time.time() - clip_start, 1))
|
|
492
|
+
if result.get("ok"):
|
|
493
|
+
log(f" → OK ({elapsed:.1f}s, {len(result.get('steps') or [])} steps)")
|
|
494
|
+
else:
|
|
495
|
+
log(f" → FAIL ({elapsed:.1f}s): {result.get('error')}")
|
|
496
|
+
write_sidecar(
|
|
497
|
+
sidecar,
|
|
498
|
+
results,
|
|
499
|
+
partial=idx < len(clips),
|
|
500
|
+
skipped_via_continue=skipped_continue,
|
|
501
|
+
calibration=calibration,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
log(f"\nBatch elapsed: {time.time() - batch_start:.1f}s")
|
|
505
|
+
succeeded = [r for r in results if r.get("ok")]
|
|
506
|
+
failed = [r for r in results if not r.get("ok")]
|
|
507
|
+
write_sidecar(
|
|
508
|
+
sidecar,
|
|
509
|
+
results,
|
|
510
|
+
partial=False,
|
|
511
|
+
skipped_via_continue=skipped_continue,
|
|
512
|
+
calibration=calibration,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
summary = {
|
|
516
|
+
"ok": not failed,
|
|
517
|
+
"processed": len(succeeded),
|
|
518
|
+
"failed": len(failed),
|
|
519
|
+
"succeeded_ids": [shot_id_from_name(r.get("clip", "")) for r in succeeded],
|
|
520
|
+
"failed_ids": [shot_id_from_name(r.get("clip", "")) for r in failed],
|
|
521
|
+
"skipped_via_continue": skipped_continue,
|
|
522
|
+
"calibration": calibration.as_dict(),
|
|
523
|
+
"sidecar_path": str(sidecar),
|
|
524
|
+
"results": results,
|
|
525
|
+
}
|
|
526
|
+
return summary
|