davinci-resolve-mcp 2.79.2 → 2.80.1
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 +115 -0
- package/README.md +1 -1
- package/docs/reference/api-limitations.md +3 -3
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +170 -17
- package/src/utils/api_truth.py +83 -23
- package/src/utils/media_analysis.py +117 -13
- package/src/utils/media_analysis_jobs.py +130 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,121 @@
|
|
|
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.1
|
|
6
|
+
|
|
7
|
+
A correction to the retime measurement contract published in v2.80.0, and a fix
|
|
8
|
+
for a documented timecode conversion that never happened.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`timeline_markers.set_current_timecode` now honors the documented
|
|
13
|
+
elapsed-timecode conversion.** The tool doc has always said timecodes before
|
|
14
|
+
the timeline start are treated as elapsed time and converted automatically —
|
|
15
|
+
but only marker actions did the conversion. `set_current_timecode` passed the
|
|
16
|
+
raw string to `Timeline.SetCurrentTimecode`, which refuses sub-start
|
|
17
|
+
timecodes with a bare `False` and no error info (measured on Studio 19.1.3.7:
|
|
18
|
+
on a timeline starting `00:59:50:00`, `00:00:21:03` failed while
|
|
19
|
+
`01:00:11:03` succeeded). The wrapper now lifts elapsed timecodes by the
|
|
20
|
+
start frame — `00:00:21:03` lands the playhead at `01:00:11:03` — with
|
|
21
|
+
drop-frame-correct formatting on DF timelines. Absolute timecodes and strings
|
|
22
|
+
the parser cannot read pass through unchanged. Marker `add()`'s conversion
|
|
23
|
+
was re-verified live on a non-zero-start timeline (elapsed `00:00:21:03` →
|
|
24
|
+
relative frame 507) and was already correct.
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
- **🚩 Correction to v2.80.0's retime witness — the recommended instrument
|
|
29
|
+
cannot see retimes.** The v2.80.0 retime entry recommended reading
|
|
30
|
+
`GetLeftOffset`/`GetRightOffset` to tell whether a retime was built. A
|
|
31
|
+
calibration with the confound removed (the SAME clip twice in ONE timeline,
|
|
32
|
+
one copy hand-set to 200%, Studio 19.1.3.7) proves that pair reads the
|
|
33
|
+
WARPED domain — position ÷ speed, span always equal to the record span — so
|
|
34
|
+
it is exact for placement and structurally blind for speed. The corrected
|
|
35
|
+
entry installs the calibrated model: judge speed by the
|
|
36
|
+
`GetSourceStartFrame`/`GetSourceEndFrame` span vs the record duration (the
|
|
37
|
+
200% copy read span 96 vs 48; a 0/0 read on xmeml-imported timelines is
|
|
38
|
+
UNKNOWN, never "no retime"), cross-checked by the `Sm2TimeMap` slope in a
|
|
39
|
+
saved Project.db or the `EXPORT_EDL` M2 rate.
|
|
40
|
+
- **Two import routes DO build constant retimes**, now documented with their
|
|
41
|
+
emission rules: OTIO `LinearTimeWarp` through `ImportTimelineFromFile` (200%
|
|
42
|
+
and 50% measured; `source_range.duration` is the RECORD span — the
|
|
43
|
+
`time_scalar` handles source consumption; source frames timecode-absolute)
|
|
44
|
+
and EDL `M2` in the exact shape Resolve's own `EXPORT_EDL` writes (200%
|
|
45
|
+
measured; event-line source span equals the record span; `* FROM CLIP NAME:`
|
|
46
|
+
drives linking). Reverse and varying-speed maps remain untested as import
|
|
47
|
+
routes and the entry says so.
|
|
48
|
+
- `docs/reference/api-limitations.md` regenerated; the `GetSourceStartFrame`
|
|
49
|
+
off-by-one entry now scopes its GetLeftOffset advice to 100%-speed placement.
|
|
50
|
+
|
|
51
|
+
### Validation
|
|
52
|
+
|
|
53
|
+
- Full offline suite: 2460 passed, 1 skipped (up exactly the 7 new tests from
|
|
54
|
+
the 2453 baseline).
|
|
55
|
+
- Live Resolve Studio 19.1.3.7: new
|
|
56
|
+
`tests/live_playhead_timecode_validation.py` harness — raw refusal control,
|
|
57
|
+
elapsed lift to `01:00:11:03`, absolute pass-through, and marker add() at
|
|
58
|
+
relative frame 507 all verified against a disposable project.
|
|
59
|
+
|
|
60
|
+
## What's New in v2.80.0
|
|
61
|
+
|
|
62
|
+
Three community PRs from @staahlarkitektur, all found on Windows, all real. Each is merged with
|
|
63
|
+
its diagnosis intact and a fix on top for what the patch didn't reach.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **`background=true` now actually runs the analysis.** `background`/`async_job` were accepted on
|
|
68
|
+
`analyze_clip` / `analyze_bin` / `analyze_file` / `analyze_project` / `analyze_sequence` and
|
|
69
|
+
silently ignored — the call ran the whole analysis inline and returned no `job_id`, which from
|
|
70
|
+
the caller's side is indistinguishable from a hang (#119). The two async opt-ins are now
|
|
71
|
+
distinct and both do what their names say:
|
|
72
|
+
- `prefer_handle=true` — creates the durable batch job and hands it back **queued**. Nothing
|
|
73
|
+
runs until you call `run_batch_job_slice`. Unchanged contract.
|
|
74
|
+
- `background=true` / `async_job=true` — creates the job **and drives it to completion
|
|
75
|
+
off-thread**, matching what `background` means on every other tool in this server. Poll
|
|
76
|
+
`batch_job_status` until `completed` / `completed_with_errors` / `canceled`.
|
|
77
|
+
|
|
78
|
+
Aliasing the two, as the PR proposed, would have replaced one silence with a quieter one: a job
|
|
79
|
+
that nothing ever advanced, polled forever. The runner deliberately does **not** hold the
|
|
80
|
+
Resolve busy gate — analysis drives ffmpeg, whisper and vision over file paths and touches the
|
|
81
|
+
scripting bridge nowhere, so holding it for an hour of transcription would lock the editor out
|
|
82
|
+
for nothing. A process-wide slice lock bounds the real cost instead: queued analyses interleave
|
|
83
|
+
a clip at a time rather than starting N ffmpeg passes at once.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- **A timeout could take 82 seconds to report a 5-second limit.** `subprocess.run(timeout=...)`
|
|
88
|
+
kills only the direct child. On Windows a bare-name PATH lookup can resolve to a shim
|
|
89
|
+
(Chocolatey, npm, a pip console script) that runs the real work as a grandchild, so the kill hit
|
|
90
|
+
the wrapper while the real `ffmpeg` kept running — and the follow-up read blocked on the pipe
|
|
91
|
+
handles it had inherited. Measured on a Chocolatey-managed machine: `ffmpeg` on PATH was a 392KB
|
|
92
|
+
shim, and a 5s timeout against an ~82s pass returned after the full 82s with "timed out after
|
|
93
|
+
5s" attached to complete, correct output (#120). `_run_command` now spawns via `Popen` in its own
|
|
94
|
+
session/process group and kills the whole tree. Beyond the PR: the kill helper no longer raises
|
|
95
|
+
(`killpg` returns EPERM, `taskkill` can be missing from PATH — either escaped and broke the
|
|
96
|
+
return contract mid-failure), the read after the kill is bounded and says so when it gives up
|
|
97
|
+
rather than hanging on a survivor, and a cancellation mid-run kills the tree instead of orphaning
|
|
98
|
+
it. Fixes every `_run_command` caller at once — the whisper CLI and every ffmpeg pass in
|
|
99
|
+
`_readthrough_analysis`, `silence_ripple`, and `deep_vision`.
|
|
100
|
+
- **The whisper CLI inherited a `PYTHONHOME` that killed it.** `PYTHONHOME`/`PYTHONPATH` point this
|
|
101
|
+
server at Resolve's bundled Python so `DaVinciResolveScript` imports. Inherited by a child that
|
|
102
|
+
is itself a *different* Python, they corrupt its stdlib resolution — and whisper's CLI is exactly
|
|
103
|
+
that. Measured: whisper on 3.14 inheriting a 3.10 `PYTHONHOME` dies on `AssertionError: SRE
|
|
104
|
+
module mismatch` (#118). The whisper subprocess now gets a scrubbed environment. This is the
|
|
105
|
+
**shipped Windows configuration**, not a local quirk: `install.py` writes `PYTHONHOME` into
|
|
106
|
+
generated client configs (issue #26) and `server.py` sets it on Windows whenever it isn't
|
|
107
|
+
already set, so every Windows install hands a foreign `PYTHONHOME` to every child it spawns.
|
|
108
|
+
|
|
109
|
+
### Corrected in the merged PRs
|
|
110
|
+
|
|
111
|
+
- The documented async return shape was wrong — `{job_id, status}` was advertised, the real
|
|
112
|
+
envelope is `{success, job, plan}` with the id at `job.job_id`. Now documented as it is, plus
|
|
113
|
+
`running` and a `note` naming the next call so the queued and running routes can't be confused.
|
|
114
|
+
- The async divert ran *after* `dry_run` was resolved from the `dry_run_first_default` preference,
|
|
115
|
+
so a user with that preference on still got `background=true` swallowed in silence. An explicit
|
|
116
|
+
`dry_run` still wins; an inherited one no longer does.
|
|
117
|
+
- A code comment attributed the whisper failure to a silent stall with an ffmpeg child at 0% CPU —
|
|
118
|
+
a diagnosis the PR's own description retracted, and one that belongs to the shim problem above.
|
|
119
|
+
|
|
5
120
|
## What's New in v2.79.2
|
|
6
121
|
|
|
7
122
|
A published contract was **wrong**. This release corrects it. If you read the retime entry in
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
|
@@ -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 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 too — re-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
|
|
121
|
-
- **Workaround / current handling:** Set clip speed/retime in the Resolve UI
|
|
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 too — re-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 NO retime is built: recalibrated 2026-08-05, every xmeml-imported clip carries a DEGENERATE time map in Project.db (Sm2TimeMap with an empty source axis — five Time Remap shapes re-measured, 15/15 clips degenerate), so no speed exists in the project data, and the API source witness reads 0/0 on those clips (see WITNESS CALIBRATION below); 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. WITNESS CALIBRATION (2026-08-05, Studio 19.1.3.7) — the positive control this entry previously lacked now exists, and it RETRACTS the witness the 2.80.0 revision of this entry recommended. The rig removed every confound: the SAME clip placed twice, adjacent, in ONE timeline, the second copy hand-set to 200% in the UI (the only way to make one — see above). GetSourceStartFrame/GetSourceEndFrame separated the copies — 1822..1870 (span 48) at 100% vs 1822..1918 (span 96) at 200% — while GetLeftOffset/GetRightOffset did NOT: 1822..1870 at 100% vs 911..959 at 200%, which is exactly position / 2. GetLeftOffset reports the WARPED (record-side) domain — position / speed, the `In` column of Project.db's Sm2TiItem — so it is exact for PLACEMENT and blind for SPEED BY CONSTRUCTION: its span equals the record span at every speed. The speed itself lives in the item's Sm2TimeMap blob (keyframe slope = ratio; the hand-set 200% reads slope exactly 2.0), which is what GetSourceStart/EndFrame and EXPORT_EDL read. THE SPEED WITNESS is therefore the GetSourceStart/EndFrame span vs the record duration. CAVEAT: on xmeml-IMPORTED timelines those return 0/0 — the importer leaves the time map's source axis empty — and a 0/0 read is UNKNOWN, never 'no retime'. Cross-checks that work everywhere: the Sm2TimeMap slope read from a saved Project.db, and the EXPORT_EDL M2 rate (rate = fps x speed/100, so 048.0 = 200% at 24 fps; `M2 ... 000.0` on every clip is the degenerate-map furniture of an xmeml import — ignore it). TWO IMPORT ROUTES DO BUILD CONSTANT RETIMES (measured 2026-08-05, media linked, judged via the calibrated witnesses above): (1) OTIO LinearTimeWarp through ImportTimelineFromFile — 200% (src 200..296 over a 48-frame record) and 50% (src 300..324 over a 48-frame record) both landed with correct source in-points; the saved Project.db shows slope 2.0 and 0.5. Emission rules: the document must be Resolve-shaped with TIMECODE-ABSOLUTE source frames (see the ImportTimelineFromFile .otio entry), the effect is `LinearTimeWarp.1` with `time_scalar`, and `source_range.duration` is the RECORD span — OTIO semantics, the time_scalar handles source consumption; sending the source span as the duration builds a spec-correctly longer clip, not a retime. (2) EDL M2 — 200% landed (src 100..196 over a 48-frame record), linked. Author the shape Resolve's own EXPORT_EDL writes: the event line's source span EQUALS the record span even under M2; the `M2 <reel> <rate> <srcInTC>` line carries the play rate in fps (048.0 = 200% at 24); `* FROM CLIP NAME:` comments drive pool linking. UNTESTED as import routes: reverse (negative time_scalar / reverse M2) and varying-speed maps — do not extrapolate the constant-speed result to them.
|
|
121
|
+
- **Workaround / current handling:** Set clip speed/retime in the Resolve UI, or BUILD it by import: OTIO LinearTimeWarp and EDL M2 both construct constant retimes through ImportTimelineFromFile (measured — emission rules in reality above); xmeml does not, in any Time Remap shape. To READ a retime back, judge speed by the GetSourceStart/EndFrame span vs the record duration — a 0/0 read (xmeml-imported timelines) is UNKNOWN, never 'no retime' — and cross-check with the Sm2TimeMap slope in a saved Project.db or the EXPORT_EDL M2 rate. Do NOT read speed with GetProperty (None), witness it via EXPORT_FCP_7_XML (degenerate), or judge it from GetLeftOffset/GetRightOffset — the 2.80.0 revision of this entry recommended that pair as the witness and it is blind by construction: it reads the warped domain (position / speed) and its span equals the record span at every speed. Keep it for PLACEMENT checks only. Reverse and varying-speed maps remain untested as import routes. 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
|
|
@@ -363,7 +363,7 @@ values, or automation-hostile modal prompts.
|
|
|
363
363
|
- **Object:** `TimelineItem`
|
|
364
364
|
- **Signature:** `() -> int`
|
|
365
365
|
- **Behavior:** Reads back one frame off on some items. Measured while verifying a constructed timeline against the clipInfos it was built from: for 4/4 items GetLeftOffset returned exactly the startFrame that was sent, while GetSourceStartFrame disagreed by 1 on some of the same items. The two are supposed to describe the same edit point, so a conform that verifies placement with GetSourceStartFrame reports phantom off-by-one drift on correctly placed clips — and would hide a real one-frame error just as easily.
|
|
366
|
-
- **Workaround / current handling:** Verify source-side placement with GetLeftOffset, which is exact. Treat GetSourceStartFrame as approximate, and never diff it against a sent startFrame to decide whether a clip landed right.
|
|
366
|
+
- **Workaround / current handling:** Verify source-side placement with GetLeftOffset, which is exact. Treat GetSourceStartFrame as approximate, and never diff it against a sent startFrame to decide whether a clip landed right. Scope: placement at 100% speed. On a retimed clip the two read DIFFERENT domains — GetLeftOffset is warped (position / speed), GetSourceStartFrame is true source — see the retime entry's witness calibration before comparing them.
|
|
367
367
|
- **Tags:** off-by-one, unreliable-return, timeline, conform, verify
|
|
368
368
|
|
|
369
369
|
### hasattr() / getattr() on Resolve API objects (attribute fabrication)
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.80.1"
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.
|
|
88
|
+
VERSION = "2.80.1"
|
|
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.
|
|
14
|
+
VERSION = "2.80.1"
|
|
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
|
|
@@ -1960,9 +1962,67 @@ def _marker_display_frame(tl, frame):
|
|
|
1960
1962
|
return frame
|
|
1961
1963
|
|
|
1962
1964
|
|
|
1963
|
-
def
|
|
1965
|
+
def _playhead_absolute_timecode(tl, timecode):
|
|
1966
|
+
"""Lift an elapsed timecode to the absolute timecode SetCurrentTimecode wants.
|
|
1967
|
+
|
|
1968
|
+
Timeline.SetCurrentTimecode only accepts the absolute timeline timecode
|
|
1969
|
+
shown in the Resolve UI; handing it a timecode below the start timecode
|
|
1970
|
+
returns False with no error info (measured on Studio 19.1.3.7: on a
|
|
1971
|
+
timeline starting 00:59:50:00, '00:00:21:03' fails while '01:00:11:03'
|
|
1972
|
+
succeeds). Mirror the marker-param contract: a timecode that parses to a
|
|
1973
|
+
frame below the timeline start is elapsed time and gets lifted by the
|
|
1974
|
+
start frame. At-or-past-start timecodes, and strings this parser cannot
|
|
1975
|
+
read, pass through unchanged so Resolve stays the arbiter of them.
|
|
1976
|
+
"""
|
|
1977
|
+
if not isinstance(timecode, str):
|
|
1978
|
+
return timecode
|
|
1979
|
+
frame, err = _timeline_timecode_to_frame_id(tl, timecode)
|
|
1980
|
+
if err:
|
|
1981
|
+
return timecode
|
|
1982
|
+
start = _timeline_start_frame(tl)
|
|
1983
|
+
if not start or frame >= start:
|
|
1984
|
+
return timecode
|
|
1985
|
+
fps, fps_err = _timeline_fps(tl)
|
|
1986
|
+
if fps_err:
|
|
1987
|
+
return timecode
|
|
1988
|
+
drop_frame = ";" in timecode
|
|
1989
|
+
try:
|
|
1990
|
+
start_tc = tl.GetStartTimecode()
|
|
1991
|
+
except Exception:
|
|
1992
|
+
start_tc = None
|
|
1993
|
+
if isinstance(start_tc, str) and start_tc:
|
|
1994
|
+
drop_frame = ";" in start_tc
|
|
1995
|
+
separator = ";" if drop_frame else ":"
|
|
1996
|
+
return _frame_id_to_timecode(
|
|
1997
|
+
frame + start, fps, separator=separator, drop_frame=drop_frame
|
|
1998
|
+
)
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def _frame_id_to_timecode(
|
|
2002
|
+
frame: int, fps: float, separator: str = ":", drop_frame: bool = False
|
|
2003
|
+
) -> str:
|
|
1964
2004
|
nominal_fps = max(1, int(round(float(fps))))
|
|
1965
2005
|
frame = max(0, int(frame))
|
|
2006
|
+
if drop_frame:
|
|
2007
|
+
# Inverse of the drop-frame arithmetic in _timecode_to_frame_id: 2 (30
|
|
2008
|
+
# fps) or 4 (60 fps) frame numbers are skipped each minute except every
|
|
2009
|
+
# tenth minute.
|
|
2010
|
+
drop = int(round(nominal_fps * 0.0666666667))
|
|
2011
|
+
if drop > 0:
|
|
2012
|
+
per_minute = nominal_fps * 60 - drop
|
|
2013
|
+
per_ten = per_minute * 10 + drop
|
|
2014
|
+
tens, rem = divmod(frame, per_ten)
|
|
2015
|
+
if rem < nominal_fps * 60:
|
|
2016
|
+
minutes = tens * 10
|
|
2017
|
+
frame_in_minute = rem
|
|
2018
|
+
else:
|
|
2019
|
+
rem -= nominal_fps * 60
|
|
2020
|
+
extra_minutes, frame_in_minute = divmod(rem, per_minute)
|
|
2021
|
+
minutes = tens * 10 + 1 + extra_minutes
|
|
2022
|
+
frame_in_minute += drop
|
|
2023
|
+
hours, minutes = divmod(minutes, 60)
|
|
2024
|
+
seconds, frames = divmod(frame_in_minute, nominal_fps)
|
|
2025
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}{separator}{frames:02d}"
|
|
1966
2026
|
total_seconds, frames = divmod(frame, nominal_fps)
|
|
1967
2027
|
hours, rem = divmod(total_seconds, 3600)
|
|
1968
2028
|
minutes, seconds = divmod(rem, 60)
|
|
@@ -8241,6 +8301,49 @@ def _media_analysis_bool(value: Any, default: bool = False) -> bool:
|
|
|
8241
8301
|
return bool(value)
|
|
8242
8302
|
|
|
8243
8303
|
|
|
8304
|
+
MEDIA_ANALYSIS_ASYNC_QUEUED = "queued"
|
|
8305
|
+
MEDIA_ANALYSIS_ASYNC_RUNNING = "running"
|
|
8306
|
+
|
|
8307
|
+
|
|
8308
|
+
def _media_analysis_async_mode(p: Dict[str, Any], *, dry_run_explicit: bool = True) -> Optional[str]:
|
|
8309
|
+
"""How an analyze_* call wants its work handled. None means synchronously.
|
|
8310
|
+
|
|
8311
|
+
Two opt-ins, deliberately not synonyms:
|
|
8312
|
+
|
|
8313
|
+
prefer_handle -> "queued". Create the durable batch job and hand
|
|
8314
|
+
it back. Nothing runs until the caller drives
|
|
8315
|
+
run_batch_job_slice. This is the pre-existing
|
|
8316
|
+
contract and is unchanged.
|
|
8317
|
+
background/async_job -> "running". Create the job AND drive it off-thread,
|
|
8318
|
+
so the work is under way when the call returns.
|
|
8319
|
+
|
|
8320
|
+
The split exists because `background` already means something specific
|
|
8321
|
+
everywhere else in this server — _run_maybe_background starts the work and
|
|
8322
|
+
the caller polls until it finishes on its own. Before, these two params were
|
|
8323
|
+
accepted here and silently ignored: the analyze_* actions collapsed to
|
|
8324
|
+
action="plan" and ran to completion inline, so a caller got no job_id and no
|
|
8325
|
+
signal, indistinguishable from a hang. Aliasing them onto prefer_handle
|
|
8326
|
+
would have replaced that with a job that never progressed — a quieter
|
|
8327
|
+
failure than the one being fixed. So `background` keeps its meaning and gets
|
|
8328
|
+
the runner it always implied.
|
|
8329
|
+
|
|
8330
|
+
`dry_run` still wins, but only when the caller asked for it. Pass
|
|
8331
|
+
dry_run_explicit=False when p["dry_run"] came from the dry_run_first_default
|
|
8332
|
+
preference rather than the call: a preference should not silently swallow an
|
|
8333
|
+
explicit async request and hand back a plan the caller never asked for,
|
|
8334
|
+
which is the same silence this whole change is closing.
|
|
8335
|
+
"""
|
|
8336
|
+
if dry_run_explicit and _media_analysis_bool(p.get("dry_run"), False):
|
|
8337
|
+
return None
|
|
8338
|
+
if _media_analysis_bool(p.get("background"), False) or _media_analysis_bool(
|
|
8339
|
+
p.get("async_job"), False
|
|
8340
|
+
):
|
|
8341
|
+
return MEDIA_ANALYSIS_ASYNC_RUNNING
|
|
8342
|
+
if _media_analysis_bool(p.get("prefer_handle"), False):
|
|
8343
|
+
return MEDIA_ANALYSIS_ASYNC_QUEUED
|
|
8344
|
+
return None
|
|
8345
|
+
|
|
8346
|
+
|
|
8244
8347
|
def _media_analysis_target_dict(raw_target: Any, p: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
8245
8348
|
p = p or {}
|
|
8246
8349
|
if raw_target is None:
|
|
@@ -17928,12 +18031,22 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
17928
18031
|
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
18032
|
resolve_output_root(analysis_root?, source_paths?) -> {project_root}
|
|
17930
18033
|
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}
|
|
18034
|
+
analyze_file(path|file_path, dry_run?, session_only?, persist?, prefer_handle?|background?|async_job?) -> {clips, manifest} | {success, job, plan, running, note} when async
|
|
18035
|
+
analyze_clip(clip_id|selected, dry_run?, session_only?, persist?, prefer_handle?|background?|async_job?) -> {clips, manifest} | {success, job, plan, running, note} when async
|
|
18036
|
+
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
|
|
18037
|
+
analyze_project(recursive?, dry_run?, session_only?, persist?, prefer_handle?|background?|async_job?) -> {clips, manifest} | {success, job, plan, running, note} when async
|
|
18038
|
+
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
18039
|
analyze_timeline(...) -> alias for analyze_sequence on the current timeline
|
|
18040
|
+
-- async opt-ins on the analyze_* actions above. Both reroute to start_batch_job and return its
|
|
18041
|
+
{success, job, plan} envelope — the id is job.job_id, NOT a top-level job_id — plus `running`
|
|
18042
|
+
and a `note` naming the next call. They differ in what happens next:
|
|
18043
|
+
prefer_handle=true job is created and left queued; nothing runs until you call
|
|
18044
|
+
run_batch_job_slice yourself. Unchanged contract.
|
|
18045
|
+
background|async_job=true job is created AND driven to completion off-thread, matching what
|
|
18046
|
+
`background` means on every other tool here. Poll batch_job_status
|
|
18047
|
+
until status is completed / completed_with_errors / canceled.
|
|
18048
|
+
An explicit dry_run=true still returns the synchronous plan and starts nothing. A dry_run that
|
|
18049
|
+
came from the dry_run_first_default preference does not override an explicit async request.
|
|
17937
18050
|
detect_sync_events(paths?|target?, event_types?, windows?) -> {files, alignment}
|
|
17938
18051
|
add_sync_event_markers(target?|paths?|detections?, confirm?) -> {added, skipped}
|
|
17939
18052
|
publish_clip_metadata(target?, fields?, slate_detection?, timed_markers?|write_markers?, dry_run?, confirm?) -> {results}
|
|
@@ -18787,21 +18900,56 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
18787
18900
|
if warnings:
|
|
18788
18901
|
target_err["warnings"] = warnings
|
|
18789
18902
|
return target_err
|
|
18903
|
+
capabilities = detect_media_analysis_capabilities()
|
|
18790
18904
|
created = create_media_analysis_batch_job(
|
|
18791
18905
|
project_name=project_name,
|
|
18792
18906
|
project_id=project_id,
|
|
18793
18907
|
records=records or [],
|
|
18794
18908
|
target=normalized_target,
|
|
18795
18909
|
params=p,
|
|
18796
|
-
capabilities=
|
|
18910
|
+
capabilities=capabilities,
|
|
18797
18911
|
name=p.get("name") or p.get("job_name") or p.get("jobName"),
|
|
18798
18912
|
)
|
|
18799
18913
|
if warnings:
|
|
18800
18914
|
created.setdefault("warnings", warnings)
|
|
18915
|
+
# A created job sits at "queued" and nothing advances it on its own.
|
|
18916
|
+
# That is the right default for start_batch_job and prefer_handle, whose
|
|
18917
|
+
# contract is "here is a handle, drive it". It is the wrong one for
|
|
18918
|
+
# background/async_job, which promise the work is under way — so those
|
|
18919
|
+
# get a runner. Reached either by the analyze_* divert (which sets
|
|
18920
|
+
# _async_mode) or by calling start_batch_job with background=true.
|
|
18921
|
+
job_id = str((created.get("job") or {}).get("job_id") or "")
|
|
18922
|
+
project_root = str((created.get("plan") or {}).get("output_root") or "")
|
|
18923
|
+
wants_runner = p.get("_async_mode") == MEDIA_ANALYSIS_ASYNC_RUNNING or (
|
|
18924
|
+
_media_analysis_bool(p.get("background"), False)
|
|
18925
|
+
or _media_analysis_bool(p.get("async_job"), False)
|
|
18926
|
+
)
|
|
18927
|
+
if wants_runner and job_id and project_root:
|
|
18928
|
+
started = start_media_analysis_batch_job_runner(
|
|
18929
|
+
project_root, job_id, capabilities=capabilities
|
|
18930
|
+
)
|
|
18931
|
+
created["running"] = bool(started.get("started"))
|
|
18932
|
+
created["note"] = (
|
|
18933
|
+
f"Analysis is running off-thread. Poll with "
|
|
18934
|
+
f"media_analysis(action='batch_job_status', params={{'job_id': '{job_id}'}})."
|
|
18935
|
+
)
|
|
18936
|
+
if not started.get("started"):
|
|
18937
|
+
created["note"] = (
|
|
18938
|
+
f"Job created but not started ({started.get('reason')}). Drive it with "
|
|
18939
|
+
f"media_analysis(action='run_batch_job_slice', params={{'job_id': '{job_id}'}})."
|
|
18940
|
+
)
|
|
18941
|
+
else:
|
|
18942
|
+
created["running"] = False
|
|
18943
|
+
created["note"] = (
|
|
18944
|
+
f"Job is queued, not running. Drive it with "
|
|
18945
|
+
f"media_analysis(action='run_batch_job_slice', params={{'job_id': '{job_id}'}}), "
|
|
18946
|
+
f"or pass background=true to have the server run it."
|
|
18947
|
+
)
|
|
18801
18948
|
return created
|
|
18802
18949
|
|
|
18803
18950
|
if action in {"analyze_file", "analyze_clip", "analyze_bin", "analyze_project", "analyze_timeline", "analyze_sequence"}:
|
|
18804
18951
|
dry_run_default = bool(_media_analysis_effective_preferences().get("dry_run_first_default"))
|
|
18952
|
+
dry_run_explicit = _has_any_param(p, "dry_run", "dryRun")
|
|
18805
18953
|
p["dry_run"] = _media_analysis_bool(p.get("dry_run"), dry_run_default)
|
|
18806
18954
|
target = _media_analysis_target_dict(p.get("target"), p)
|
|
18807
18955
|
if target.get("_invalid_target"):
|
|
@@ -18821,15 +18969,17 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
18821
18969
|
"track_types": p.get("track_types") or p.get("trackTypes") or target.get("track_types") or target.get("trackTypes"),
|
|
18822
18970
|
})
|
|
18823
18971
|
p["target"] = target
|
|
18824
|
-
# E3 —
|
|
18825
|
-
#
|
|
18826
|
-
#
|
|
18827
|
-
# Default
|
|
18972
|
+
# E3 — async opt-ins. `prefer_handle` hands back a queued job for the
|
|
18973
|
+
# caller to drive; `background`/`async_job` additionally start driving
|
|
18974
|
+
# it. Either way the call returns at once instead of blocking on
|
|
18975
|
+
# vision/transcription. Default: unchanged blocking semantics.
|
|
18828
18976
|
# The start_batch_job handler lives ABOVE this block in the dispatch
|
|
18829
18977
|
# chain, so we can't just rewrite `action` and fall through — we
|
|
18830
18978
|
# re-enter the tool with the rewritten action via await so the
|
|
18831
18979
|
# handler chain restarts from the top.
|
|
18832
|
-
|
|
18980
|
+
async_mode = _media_analysis_async_mode(p, dry_run_explicit=dry_run_explicit)
|
|
18981
|
+
if async_mode:
|
|
18982
|
+
p["_async_mode"] = async_mode
|
|
18833
18983
|
return await media_analysis("start_batch_job", p, ctx)
|
|
18834
18984
|
action = "plan"
|
|
18835
18985
|
|
|
@@ -21531,9 +21681,12 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
|
|
|
21531
21681
|
|
|
21532
21682
|
Marker frames are RELATIVE to the timeline start: frame 0 is the first
|
|
21533
21683
|
frame of the timeline, even when the timeline starts at 01:00:00:00.
|
|
21534
|
-
|
|
21535
|
-
(timecodes before the start timecode are treated as elapsed
|
|
21536
|
-
converted to relative frames automatically.
|
|
21684
|
+
Marker timecode params are absolute timeline timecode as shown in the
|
|
21685
|
+
Resolve UI (timecodes before the start timecode are treated as elapsed
|
|
21686
|
+
time) and are converted to relative frames automatically.
|
|
21687
|
+
set_current_timecode accepts the same convention: elapsed timecodes below
|
|
21688
|
+
the start timecode are lifted to absolute before calling Resolve, which
|
|
21689
|
+
itself refuses sub-start timecodes with a bare False.
|
|
21537
21690
|
|
|
21538
21691
|
Actions:
|
|
21539
21692
|
add(frame|frame_id|frameId|timecode?, color?, name?, note?, duration?, custom_data?) -> {success, frame}
|
|
@@ -21598,7 +21751,7 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
|
|
|
21598
21751
|
elif action == "get_current_timecode":
|
|
21599
21752
|
return {"timecode": tl.GetCurrentTimecode()}
|
|
21600
21753
|
elif action == "set_current_timecode":
|
|
21601
|
-
return {"success": bool(tl.SetCurrentTimecode(p["timecode"]))}
|
|
21754
|
+
return {"success": bool(tl.SetCurrentTimecode(_playhead_absolute_timecode(tl, p["timecode"])))}
|
|
21602
21755
|
elif action == "get_current_video_item":
|
|
21603
21756
|
it = tl.GetCurrentVideoItem()
|
|
21604
21757
|
return {"name": it.GetName(), "id": it.GetUniqueId()} if it else {"name": None, "id": None}
|
package/src/utils/api_truth.py
CHANGED
|
@@ -645,7 +645,11 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
645
645
|
"a real one-frame error just as easily.",
|
|
646
646
|
"recommended": "Verify source-side placement with GetLeftOffset, which is exact. "
|
|
647
647
|
"Treat GetSourceStartFrame as approximate, and never diff it "
|
|
648
|
-
"against a sent startFrame to decide whether a clip landed right."
|
|
648
|
+
"against a sent startFrame to decide whether a clip landed right. "
|
|
649
|
+
"Scope: placement at 100% speed. On a retimed clip the two read "
|
|
650
|
+
"DIFFERENT domains — GetLeftOffset is warped (position / speed), "
|
|
651
|
+
"GetSourceStartFrame is true source — see the retime entry's "
|
|
652
|
+
"witness calibration before comparing them.",
|
|
649
653
|
"tags": ["off-by-one", "unreliable-return", "timeline", "conform", "verify"],
|
|
650
654
|
"submit": "bug",
|
|
651
655
|
},
|
|
@@ -710,10 +714,14 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
710
714
|
"one 200% clip per timeline, a document carrying warped "
|
|
711
715
|
"<in>/<out>, true-source pproTicks, <duration> = fileLen/ratio "
|
|
712
716
|
"and a constant-slope graphdict imports cleanly, the control "
|
|
713
|
-
"lands correct, and
|
|
714
|
-
"
|
|
715
|
-
"
|
|
716
|
-
"
|
|
717
|
+
"lands correct, and NO retime is built: recalibrated 2026-08-05, "
|
|
718
|
+
"every xmeml-imported clip carries a DEGENERATE time map in "
|
|
719
|
+
"Project.db (Sm2TimeMap with an empty source axis — five Time "
|
|
720
|
+
"Remap shapes re-measured, 15/15 clips degenerate), so no speed "
|
|
721
|
+
"exists in the project data, and the API source witness reads "
|
|
722
|
+
"0/0 on those clips (see WITNESS CALIBRATION below); emitting "
|
|
723
|
+
"the identical document WITHOUT the graphdict gives the "
|
|
724
|
+
"identical result; (c) `reverse` does not survive "
|
|
717
725
|
"either; (d) THE HAZARD, and it is the part that bites: Resolve "
|
|
718
726
|
"reads <in> LITERALLY as the true source frame, honouring neither "
|
|
719
727
|
"the ticks nor the graphdict. Import a genuine Premiere XML that "
|
|
@@ -750,24 +758,76 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
750
758
|
"verifying a retime by round-tripping through EXPORT_FCP_7_XML "
|
|
751
759
|
"is reading furniture, and the identity Time Remap blocks "
|
|
752
760
|
"present on every clip are what make the route look like it "
|
|
753
|
-
"should work."
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
761
|
+
"should work. "
|
|
762
|
+
"WITNESS CALIBRATION (2026-08-05, Studio 19.1.3.7) — the "
|
|
763
|
+
"positive control this entry previously lacked now exists, and "
|
|
764
|
+
"it RETRACTS the witness the 2.80.0 revision of this entry "
|
|
765
|
+
"recommended. The rig removed every confound: the SAME clip "
|
|
766
|
+
"placed twice, adjacent, in ONE timeline, the second copy "
|
|
767
|
+
"hand-set to 200% in the UI (the only way to make one — see "
|
|
768
|
+
"above). GetSourceStartFrame/GetSourceEndFrame separated the "
|
|
769
|
+
"copies — 1822..1870 (span 48) at 100% vs 1822..1918 (span 96) "
|
|
770
|
+
"at 200% — while GetLeftOffset/GetRightOffset did NOT: "
|
|
771
|
+
"1822..1870 at 100% vs 911..959 at 200%, which is exactly "
|
|
772
|
+
"position / 2. GetLeftOffset reports the WARPED (record-side) "
|
|
773
|
+
"domain — position / speed, the `In` column of Project.db's "
|
|
774
|
+
"Sm2TiItem — so it is exact for PLACEMENT and blind for SPEED "
|
|
775
|
+
"BY CONSTRUCTION: its span equals the record span at every "
|
|
776
|
+
"speed. The speed itself lives in the item's Sm2TimeMap blob "
|
|
777
|
+
"(keyframe slope = ratio; the hand-set 200% reads slope exactly "
|
|
778
|
+
"2.0), which is what GetSourceStart/EndFrame and EXPORT_EDL "
|
|
779
|
+
"read. THE SPEED WITNESS is therefore the GetSourceStart/"
|
|
780
|
+
"EndFrame span vs the record duration. CAVEAT: on "
|
|
781
|
+
"xmeml-IMPORTED timelines those return 0/0 — the importer "
|
|
782
|
+
"leaves the time map's source axis empty — and a 0/0 read is "
|
|
783
|
+
"UNKNOWN, never 'no retime'. Cross-checks that work everywhere: "
|
|
784
|
+
"the Sm2TimeMap slope read from a saved Project.db, and the "
|
|
785
|
+
"EXPORT_EDL M2 rate (rate = fps x speed/100, so 048.0 = 200% at "
|
|
786
|
+
"24 fps; `M2 ... 000.0` on every clip is the degenerate-map "
|
|
787
|
+
"furniture of an xmeml import — ignore it). "
|
|
788
|
+
"TWO IMPORT ROUTES DO BUILD CONSTANT RETIMES (measured "
|
|
789
|
+
"2026-08-05, media linked, judged via the calibrated witnesses "
|
|
790
|
+
"above): (1) OTIO LinearTimeWarp through ImportTimelineFromFile "
|
|
791
|
+
"— 200% (src 200..296 over a 48-frame record) and 50% (src "
|
|
792
|
+
"300..324 over a 48-frame record) both landed with correct "
|
|
793
|
+
"source in-points; the saved Project.db shows slope 2.0 and "
|
|
794
|
+
"0.5. Emission rules: the document must be Resolve-shaped with "
|
|
795
|
+
"TIMECODE-ABSOLUTE source frames (see the ImportTimelineFromFile "
|
|
796
|
+
".otio entry), the effect is `LinearTimeWarp.1` with "
|
|
797
|
+
"`time_scalar`, and `source_range.duration` is the RECORD span "
|
|
798
|
+
"— OTIO semantics, the time_scalar handles source consumption; "
|
|
799
|
+
"sending the source span as the duration builds a "
|
|
800
|
+
"spec-correctly longer clip, not a retime. (2) EDL M2 — 200% "
|
|
801
|
+
"landed (src 100..196 over a 48-frame record), linked. Author "
|
|
802
|
+
"the shape Resolve's own EXPORT_EDL writes: the event line's "
|
|
803
|
+
"source span EQUALS the record span even under M2; the "
|
|
804
|
+
"`M2 <reel> <rate> <srcInTC>` line carries the play rate in "
|
|
805
|
+
"fps (048.0 = 200% at 24); `* FROM CLIP NAME:` comments drive "
|
|
806
|
+
"pool linking. UNTESTED as import routes: reverse (negative "
|
|
807
|
+
"time_scalar / reverse M2) and varying-speed maps — do not "
|
|
808
|
+
"extrapolate the constant-speed result to them.",
|
|
809
|
+
"recommended": "Set clip speed/retime in the Resolve UI, or BUILD it by "
|
|
810
|
+
"import: OTIO LinearTimeWarp and EDL M2 both construct "
|
|
811
|
+
"constant retimes through ImportTimelineFromFile (measured "
|
|
812
|
+
"— emission rules in reality above); xmeml does not, in any "
|
|
813
|
+
"Time Remap shape. To READ a retime back, judge speed by "
|
|
814
|
+
"the GetSourceStart/EndFrame span vs the record duration — "
|
|
815
|
+
"a 0/0 read (xmeml-imported timelines) is UNKNOWN, never "
|
|
816
|
+
"'no retime' — and cross-check with the Sm2TimeMap slope in "
|
|
817
|
+
"a saved Project.db or the EXPORT_EDL M2 rate. Do NOT read "
|
|
818
|
+
"speed with GetProperty (None), witness it via "
|
|
819
|
+
"EXPORT_FCP_7_XML (degenerate), or judge it from "
|
|
820
|
+
"GetLeftOffset/GetRightOffset — the 2.80.0 revision of this "
|
|
821
|
+
"entry recommended that pair as the witness and it is blind "
|
|
822
|
+
"by construction: it reads the warped domain (position / "
|
|
823
|
+
"speed) and its span equals the record span at every speed. "
|
|
824
|
+
"Keep it for PLACEMENT checks only. Reverse and "
|
|
825
|
+
"varying-speed maps remain untested as import routes. And "
|
|
826
|
+
"if you are importing a real Premiere XML that contains "
|
|
827
|
+
"retimes, treat every retimed clip's source position as "
|
|
828
|
+
"WRONG — placed at <in>, i.e. in / ratio — until it is "
|
|
829
|
+
"checked against a reference; the lengths and the links "
|
|
830
|
+
"will look right.",
|
|
771
831
|
"tags": ["missing-method", "timeline", "retime", "speed", "interchange",
|
|
772
832
|
"silent-failure", "unreliable-return"],
|
|
773
833
|
"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
|
|
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
|
-
|
|
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
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
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
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
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
|
-
|
|
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,
|