davinci-resolve-mcp 2.80.2 → 2.81.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 +67 -0
- package/README.md +1 -1
- package/docs/README.md +9 -1
- package/docs/reference/api-limitations.md +10 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +95 -4
- package/src/utils/api_truth.py +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,73 @@
|
|
|
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.81.0
|
|
6
|
+
|
|
7
|
+
One render bug where every readback agreed and the file disagreed, plus the two
|
|
8
|
+
community skill contributions that were open against it.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`prepare_render_job` inherited the Deliver page's loaded preset, and could
|
|
13
|
+
queue an mp4 that rendered with no video stream** (issue #123, reported with
|
|
14
|
+
a full measurement by @chenyuxiaojin). `SetRenderSettings` applies the keys a
|
|
15
|
+
caller passes *on top of* whatever render state the Deliver page is holding
|
|
16
|
+
rather than replacing it, and a loaded preset carries more state than those
|
|
17
|
+
keys. Measured 2026-07-08: after an MP3 render through the stock **Audio
|
|
18
|
+
Only** preset, a job queued with an explicit `ExportVideo: true` and an `.mp4`
|
|
19
|
+
target returned `settings_success: true` and a real `job_id`, `list_jobs`
|
|
20
|
+
reported `IsExportVideo: true`, and the rendered file held only an AAC stream.
|
|
21
|
+
The single visible tell was 18 minutes of material "rendering" in ~10 seconds.
|
|
22
|
+
|
|
23
|
+
No caller-side check could have caught it, and the reason is worse than the
|
|
24
|
+
bug itself: the scripting API documents neither `GetRenderSettings` nor
|
|
25
|
+
`GetCurrentRenderPresetName`, so the inherited state cannot be read at all.
|
|
26
|
+
Detection is unreachable; only pinning is.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **`from_preset` on `prepare_render_job`** (and through
|
|
31
|
+
`prepare_delivery_job`) runs `LoadRenderPreset` before the explicit settings
|
|
32
|
+
go on top, so a caller pins the base state instead of inheriting one.
|
|
33
|
+
`PresetName` flipping to `Custom` once the explicit settings land is expected.
|
|
34
|
+
The name is validated against `GetRenderPresetList` first, because
|
|
35
|
+
`LoadRenderPreset` refuses an unknown name with a bare `False` that is
|
|
36
|
+
indistinguishable from any other refusal — and a `False` of either kind now
|
|
37
|
+
refuses to queue rather than falling through to an inheriting render.
|
|
38
|
+
- **An inherited-state warning** when a job asks for `ExportVideo: true` without
|
|
39
|
+
a pin, naming the risk and saying plainly that the job readback is not a
|
|
40
|
+
witness for the rendered file — verify a `codec_type=video` stream before
|
|
41
|
+
reporting a deliverable. The `before` snapshot now also reports
|
|
42
|
+
`settings_readable: false` and what is unreadable, instead of leaving the gap
|
|
43
|
+
unnamed.
|
|
44
|
+
- **`resolve-tighten-recording` skill** (PR #126, @chenyuxiaojin) — the
|
|
45
|
+
subtractive counterpart to `resolve-rough-cut`: one long single-take recording
|
|
46
|
+
in, a tightened variant timeline out, original untouched. Measured live
|
|
47
|
+
against Studio 21.0.1.11 on a real 28.5-minute recording. Its centerpiece is
|
|
48
|
+
the coordinate-system trap between plan `keep_ranges` (source frames,
|
|
49
|
+
exclusive end) and `structural_diff.added` (record frames) — feed one where
|
|
50
|
+
the other is expected and every clip lands at the wrong moment of the right
|
|
51
|
+
file, with correct cut lengths and no error. Also documents the three classes
|
|
52
|
+
of content silence-driven tightening cannot hear, including the whisper
|
|
53
|
+
swallowed-retake blind spot (issue #125).
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **`resolve-rough-cut` reconciled with the `api_truth` ledger** (PR #115,
|
|
58
|
+
@bolnet). Two rows contradicted the ledger the skill itself points at. Import
|
|
59
|
+
order was backwards — `ImportMedia` has no destination parameter and always
|
|
60
|
+
lands in the *current* folder, so the bin must be created and made current
|
|
61
|
+
*before* importing. And the traps table still asserted that a comp attached to
|
|
62
|
+
a media clip "never renders", a blanket claim the ledger retracted on
|
|
63
|
+
2026-08-02: a comp wired `MediaIn → Blur → MediaOut` does render, and an
|
|
64
|
+
unrooted `MediaOut` fails the render job outright rather than being silently
|
|
65
|
+
bypassed. Every row now names the build it was confirmed on, and a note
|
|
66
|
+
records that a **running** MCP keeps executing the version it started with, so
|
|
67
|
+
`git pull` does not refresh the ledger until restart.
|
|
68
|
+
- The skill index in `docs/README.md` now lists the two end-to-end assembly
|
|
69
|
+
recipes (`resolve-rough-cut`, `resolve-tighten-recording`), neither of which
|
|
70
|
+
had ever appeared there, and `resolve-edit` points at the tighten skill.
|
|
71
|
+
|
|
5
72
|
## What's New in v2.80.2
|
|
6
73
|
|
|
7
74
|
Agent tooling only. Ten Claude Code skills that this repository has shipped and
|
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)
|
package/docs/README.md
CHANGED
|
@@ -61,7 +61,15 @@ Each skill is a directory containing `SKILL.md`. Claude Code does not discover
|
|
|
61
61
|
loose `.md` files in `.claude/skills/`; a skill placed at the top level of that
|
|
62
62
|
directory silently never loads.
|
|
63
63
|
|
|
64
|
-
Two
|
|
64
|
+
Two end-to-end assembly recipes sit alongside the domain skills. Where a domain
|
|
65
|
+
skill routes, these two walk a whole job:
|
|
66
|
+
|
|
67
|
+
- `resolve-rough-cut` (`.claude/skills/resolve-rough-cut/SKILL.md`) — **additive**:
|
|
68
|
+
select shots from a folder of many clips into an assembled timeline.
|
|
69
|
+
- `resolve-tighten-recording` (`.claude/skills/resolve-tighten-recording/SKILL.md`)
|
|
70
|
+
— **subtractive**: remove dead air from one long single-take recording.
|
|
71
|
+
|
|
72
|
+
Two more sit outside the domain routing:
|
|
65
73
|
|
|
66
74
|
- `house-style` (`.claude/skills/house-style/SKILL.md`) — accumulated editorial
|
|
67
75
|
corrections, so the same note is not given twice. Claude-only; append to it
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.2
|
|
14
14
|
|
|
15
|
-
**Totals:** 25 missing capabilities,
|
|
15
|
+
**Totals:** 25 missing capabilities, 29 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
|
|
@@ -436,6 +436,15 @@ values, or automation-hostile modal prompts.
|
|
|
436
436
|
- **Reference:** [issue #59](https://github.com/samuelgursky/davinci-resolve-mcp/issues/59)
|
|
437
437
|
- **Tags:** render, deliver, silent-failure, id-vs-label
|
|
438
438
|
|
|
439
|
+
### Project.SetRenderSettings (inherits the loaded preset)
|
|
440
|
+
|
|
441
|
+
- **Object:** `Project`
|
|
442
|
+
- **Signature:** `({settings}) -> bool`
|
|
443
|
+
- **Behavior:** SetRenderSettings applies the passed keys ON TOP of whatever render state the Deliver page is holding; it does not replace it. A loaded preset carries more state than the keys a caller passes, and that state survives. Measured 2026-07-08: after a render through the stock 'Audio Only' preset, a job queued with an explicit ExportVideo=True and an .mp4 target returned settings_success=True and a real job id, GetRenderJobList reported IsExportVideo=True, and the rendered .mp4 contained only an AAC stream with NO video stream (ffprobe) — 18 minutes of material 'rendered' in ~10 seconds. The job readback is therefore NOT a witness for the rendered file. There is also no way to detect the inherited state: the scripting API documents no GetRenderSettings and no GetCurrentRenderPresetName, so the base state can be pinned but never read.
|
|
444
|
+
- **Workaround / current handling:** Pin the base state instead of inheriting one — prepare_render_job(from_preset='<a video preset>') runs LoadRenderPreset before the explicit settings go on top (PresetName flips to 'Custom' once they do, which is expected). Then verify the OUTPUT, not the job: ffprobe for a codec_type=video stream. A long timeline that completes in seconds is the tell.
|
|
445
|
+
- **Reference:** [issue #123](https://github.com/samuelgursky/davinci-resolve-mcp/issues/123)
|
|
446
|
+
- **Tags:** render, deliver, silent-failure, preset, readback-lies
|
|
447
|
+
|
|
439
448
|
### ProjectManager.SaveProject
|
|
440
449
|
|
|
441
450
|
- **Object:** `ProjectManager`
|
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.81.0"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
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.81.0"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.81.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -16124,13 +16124,66 @@ def _render_settings_snapshot(proj):
|
|
|
16124
16124
|
settings = _ser(proj.GetRenderSettings())
|
|
16125
16125
|
else:
|
|
16126
16126
|
settings = {"error": "GetRenderSettings unavailable"}
|
|
16127
|
-
|
|
16127
|
+
snapshot = {
|
|
16128
16128
|
"format_and_codec": _ser(proj.GetCurrentRenderFormatAndCodec()),
|
|
16129
16129
|
"mode": _ser(proj.GetCurrentRenderMode()),
|
|
16130
16130
|
"settings": settings,
|
|
16131
16131
|
"jobs": _ser(proj.GetRenderJobList() or []),
|
|
16132
16132
|
"is_rendering": bool(proj.IsRenderingInProgress()),
|
|
16133
16133
|
}
|
|
16134
|
+
# The Deliver page's loaded preset carries render state beyond the keys a
|
|
16135
|
+
# caller passes, and SetRenderSettings applies on TOP of it rather than
|
|
16136
|
+
# replacing it. The scripting API exposes no reader for either half — there
|
|
16137
|
+
# is no GetCurrentRenderPresetName, and GetRenderSettings is not in the
|
|
16138
|
+
# documented surface — so this snapshot cannot report what is being
|
|
16139
|
+
# inherited. Say that in the payload instead of leaving the gap unnamed.
|
|
16140
|
+
if not isinstance(settings, dict) or "error" in settings:
|
|
16141
|
+
snapshot["settings_readable"] = False
|
|
16142
|
+
snapshot["inherited_state"] = (
|
|
16143
|
+
"unreadable: no GetRenderSettings / GetCurrentRenderPresetName in the "
|
|
16144
|
+
"scripting API. Whatever preset the Deliver page holds survives into "
|
|
16145
|
+
"this job for every key not explicitly passed. Pass from_preset to pin "
|
|
16146
|
+
"the base state."
|
|
16147
|
+
)
|
|
16148
|
+
else:
|
|
16149
|
+
snapshot["settings_readable"] = True
|
|
16150
|
+
return snapshot
|
|
16151
|
+
|
|
16152
|
+
|
|
16153
|
+
def _render_preset_pin(proj, preset_name: str):
|
|
16154
|
+
"""LoadRenderPreset with the name validated against the live preset list.
|
|
16155
|
+
|
|
16156
|
+
Returns (result_dict, err). LoadRenderPreset returns a bare False for a name
|
|
16157
|
+
that does not exist, which is indistinguishable from any other refusal, so
|
|
16158
|
+
the name is checked first and a miss names the available presets.
|
|
16159
|
+
"""
|
|
16160
|
+
try:
|
|
16161
|
+
available = [str(x) for x in (proj.GetRenderPresetList() or [])]
|
|
16162
|
+
except Exception:
|
|
16163
|
+
available = []
|
|
16164
|
+
if available and preset_name not in available:
|
|
16165
|
+
return None, _err(
|
|
16166
|
+
f"Render preset not found: {preset_name}",
|
|
16167
|
+
code="RENDER_PRESET_NOT_FOUND",
|
|
16168
|
+
category="invalid_input",
|
|
16169
|
+
reason="LoadRenderPreset refuses an unknown name with a bare False; no job was queued.",
|
|
16170
|
+
remediation="Use render(action='list_presets') for the names this project carries.",
|
|
16171
|
+
state={"requested_preset": preset_name, "available_presets": available},
|
|
16172
|
+
)
|
|
16173
|
+
loaded = bool(proj.LoadRenderPreset(preset_name))
|
|
16174
|
+
if not loaded:
|
|
16175
|
+
return None, _err(
|
|
16176
|
+
f"Could not load render preset: {preset_name}",
|
|
16177
|
+
code="RENDER_PRESET_LOAD_FAILED",
|
|
16178
|
+
category="engine_refused",
|
|
16179
|
+
reason=(
|
|
16180
|
+
"LoadRenderPreset returned False. Refusing to queue: the job would "
|
|
16181
|
+
"otherwise inherit whatever unrelated state the Deliver page holds."
|
|
16182
|
+
),
|
|
16183
|
+
remediation="Check render(action='list_presets'), then retry.",
|
|
16184
|
+
state={"requested_preset": preset_name, "available_presets": available},
|
|
16185
|
+
)
|
|
16186
|
+
return {"preset": preset_name, "loaded": True}, None
|
|
16134
16187
|
|
|
16135
16188
|
|
|
16136
16189
|
def _validate_render_settings_payload(settings: Dict[str, Any], *, require_temp_target: bool = False):
|
|
@@ -16230,6 +16283,16 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
|
|
|
16230
16283
|
if p.get("dry_run"):
|
|
16231
16284
|
return _ok(validation=validation, format=p.get("format"), codec=p.get("codec"))
|
|
16232
16285
|
before = _render_settings_snapshot(proj)
|
|
16286
|
+
# Pin the base render state before layering explicit settings on top. Without
|
|
16287
|
+
# this the job inherits the Deliver page's loaded preset for every key the
|
|
16288
|
+
# caller does not pass — an Audio Only preset plus ExportVideo:True has been
|
|
16289
|
+
# measured to queue a job that reads back IsExportVideo:True and renders an
|
|
16290
|
+
# mp4 with no video stream (issue #123).
|
|
16291
|
+
preset_pin = None
|
|
16292
|
+
if p.get("from_preset"):
|
|
16293
|
+
preset_pin, err = _render_preset_pin(proj, str(p["from_preset"]))
|
|
16294
|
+
if err:
|
|
16295
|
+
return err
|
|
16233
16296
|
format_success = None
|
|
16234
16297
|
if p.get("format") and p.get("codec"):
|
|
16235
16298
|
formats = _render_formats(proj)
|
|
@@ -16261,7 +16324,7 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
|
|
|
16261
16324
|
)
|
|
16262
16325
|
settings_success = bool(proj.SetRenderSettings(settings))
|
|
16263
16326
|
job_id = proj.AddRenderJob() if settings_success else None
|
|
16264
|
-
|
|
16327
|
+
result = {
|
|
16265
16328
|
"success": bool(job_id),
|
|
16266
16329
|
"job_id": job_id,
|
|
16267
16330
|
"format_success": format_success,
|
|
@@ -16269,6 +16332,30 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
|
|
|
16269
16332
|
"before": before,
|
|
16270
16333
|
"settings": settings,
|
|
16271
16334
|
}
|
|
16335
|
+
if preset_pin:
|
|
16336
|
+
result["preset_pinned"] = preset_pin
|
|
16337
|
+
elif settings.get("ExportVideo") is True:
|
|
16338
|
+
# No pin, and the caller is asking for video. This is the exact shape
|
|
16339
|
+
# that produced a video-less mp4 in issue #123: the queued job's
|
|
16340
|
+
# IsExportVideo readback agreed with the request and the file did not.
|
|
16341
|
+
# The job readback is not a witness for the rendered file, so say so
|
|
16342
|
+
# here rather than let success=True imply a verified deliverable.
|
|
16343
|
+
result["warnings"] = result.get("warnings", []) + [{
|
|
16344
|
+
"code": "RENDER_PRESET_STATE_INHERITED",
|
|
16345
|
+
"message": (
|
|
16346
|
+
"This job inherits the Deliver page's current render state for every "
|
|
16347
|
+
"key not passed in settings, and that state is not readable from the "
|
|
16348
|
+
"scripting API. A previously loaded audio-only preset has been measured "
|
|
16349
|
+
"to survive an explicit ExportVideo:true and render an mp4 with no video "
|
|
16350
|
+
"stream, while the job readback reported IsExportVideo:true."
|
|
16351
|
+
),
|
|
16352
|
+
"remediation": (
|
|
16353
|
+
"Pass from_preset='<a video preset>' to pin the base state, and verify "
|
|
16354
|
+
"the rendered file has a codec_type=video stream before reporting it "
|
|
16355
|
+
"delivered — a long timeline that 'renders' in seconds is the tell."
|
|
16356
|
+
),
|
|
16357
|
+
}]
|
|
16358
|
+
return result
|
|
16272
16359
|
|
|
16273
16360
|
|
|
16274
16361
|
# ── Delivery targets ────────────────────────────────────────────────────────
|
|
@@ -16585,7 +16672,11 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
16585
16672
|
probe_render_settings() -> {format_and_codec, mode, settings, jobs, is_rendering}
|
|
16586
16673
|
validate_render_settings(settings, require_temp_target?) -> {valid, errors, unknown_keys}
|
|
16587
16674
|
safe_set_render_settings(settings, dry_run?, restore?, require_temp_target?) -> {success, diff}
|
|
16588
|
-
prepare_render_job(target_dir, settings?, format?, codec?, custom_name?, dry_run?) -> {success, job_id}
|
|
16675
|
+
prepare_render_job(target_dir, settings?, format?, codec?, custom_name?, from_preset?, dry_run?) -> {success, job_id}
|
|
16676
|
+
from_preset pins the base render state (LoadRenderPreset) before the
|
|
16677
|
+
explicit settings go on top. Without it the job inherits the Deliver
|
|
16678
|
+
page's loaded preset for every key not passed, which the API gives no
|
|
16679
|
+
way to read back — see the SetRenderSettings api_truth entry.
|
|
16589
16680
|
render_job_lifecycle_probe(target_dir, settings?, format?, codec?, custom_name?) -> {success, job_id, status_before_delete}
|
|
16590
16681
|
quick_export_capabilities() -> {presets, safe_params, guards}
|
|
16591
16682
|
safe_quick_export(preset, target_dir?|params?, custom_name?, dry_run?, allow_render?) -> {success, status}
|
package/src/utils/api_truth.py
CHANGED
|
@@ -1373,6 +1373,36 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
1373
1373
|
"issue": 59,
|
|
1374
1374
|
"mitigation": ["_render_format_id", "_render_codec_id"],
|
|
1375
1375
|
},
|
|
1376
|
+
{
|
|
1377
|
+
"symbol": "Project.SetRenderSettings (inherits the loaded preset)",
|
|
1378
|
+
"object": "Project",
|
|
1379
|
+
"signature": "({settings}) -> bool",
|
|
1380
|
+
"reality": "SetRenderSettings applies the passed keys ON TOP of whatever "
|
|
1381
|
+
"render state the Deliver page is holding; it does not replace "
|
|
1382
|
+
"it. A loaded preset carries more state than the keys a caller "
|
|
1383
|
+
"passes, and that state survives. Measured 2026-07-08: after a "
|
|
1384
|
+
"render through the stock 'Audio Only' preset, a job queued with "
|
|
1385
|
+
"an explicit ExportVideo=True and an .mp4 target returned "
|
|
1386
|
+
"settings_success=True and a real job id, GetRenderJobList "
|
|
1387
|
+
"reported IsExportVideo=True, and the rendered .mp4 contained "
|
|
1388
|
+
"only an AAC stream with NO video stream (ffprobe) — 18 minutes "
|
|
1389
|
+
"of material 'rendered' in ~10 seconds. The job readback is "
|
|
1390
|
+
"therefore NOT a witness for the rendered file. There is also no "
|
|
1391
|
+
"way to detect the inherited state: the scripting API documents "
|
|
1392
|
+
"no GetRenderSettings and no GetCurrentRenderPresetName, so the "
|
|
1393
|
+
"base state can be pinned but never read.",
|
|
1394
|
+
"recommended": "Pin the base state instead of inheriting one — "
|
|
1395
|
+
"prepare_render_job(from_preset='<a video preset>') runs "
|
|
1396
|
+
"LoadRenderPreset before the explicit settings go on top "
|
|
1397
|
+
"(PresetName flips to 'Custom' once they do, which is "
|
|
1398
|
+
"expected). Then verify the OUTPUT, not the job: ffprobe for "
|
|
1399
|
+
"a codec_type=video stream. A long timeline that completes "
|
|
1400
|
+
"in seconds is the tell.",
|
|
1401
|
+
"tags": ["render", "deliver", "silent-failure", "preset", "readback-lies"],
|
|
1402
|
+
"submit": "bug",
|
|
1403
|
+
"issue": 123,
|
|
1404
|
+
"mitigation": ["_render_preset_pin", "_prepare_render_job"],
|
|
1405
|
+
},
|
|
1376
1406
|
{
|
|
1377
1407
|
"symbol": "Project.SetCurrentRenderFormatAndCodec",
|
|
1378
1408
|
"object": "Project",
|