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 ADDED
@@ -0,0 +1,5 @@
1
+ """Standalone Neat Video Auto Profile automation."""
2
+
3
+ from autoneat.api import ProfileOptions, run_profile
4
+
5
+ __all__ = ["ProfileOptions", "run_profile"]
autoneat/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from autoneat.cli import main
2
+
3
+ raise SystemExit(main())
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