davinci-resolve-mcp 2.103.4 → 2.104.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,106 @@
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.104.0
6
+
7
+ The read/write symmetry audit's worklist, worked. PR #162's AST rewrite left
8
+ eight `set_` actions with no readback; live probing on Studio 19.1.3.7 sorted
9
+ them into four the API supports and four it simply cannot — and turned up a
10
+ render-pipeline failure mode along the way.
11
+
12
+ ### Added
13
+
14
+ - `media_pool.get_clip_marks` — read mark in/out for a set of media-pool clips,
15
+ the read twin of `set_clip_marks` (live-verified round trip: set 12/60, read
16
+ 12/60).
17
+ - `timeline.get_clips_linked` — per-item link readback via
18
+ `TimelineItem.GetLinkedItems` (live-verified: a video item returns its audio
19
+ twin).
20
+ - `timeline.get_title_text` — the read twin of `set_title_text`. Resolves the
21
+ same heuristic title-property keys as the setter, and falls back to reading
22
+ `StyledText` off the TextPlus tool in the item's Fusion comp — on Studio
23
+ 19.1.3 the property route exposes no title keys at all (the setter fails
24
+ there too), while the comp route reads and writes fine.
25
+ - `media_pool_item_markers.get_name` — the markers group carried `set_name`
26
+ with no read twin.
27
+ - `render.verify_output(job_id)` — checks the actual output file against the
28
+ job's own mark range: existence, size, ffprobe duration, and a
29
+ duration-ratio warning when a Complete job produced a near-empty stub (the
30
+ issue #164 signature: content the render engine never visited). Verify
31
+ before deleting the job — deleted jobs carry no TargetDir to check.
32
+
33
+ ### Documented (api_truth, Blackmagic-facing report regenerated)
34
+
35
+ Four readbacks the API cannot express, each now a submit-tagged entry:
36
+ `SetCDL` (no GetCDL anywhere — read grades via DRX decode instead),
37
+ `SetNodeEnabled` (no GetNodeEnabled), `SetKeyframeInterpolation` (nothing
38
+ returns interpolation; the whole keyframe family is absent on 19.1.3), and
39
+ `SetHighPriority` (no getter, irreversible per session). The symmetry report's
40
+ high-signal gap list is now exactly these four.
41
+
42
+ And one render-pipeline bug found the hard way: **deleting or closing a
43
+ project while its render job is running wedges Resolve** — the orphaned
44
+ render's `IsRenderingInProgress` sticks True on every subsequent project,
45
+ `StopRendering` does not clear it, new render jobs sit at 0% forever, then
46
+ `StartRendering` starts returning False and `Resolve.Quit()` is refused
47
+ behind a quit-confirm dialog. Reproduced live on Studio 19.1.3.7. Poll
48
+ `GetRenderJobStatus` for completion, never `IsRenderingInProgress`, and never
49
+ close a project mid-render.
50
+
51
+ ### Version ledger
52
+
53
+ `MediaPoolItem.GetMarkInOut` and `TimelineItem.GetLinkedItems` enter the
54
+ evidence gates as measured-present on 19.1.3.7 (introduction versions
55
+ unbisected; the floors err toward refusing on older builds).
56
+
57
+ ## What's New in v2.103.5
58
+
59
+ Two fixes that fell out of auditing the code around this week's releases — the
60
+ same failure classes as #161 and #164, found one tier up from where each was
61
+ originally fixed.
62
+
63
+ **Cache reuse was permanently poisoned on most real installs.** The v2.103.3
64
+ transcription fix taught batch jobs that a declined transcription is not a clip
65
+ failure — but the cache layer had the same default-ON blindness.
66
+ `_report_missing_layers` counted any non-success transcript as a missing layer,
67
+ and the capability gate only screens for a missing backend, not for the stock
68
+ configuration: Whisper installed, `allow_model_download` unset. On such a
69
+ machine every analysis writes a declined "skipped" transcript, every cached
70
+ report carries `missing_layers: ['transcription']`, and `find_reusable_report`
71
+ returns `reusable: False` — forever. Every analyze call silently re-ran full
72
+ analysis (frame extraction included) and produced the same skipped transcript
73
+ again. An unfixable loop, reproduced end-to-end before the fix and green after.
74
+
75
+ A transcript-less report is now a missing layer only when a re-run could supply
76
+ the transcript: the cached payload shows a real attempt that failed (a timeout
77
+ retry may succeed), or the current options would now actually run a backend
78
+ (mock or HTTP backends, or `allow_model_download=true`). Because the check is
79
+ recomputed per request, flipping `allow_model_download` on later correctly
80
+ refuses reuse and finally produces the transcript.
81
+
82
+ **Absolute recordFrames below the timeline start are now refused.** Issue #164
83
+ documented that `recordFrame` counts from Resolve's global frame zero and that
84
+ content placed before the timeline start reads back correctly while rendering
85
+ as ~0 frames. The wrapper's `record_frame_mode='relative'` default shields
86
+ callers — but `record_frame_mode='absolute'` passed any value straight through,
87
+ so an absolute-mode caller with relative-style values reproduced the silent
88
+ stub through this server's own tools. `_normalize_record_frame` (both the
89
+ compound and granular copies) now rejects an absolute value below the
90
+ timeline's start frame with an error naming the convention; internal
91
+ absolute-mode flows (`ripple_insert` cursors) derive their frames from
92
+ timeline reads and cannot trip it. The Resolve UI cannot place content there,
93
+ so no legitimate call is lost.
94
+
95
+ ### Fixed
96
+
97
+ - A declined transcription (no `allow_model_download` opt-in, unavailable or
98
+ not-implemented backend) no longer marks cached analysis reports
99
+ incomplete, so report reuse works on default installs again. Opting into
100
+ model downloads later invalidates reuse and produces the transcript.
101
+ - `record_frame_mode='absolute'` values below the timeline start frame are
102
+ refused with a remediation instead of silently placing content the render
103
+ engine never visits (#164).
104
+
5
105
  ## What's New in v2.103.4
6
106
 
7
107
  **A frame-numbering trap, documented where agents will look it up.** Issue #164
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.103.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.104.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.103.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.104.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.103.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.104.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 29 missing capabilities, 38 bugs / unreliable behaviors.
15
+ **Totals:** 33 missing capabilities, 39 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -239,6 +239,38 @@ equivalent, blocking full automation.
239
239
  - **Workaround / current handling:** Check GetRenderCodecs(format) first; when it is empty, treat the format as unreachable through this API rather than guessing a codec value. Render audio-only via ExportVideo=False on a format that does expose codecs, or drive it from a saved render preset.
240
240
  - **Tags:** render, deliver, audio, unsupported
241
241
 
242
+ ### TimelineItem.SetCDL (write-only — no GetCDL anywhere)
243
+
244
+ - **Object:** `TimelineItem`
245
+ - **Signature:** `({NodeIndex, Slope, Offset, Power, Saturation}) -> Bool`
246
+ - **Behavior:** SetCDL writes a node's CDL but no object exposes a read: no GetCDL on TimelineItem or Graph in the API reference, and dir() on a live Graph confirms (Studio 19.1.3.7). A grade applied via SetCDL cannot be read back, diffed, or verified through the API.
247
+ - **Workaround / current handling:** Track intended CDL values in the caller, or read the actual grade by exporting a DRX still and decoding it (this repo's drx tool decodes 100% of DRX params — slope/offset/power/sat included).
248
+ - **Tags:** color, missing-method, readback
249
+
250
+ ### Graph.SetNodeEnabled (write-only — no GetNodeEnabled)
251
+
252
+ - **Object:** `Graph`
253
+ - **Signature:** `(nodeIndex, bool) -> Bool`
254
+ - **Behavior:** A node's bypass state can be set but never read: no GetNodeEnabled in the API reference, and dir() on a live Graph confirms (Studio 19.1.3.7). After a SetNodeEnabled the caller cannot verify it took, and the pre-existing state of a node someone toggled in the UI is unknowable.
255
+ - **Workaround / current handling:** Treat node-enable state as write-only: record what you set, and verify visually (rendered-frame compare) when the state matters.
256
+ - **Tags:** color, missing-method, readback
257
+
258
+ ### TimelineItem.SetKeyframeInterpolation (write-only)
259
+
260
+ - **Object:** `TimelineItem`
261
+ - **Signature:** `(property, frame, type) -> Bool`
262
+ - **Behavior:** Interpolation can be written per keyframe but nothing returns it: GetKeyframeAtIndex/GetPropertyAtKeyframeIndex expose frame and value only (API reference). On Studio 19.1.3.7 the whole keyframe method family is absent from dir() — these methods are 20.x+.
263
+ - **Workaround / current handling:** Record interpolation choices in the caller; readback is not available at any version.
264
+ - **Tags:** timeline, missing-method, readback, keyframes
265
+
266
+ ### Resolve.SetHighPriority (write-only, irreversible per session)
267
+
268
+ - **Object:** `Resolve`
269
+ - **Signature:** `() -> Bool`
270
+ - **Behavior:** Raises the Resolve process priority; there is no getter and no way to lower it again through the API (confirmed absent from dir() on Studio 19.1.3.7).
271
+ - **Workaround / current handling:** Call it only when the user asked for a long render on a dedicated machine; state cannot be read back or undone without restarting Resolve.
272
+ - **Tags:** app-control, missing-method, readback
273
+
242
274
  ### TimelineItem.CreateMagicMask (needs operator clicks)
243
275
 
244
276
  - **Object:** `TimelineItem`
@@ -564,3 +596,11 @@ values, or automation-hostile modal prompts.
564
596
  - **Behavior:** The returned TimelineItem objects can have an unreadable/empty GetUniqueId, notably when the clipInfo recordFrame lands in a span still occupied by another item (any duplicate-then-delete 'move' whose offset is smaller than the item duration hits this). The item may not actually exist on the timeline. A session that trusted the non-empty return and deleted the sources lost 26 clips (Portugal timeline, 2026-08-19).
565
597
  - **Workaround / current handling:** Never treat AppendToTimeline's return as proof of placement. Re-enumerate the track and match on record frame + duration before any dependent delete; never shift items by duplicate-then-delete into occupied spans — use timeline.ripple_insert, which rebuilds the tail into free space and verifies by readback.
566
598
  - **Tags:** editorial, silent-failure, unreliable-return, timeline
599
+
600
+ ### Project.IsRenderingInProgress (stuck True after deleting the rendering project)
601
+
602
+ - **Object:** `Project`
603
+ - **Signature:** `() -> Bool`
604
+ - **Behavior:** Deleting or closing a project while its render job is still running orphans the render and wedges the whole render pipeline: the output file stops growing and Resolve idles at 0% CPU, IsRenderingInProgress on the NEXT current project reports True indefinitely, StopRendering does not clear it, NEW render jobs sit at 0% forever (then StartRendering starts returning False), and Resolve.Quit() is refused because the app believes a render is running — even project creation can start returning None behind the quit-confirm dialog (reproduced live on Studio 19.1.3.7, 2026-08-29).
605
+ - **Workaround / current handling:** Never close or delete a project while IsRenderingInProgress is True — StopRendering first, wait for False, then close. Once wedged, only a manual quit (confirming the dialog) or force-quit clears it; treat a True that persists at 0% CPU with a static output file as stuck rather than rendering. Poll GetRenderJobStatus for completion instead of IsRenderingInProgress, which this failure poisons.
606
+ - **Tags:** render, silent-failure, unreliable-return
@@ -3,19 +3,15 @@
3
3
  # Read/Write Symmetry Audit
4
4
 
5
5
  - write-style action occurrences scanned: **116**
6
- - write-style action occurrences with a matching read: **67**
7
- - distinct high-signal `set_` actions without a direct/known readback: **8**
6
+ - write-style action occurrences with a matching read: **71**
7
+ - distinct high-signal `set_` actions without a direct/known readback: **4**
8
8
 
9
9
  ## High-signal gaps — `set_` with no direct/known readback
10
10
 
11
11
  - `set_cdl`
12
- - `set_clip_marks`
13
- - `set_clips_linked`
14
12
  - `set_high_priority`
15
13
  - `set_keyframe_interpolation`
16
- - `set_name`
17
14
  - `set_node_enabled`
18
- - `set_title_text`
19
15
 
20
16
  ## Low-signal (create/add/insert/apply/import — usually expected): 40 distinct names
21
17
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.103.4"
40
+ VERSION = "2.104.0"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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.103.4",
3
+ "version": "2.104.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.103.4"
90
+ VERSION = "2.104.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
@@ -572,7 +572,22 @@ def _normalize_record_frame(ci, index, timeline_start_frame=None):
572
572
  }
573
573
 
574
574
  start = _frame_int(timeline_start_frame)
575
- if start in (None, 0) or mode == "absolute":
575
+ if mode == "absolute":
576
+ # recordFrame counts from Resolve's global frame zero; a value below
577
+ # the timeline start renders as ~0 frames while every readback agrees
578
+ # (issue #164). No legitimate placement exists there — refuse.
579
+ if start not in (None, 0) and rf < start:
580
+ return None, {
581
+ "error": (
582
+ f"clip_infos[{index}] recordFrame {rf} is before the timeline "
583
+ f"start frame {start}. recordFrame is timeline-absolute, so "
584
+ "content placed there reads back correctly but renders as ~0 "
585
+ "frames. Use record_frame_mode='relative' (default) or pass an "
586
+ f"absolute frame >= {start}."
587
+ )
588
+ }
589
+ return rf, None
590
+ if start in (None, 0):
576
591
  return rf, None
577
592
  if mode == "auto":
578
593
  return (start + rf) if rf < start else rf, None
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.103.4"
14
+ VERSION = "2.104.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -2894,7 +2894,24 @@ def _normalize_record_frame(
2894
2894
  )
2895
2895
 
2896
2896
  start = _frame_int(timeline_start_frame)
2897
- if start in (None, 0) or mode == "absolute":
2897
+ if mode == "absolute":
2898
+ # recordFrame counts from Resolve's global frame zero. An absolute
2899
+ # value below the timeline's own start frame places content the render
2900
+ # engine never visits: every readback agrees, the render reports
2901
+ # Complete, and the output is a near-empty stub (issue #164; see the
2902
+ # api_truth recordFrame timeline-absolute origin entry). The Resolve UI
2903
+ # cannot place content there, so there is no legitimate case — refuse.
2904
+ if start not in (None, 0) and rf < start:
2905
+ return None, _err(
2906
+ f"clip_infos[{index}] recordFrame {rf} is before the timeline start "
2907
+ f"frame {start}. recordFrame is timeline-absolute (counted from frame "
2908
+ "zero, not from the timeline's start), so content placed there reads "
2909
+ "back correctly but renders as ~0 frames. Use the default "
2910
+ "record_frame_mode='relative' with an offset from the timeline start, "
2911
+ f"or pass an absolute frame >= {start}."
2912
+ )
2913
+ return rf, None
2914
+ if start in (None, 0):
2898
2915
  return rf, None
2899
2916
  if mode == "auto":
2900
2917
  return (start + rf) if rf < start else rf, None
@@ -5595,6 +5612,76 @@ def _timeline_title_property_scan(tl, p: Dict[str, Any]):
5595
5612
  }
5596
5613
 
5597
5614
 
5615
+ def _timeline_get_title_text(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5616
+ """Read a title item's text back — the read twin of set_title_text.
5617
+
5618
+ Same key resolution as the setter: an explicit property_key wins, otherwise
5619
+ the heuristic title-key candidates from the item's property map. Returns the
5620
+ first key that holds a non-empty string, plus every candidate's value so a
5621
+ caller can see which key actually carries the text on this generator.
5622
+ """
5623
+ item, err = _timeline_resolve_item_optional(tl, p)
5624
+ if err:
5625
+ return err
5626
+ property_key = p.get("property_key") or p.get("key")
5627
+ keys: List[str] = []
5628
+ if property_key:
5629
+ keys.append(str(property_key))
5630
+ else:
5631
+ flat, exc_text = _timeline_item_get_property_map(item, _ser)
5632
+ if exc_text and not flat:
5633
+ return _err(f"GetProperty failed: {exc_text}")
5634
+ for row in _candidate_title_property_keys(flat):
5635
+ if row["key"] not in keys:
5636
+ keys.append(row["key"])
5637
+ if not keys:
5638
+ keys = ["Styled Text", "StyledText", "Text", "Rich Text"]
5639
+ values: List[Dict[str, Any]] = []
5640
+ text = None
5641
+ text_key = None
5642
+ for key in keys:
5643
+ rec: Dict[str, Any] = {"property_key": key}
5644
+ try:
5645
+ value = item.GetProperty(key)
5646
+ except Exception as exc:
5647
+ rec["error"] = str(exc)
5648
+ values.append(rec)
5649
+ continue
5650
+ rec["value"] = _ser(value)
5651
+ values.append(rec)
5652
+ if text is None and isinstance(value, str) and value.strip():
5653
+ text = value
5654
+ text_key = key
5655
+ source = "property" if text is not None else None
5656
+ if text is None:
5657
+ # SetProperty/GetProperty title keys are not exposed on every build
5658
+ # (absent for Text+ on Studio 19.1.3, where set_title_text also
5659
+ # fails). The Fusion comp carries the same text as the TextPlus
5660
+ # tool's StyledText input, which reads back fine there — so fall
5661
+ # through to the comp when the property route finds nothing.
5662
+ try:
5663
+ if int(item.GetFusionCompCount() or 0) > 0:
5664
+ comp = item.GetFusionCompByIndex(1)
5665
+ tools = comp.GetToolList(False, "TextPlus") if comp else None
5666
+ for key in (tools or {}):
5667
+ value = tools[key].GetInput("StyledText")
5668
+ if isinstance(value, str) and value.strip():
5669
+ text = value
5670
+ text_key = "StyledText"
5671
+ source = "fusion_comp"
5672
+ break
5673
+ except Exception as exc:
5674
+ values.append({"fusion_comp_error": str(exc)})
5675
+ return {
5676
+ "success": text is not None,
5677
+ "timeline_item_id": _safe_timeline_item_id(item),
5678
+ "text": text,
5679
+ "property_key": text_key,
5680
+ "source": source,
5681
+ "values": values,
5682
+ }
5683
+
5684
+
5598
5685
  def _timeline_set_title_text(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5599
5686
  item, err = _timeline_resolve_item_optional(tl, p)
5600
5687
  if err:
@@ -8617,6 +8704,7 @@ _MEDIA_POOL_KERNEL_ACTIONS = [
8617
8704
  "link_proxy_checked",
8618
8705
  "link_full_resolution_checked",
8619
8706
  "set_clip_marks",
8707
+ "get_clip_marks",
8620
8708
  "clear_clip_marks",
8621
8709
  "copy_clip_annotations",
8622
8710
  "media_pool_boundary_report",
@@ -12826,6 +12914,41 @@ def _clip_media_signature(clip):
12826
12914
  return signature
12827
12915
 
12828
12916
 
12917
+ def _ffprobe_media_summary(path: str) -> Optional[Dict[str, Any]]:
12918
+ """Small duration/stream summary of a rendered file; None when ffprobe is unusable."""
12919
+ ffprobe = shutil.which("ffprobe")
12920
+ if not ffprobe:
12921
+ return None
12922
+ try:
12923
+ proc = subprocess.run(
12924
+ [ffprobe, "-v", "error", "-show_entries",
12925
+ "format=duration,size:stream=codec_type,codec_name",
12926
+ "-of", "json", path],
12927
+ capture_output=True, encoding="utf-8", errors="replace",
12928
+ timeout=20, stdin=subprocess.DEVNULL,
12929
+ )
12930
+ except Exception:
12931
+ return None
12932
+ if proc.returncode != 0:
12933
+ return {"error": (proc.stderr or "ffprobe failed").strip()[:300]}
12934
+ try:
12935
+ payload = json.loads(proc.stdout or "{}")
12936
+ except ValueError:
12937
+ return None
12938
+ fmt = payload.get("format") or {}
12939
+ summary: Dict[str, Any] = {
12940
+ "streams": [
12941
+ {"codec_type": st.get("codec_type"), "codec_name": st.get("codec_name")}
12942
+ for st in payload.get("streams") or []
12943
+ ],
12944
+ }
12945
+ try:
12946
+ summary["duration_seconds"] = float(fmt.get("duration"))
12947
+ except (TypeError, ValueError):
12948
+ summary["duration_seconds"] = None
12949
+ return summary
12950
+
12951
+
12829
12952
  def _probe_media_file(path: str):
12830
12953
  ffprobe = shutil.which("ffprobe")
12831
12954
  if not ffprobe:
@@ -13354,6 +13477,23 @@ def _set_clip_marks(root, mp, p: Dict[str, Any]):
13354
13477
  return {"success": all(row.get("success") for row in results), "count": len(results), "missing": missing, "results": results}
13355
13478
 
13356
13479
 
13480
+ def _get_clip_marks(root, mp, p: Dict[str, Any]):
13481
+ """Read mark in/out for a set of media-pool clips — the read twin of set_clip_marks."""
13482
+ resolved, err = _clips_from_params(root, mp, p)
13483
+ if err:
13484
+ return err
13485
+ clips, missing = resolved
13486
+ results = []
13487
+ for clip in clips:
13488
+ clip_id = _safe_media_pool_item_id(clip)
13489
+ gate = _requires_method(clip, "GetMarkInOut", "19.1")
13490
+ if gate:
13491
+ return gate
13492
+ marks = clip.GetMarkInOut() or {}
13493
+ results.append({"clip_id": clip_id, "name": clip.GetName(), "marks": _ser(marks)})
13494
+ return {"success": True, "count": len(results), "missing": missing, "results": results}
13495
+
13496
+
13357
13497
  def _clear_clip_marks(root, mp, p: Dict[str, Any]):
13358
13498
  resolved, err = _clips_from_params(root, mp, p)
13359
13499
  if err:
@@ -18440,6 +18580,11 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
18440
18580
  delete_all_jobs() -> {success}
18441
18581
  list_jobs() -> {jobs}
18442
18582
  get_job_status(job_id) -> {status}
18583
+ verify_output(job_id) -> {status, output_path, output_exists, output_size_bytes, output_probe, expected_duration_seconds, duration_ratio, warnings, verified}
18584
+ JobStatus reports Complete even when the render produced a near-empty
18585
+ stub (content before the timeline start, issue #164) — this checks the
18586
+ actual output file against the job's mark range. Verify BEFORE deleting
18587
+ the job; deleted jobs carry no TargetDir to check.
18443
18588
  start(job_ids?, interactive?) -> {success}
18444
18589
  stop() -> {success}
18445
18590
  is_rendering() -> {rendering}
@@ -18504,6 +18649,70 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
18504
18649
  return {"jobs": _ser(proj.GetRenderJobList())}
18505
18650
  elif action == "get_job_status":
18506
18651
  return _ser(proj.GetRenderJobStatus(p["job_id"]))
18652
+ elif action == "verify_output":
18653
+ # JobStatus lies by omission: a job whose content renders as nothing
18654
+ # (e.g. clips placed before the timeline start, issue #164) still
18655
+ # reports Complete at 100% with a stub output file. Verify the file.
18656
+ job_id = p.get("job_id")
18657
+ if not job_id:
18658
+ return _err("verify_output requires job_id")
18659
+ status = _ser(proj.GetRenderJobStatus(job_id)) or {}
18660
+ jobs = _ser(proj.GetRenderJobList()) or []
18661
+ job = next((j for j in jobs if j.get("JobId") == job_id), None)
18662
+ if job is None:
18663
+ return _err(
18664
+ f"Render job {job_id!r} is not in the render queue "
18665
+ "(deleted jobs cannot be verified — verify before deleting)",
18666
+ code="JOB_NOT_FOUND", category="invalid_input",
18667
+ )
18668
+ result: Dict[str, Any] = {"job_id": job_id, "status": status}
18669
+ target_dir = job.get("TargetDir")
18670
+ filename = job.get("OutputFilename")
18671
+ warnings: List[str] = []
18672
+ output_path = None
18673
+ if target_dir and filename:
18674
+ output_path = os.path.join(target_dir, filename)
18675
+ else:
18676
+ warnings.append("Job carries no TargetDir/OutputFilename to verify against.")
18677
+ result["output_path"] = output_path
18678
+ if output_path:
18679
+ exists = os.path.exists(output_path)
18680
+ result["output_exists"] = exists
18681
+ if exists:
18682
+ result["output_size_bytes"] = os.path.getsize(output_path)
18683
+ probe = _ffprobe_media_summary(output_path)
18684
+ if probe:
18685
+ result["output_probe"] = probe
18686
+ duration = probe.get("duration_seconds")
18687
+ # Expected duration from the job's own mark range and rate.
18688
+ try:
18689
+ frames = int(job.get("MarkOut")) - int(job.get("MarkIn")) + 1
18690
+ fps = float(str(job.get("FrameRate")))
18691
+ expected = frames / fps if fps > 0 else None
18692
+ except (TypeError, ValueError):
18693
+ expected = None
18694
+ result["expected_duration_seconds"] = expected
18695
+ if duration is not None and expected and expected > 0:
18696
+ result["duration_ratio"] = round(duration / expected, 4)
18697
+ if duration < expected * 0.5:
18698
+ warnings.append(
18699
+ f"Output duration {duration:.2f}s is under half the job's "
18700
+ f"mark range ({expected:.2f}s). A Complete status with a "
18701
+ "near-empty file is the signature of content the render "
18702
+ "engine never visited — e.g. clips placed before the "
18703
+ "timeline start (recordFrame counted from absolute zero, "
18704
+ "issue #164)."
18705
+ )
18706
+ else:
18707
+ if status.get("JobStatus") == "Complete":
18708
+ warnings.append(
18709
+ "JobStatus is Complete but the output file does not exist."
18710
+ )
18711
+ result["warnings"] = warnings
18712
+ result["verified"] = bool(
18713
+ output_path and result.get("output_exists") and not warnings
18714
+ )
18715
+ return result
18507
18716
  elif action == "start":
18508
18717
  job_ids = p.get("job_ids")
18509
18718
  interactive = p.get("interactive", False)
@@ -18628,7 +18837,7 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
18628
18837
  return _safe_quick_export(proj, p)
18629
18838
  elif action == "export_render_boundary_report":
18630
18839
  return _export_render_boundary_report(proj, p)
18631
- return _unknown(action, ["add_job","delete_job","delete_all_jobs","list_jobs","get_job_status","start","stop","is_rendering","get_formats","get_codecs","get_format_and_codec","set_format_and_codec","get_mode","set_mode","get_resolutions","get_settings","set_settings","list_presets","load_preset","save_preset","delete_preset","quick_export_presets","quick_export",*_RENDER_KERNEL_ACTIONS])
18840
+ return _unknown(action, ["add_job","delete_job","delete_all_jobs","list_jobs","get_job_status","verify_output","start","stop","is_rendering","get_formats","get_codecs","get_format_and_codec","set_format_and_codec","get_mode","set_mode","get_resolutions","get_settings","set_settings","list_presets","load_preset","save_preset","delete_preset","quick_export_presets","quick_export",*_RENDER_KERNEL_ACTIONS])
18632
18841
 
18633
18842
 
18634
18843
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -19168,6 +19377,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
19168
19377
  return _link_full_resolution_checked(root, p)
19169
19378
  elif action == "set_clip_marks":
19170
19379
  return _set_clip_marks(root, mp, p)
19380
+ elif action == "get_clip_marks":
19381
+ return _get_clip_marks(root, mp, p)
19171
19382
  elif action == "clear_clip_marks":
19172
19383
  return _clear_clip_marks(root, mp, p)
19173
19384
  elif action == "copy_clip_annotations":
@@ -19837,6 +20048,8 @@ def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None
19837
20048
  return {"flags": clip.GetFlagList()}
19838
20049
  elif action == "clear_flags":
19839
20050
  return {"success": bool(clip.ClearFlags(p["color"]))}
20051
+ elif action == "get_name":
20052
+ return {"name": clip.GetName()}
19840
20053
  elif action == "set_name":
19841
20054
  missing = _requires_method(clip, "SetName", "20.2")
19842
20055
  if missing:
@@ -19863,7 +20076,7 @@ def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None
19863
20076
  if not replacement_path:
19864
20077
  return _err("Provide path or file_path")
19865
20078
  return {"success": bool(clip.ReplaceClipPreserveSubClip(replacement_path))}
19866
- return _unknown(action, ["add","get_all","get_by_custom_data","update_custom_data","get_custom_data","delete_by_color","delete_at_frame","delete_by_custom_data","add_flag","get_flags","clear_flags","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip"])
20079
+ return _unknown(action, ["add","get_all","get_by_custom_data","update_custom_data","get_custom_data","delete_by_color","delete_at_frame","delete_by_custom_data","add_flag","get_flags","clear_flags","get_name","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip"])
19867
20080
 
19868
20081
 
19869
20082
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -22907,7 +23120,8 @@ _TIMELINE_ACTIONS = [
22907
23120
  "overwrite_range", "lift_range", "story_spine_report", "create_variant_from_ranges",
22908
23121
  "bulk_set_item_properties", "apply_look_to_items", "thumbnail_contact_sheet",
22909
23122
  "marker_thumbnail_review", "edit_kernel_capabilities", "probe_edit_kernel_item",
22910
- "title_property_scan", "set_title_text", "bulk_set_title_text", "create_compound_clip",
23123
+ "title_property_scan", "set_title_text", "get_title_text", "get_clips_linked",
23124
+ "bulk_set_title_text", "create_compound_clip",
22911
23125
  "create_fusion_clip", "import_into_timeline", "export", "get_setting", "set_setting",
22912
23126
  "insert_generator", "insert_fusion_generator", "insert_fusion_composition",
22913
23127
  "insert_ofx_generator", "insert_title", "insert_fusion_title", "get_unique_id",
@@ -23401,6 +23615,29 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
23401
23615
  return _timeline_title_property_scan(tl, p)
23402
23616
  elif action == "set_title_text":
23403
23617
  return _timeline_set_title_text(tl, p)
23618
+ elif action == "get_title_text":
23619
+ return _timeline_get_title_text(tl, p)
23620
+ elif action == "get_clips_linked":
23621
+ ids = p.get("clip_ids") or ([p["clip_id"]] if p.get("clip_id") else None)
23622
+ if not ids:
23623
+ return _err("get_clips_linked requires clip_id or clip_ids")
23624
+ groups = []
23625
+ for cid in ids:
23626
+ it = _find_timeline_item_by_id(tl, cid)
23627
+ if not it:
23628
+ groups.append({"clip_id": cid, "error": "not found"})
23629
+ continue
23630
+ missing = _requires_method(it, "GetLinkedItems", "19.1")
23631
+ if missing:
23632
+ return missing
23633
+ linked = it.GetLinkedItems() or []
23634
+ groups.append({
23635
+ "clip_id": cid,
23636
+ "name": it.GetName(),
23637
+ "linked": bool(linked),
23638
+ "linked_items": [{"name": li.GetName(), "id": li.GetUniqueId()} for li in linked],
23639
+ })
23640
+ return {"groups": groups, "count": len(groups)}
23404
23641
  elif action == "bulk_set_title_text":
23405
23642
  return _timeline_bulk_set_title_text(tl, p)
23406
23643
  elif action == "create_compound_clip":
@@ -1884,7 +1884,9 @@ API_TRUTH: List[Dict[str, Any]] = [
1884
1884
  "media_pool.append_to_timeline defaults to "
1885
1885
  "record_frame_mode='relative' and adds the start frame, "
1886
1886
  "so pass record_frame_mode='absolute' only for raw "
1887
- "Resolve frame numbers. When driving the API directly, "
1887
+ "Resolve frame numbers and since v2.103.5 an absolute "
1888
+ "value below the timeline start is refused outright. "
1889
+ "When driving the API directly, "
1888
1890
  "never treat JobStatus Complete as proof a render "
1889
1891
  "worked — check the output file's duration, not just "
1890
1892
  "that the job finished.",
@@ -2265,6 +2267,90 @@ API_TRUTH: List[Dict[str, Any]] = [
2265
2267
  "mitigation": ["_append_and_recover_timeline_item duplicate_verified gate",
2266
2268
  "_timeline_ripple_insert_impl"],
2267
2269
  },
2270
+ {
2271
+ "symbol": "TimelineItem.SetCDL (write-only — no GetCDL anywhere)",
2272
+ "object": "TimelineItem",
2273
+ "signature": "({NodeIndex, Slope, Offset, Power, Saturation}) -> Bool",
2274
+ "reality": "SetCDL writes a node's CDL but no object exposes a read: "
2275
+ "no GetCDL on TimelineItem or Graph in the API reference, "
2276
+ "and dir() on a live Graph confirms (Studio 19.1.3.7). A "
2277
+ "grade applied via SetCDL cannot be read back, diffed, or "
2278
+ "verified through the API.",
2279
+ "recommended": "Track intended CDL values in the caller, or read the "
2280
+ "actual grade by exporting a DRX still and decoding it "
2281
+ "(this repo's drx tool decodes 100% of DRX params — "
2282
+ "slope/offset/power/sat included).",
2283
+ "tags": ["color", "missing-method", "readback"],
2284
+ "submit": "missing",
2285
+ },
2286
+ {
2287
+ "symbol": "Graph.SetNodeEnabled (write-only — no GetNodeEnabled)",
2288
+ "object": "Graph",
2289
+ "signature": "(nodeIndex, bool) -> Bool",
2290
+ "reality": "A node's bypass state can be set but never read: no "
2291
+ "GetNodeEnabled in the API reference, and dir() on a live "
2292
+ "Graph confirms (Studio 19.1.3.7). After a SetNodeEnabled "
2293
+ "the caller cannot verify it took, and the pre-existing "
2294
+ "state of a node someone toggled in the UI is unknowable.",
2295
+ "recommended": "Treat node-enable state as write-only: record what "
2296
+ "you set, and verify visually (rendered-frame compare) "
2297
+ "when the state matters.",
2298
+ "tags": ["color", "missing-method", "readback"],
2299
+ "submit": "missing",
2300
+ },
2301
+ {
2302
+ "symbol": "TimelineItem.SetKeyframeInterpolation (write-only)",
2303
+ "object": "TimelineItem",
2304
+ "signature": "(property, frame, type) -> Bool",
2305
+ "reality": "Interpolation can be written per keyframe but nothing "
2306
+ "returns it: GetKeyframeAtIndex/GetPropertyAtKeyframeIndex "
2307
+ "expose frame and value only (API reference). On Studio "
2308
+ "19.1.3.7 the whole keyframe method family is absent from "
2309
+ "dir() — these methods are 20.x+.",
2310
+ "recommended": "Record interpolation choices in the caller; readback "
2311
+ "is not available at any version.",
2312
+ "tags": ["timeline", "missing-method", "readback", "keyframes"],
2313
+ "submit": "missing",
2314
+ },
2315
+ {
2316
+ "symbol": "Resolve.SetHighPriority (write-only, irreversible per session)",
2317
+ "object": "Resolve",
2318
+ "signature": "() -> Bool",
2319
+ "reality": "Raises the Resolve process priority; there is no getter "
2320
+ "and no way to lower it again through the API (confirmed "
2321
+ "absent from dir() on Studio 19.1.3.7).",
2322
+ "recommended": "Call it only when the user asked for a long render on "
2323
+ "a dedicated machine; state cannot be read back or "
2324
+ "undone without restarting Resolve.",
2325
+ "tags": ["app-control", "missing-method", "readback"],
2326
+ "submit": "missing",
2327
+ },
2328
+ {
2329
+ "symbol": "Project.IsRenderingInProgress (stuck True after deleting the rendering project)",
2330
+ "object": "Project",
2331
+ "signature": "() -> Bool",
2332
+ "reality": "Deleting or closing a project while its render job is "
2333
+ "still running orphans the render and wedges the whole "
2334
+ "render pipeline: the output file stops growing and "
2335
+ "Resolve idles at 0% CPU, IsRenderingInProgress on the "
2336
+ "NEXT current project reports True indefinitely, "
2337
+ "StopRendering does not clear it, NEW render jobs sit at "
2338
+ "0% forever (then StartRendering starts returning False), "
2339
+ "and Resolve.Quit() is refused because the app believes a "
2340
+ "render is running — even project creation can start "
2341
+ "returning None behind the quit-confirm dialog "
2342
+ "(reproduced live on Studio 19.1.3.7, 2026-08-29).",
2343
+ "recommended": "Never close or delete a project while "
2344
+ "IsRenderingInProgress is True — StopRendering first, "
2345
+ "wait for False, then close. Once wedged, only a "
2346
+ "manual quit (confirming the dialog) or force-quit "
2347
+ "clears it; treat a True that persists at 0% CPU with "
2348
+ "a static output file as stuck rather than rendering. "
2349
+ "Poll GetRenderJobStatus for completion instead of "
2350
+ "IsRenderingInProgress, which this failure poisons.",
2351
+ "tags": ["render", "silent-failure", "unreliable-return"],
2352
+ "submit": "bug",
2353
+ },
2268
2354
  {
2269
2355
  "symbol": "TimelineItem.CreateMagicMask (needs operator clicks)",
2270
2356
  "object": "TimelineItem",
@@ -227,6 +227,30 @@ def transcription_attempt_failed(transcript: Any, *, enabled: bool) -> bool:
227
227
  return status not in TRANSCRIPTION_UNATTEMPTED_STATUSES
228
228
 
229
229
 
230
+ def transcription_options_would_attempt(transcription: Dict[str, Any]) -> bool:
231
+ """True when these options would actually run a transcription backend.
232
+
233
+ Mirrors _transcribe's early-outs. The local backends (whisper_cli,
234
+ mlx_whisper — also what a backend of None resolves to) refuse to run
235
+ without allow_model_download=true, whisper_cpp is not_implemented, and
236
+ the resolve backend is refused by design; mock and HTTP-provider
237
+ backends always attempt. Used by _report_missing_layers to decide
238
+ whether re-running an analysis could produce a transcript a cached
239
+ report lacks — if it could not, the cached report is as complete as a
240
+ fresh run would be. Known blind spot: backend None on a machine whose
241
+ only configured backend is an HTTP provider reports False here; name
242
+ the http_* backend explicitly to get cache invalidation.
243
+ """
244
+ backend = transcription.get("backend")
245
+ if backend in {"mock", "local_mock"}:
246
+ return True
247
+ if isinstance(backend, str) and backend.startswith(HTTP_TRANSCRIPTION_BACKEND_PREFIX):
248
+ return True
249
+ if backend in {"whisper_cpp", "resolve"}:
250
+ return False
251
+ return _coerce_bool(transcription.get("allow_model_download"), default=False)
252
+
253
+
230
254
  def _annotate_clip_transcript_failure(clip_result: Dict[str, Any], transcript: Any) -> None:
231
255
  """Mark a clip failed when requested transcription did not complete.
232
256
 
@@ -3665,7 +3689,19 @@ def _report_missing_layers(report: Dict[str, Any], depth: str, options: Dict[str
3665
3689
  if _coerce_bool(transcription.get("enabled"), default=DEFAULT_TRANSCRIPTION_ENABLED):
3666
3690
  transcript = report.get("transcription") or {}
3667
3691
  if not transcript.get("success") or transcript.get("status") == "skipped":
3668
- missing.append("transcription")
3692
+ # A transcript-less report is a missing layer only when a re-run
3693
+ # could supply the transcript: either the cached payload shows a
3694
+ # real attempt that failed (a retry may fix a timeout), or the
3695
+ # current options would now actually run a backend. Transcription
3696
+ # is enabled by default while allow_model_download is not, so on a
3697
+ # stock install every report carries a declined "skipped" payload —
3698
+ # counting that as missing made every cached report reusable=False
3699
+ # forever, silently defeating cache reuse with full re-analysis
3700
+ # that could never produce the transcript either.
3701
+ if transcription_attempt_failed(
3702
+ transcript, enabled=True
3703
+ ) or transcription_options_would_attempt(transcription):
3704
+ missing.append("transcription")
3669
3705
  vision = options.get("vision") or {}
3670
3706
  if _coerce_bool(vision.get("enabled"), default=False):
3671
3707
  visual = report.get("visual") or {}
@@ -118,6 +118,23 @@ _EVIDENCE_GATES: List[Dict[str, Any]] = [
118
118
  "workaround is unavailable.",
119
119
  "issue": 128,
120
120
  },
121
+ {
122
+ "symbol": "MediaPoolItem.GetMarkInOut",
123
+ "introduced_in": "19.1",
124
+ "source": "measured",
125
+ "note": "Present on Studio 19.1.3.7 (confirmed live 2026-08-29, "
126
+ "set/get round trip). Introduction version not bisected — "
127
+ "19.1 is the highest floor this repo can attest, so older "
128
+ "builds may have it too and the gate errs toward refusing.",
129
+ },
130
+ {
131
+ "symbol": "TimelineItem.GetLinkedItems",
132
+ "introduced_in": "19.1",
133
+ "source": "measured",
134
+ "note": "Present on Studio 19.1.3.7 (confirmed live 2026-08-29, "
135
+ "returned the audio twin of a video item). Introduction "
136
+ "version not bisected — same caveat as GetMarkInOut.",
137
+ },
121
138
  {
122
139
  # Named on Project, not Timeline: the shipped README lists it in the
123
140
  # Project section and src/server.py calls it on the project handle. The