davinci-resolve-mcp 2.79.1 → 2.80.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,120 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.80.0
6
+
7
+ Three community PRs from @staahlarkitektur, all found on Windows, all real. Each is merged with
8
+ its diagnosis intact and a fix on top for what the patch didn't reach.
9
+
10
+ ### Added
11
+
12
+ - **`background=true` now actually runs the analysis.** `background`/`async_job` were accepted on
13
+ `analyze_clip` / `analyze_bin` / `analyze_file` / `analyze_project` / `analyze_sequence` and
14
+ silently ignored — the call ran the whole analysis inline and returned no `job_id`, which from
15
+ the caller's side is indistinguishable from a hang (#119). The two async opt-ins are now
16
+ distinct and both do what their names say:
17
+ - `prefer_handle=true` — creates the durable batch job and hands it back **queued**. Nothing
18
+ runs until you call `run_batch_job_slice`. Unchanged contract.
19
+ - `background=true` / `async_job=true` — creates the job **and drives it to completion
20
+ off-thread**, matching what `background` means on every other tool in this server. Poll
21
+ `batch_job_status` until `completed` / `completed_with_errors` / `canceled`.
22
+
23
+ Aliasing the two, as the PR proposed, would have replaced one silence with a quieter one: a job
24
+ that nothing ever advanced, polled forever. The runner deliberately does **not** hold the
25
+ Resolve busy gate — analysis drives ffmpeg, whisper and vision over file paths and touches the
26
+ scripting bridge nowhere, so holding it for an hour of transcription would lock the editor out
27
+ for nothing. A process-wide slice lock bounds the real cost instead: queued analyses interleave
28
+ a clip at a time rather than starting N ffmpeg passes at once.
29
+
30
+ ### Fixed
31
+
32
+ - **A timeout could take 82 seconds to report a 5-second limit.** `subprocess.run(timeout=...)`
33
+ kills only the direct child. On Windows a bare-name PATH lookup can resolve to a shim
34
+ (Chocolatey, npm, a pip console script) that runs the real work as a grandchild, so the kill hit
35
+ the wrapper while the real `ffmpeg` kept running — and the follow-up read blocked on the pipe
36
+ handles it had inherited. Measured on a Chocolatey-managed machine: `ffmpeg` on PATH was a 392KB
37
+ shim, and a 5s timeout against an ~82s pass returned after the full 82s with "timed out after
38
+ 5s" attached to complete, correct output (#120). `_run_command` now spawns via `Popen` in its own
39
+ session/process group and kills the whole tree. Beyond the PR: the kill helper no longer raises
40
+ (`killpg` returns EPERM, `taskkill` can be missing from PATH — either escaped and broke the
41
+ return contract mid-failure), the read after the kill is bounded and says so when it gives up
42
+ rather than hanging on a survivor, and a cancellation mid-run kills the tree instead of orphaning
43
+ it. Fixes every `_run_command` caller at once — the whisper CLI and every ffmpeg pass in
44
+ `_readthrough_analysis`, `silence_ripple`, and `deep_vision`.
45
+ - **The whisper CLI inherited a `PYTHONHOME` that killed it.** `PYTHONHOME`/`PYTHONPATH` point this
46
+ server at Resolve's bundled Python so `DaVinciResolveScript` imports. Inherited by a child that
47
+ is itself a *different* Python, they corrupt its stdlib resolution — and whisper's CLI is exactly
48
+ that. Measured: whisper on 3.14 inheriting a 3.10 `PYTHONHOME` dies on `AssertionError: SRE
49
+ module mismatch` (#118). The whisper subprocess now gets a scrubbed environment. This is the
50
+ **shipped Windows configuration**, not a local quirk: `install.py` writes `PYTHONHOME` into
51
+ generated client configs (issue #26) and `server.py` sets it on Windows whenever it isn't
52
+ already set, so every Windows install hands a foreign `PYTHONHOME` to every child it spawns.
53
+
54
+ ### Corrected in the merged PRs
55
+
56
+ - The documented async return shape was wrong — `{job_id, status}` was advertised, the real
57
+ envelope is `{success, job, plan}` with the id at `job.job_id`. Now documented as it is, plus
58
+ `running` and a `note` naming the next call so the queued and running routes can't be confused.
59
+ - The async divert ran *after* `dry_run` was resolved from the `dry_run_first_default` preference,
60
+ so a user with that preference on still got `background=true` swallowed in silence. An explicit
61
+ `dry_run` still wins; an inherited one no longer does.
62
+ - A code comment attributed the whisper failure to a silent stall with an ffmpeg child at 0% CPU —
63
+ a diagnosis the PR's own description retracted, and one that belongs to the shim problem above.
64
+
65
+ ## What's New in v2.79.2
66
+
67
+ A published contract was **wrong**. This release corrects it. If you read the retime entry in
68
+ v2.79.0 or v2.79.1 and built anything on it, read this.
69
+
70
+ ### Corrected
71
+
72
+ - **The `Clip speed / retime ratio and speed ramps` entry stated a rule that does not exist,
73
+ and missed the hazard that does.** The interchange half of that entry claimed *"any
74
+ `<in>`/`<pproTicksIn>` inconsistency is silently REJECTED, measured in BOTH orientations."*
75
+ **That claim is false and has been removed.** It came from an emitter that wrote
76
+ `ticks = in × ticks-per-frame` at every speed — so what it measured was its own malformed
77
+ files being refused, not a rule of Resolve's importer. In a real Premiere FCP7 export a
78
+ retimed clip's `<in>` and `pproTicksIn` are *supposed* to disagree, by exactly the speed
79
+ ratio: `<in>`/`<out>` are the post-retime (warped) domain and span the **record** duration,
80
+ `pproTicksIn/Out` carry the **true source** position, and `<duration>` is the file length in
81
+ the warped domain. The entry now states that convention with the tick arithmetic shown
82
+ (254016000000/24 = 10584000000 ticks per frame), and notes that
83
+ `resolve-advanced/server/prproj.mjs` already derives Premiere speed from the same tick
84
+ geometry.
85
+ - **The graphdict evidence has been replaced.** The "dead in FOUR separate shapes / 0 of 2
86
+ landed" table and the "200% clip emitted `in 200 / out 296` clamped to `out 248`" line
87
+ described that same malformed input being normalized, and did not support the conclusion
88
+ they were cited for. Re-tested on 19.1.3.7 in Premiere's actual convention — one 100%
89
+ control clip plus one 200% clip per timeline — the document imports, the control lands
90
+ correct, and the retimed clip reads back `src 1500..1548`: a 48-frame source span over a
91
+ 48-frame record span, i.e. no retime. Identical result with the graphdict removed. **The
92
+ conclusion is unchanged** — the scripting-API xmeml import builds no retime — only the
93
+ evidence behind it.
94
+ - **The real hazard, previously absent, is now documented.** **Resolve reads `<in>` literally
95
+ as the true source frame**, honouring neither the ticks nor the graphdict. So importing a
96
+ genuine Premiere XML that contains retimes places every retimed clip at `in ÷ ratio` — the
97
+ 200% clip above lands on source frame 1957 instead of 3914. No error, cut lengths still
98
+ correct, every clip linked and online, timeline renders. It reads as a good conform while
99
+ sitting at the wrong moment of the right file — the same failure class as the Avid AAF
100
+ camera-file link, and `docs/guides/conforming-an-avid-aaf.md` now cross-references it.
101
+ - **The claim is scoped honestly.** All of it describes the **scripting-API** import
102
+ (`ImportTimelineFromFile`). Resolve's **UI** importer (File > Import > Timeline) is
103
+ **untested**, and that is how editors usually conform a Premiere XML — the entry no longer
104
+ implies otherwise. The existing "no positive control" caveat is kept: no clip *known* to be
105
+ retimed has been read back through `GetLeftOffset`/`GetRightOffset`, because there is no
106
+ scripting path to create one.
107
+
108
+ The `SetProperty`/`GetProperty` half of the entry was re-measured on 19.1.3.7 and is
109
+ unaffected. `docs/reference/api-limitations.md` is generated from `src/utils/api_truth.py` and
110
+ was regenerated.
111
+
112
+ ### Validation
113
+
114
+ - `gen_api_limitations.py --check`, `test_api_limitations_doc`, static/drift guards,
115
+ `audit_api_parity.py`, agent-rules drift, `--help`/`--version`, `npm pack --dry-run`,
116
+ `git diff --check`: clean.
117
+ - Docs-only; no code path changed, so no live Resolve validation was required.
118
+
5
119
  ## What's New in v2.79.1
6
120
 
7
121
  One redirect that was never written, in a dispatcher whose other container formats all have one.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.79.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.80.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
@@ -86,6 +86,13 @@ state as the thing it is checking cannot contradict it.
86
86
  including `MediaPool.ImportTimelineFromFile` and the `AppendToTimeline`
87
87
  placement and durability limits you will hit if you build the timeline
88
88
  yourself.
89
+ - `docs/reference/api-limitations.md` → *Clip speed / retime ratio and speed
90
+ ramps* — the same failure class in the Premiere XML route. Importing an FCP7
91
+ XML that contains retimes through the scripting API places every retimed clip
92
+ at `<in>`, which is the true source frame divided by the speed ratio. Lengths
93
+ correct, links correct, wrong moment of the right file, no warning. If your
94
+ turnover is a Premiere XML rather than an AAF, read that entry before trusting
95
+ the conform.
89
96
  - `docs/guides/headless-edit-loop.md` — which interchange formats relink at all
90
97
  when media has moved (DRT, OTIO and EDL do not).
91
98
  </content>
@@ -117,8 +117,8 @@ equivalent, blocking full automation.
117
117
  ### Clip speed / retime ratio and speed ramps
118
118
 
119
119
  - **Object:** `TimelineItem`
120
- - **Behavior:** SetProperty exposes only retime *quality* (RetimeProcess, MotionEstimation) and transform/crop/composite/opacity keys — not the speed value itself. There is no way to set a clip to a given % speed, reverse it, or author a speed ramp. Verified against the documented SetProperty key list AND by live mutating attempt on 21.0.0: SetProperty('Speed'|'PlaybackSpeed'|'RetimeSpeed'|'ClipSpeed', 50) all return False, while SetProperty('RetimeProcess', 1) returns True. THE READ SIDE IS AS DEAD AS THE WRITE SIDE, which is easy to miss: re-measured on Studio 19.1.3.7 against a placed item, GetProperty('Speed'), GetProperty('PlaybackSpeed'), GetProperty('RetimeSpeed') and GetProperty('ClipSpeed') ALL return None, and the keyless GetProperty() dict (26 keys on that item) carries no speed value at all — its only retime key is RetimeProcess, which is quality, not ratio. SetProperty('Speed', 1.75) returned False on 19.1.3.7 too, so the write refusal is not specific to 21.0.0. Note the 21.0.0 stamp above covers the SetProperty measurements only. THE INTERCHANGE ROUTE IS ALSO CLOSEDcarrying a retime in through FCP7 XML does not work either, measured on 19.1.3 with three real files, clips at 100/200/50% on V1, each variant imported as its own timeline: (a) the importer IGNORES the scalar Time Remap speed filter and the clips arrive at 100%; (b) `graphdict` is dead in FOUR separate shapes the full Premiere form (variablespeed=0 + speed + reverse=FALSE + frameblending + 4-keyframe graphdict + FCPCurve), graphdict alone with no <speed> param, a 100% REVERSE (reverse=TRUE plus a descending graphdict, the exact shape a real Premiere export carries), and variablespeed=1 + graphdict 0 of 2 retimes landed in every one; (c) `reverse` did not survive either; (d) any <in>/<pproTicksIn> inconsistency is silently REJECTED, measured in BOTH orientations. The emitted maps were verified constant-slope against real Premiere exports before importing, and every imported clip came back consuming exactly its record span of source (a 200% clip emitted in 200 / out 296 was clamped to out 248 48 source frames over a 48-frame record span). Placement is NOT the problem: the same route imported 573 clips with 572 of 573 matching by track and record position with source frames exact, and the importer BUILT a 59-frame dissolve. The retime gap is specific, not general. TRAP: Resolve's own FCP7 export cannot witness a speed. It writes a DEGENERATE Time Remap on every clip — `speed` value 0 (not 100) and a graphdict whose keyframe `value`s are all 0 while its `when`s carry the clip's source in/out — so anyone verifying a retime by round-tripping through EXPORT_FCP_7_XML is reading furniture, and the identity Time Remap blocks present on every clip are what make the route look like it should work.
121
- - **Workaround / current handling:** Set clip speed/retime in the Resolve UI; no scripted equivalent exists, and no interchange route carries one in. Do NOT read speed back with GetProperty (None) or witness it via EXPORT_FCP_7_XML (degenerate). Read the clip's GEOMETRY instead — GetLeftOffset / GetRightOffset, which on the 200% clip read 200 / 248 and agreed with the export's in/out. Caveat worth stating: no positive control has been run — no clip KNOWN to be retimed has been read back through those two witnesses, because there is no scripting path to create one. So the geometry witness is the best available, not a proven one. Either way the interchange route is rejected: no retime was built, and none could have been verified.
120
+ - **Behavior:** SetProperty exposes only retime *quality* (RetimeProcess, MotionEstimation) and transform/crop/composite/opacity keys — not the speed value itself. There is no way to set a clip to a given % speed, reverse it, or author a speed ramp. Verified against the documented SetProperty key list AND by live mutating attempt on 21.0.0: SetProperty('Speed'|'PlaybackSpeed'|'RetimeSpeed'|'ClipSpeed', 50) all return False, while SetProperty('RetimeProcess', 1) returns True. THE READ SIDE IS AS DEAD AS THE WRITE SIDE, which is easy to miss: re-measured on Studio 19.1.3.7 against a placed item, GetProperty('Speed'), GetProperty('PlaybackSpeed'), GetProperty('RetimeSpeed') and GetProperty('ClipSpeed') ALL return None, and the keyless GetProperty() dict (26 keys on that item) carries no speed value at all — its only retime key is RetimeProcess, which is quality, not ratio. SetProperty('Speed', 1.75) returned False on 19.1.3.7 too, so the write refusal is not specific to 21.0.0. Note the 21.0.0 stamp above covers the SetProperty measurements only. THE SCRIPTING-API xmeml IMPORT BUILDS NO RETIME and the way it fails is worse than a no-op. First, what Premiere actually writes, because having this backwards is what produced the wrong contract this entry published in 2.79.0–2.79.1 (see CORRECTION below). In an FCP7 XML a retimed clipitem's <in>/<out> live in the POST-RETIME (warped) domain and always span the RECORD duration; pproTicksIn/pproTicksOut carry the TRUE SOURCE position; and <duration> is the file length expressed in the warped domain. A real 200% clip at 24 fps: <in>1957</in> <out>1971</out> — span 14, EQUAL to its record span; pproTicksIn 41425776000000 and pproTicksOut 41722128000000, which at 254016000000/24 = 10584000000 ticks per frame are source frames 3914 and 3942, exactly 1957x2 and 1971x2, a 28-frame source span over a 14-frame record span; <duration>24292</duration> for a 48584-frame file; and a graphdict mapping warped to true source with the ratio as its slope (when 17910 -> value 35820). So for a retimed clip <in> and pproTicksIn are SUPPOSED to disagree, by exactly the ratio. The same relationship seen from the other side is already encoded in this repo: resolve-advanced/server/prproj.mjs derives Premiere speed from tick geometry as |srcSpan / recSpan| * 100, reversing when in > out. Against that convention, measured on 19.1.3/19.1.3.7: (a) the importer IGNORES the scalar Time Remap speed filter and the clips arrive at 100%; (b) `graphdict` is ignored toore-tested in Premiere's exact convention with one 100% control clip and one 200% clip per timeline, a document carrying warped <in>/<out>, true-source pproTicks, <duration> = fileLen/ratio and a constant-slope graphdict imports cleanly, the control lands correct, and the 200% clip reads back src 1500..1548 — a 48-frame source span over a 48-frame record span, i.e. NO retime; emitting the identical document WITHOUT the graphdict gives the identical result; (c) `reverse` does not survive either; (d) THE HAZARD, and it is the part that bites: Resolve reads <in> LITERALLY as the true source frame, honouring neither the ticks nor the graphdict. Import a genuine Premiere XML that contains retimes and every retimed clip is placed at in / ratio — the 200% clip above lands on source frame 1957 instead of 3914. There is no error, the cut lengths are still correct, every clip is linked and online, and the timeline renders — so it reads as a good conform while sitting at the wrong moment of the right file. This is the same failure class as the Avid AAF camera-file link (docs/guides/conforming-an-avid-aaf.md): wrong in a way only a frame comparison against a reference can see. SCOPE: all of the above is the SCRIPTING-API import (ImportTimelineFromFile). Resolve's UI importer (File > Import > Timeline) has NOT been tested, and that is how editors usually conform a Premiere XML — do not read this as covering it. CORRECTION: this entry as published in 2.79.0–2.79.1 also claimed that any <in>/<pproTicksIn> inconsistency is silently REJECTED in both orientations. That claim was FALSE and has been removed it came from an emitter writing ticks = in x ticks-per-frame at every speed, so what it observed was its own malformed files being refused. The graphdict evidence published with it (dead in FOUR shapes, 0 of 2 landed, a 200% clip emitted in 200 / out 296 'clamped' to out 248) described that same malformed input being normalized and is replaced by the re-test above. The conclusion is unchanged; only its evidence is. Placement is NOT the problem: the same route imported 573 clips with 572 of 573 matching by track and record position with source frames exact, and the importer BUILT a 59-frame dissolve. The retime gap is specific, not general. TRAP: Resolve's own FCP7 export cannot witness a speed. It writes a DEGENERATE Time Remap on every clip — `speed` value 0 (not 100) and a graphdict whose keyframe `value`s are all 0 while its `when`s carry the clip's source in/out — so anyone verifying a retime by round-tripping through EXPORT_FCP_7_XML is reading furniture, and the identity Time Remap blocks present on every clip are what make the route look like it should work.
121
+ - **Workaround / current handling:** Set clip speed/retime in the Resolve UI; no scripted equivalent exists, and the scripting-API xmeml import does not carry one in. Do NOT read speed back with GetProperty (None) or witness it via EXPORT_FCP_7_XML (degenerate). Read the clip's GEOMETRY instead — GetLeftOffset / GetRightOffset, whose source span is what shows whether a retime was built (the re-tested 200% clip read src 1500..1548, 48 source frames over a 48-frame record, so none was). Caveat worth keeping: there is still no positive control — no clip KNOWN to be retimed has been read back through those two witnesses, because there is no scripting path to create one, so the geometry witness is the best available, not a proven one. And if you are importing a real Premiere XML that contains retimes, treat every retimed clip's source position as WRONG — placed at <in>, i.e. in / ratio — until it is checked against a reference; the lengths and the links will look right.
122
122
  - **Tags:** missing-method, timeline, retime, speed, interchange, silent-failure, unreliable-return
123
123
 
124
124
  ### Color node graph editing and primary grade values
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.79.1"
39
+ VERSION = "2.80.0"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.79.1",
3
+ "version": "2.80.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.79.1"
88
+ VERSION = "2.80.0"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.79.1"
14
+ VERSION = "2.80.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -104,9 +104,11 @@ from src.utils.media_analysis_jobs import (
104
104
  batch_job_status as media_analysis_batch_job_status,
105
105
  cancel_batch_job as cancel_media_analysis_batch_job,
106
106
  create_batch_job as create_media_analysis_batch_job,
107
+ join_batch_job_runner as join_media_analysis_batch_job_runner,
107
108
  list_batch_jobs as list_media_analysis_batch_jobs,
108
109
  resume_batch_job as resume_media_analysis_batch_job,
109
110
  run_batch_job_slice as run_media_analysis_batch_job_slice,
111
+ start_batch_job_runner as start_media_analysis_batch_job_runner,
110
112
  )
111
113
  from src.utils.platform import get_resolve_paths, get_resolve_plugin_paths
112
114
  from src.utils.resolve_connection import connect_resolve
@@ -8241,6 +8243,49 @@ def _media_analysis_bool(value: Any, default: bool = False) -> bool:
8241
8243
  return bool(value)
8242
8244
 
8243
8245
 
8246
+ MEDIA_ANALYSIS_ASYNC_QUEUED = "queued"
8247
+ MEDIA_ANALYSIS_ASYNC_RUNNING = "running"
8248
+
8249
+
8250
+ def _media_analysis_async_mode(p: Dict[str, Any], *, dry_run_explicit: bool = True) -> Optional[str]:
8251
+ """How an analyze_* call wants its work handled. None means synchronously.
8252
+
8253
+ Two opt-ins, deliberately not synonyms:
8254
+
8255
+ prefer_handle -> "queued". Create the durable batch job and hand
8256
+ it back. Nothing runs until the caller drives
8257
+ run_batch_job_slice. This is the pre-existing
8258
+ contract and is unchanged.
8259
+ background/async_job -> "running". Create the job AND drive it off-thread,
8260
+ so the work is under way when the call returns.
8261
+
8262
+ The split exists because `background` already means something specific
8263
+ everywhere else in this server — _run_maybe_background starts the work and
8264
+ the caller polls until it finishes on its own. Before, these two params were
8265
+ accepted here and silently ignored: the analyze_* actions collapsed to
8266
+ action="plan" and ran to completion inline, so a caller got no job_id and no
8267
+ signal, indistinguishable from a hang. Aliasing them onto prefer_handle
8268
+ would have replaced that with a job that never progressed — a quieter
8269
+ failure than the one being fixed. So `background` keeps its meaning and gets
8270
+ the runner it always implied.
8271
+
8272
+ `dry_run` still wins, but only when the caller asked for it. Pass
8273
+ dry_run_explicit=False when p["dry_run"] came from the dry_run_first_default
8274
+ preference rather than the call: a preference should not silently swallow an
8275
+ explicit async request and hand back a plan the caller never asked for,
8276
+ which is the same silence this whole change is closing.
8277
+ """
8278
+ if dry_run_explicit and _media_analysis_bool(p.get("dry_run"), False):
8279
+ return None
8280
+ if _media_analysis_bool(p.get("background"), False) or _media_analysis_bool(
8281
+ p.get("async_job"), False
8282
+ ):
8283
+ return MEDIA_ANALYSIS_ASYNC_RUNNING
8284
+ if _media_analysis_bool(p.get("prefer_handle"), False):
8285
+ return MEDIA_ANALYSIS_ASYNC_QUEUED
8286
+ return None
8287
+
8288
+
8244
8289
  def _media_analysis_target_dict(raw_target: Any, p: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
8245
8290
  p = p or {}
8246
8291
  if raw_target is None:
@@ -17928,12 +17973,22 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
17928
17973
  set_ai_governance(preset?, mode?, overrides?) -> {success, tier, mode, overrides} — set the tier, the mode (advisory|enforce), and/or overrides (deblur_runs, speech_runs, render_bytes, render_wall_clock_ms; int or "unlimited"). In enforce mode a blocked run returns GOVERNANCE_BLOCKED; pass override_governance=true on the op to consciously exceed the tier once.
17929
17974
  resolve_output_root(analysis_root?, source_paths?) -> {project_root}
17930
17975
  plan(target, depth?, analysis_root?, transcription?, vision?, dry_run?) -> {clips, artifacts}
17931
- analyze_file(path|file_path, dry_run?, session_only?, persist?) -> {clips, manifest}
17932
- analyze_clip(clip_id|selected, dry_run?, session_only?, persist?) -> {clips, manifest}
17933
- analyze_bin(path|bin_path, recursive?, dry_run?, session_only?, persist?) -> {clips, manifest}
17934
- analyze_project(recursive?, dry_run?, session_only?, persist?) -> {clips, manifest}
17935
- analyze_sequence(timeline_index?, track_types?, dry_run?, session_only?, persist?) -> {clips, manifest}
17976
+ analyze_file(path|file_path, dry_run?, session_only?, persist?, prefer_handle?|background?|async_job?) -> {clips, manifest} | {success, job, plan, running, note} when async
17977
+ analyze_clip(clip_id|selected, dry_run?, session_only?, persist?, prefer_handle?|background?|async_job?) -> {clips, manifest} | {success, job, plan, running, note} when async
17978
+ analyze_bin(path|bin_path, recursive?, dry_run?, session_only?, persist?, prefer_handle?|background?|async_job?) -> {clips, manifest} | {success, job, plan, running, note} when async
17979
+ analyze_project(recursive?, dry_run?, session_only?, persist?, prefer_handle?|background?|async_job?) -> {clips, manifest} | {success, job, plan, running, note} when async
17980
+ analyze_sequence(timeline_index?, track_types?, dry_run?, session_only?, persist?, prefer_handle?|background?|async_job?) -> {clips, manifest} | {success, job, plan, running, note} when async
17936
17981
  analyze_timeline(...) -> alias for analyze_sequence on the current timeline
17982
+ -- async opt-ins on the analyze_* actions above. Both reroute to start_batch_job and return its
17983
+ {success, job, plan} envelope — the id is job.job_id, NOT a top-level job_id — plus `running`
17984
+ and a `note` naming the next call. They differ in what happens next:
17985
+ prefer_handle=true job is created and left queued; nothing runs until you call
17986
+ run_batch_job_slice yourself. Unchanged contract.
17987
+ background|async_job=true job is created AND driven to completion off-thread, matching what
17988
+ `background` means on every other tool here. Poll batch_job_status
17989
+ until status is completed / completed_with_errors / canceled.
17990
+ An explicit dry_run=true still returns the synchronous plan and starts nothing. A dry_run that
17991
+ came from the dry_run_first_default preference does not override an explicit async request.
17937
17992
  detect_sync_events(paths?|target?, event_types?, windows?) -> {files, alignment}
17938
17993
  add_sync_event_markers(target?|paths?|detections?, confirm?) -> {added, skipped}
17939
17994
  publish_clip_metadata(target?, fields?, slate_detection?, timed_markers?|write_markers?, dry_run?, confirm?) -> {results}
@@ -18787,21 +18842,56 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
18787
18842
  if warnings:
18788
18843
  target_err["warnings"] = warnings
18789
18844
  return target_err
18845
+ capabilities = detect_media_analysis_capabilities()
18790
18846
  created = create_media_analysis_batch_job(
18791
18847
  project_name=project_name,
18792
18848
  project_id=project_id,
18793
18849
  records=records or [],
18794
18850
  target=normalized_target,
18795
18851
  params=p,
18796
- capabilities=detect_media_analysis_capabilities(),
18852
+ capabilities=capabilities,
18797
18853
  name=p.get("name") or p.get("job_name") or p.get("jobName"),
18798
18854
  )
18799
18855
  if warnings:
18800
18856
  created.setdefault("warnings", warnings)
18857
+ # A created job sits at "queued" and nothing advances it on its own.
18858
+ # That is the right default for start_batch_job and prefer_handle, whose
18859
+ # contract is "here is a handle, drive it". It is the wrong one for
18860
+ # background/async_job, which promise the work is under way — so those
18861
+ # get a runner. Reached either by the analyze_* divert (which sets
18862
+ # _async_mode) or by calling start_batch_job with background=true.
18863
+ job_id = str((created.get("job") or {}).get("job_id") or "")
18864
+ project_root = str((created.get("plan") or {}).get("output_root") or "")
18865
+ wants_runner = p.get("_async_mode") == MEDIA_ANALYSIS_ASYNC_RUNNING or (
18866
+ _media_analysis_bool(p.get("background"), False)
18867
+ or _media_analysis_bool(p.get("async_job"), False)
18868
+ )
18869
+ if wants_runner and job_id and project_root:
18870
+ started = start_media_analysis_batch_job_runner(
18871
+ project_root, job_id, capabilities=capabilities
18872
+ )
18873
+ created["running"] = bool(started.get("started"))
18874
+ created["note"] = (
18875
+ f"Analysis is running off-thread. Poll with "
18876
+ f"media_analysis(action='batch_job_status', params={{'job_id': '{job_id}'}})."
18877
+ )
18878
+ if not started.get("started"):
18879
+ created["note"] = (
18880
+ f"Job created but not started ({started.get('reason')}). Drive it with "
18881
+ f"media_analysis(action='run_batch_job_slice', params={{'job_id': '{job_id}'}})."
18882
+ )
18883
+ else:
18884
+ created["running"] = False
18885
+ created["note"] = (
18886
+ f"Job is queued, not running. Drive it with "
18887
+ f"media_analysis(action='run_batch_job_slice', params={{'job_id': '{job_id}'}}), "
18888
+ f"or pass background=true to have the server run it."
18889
+ )
18801
18890
  return created
18802
18891
 
18803
18892
  if action in {"analyze_file", "analyze_clip", "analyze_bin", "analyze_project", "analyze_timeline", "analyze_sequence"}:
18804
18893
  dry_run_default = bool(_media_analysis_effective_preferences().get("dry_run_first_default"))
18894
+ dry_run_explicit = _has_any_param(p, "dry_run", "dryRun")
18805
18895
  p["dry_run"] = _media_analysis_bool(p.get("dry_run"), dry_run_default)
18806
18896
  target = _media_analysis_target_dict(p.get("target"), p)
18807
18897
  if target.get("_invalid_target"):
@@ -18821,15 +18911,17 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
18821
18911
  "track_types": p.get("track_types") or p.get("trackTypes") or target.get("track_types") or target.get("trackTypes"),
18822
18912
  })
18823
18913
  p["target"] = target
18824
- # E3 — `prefer_handle` opt-in. When true AND this isn't a dry-run,
18825
- # divert to the durable batch-job machinery so the call returns a
18826
- # job_id immediately instead of blocking on vision/transcription.
18827
- # Default false: existing blocking semantics are preserved.
18914
+ # E3 — async opt-ins. `prefer_handle` hands back a queued job for the
18915
+ # caller to drive; `background`/`async_job` additionally start driving
18916
+ # it. Either way the call returns at once instead of blocking on
18917
+ # vision/transcription. Default: unchanged blocking semantics.
18828
18918
  # The start_batch_job handler lives ABOVE this block in the dispatch
18829
18919
  # chain, so we can't just rewrite `action` and fall through — we
18830
18920
  # re-enter the tool with the rewritten action via await so the
18831
18921
  # handler chain restarts from the top.
18832
- if _media_analysis_bool(p.get("prefer_handle"), False) and not p.get("dry_run"):
18922
+ async_mode = _media_analysis_async_mode(p, dry_run_explicit=dry_run_explicit)
18923
+ if async_mode:
18924
+ p["_async_mode"] = async_mode
18833
18925
  return await media_analysis("start_batch_job", p, ctx)
18834
18926
  action = "plan"
18835
18927
 
@@ -680,25 +680,66 @@ API_TRUTH: List[Dict[str, Any]] = [
680
680
  "'Speed', 1.75) returned False on 19.1.3.7 too, so the write "
681
681
  "refusal is not specific to 21.0.0. Note the 21.0.0 stamp above "
682
682
  "covers the SetProperty measurements only. "
683
- "THE INTERCHANGE ROUTE IS ALSO CLOSEDcarrying a retime in "
684
- "through FCP7 XML does not work either, measured on 19.1.3 with "
685
- "three real files, clips at 100/200/50% on V1, each variant "
686
- "imported as its own timeline: (a) the importer IGNORES the "
687
- "scalar Time Remap speed filter and the clips arrive at 100%; "
688
- "(b) `graphdict` is dead in FOUR separate shapes — the full "
689
- "Premiere form (variablespeed=0 + speed + reverse=FALSE + "
690
- "frameblending + 4-keyframe graphdict + FCPCurve), graphdict "
691
- "alone with no <speed> param, a 100% REVERSE (reverse=TRUE plus "
692
- "a descending graphdict, the exact shape a real Premiere export "
693
- "carries), and variablespeed=1 + graphdict 0 of 2 retimes "
694
- "landed in every one; (c) `reverse` did not survive either; "
695
- "(d) any <in>/<pproTicksIn> inconsistency is silently REJECTED, "
696
- "measured in BOTH orientations. The emitted maps were verified "
697
- "constant-slope against real Premiere exports before importing, "
698
- "and every imported clip came back consuming exactly its record "
699
- "span of source (a 200% clip emitted in 200 / out 296 was "
700
- "clamped to out 248 48 source frames over a 48-frame record "
701
- "span). Placement is NOT the problem: the same route imported "
683
+ "THE SCRIPTING-API xmeml IMPORT BUILDS NO RETIME and the way "
684
+ "it fails is worse than a no-op. "
685
+ "First, what Premiere actually writes, because having this "
686
+ "backwards is what produced the wrong contract this entry "
687
+ "published in 2.79.0–2.79.1 (see CORRECTION below). In an FCP7 "
688
+ "XML a retimed clipitem's <in>/<out> live in the POST-RETIME "
689
+ "(warped) domain and always span the RECORD duration; "
690
+ "pproTicksIn/pproTicksOut carry the TRUE SOURCE position; and "
691
+ "<duration> is the file length expressed in the warped domain. A "
692
+ "real 200% clip at 24 fps: <in>1957</in> <out>1971</out> span "
693
+ "14, EQUAL to its record span; pproTicksIn 41425776000000 and "
694
+ "pproTicksOut 41722128000000, which at 254016000000/24 = "
695
+ "10584000000 ticks per frame are source frames 3914 and 3942, "
696
+ "exactly 1957x2 and 1971x2, a 28-frame source span over a "
697
+ "14-frame record span; <duration>24292</duration> for a "
698
+ "48584-frame file; and a graphdict mapping warped to true source "
699
+ "with the ratio as its slope (when 17910 -> value 35820). So for "
700
+ "a retimed clip <in> and pproTicksIn are SUPPOSED to disagree, by "
701
+ "exactly the ratio. The same relationship seen from the other "
702
+ "side is already encoded in this repo: "
703
+ "resolve-advanced/server/prproj.mjs derives Premiere speed from "
704
+ "tick geometry as |srcSpan / recSpan| * 100, reversing when in > "
705
+ "out. "
706
+ "Against that convention, measured on 19.1.3/19.1.3.7: (a) the "
707
+ "importer IGNORES the scalar Time Remap speed filter and the "
708
+ "clips arrive at 100%; (b) `graphdict` is ignored too — re-tested "
709
+ "in Premiere's exact convention with one 100% control clip and "
710
+ "one 200% clip per timeline, a document carrying warped "
711
+ "<in>/<out>, true-source pproTicks, <duration> = fileLen/ratio "
712
+ "and a constant-slope graphdict imports cleanly, the control "
713
+ "lands correct, and the 200% clip reads back src 1500..1548 — a "
714
+ "48-frame source span over a 48-frame record span, i.e. NO "
715
+ "retime; emitting the identical document WITHOUT the graphdict "
716
+ "gives the identical result; (c) `reverse` does not survive "
717
+ "either; (d) THE HAZARD, and it is the part that bites: Resolve "
718
+ "reads <in> LITERALLY as the true source frame, honouring neither "
719
+ "the ticks nor the graphdict. Import a genuine Premiere XML that "
720
+ "contains retimes and every retimed clip is placed at in / ratio "
721
+ "— the 200% clip above lands on source frame 1957 instead of "
722
+ "3914. There is no error, the cut lengths are still correct, "
723
+ "every clip is linked and online, and the timeline renders — so "
724
+ "it reads as a good conform while sitting at the wrong moment of "
725
+ "the right file. This is the same failure class as the Avid AAF "
726
+ "camera-file link (docs/guides/conforming-an-avid-aaf.md): wrong "
727
+ "in a way only a frame comparison against a reference can see. "
728
+ "SCOPE: all of the above is the SCRIPTING-API import "
729
+ "(ImportTimelineFromFile). Resolve's UI importer (File > Import > "
730
+ "Timeline) has NOT been tested, and that is how editors usually "
731
+ "conform a Premiere XML — do not read this as covering it. "
732
+ "CORRECTION: this entry as published in 2.79.0–2.79.1 also "
733
+ "claimed that any <in>/<pproTicksIn> inconsistency is silently "
734
+ "REJECTED in both orientations. That claim was FALSE and has been "
735
+ "removed — it came from an emitter writing ticks = in x "
736
+ "ticks-per-frame at every speed, so what it observed was its own "
737
+ "malformed files being refused. The graphdict evidence published "
738
+ "with it (dead in FOUR shapes, 0 of 2 landed, a 200% clip emitted "
739
+ "in 200 / out 296 'clamped' to out 248) described that same "
740
+ "malformed input being normalized and is replaced by the re-test "
741
+ "above. The conclusion is unchanged; only its evidence is. "
742
+ "Placement is NOT the problem: the same route imported "
702
743
  "573 clips with 572 of 573 matching by track and record position "
703
744
  "with source frames exact, and the importer BUILT a 59-frame "
704
745
  "dissolve. The retime gap is specific, not general. "
@@ -711,18 +752,22 @@ API_TRUTH: List[Dict[str, Any]] = [
711
752
  "present on every clip are what make the route look like it "
712
753
  "should work.",
713
754
  "recommended": "Set clip speed/retime in the Resolve UI; no scripted "
714
- "equivalent exists, and no interchange route carries one "
715
- "in. Do NOT read speed back with GetProperty (None) or "
716
- "witness it via EXPORT_FCP_7_XML (degenerate). Read the "
717
- "clip's GEOMETRY instead — GetLeftOffset / GetRightOffset, "
718
- "which on the 200% clip read 200 / 248 and agreed with the "
719
- "export's in/out. Caveat worth stating: no positive control "
720
- "has been run no clip KNOWN to be retimed has been read "
721
- "back through those two witnesses, because there is no "
722
- "scripting path to create one. So the geometry witness is "
723
- "the best available, not a proven one. Either way the "
724
- "interchange route is rejected: no retime was built, and "
725
- "none could have been verified.",
755
+ "equivalent exists, and the scripting-API xmeml import does "
756
+ "not carry one in. Do NOT read speed back with GetProperty "
757
+ "(None) or witness it via EXPORT_FCP_7_XML (degenerate). "
758
+ "Read the clip's GEOMETRY instead — GetLeftOffset / "
759
+ "GetRightOffset, whose source span is what shows whether a "
760
+ "retime was built (the re-tested 200% clip read src "
761
+ "1500..1548, 48 source frames over a 48-frame record, so "
762
+ "none was). Caveat worth keeping: there is still no positive "
763
+ "control no clip KNOWN to be retimed has been read back "
764
+ "through those two witnesses, because there is no scripting "
765
+ "path to create one, so the geometry witness is the best "
766
+ "available, not a proven one. And if you are importing a real "
767
+ "Premiere XML that contains retimes, treat every retimed "
768
+ "clip's source position as WRONG — placed at <in>, i.e. "
769
+ "in / ratio — until it is checked against a reference; the "
770
+ "lengths and the links will look right.",
726
771
  "tags": ["missing-method", "timeline", "retime", "speed", "interchange",
727
772
  "silent-failure", "unreliable-return"],
728
773
  "submit": "missing",
@@ -17,6 +17,7 @@ import os
17
17
  import platform as _platform
18
18
  import re
19
19
  import shutil
20
+ import signal
20
21
  import sqlite3
21
22
  import subprocess
22
23
  import sys
@@ -2376,23 +2377,105 @@ def build_plan(
2376
2377
  }
2377
2378
 
2378
2379
 
2379
- def _run_command(args: List[str], timeout: int = COMMAND_TIMEOUT_SECONDS) -> Tuple[int, str, str]:
2380
+ def _kill_process_tree(pid: int) -> None:
2381
+ """Best-effort: terminate pid and its descendants, not just the direct child.
2382
+
2383
+ Popen.kill() reaches only the immediate child. On Windows a bare-name PATH
2384
+ lookup can resolve to a wrapper — a Chocolatey/npm shim, a pip console
2385
+ script — that runs the real work as a grandchild, which a single-PID kill
2386
+ leaves untouched. Measured: `ffmpeg` on PATH was a 392KB shim, and a 5s
2387
+ timeout against an ~82s real ffmpeg pass had no effect at all, because the
2388
+ surviving grandchild still held the stdout/stderr handles it had inherited
2389
+ and the follow-up read blocked until it finished on its own.
2390
+
2391
+ Failure here is never fatal. The caller is already on its error path and
2392
+ owes its own caller a (code, stdout, stderr) tuple, so this must not raise:
2393
+ `taskkill` can be absent from PATH and `killpg` can return EPERM, which is
2394
+ why the whole branch catches OSError rather than only ProcessLookupError.
2395
+ """
2380
2396
  try:
2381
- proc = subprocess.run(
2397
+ if os.name == "nt":
2398
+ subprocess.run(
2399
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
2400
+ capture_output=True,
2401
+ check=False,
2402
+ )
2403
+ else:
2404
+ os.killpg(pid, signal.SIGKILL)
2405
+ except OSError:
2406
+ pass
2407
+
2408
+
2409
+ # How long to wait for the pipes to drain after a tree kill. The kill is
2410
+ # best-effort, so this read has to be bounded: anything that escaped it still
2411
+ # holds the inherited pipe handles, and an unbounded read there would hang for
2412
+ # exactly the reason the kill exists.
2413
+ _POST_KILL_DRAIN_SECONDS = 5
2414
+
2415
+
2416
+ def _run_command(
2417
+ args: List[str],
2418
+ timeout: int = COMMAND_TIMEOUT_SECONDS,
2419
+ env: Optional[Dict[str, str]] = None,
2420
+ ) -> Tuple[int, str, str]:
2421
+ """Run args to completion and return (returncode, stdout, stderr).
2422
+
2423
+ Spawned via Popen rather than subprocess.run so a timeout can kill the whole
2424
+ process tree instead of one PID — see _kill_process_tree.
2425
+
2426
+ `env=None` inherits this process's environment, matching what subprocess.run
2427
+ did. Pass an explicit mapping for a child that must not inherit it: on
2428
+ Windows this server sets PYTHONHOME so the fusionscript bridge can find
2429
+ Resolve's Python, and a child that is itself a *different* Python (the
2430
+ whisper CLI) dies loading a foreign stdlib against its own C extensions.
2431
+
2432
+ Returns 124 on timeout, 127 when the binary cannot be spawned.
2433
+ """
2434
+ popen_kwargs: Dict[str, Any] = {}
2435
+ if os.name == "nt":
2436
+ # Isolates the child from console signals sent to the server. Note this
2437
+ # is not what makes the tree kill work — taskkill /T walks parent-child
2438
+ # links, not process groups. start_new_session is load-bearing on POSIX,
2439
+ # where killpg needs the child to lead a group of its own.
2440
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
2441
+ else:
2442
+ popen_kwargs["start_new_session"] = True
2443
+ try:
2444
+ proc = subprocess.Popen(
2382
2445
  args,
2383
- capture_output=True,
2384
- timeout=timeout,
2385
- check=False,
2446
+ stdout=subprocess.PIPE,
2447
+ stderr=subprocess.PIPE,
2448
+ env=env,
2449
+ **popen_kwargs,
2386
2450
  )
2387
- except subprocess.TimeoutExpired as exc:
2388
- stdout = exc.stdout.decode("utf-8", errors="replace") if exc.stdout else ""
2389
- stderr_tail = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else ""
2390
- return 124, stdout, f"Command timed out after {timeout}s. {stderr_tail}".strip()
2391
2451
  except OSError as exc:
2392
2452
  return 127, "", str(exc)
2393
- stdout = proc.stdout.decode("utf-8", errors="replace") if proc.stdout else ""
2394
- stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
2395
- return proc.returncode, stdout, stderr
2453
+ try:
2454
+ stdout, stderr = proc.communicate(timeout=timeout)
2455
+ except subprocess.TimeoutExpired:
2456
+ _kill_process_tree(proc.pid)
2457
+ abandoned = False
2458
+ try:
2459
+ stdout, stderr = proc.communicate(timeout=_POST_KILL_DRAIN_SECONDS)
2460
+ except subprocess.TimeoutExpired:
2461
+ # A descendant outlived the tree kill and still holds the pipes.
2462
+ # Give up the output rather than block — a stalled caller is a
2463
+ # worse outcome than a timeout report with no stderr tail.
2464
+ stdout, stderr = b"", b""
2465
+ abandoned = True
2466
+ stdout_s = stdout.decode("utf-8", errors="replace") if stdout else ""
2467
+ stderr_s = stderr.decode("utf-8", errors="replace") if stderr else ""
2468
+ detail = " Output abandoned: a descendant survived the kill." if abandoned else ""
2469
+ return 124, stdout_s, f"Command timed out after {timeout}s.{detail} {stderr_s}".strip()
2470
+ except BaseException:
2471
+ # subprocess.run kills the child on any exception on the way out;
2472
+ # Popen does not. Under the server's threaded dispatch a cancellation
2473
+ # or KeyboardInterrupt here would otherwise leave an orphaned tree.
2474
+ _kill_process_tree(proc.pid)
2475
+ raise
2476
+ stdout_s = stdout.decode("utf-8", errors="replace") if stdout else ""
2477
+ stderr_s = stderr.decode("utf-8", errors="replace") if stderr else ""
2478
+ return proc.returncode, stdout_s, stderr_s
2396
2479
 
2397
2480
 
2398
2481
  def _write_json(path: str, payload: Dict[str, Any]) -> None:
@@ -4023,7 +4106,28 @@ def _transcribe_with_whisper_cli(path: str, artifacts: Dict[str, Any], transcrip
4023
4106
  ]
4024
4107
  if transcription.get("language"):
4025
4108
  cmd.extend(["--language", str(transcription["language"])])
4026
- code, _, stderr = _run_command(cmd, timeout=int(transcription.get("timeout", 1800)))
4109
+ # PYTHONHOME/PYTHONPATH point this server at Resolve's bundled Python so
4110
+ # DaVinciResolveScript imports. Inherited by a child that is itself a
4111
+ # *different* Python, they corrupt its stdlib resolution — and the whisper
4112
+ # CLI is exactly that: a Python program, frequently on another interpreter
4113
+ # entirely. Measured: whisper under Python 3.14 inheriting a 3.10
4114
+ # PYTHONHOME loads 3.10's stdlib against its own compiled extensions and
4115
+ # dies on `AssertionError: SRE module mismatch`. That crash is fast, not a
4116
+ # hang; it only reads as one when something else delays the response.
4117
+ #
4118
+ # This is the shipped Windows configuration, not a local quirk: install.py
4119
+ # writes PYTHONHOME into generated client configs (see docs/install.md,
4120
+ # issue #26), and server.py sets it on Windows whenever it isn't already
4121
+ # set. So every Windows install hands a foreign PYTHONHOME to every child
4122
+ # it spawns, and any Python-based tool added here needs the same scrub.
4123
+ #
4124
+ # PYTHONIOENCODING=utf-8 is unrelated: it avoids a UnicodeEncodeError in
4125
+ # whisper's own argparse help text on a non-UTF-8 console.
4126
+ whisper_env = dict(os.environ)
4127
+ whisper_env.pop("PYTHONHOME", None)
4128
+ whisper_env.pop("PYTHONPATH", None)
4129
+ whisper_env["PYTHONIOENCODING"] = "utf-8"
4130
+ code, _, stderr = _run_command(cmd, timeout=int(transcription.get("timeout", 1800)), env=whisper_env)
4027
4131
  if code != 0:
4028
4132
  return {"success": False, "backend": "whisper_cli", "error": stderr.strip() or "whisper CLI failed"}
4029
4133
  json_files = sorted(Path(work_dir).glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
@@ -13,6 +13,7 @@ import hashlib
13
13
  import json
14
14
  import os
15
15
  import sqlite3
16
+ import threading
16
17
  import time
17
18
  from pathlib import Path
18
19
  from typing import Any, Dict, Iterable, List, Optional, Tuple
@@ -795,6 +796,135 @@ def run_batch_job_slice(
795
796
  }
796
797
 
797
798
 
799
+ # A job reaching one of these is finished; the runner stops rather than
800
+ # spinning on a queue that will never drain.
801
+ TERMINAL_JOB_STATUSES = {"completed", "completed_with_errors", "canceled"}
802
+
803
+ # Runner threads in flight, keyed by (project_root, job_id). Starting a second
804
+ # runner for a job already being driven is a no-op, not a second pump — two
805
+ # pumps on one job would race for the same pending rows.
806
+ _ACTIVE_RUNNERS: Dict[Tuple[str, str], threading.Thread] = {}
807
+ _RUNNERS_LOCK = threading.Lock()
808
+
809
+ # Exactly one slice executes at a time across the whole process. Slices are
810
+ # bounded (max_clips defaults to 1), so several jobs interleave a clip at a
811
+ # time instead of one starving the others — and a laptop never ends up running
812
+ # N ffmpeg/whisper passes at once because someone queued N analyses.
813
+ _SLICE_LOCK = threading.Lock()
814
+
815
+
816
+ def _job_status_value(root: str, job_id: str) -> Optional[str]:
817
+ """Current status string for a job, or None if it no longer exists.
818
+
819
+ Deliberately not batch_job_status: that assembles every clip row and event
820
+ for the caller, and the runner only needs the one column between slices.
821
+ """
822
+ conn = _connect_jobs(root)
823
+ try:
824
+ row = conn.execute("SELECT status FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
825
+ return str(row["status"]) if row else None
826
+ finally:
827
+ conn.close()
828
+
829
+
830
+ def _drive_batch_job(root: str, job_id: str, capabilities: Optional[Dict[str, Any]], max_clips: int) -> None:
831
+ """Run slices back to back until the job finishes, is canceled, or stalls."""
832
+ try:
833
+ while True:
834
+ status = _job_status_value(root, job_id)
835
+ if status is None or status in TERMINAL_JOB_STATUSES:
836
+ return
837
+ with _SLICE_LOCK:
838
+ result = run_batch_job_slice(
839
+ root, job_id, max_clips=max_clips, capabilities=capabilities
840
+ )
841
+ if not result.get("success"):
842
+ return
843
+ # No pending rows left to claim. Either the job just finished (the
844
+ # status check above catches that next pass) or it is wedged; in
845
+ # both cases another identical slice would be a spin.
846
+ if not int(result.get("processed_count") or 0):
847
+ return
848
+ if str((result.get("job") or {}).get("status") or "") in TERMINAL_JOB_STATUSES:
849
+ return
850
+ except Exception as exc: # pragma: no cover - defensive; per-clip errors are handled in the slice
851
+ conn = _connect_jobs(root)
852
+ try:
853
+ _event(conn, job_id, "error", "Batch job runner stopped", {"error": f"{type(exc).__name__}: {exc}"})
854
+ conn.commit()
855
+ except Exception:
856
+ pass
857
+ finally:
858
+ conn.close()
859
+ finally:
860
+ with _RUNNERS_LOCK:
861
+ _ACTIVE_RUNNERS.pop((root, job_id), None)
862
+
863
+
864
+ def start_batch_job_runner(
865
+ project_root: str,
866
+ job_id: str,
867
+ *,
868
+ capabilities: Optional[Dict[str, Any]] = None,
869
+ max_clips: int = 1,
870
+ ) -> Dict[str, Any]:
871
+ """Drive a queued batch job to completion on a daemon thread.
872
+
873
+ This is what makes `background=true` mean the same thing on the analyze_*
874
+ actions as it does everywhere else in the server: the work is running when
875
+ the call returns, and the caller polls until it stops. Without it,
876
+ start_batch_job only ever left a row at status "queued" that nothing
877
+ advanced, so a caller that trusted the name waited forever.
878
+
879
+ Deliberately NOT routed through background_jobs.start_job, which wraps its
880
+ worker in resolve_busy.long_resolve_op. That gate exists to serialize calls
881
+ against Resolve's single-threaded scripting bridge, and analysis touches it
882
+ nowhere — media_analysis and this module drive ffmpeg, whisper and vision
883
+ over file paths only. Holding the gate for an hour of transcription would
884
+ lock the editor out of Resolve for the duration, for no benefit.
885
+
886
+ Returns {"started": bool, "reason": str} — `started` is False when a runner
887
+ is already driving this job or the job is already finished, neither of
888
+ which is an error.
889
+ """
890
+ root = normalize_path(project_root)
891
+ key = (root, job_id)
892
+ status = _job_status_value(root, job_id)
893
+ if status is None:
894
+ return {"started": False, "reason": "job_not_found"}
895
+ if status in TERMINAL_JOB_STATUSES:
896
+ return {"started": False, "reason": f"job_already_{status}"}
897
+ with _RUNNERS_LOCK:
898
+ existing = _ACTIVE_RUNNERS.get(key)
899
+ if existing is not None and existing.is_alive():
900
+ return {"started": False, "reason": "already_running"}
901
+ thread = threading.Thread(
902
+ target=_drive_batch_job,
903
+ args=(root, job_id, capabilities, max(1, int(max_clips or 1))),
904
+ name=f"media-analysis-job-{job_id}",
905
+ daemon=True,
906
+ )
907
+ _ACTIVE_RUNNERS[key] = thread
908
+ thread.start()
909
+ return {"started": True, "reason": "running"}
910
+
911
+
912
+ def join_batch_job_runner(project_root: str, job_id: str, timeout: Optional[float] = None) -> bool:
913
+ """Block until this job's runner exits. True if it is gone, False on timeout.
914
+
915
+ A daemon thread dies with the process, so a server restart leaves a job
916
+ stuck at "running" with nothing driving it — resume_batch_job is the way
917
+ back from that. This exists so tests (and any caller that genuinely needs
918
+ to wait) don't have to poll the database.
919
+ """
920
+ with _RUNNERS_LOCK:
921
+ thread = _ACTIVE_RUNNERS.get((normalize_path(project_root), job_id))
922
+ if thread is None:
923
+ return True
924
+ thread.join(timeout)
925
+ return not thread.is_alive()
926
+
927
+
798
928
  def project_root_for_dashboard(project_name: Any, project_id: Any = None, analysis_root: Any = None, source_paths: Optional[Iterable[Any]] = None) -> Dict[str, Any]:
799
929
  return resolve_output_root(
800
930
  project_name=project_name,