davinci-resolve-mcp 2.59.0 → 2.60.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 +49 -0
- package/README.md +1 -1
- package/docs/SKILL.md +4 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +90 -6
- package/src/utils/api_truth.py +24 -0
- package/src/utils/destructive_hook.py +4 -1
- package/src/utils/edit_engine.py +34 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,55 @@
|
|
|
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.60.0
|
|
6
|
+
|
|
7
|
+
A community bug-fix bundle — three external contributions plus the three issues
|
|
8
|
+
they filed, integrated with the contributors credited as co-authors.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Frame accuracy (`edit_engine`)** — `MediaPool.AppendToTimeline` clipInfo
|
|
13
|
+
`endFrame` is an *exclusive* bound (duration = `endFrame - startFrame`), but
|
|
14
|
+
three plan builders wrote an inclusive end (`round(t*fps) - 1`) and
|
|
15
|
+
`execute_selects` advanced its record cursor by `end - start + 1`. Result:
|
|
16
|
+
`plan_tighten` kept ranges were one frame short per segment (~4.3s across 130
|
|
17
|
+
segments), `plan_selects`/`plan_swap` source ranges were one frame short, and
|
|
18
|
+
`execute_selects` left a 1-frame gap between selects. Now half-open everywhere.
|
|
19
|
+
(#82, thanks @chenyuxiaojin)
|
|
20
|
+
- **Timeline rename no longer archives (`destructive_hook`)** — `timeline.set_name`
|
|
21
|
+
was version-on-mutate, so renames spawned redundant `_archived` timelines
|
|
22
|
+
(and renaming an archive archived the archive). A rename is content-preserving,
|
|
23
|
+
so it's out of the destructive registry. (#83, thanks @chenyuxiaojin)
|
|
24
|
+
- **Windows startup (`server`)** — initialize the Resolve scripting env
|
|
25
|
+
(PYTHONHOME, PATH, `os.add_dll_directory`) before importing the fusionscript
|
|
26
|
+
bridge, avoiding a native access violation that crashed network transports
|
|
27
|
+
before bind. No-op off Windows. (#78, thanks @POLEPALLIANVESH)
|
|
28
|
+
- Fixed a stale offline test that patched a refactored-away seam
|
|
29
|
+
(`project_manager` delete routing), restoring a fully green baseline.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **`execute_tighten(..., include_details?)`** — the readback `structural_diff`
|
|
34
|
+
is compact by default (counts + a small head/tail sample) instead of embedding
|
|
35
|
+
every before/after item id (226 KB for a 130-segment tighten). The full
|
|
36
|
+
per-item diff is persisted in the plan record (`get_plan` →
|
|
37
|
+
`execution_summary.structural_diff`) and returned inline with
|
|
38
|
+
`include_details=true`. (#84, thanks @chenyuxiaojin)
|
|
39
|
+
- **`plan_tighten` skip dedup** — identical `(item, reason)` skip rows collapse
|
|
40
|
+
into one entry with a `count` (an unanalyzed layer no longer repeats the same
|
|
41
|
+
row once per segment). (#81, thanks @chenyuxiaojin)
|
|
42
|
+
|
|
43
|
+
### Documentation
|
|
44
|
+
|
|
45
|
+
- Recorded the `AppendToTimeline` `endFrame` exclusive-bound semantics in the
|
|
46
|
+
`api_truth` ledger (internal quirk entry). (#80, thanks @chenyuxiaojin)
|
|
47
|
+
|
|
48
|
+
### Validation
|
|
49
|
+
|
|
50
|
+
- Full offline suite green (1343 tests). Static/drift guards pass.
|
|
51
|
+
- Live Resolve validation of the #82 frame-accuracy fix (selects butt-join +
|
|
52
|
+
tighten frame-exact keep ranges) on a disposable project.
|
|
53
|
+
|
|
5
54
|
## What's New in v2.59.0
|
|
6
55
|
|
|
7
56
|
First-class conform ingest for **AAF** and **DRP**, an offline **Premiere `.prproj`** reader with
|
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/SKILL.md
CHANGED
|
@@ -794,8 +794,10 @@ clip-count readback plus `brain_edits` rationale rows.
|
|
|
794
794
|
media-id fallback), and `readback` carries per-track-type
|
|
795
795
|
`track_counts` plus an `audio_accounting` block so swap symmetry is
|
|
796
796
|
verifiable. `execute_tighten` readback gains `structural_diff` (source
|
|
797
|
-
vs variant, via the same engine as `diff_timelines`)
|
|
798
|
-
|
|
797
|
+
vs variant, via the same engine as `diff_timelines`) — compact by
|
|
798
|
+
default (counts + a small sample; full per-item diff persisted in the
|
|
799
|
+
plan record via `get_plan`, or inline with `include_details=true`);
|
|
800
|
+
`execute_selects` readback gains a `usage_summary`.
|
|
799
801
|
- `list_plans(limit?)` / `get_plan(plan_id)`.
|
|
800
802
|
|
|
801
803
|
The engine needs the analysis substrate: analyzed clips in the DB (run
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.60.0"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.60.0"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
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.60.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -745,6 +745,32 @@ resolve = None
|
|
|
745
745
|
dvr_script = None
|
|
746
746
|
_resolve_lock = threading.RLock()
|
|
747
747
|
|
|
748
|
+
# On Windows the fusionscript native bridge DLL must be locatable before the
|
|
749
|
+
# Python import machinery attempts to load it. Setting PYTHONHOME, prepending
|
|
750
|
+
# the Resolve install directory to PATH, and registering it with
|
|
751
|
+
# os.add_dll_directory() ensures the dynamic loader can find fusionscript.dll
|
|
752
|
+
# and its dependencies even when the server is launched from a virtual-env or
|
|
753
|
+
# a working directory that is not the Resolve install directory. These steps
|
|
754
|
+
# MUST happen before `import DaVinciResolveScript` or the import triggers a
|
|
755
|
+
# native access-violation on Windows (crash before port bind in network mode).
|
|
756
|
+
if sys.platform.startswith("win") and RESOLVE_LIB_PATH:
|
|
757
|
+
_resolve_install_dir = os.path.dirname(RESOLVE_LIB_PATH)
|
|
758
|
+
if os.path.isdir(_resolve_install_dir):
|
|
759
|
+
# Ensure Python's own runtime DLLs are discoverable by the bridge.
|
|
760
|
+
if not os.environ.get("PYTHONHOME"):
|
|
761
|
+
os.environ["PYTHONHOME"] = sys.base_prefix
|
|
762
|
+
# Prepend Resolve's install dir to PATH so the loader finds
|
|
763
|
+
# fusionscript.dll and sibling DLLs even without a system-wide install.
|
|
764
|
+
_cur_path = os.environ.get("PATH", "")
|
|
765
|
+
if _resolve_install_dir.lower() not in _cur_path.lower():
|
|
766
|
+
os.environ["PATH"] = _resolve_install_dir + os.pathsep + _cur_path
|
|
767
|
+
# os.add_dll_directory is available on Python 3.8+ (Windows only).
|
|
768
|
+
if hasattr(os, "add_dll_directory"):
|
|
769
|
+
try:
|
|
770
|
+
os.add_dll_directory(_resolve_install_dir)
|
|
771
|
+
except OSError:
|
|
772
|
+
pass
|
|
773
|
+
|
|
748
774
|
try:
|
|
749
775
|
import DaVinciResolveScript as dvr_script
|
|
750
776
|
logger.info("DaVinciResolveScript module loaded")
|
|
@@ -17880,6 +17906,47 @@ def _edit_engine_capture(tl) -> Dict[str, Any]:
|
|
|
17880
17906
|
return out
|
|
17881
17907
|
|
|
17882
17908
|
|
|
17909
|
+
def _compact_structural_diff(diff: Any, *, sample_n: int = 3) -> Any:
|
|
17910
|
+
"""Trim a compare_usage_snapshots result for the default execute response.
|
|
17911
|
+
|
|
17912
|
+
The full added/removed/moved/trimmed rows (one per timeline item, each with
|
|
17913
|
+
media_pool_item_id + track + in/out frames) dominate large tighten
|
|
17914
|
+
responses — 226 KB for a 130-segment tighten (issue #84). The counts already
|
|
17915
|
+
live in `summary`; keep those plus a small head/tail sample per change kind.
|
|
17916
|
+
The full diff is persisted in the executed plan record (edit_engine
|
|
17917
|
+
get_plan(plan_id) → plan.execution_summary.structural_diff) and returned
|
|
17918
|
+
inline when the caller passes include_details=true.
|
|
17919
|
+
"""
|
|
17920
|
+
if not isinstance(diff, dict):
|
|
17921
|
+
return diff
|
|
17922
|
+
# An error stand-in (diff failed) or an already-compact payload: pass through.
|
|
17923
|
+
if "summary" not in diff:
|
|
17924
|
+
return diff
|
|
17925
|
+
sample: Dict[str, Any] = {}
|
|
17926
|
+
omitted = 0
|
|
17927
|
+
for kind in ("added", "removed", "moved", "trimmed"):
|
|
17928
|
+
rows = diff.get(kind)
|
|
17929
|
+
if not isinstance(rows, list) or not rows:
|
|
17930
|
+
continue
|
|
17931
|
+
if len(rows) <= sample_n:
|
|
17932
|
+
sample[kind] = list(rows)
|
|
17933
|
+
continue
|
|
17934
|
+
# Head sample + final row so the caller sees both ends of the change set.
|
|
17935
|
+
sample[kind] = list(rows[: sample_n - 1]) + [rows[-1]]
|
|
17936
|
+
omitted += len(rows) - sample_n
|
|
17937
|
+
return {
|
|
17938
|
+
"summary": diff.get("summary"),
|
|
17939
|
+
"sample": sample,
|
|
17940
|
+
"truncated": True,
|
|
17941
|
+
"omitted_rows": omitted,
|
|
17942
|
+
"detail_hint": (
|
|
17943
|
+
"Full per-item diff persisted in the plan record — retrieve via "
|
|
17944
|
+
"edit_engine get_plan(plan_id) → plan.execution_summary.structural_diff, "
|
|
17945
|
+
"or re-run with include_details=true to inline it."
|
|
17946
|
+
),
|
|
17947
|
+
}
|
|
17948
|
+
|
|
17949
|
+
|
|
17883
17950
|
def _edit_engine_track_counts(tl) -> Dict[str, int]:
|
|
17884
17951
|
"""Per-track-type item counts — the swap symmetry signal in readback."""
|
|
17885
17952
|
counts: Dict[str, int] = {}
|
|
@@ -18001,9 +18068,12 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18001
18068
|
evidence for the current (or named) timeline. Kept ranges mirror onto the
|
|
18002
18069
|
items' linked audio tracks by default so the variant is audible; pass
|
|
18003
18070
|
include_audio=false for a video-only (silent) assembly.
|
|
18004
|
-
- execute_tighten(plan_id, confirm_token?) — assembles a
|
|
18005
|
-
timeline from the plan's keep ranges (video + mirrored
|
|
18006
|
-
mutating the original. Readback includes an audio_accounting
|
|
18071
|
+
- execute_tighten(plan_id, confirm_token?, include_details?) — assembles a
|
|
18072
|
+
tightened VARIANT timeline from the plan's keep ranges (video + mirrored
|
|
18073
|
+
audio), never mutating the original. Readback includes an audio_accounting
|
|
18074
|
+
block and a compact structural_diff (counts + a small sample); the full
|
|
18075
|
+
per-item diff is persisted in the plan record (get_plan → execution_summary)
|
|
18076
|
+
and returned inline only when include_details=true.
|
|
18007
18077
|
- plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
|
|
18008
18078
|
— alternates for one timeline item via the similarity index.
|
|
18009
18079
|
- execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
|
|
@@ -18161,7 +18231,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18161
18231
|
build_errors.append({"index": i, "error": row_err.get("error")})
|
|
18162
18232
|
continue
|
|
18163
18233
|
built.append(row)
|
|
18164
|
-
|
|
18234
|
+
# AppendToTimeline endFrame is exclusive: the item occupies
|
|
18235
|
+
# (end - start) frames. Advancing by +1 more leaves a 1-frame gap
|
|
18236
|
+
# (black flash) between consecutive selects. See api_truth endFrame entry.
|
|
18237
|
+
record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0))
|
|
18165
18238
|
appended = mp.AppendToTimeline(built) if built else None
|
|
18166
18239
|
readback = _edit_engine_capture(tl)
|
|
18167
18240
|
# A diff against a source timeline is meaningless for a fresh assembly;
|
|
@@ -18291,12 +18364,20 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18291
18364
|
)
|
|
18292
18365
|
except Exception:
|
|
18293
18366
|
pass
|
|
18367
|
+
# The full per-item structural diff is persisted in the plan record so it
|
|
18368
|
+
# stays retrievable (edit_engine get_plan) without bloating every
|
|
18369
|
+
# response — see issue #84. The MCP response carries a compact form by
|
|
18370
|
+
# default and the full diff only when include_details=true.
|
|
18371
|
+
include_details = _media_analysis_bool(
|
|
18372
|
+
p.get("include_details", p.get("includeDetails")), False
|
|
18373
|
+
)
|
|
18294
18374
|
_edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
|
|
18295
18375
|
"variant_timeline": variant.get("name") or variant_name,
|
|
18296
18376
|
"keep_ranges": len(keep_ranges),
|
|
18297
18377
|
"lifts": len(lifts),
|
|
18298
18378
|
"before": before,
|
|
18299
18379
|
"after": after,
|
|
18380
|
+
"structural_diff": structural_diff,
|
|
18300
18381
|
})
|
|
18301
18382
|
return {
|
|
18302
18383
|
"success": True,
|
|
@@ -18316,7 +18397,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18316
18397
|
if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
|
|
18317
18398
|
else None
|
|
18318
18399
|
),
|
|
18319
|
-
"structural_diff":
|
|
18400
|
+
"structural_diff": (
|
|
18401
|
+
structural_diff if include_details
|
|
18402
|
+
else _compact_structural_diff(structural_diff)
|
|
18403
|
+
),
|
|
18320
18404
|
"audio_accounting": {
|
|
18321
18405
|
"planned_audio_ranges": audio_keep_ranges,
|
|
18322
18406
|
"planned_video_ranges": video_keep_ranges,
|
package/src/utils/api_truth.py
CHANGED
|
@@ -508,6 +508,30 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
508
508
|
"pass-through.",
|
|
509
509
|
"tags": ["silent-failure", "color", "node-graph", "layout", "drx"],
|
|
510
510
|
},
|
|
511
|
+
{
|
|
512
|
+
"symbol": "MediaPool.AppendToTimeline clipInfo endFrame (exclusive bound)",
|
|
513
|
+
"object": "MediaPool",
|
|
514
|
+
"signature": "([{mediaPoolItem, startFrame, endFrame, recordFrame, "
|
|
515
|
+
"trackIndex, mediaType}]) -> [TimelineItem]",
|
|
516
|
+
"reality": "clipInfo endFrame is an EXCLUSIVE bound: the appended item's "
|
|
517
|
+
"duration is endFrame - startFrame frames, not "
|
|
518
|
+
"endFrame - startFrame + 1. Verified by readback probe on "
|
|
519
|
+
"Resolve Studio 21.0 — appending {startFrame: 6254, "
|
|
520
|
+
"endFrame: 6348} yields a 94-frame item, and 130 such "
|
|
521
|
+
"appends whose absolute recordFrames advance by exactly "
|
|
522
|
+
"(endFrame - startFrame) assemble a gap-free track whose "
|
|
523
|
+
"total length equals the sum of (end - start). Code that "
|
|
524
|
+
"assumes an inclusive bound drifts one frame per clip: "
|
|
525
|
+
"advancing a record cursor by (end - start + 1) leaves a "
|
|
526
|
+
"1-frame hole between consecutive clips, and converting a "
|
|
527
|
+
"half-open range with (end - 1) shaves the range's last "
|
|
528
|
+
"frame.",
|
|
529
|
+
"recommended": "Treat [startFrame, endFrame) as half-open everywhere: "
|
|
530
|
+
"duration = endFrame - startFrame; next recordFrame = "
|
|
531
|
+
"previous recordFrame + duration; no ±1 corrections "
|
|
532
|
+
"when mirroring keep-ranges into clipInfos.",
|
|
533
|
+
"tags": ["timeline", "edit", "off-by-one", "readback"],
|
|
534
|
+
},
|
|
511
535
|
]
|
|
512
536
|
|
|
513
537
|
|
|
@@ -99,7 +99,10 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
|
|
|
99
99
|
"set_track_enable",
|
|
100
100
|
"set_track_name",
|
|
101
101
|
"set_voice_isolation_state",
|
|
102
|
-
|
|
102
|
+
# NOTE: timeline.set_name (rename) is intentionally NOT here. A rename is
|
|
103
|
+
# content-preserving and trivially reversible, so version-on-mutate added
|
|
104
|
+
# no recovery value — it only spawned redundant `_archived` copies (one per
|
|
105
|
+
# rename, and renaming an archive archived the archive). Issue #83.
|
|
103
106
|
"set_start_timecode",
|
|
104
107
|
"set_setting",
|
|
105
108
|
"set_mark_in_out",
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -262,7 +262,10 @@ def plan_selects(
|
|
|
262
262
|
if isinstance(clip_duration, (int, float)) and clip_duration:
|
|
263
263
|
src_end = min(src_end, float(clip_duration))
|
|
264
264
|
start_frame = int(round(src_start * fps))
|
|
265
|
-
|
|
265
|
+
# AppendToTimeline clipInfo endFrame is a half-open (exclusive) bound —
|
|
266
|
+
# duration = endFrame - startFrame. See api_truth "AppendToTimeline
|
|
267
|
+
# clipInfo endFrame". No -1: that would shave the last frame of every select.
|
|
268
|
+
end_frame = max(start_frame + 1, int(round(src_end * fps)))
|
|
266
269
|
decision = {k: v for k, v in candidate.items() if not k.startswith("_")}
|
|
267
270
|
decision["source_frame_range"] = [start_frame, end_frame]
|
|
268
271
|
decisions.append(decision)
|
|
@@ -339,6 +342,29 @@ def _gaps_in_range(
|
|
|
339
342
|
return gaps
|
|
340
343
|
|
|
341
344
|
|
|
345
|
+
def _dedupe_skipped(skipped: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
346
|
+
"""Collapse identical (item, reason) skip rows into one entry with a count.
|
|
347
|
+
|
|
348
|
+
A timeline layer cut into many segments from one unanalyzed source clip
|
|
349
|
+
otherwise reports the same skip once per segment (87 identical rows in a
|
|
350
|
+
real two-layer session), which bloats the plan payload without adding
|
|
351
|
+
signal. First occurrence order is preserved.
|
|
352
|
+
"""
|
|
353
|
+
out: List[Dict[str, Any]] = []
|
|
354
|
+
index: Dict[Tuple[Any, Any], Dict[str, Any]] = {}
|
|
355
|
+
for row in skipped:
|
|
356
|
+
key = (row.get("item"), row.get("reason"))
|
|
357
|
+
hit = index.get(key)
|
|
358
|
+
if hit is None:
|
|
359
|
+
hit = dict(row)
|
|
360
|
+
hit["count"] = 1
|
|
361
|
+
index[key] = hit
|
|
362
|
+
out.append(hit)
|
|
363
|
+
else:
|
|
364
|
+
hit["count"] += 1
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
342
368
|
def plan_tighten(
|
|
343
369
|
project_root: str,
|
|
344
370
|
*,
|
|
@@ -440,6 +466,7 @@ def plan_tighten(
|
|
|
440
466
|
},
|
|
441
467
|
})
|
|
442
468
|
|
|
469
|
+
skipped = _dedupe_skipped(skipped)
|
|
443
470
|
if not lifts:
|
|
444
471
|
return {
|
|
445
472
|
"success": False,
|
|
@@ -492,7 +519,9 @@ def plan_tighten(
|
|
|
492
519
|
audio_indices = [1]
|
|
493
520
|
for seg_start, seg_end in segments:
|
|
494
521
|
start_frame = int(round(seg_start * clip_fps))
|
|
495
|
-
|
|
522
|
+
# Half-open (exclusive) endFrame — duration = end - start. No -1, else
|
|
523
|
+
# every kept segment loses its last frame (see api_truth endFrame entry).
|
|
524
|
+
end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)))
|
|
496
525
|
keep_ranges.append({
|
|
497
526
|
"clip_id": spec["resolve_clip_id"],
|
|
498
527
|
"start_frame": start_frame,
|
|
@@ -642,7 +671,9 @@ def plan_swap(
|
|
|
642
671
|
alt = dict(alt_clip)
|
|
643
672
|
alt_fps = _clip_fps(alt)
|
|
644
673
|
start_frame = int(round(float(alt_start) * alt_fps))
|
|
645
|
-
|
|
674
|
+
# Half-open (exclusive) endFrame — duration = end - start fills the slot
|
|
675
|
+
# exactly. No -1, else the replacement lands one frame short of the slot.
|
|
676
|
+
end_frame = start_frame + int(round(needed_seconds * alt_fps))
|
|
646
677
|
return {
|
|
647
678
|
"score": score,
|
|
648
679
|
"clip_uuid": clip_uuid,
|