davinci-resolve-mcp 2.50.0 → 2.52.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 +39 -0
- package/README.md +1 -1
- package/docs/SKILL.md +26 -14
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +44 -5
- package/src/utils/edit_engine.py +45 -4
- package/src/utils/embeddings.py +258 -4
- package/src/utils/media_analysis.py +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
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.52.0
|
|
6
|
+
|
|
7
|
+
`edit_engine` tighten now carries audio. Previously `execute_tighten`
|
|
8
|
+
assembled a video-only variant — a speech-driven dead-air cut came out
|
|
9
|
+
silent, with nothing in the preview or readback to warn you (issue #67).
|
|
10
|
+
|
|
11
|
+
- **Fixed** `plan_tighten` mirrors every kept video range onto its linked
|
|
12
|
+
audio track(s) with identical source frames, so the assembled variant
|
|
13
|
+
stays frame-locked and audible. Audio targets the item's detected linked
|
|
14
|
+
audio tracks (same `GetLinkedItems` matching `execute_swap` uses), falling
|
|
15
|
+
back to audio track 1 where a single linked A/V clip's audio lives.
|
|
16
|
+
- **Added** `include_audio` parameter to `plan_tighten` (default `true`).
|
|
17
|
+
Pass `include_audio=false` for the prior video-only assembly.
|
|
18
|
+
- **Added** an `audio_accounting` block to the `execute_tighten` confirm
|
|
19
|
+
preview and readback (planned vs. actual audio/video item counts), so a
|
|
20
|
+
silent variant can no longer ship unnoticed. Old video-only plans
|
|
21
|
+
re-executed against this build still work and are now loudly flagged as
|
|
22
|
+
silent.
|
|
23
|
+
|
|
24
|
+
## What's New in v2.51.0
|
|
25
|
+
|
|
26
|
+
CLAP audio embeddings — the final phase of the post-program improvements.
|
|
27
|
+
Detection-based like every other backend (never auto-installed); local
|
|
28
|
+
compute, so nothing touches the caps ledger.
|
|
29
|
+
|
|
30
|
+
- **Added** audio embedding backend detection: CLAP via `transformers`
|
|
31
|
+
(laion/clap-htsat-unfused, preferred) or the `laion_clap` package, plus
|
|
32
|
+
torch + ffmpeg. `capabilities` gains an `audio` block with install
|
|
33
|
+
guidance and a `clap_audio` Tools-page entry.
|
|
34
|
+
- **Added** `build_embeddings(kinds=["audio"])`: one CLAP window per shot
|
|
35
|
+
(center-cropped to ~10 s, piped from the source media as raw PCM —
|
|
36
|
+
read-only on source media, no temp files) plus a clip-level mean vector,
|
|
37
|
+
stored as `embedding_kind="audio"` rows. Idempotent via content hashes;
|
|
38
|
+
offline media is reported in `skipped_missing_media`; a missing backend
|
|
39
|
+
is a graceful skip with install guidance.
|
|
40
|
+
- **Added** `find_similar(kind="audio")`: shot/clip queries over the audio
|
|
41
|
+
vectors, and free-text queries ("engine revving") via the CLAP text
|
|
42
|
+
encoder.
|
|
43
|
+
|
|
5
44
|
## What's New in v2.50.0
|
|
6
45
|
|
|
7
46
|
The last JSON-fed readers now source from the DB-canonical analysis store
|
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
|
@@ -547,15 +547,23 @@ no editorial suggestions): `same_setup_as` / `alt_take_of` (symmetric) and
|
|
|
547
547
|
vendor tokens, so nothing here touches the caps ledger. Backends are
|
|
548
548
|
detected, never installed (capabilities lists them with install guidance):
|
|
549
549
|
text = ollama serving `nomic-embed-text` or sentence-transformers; visual =
|
|
550
|
-
open_clip (ViT-B-32, needs torch).
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
550
|
+
open_clip (ViT-B-32, needs torch); audio (v2.51.0+) = CLAP via
|
|
551
|
+
`transformers` (laion/clap-htsat-unfused, preferred) or the `laion_clap`
|
|
552
|
+
package — needs torch + ffmpeg.
|
|
553
|
+
- `build_embeddings(kinds=["text","visual","audio"]?, clip_id?)` —
|
|
554
|
+
idempotent; embeds clip summaries, shot descriptions (+ deep field
|
|
555
|
+
groups), transcript segments, and sampled frames (per-shot visual vector
|
|
556
|
+
= mean of its frames'). `kinds=["audio"]` embeds one CLAP window per shot
|
|
557
|
+
(center-cropped to ~10s, piped from the source media as raw PCM —
|
|
558
|
+
read-only, no temp files) plus a clip-level mean vector; clips whose
|
|
559
|
+
media is offline are reported in `skipped_missing_media`. Only re-embeds
|
|
560
|
+
entities whose content changed.
|
|
561
|
+
- `find_similar(text=… | clip_id=… | clip_id+shot_index,
|
|
562
|
+
kind="text"|"visual"|"audio", entity_types?, limit?)` — brute-force
|
|
563
|
+
cosine over the project's vectors. Free-text visual queries use the CLIP
|
|
564
|
+
text encoder ("cracked windshield" finds the frame); free-text audio
|
|
565
|
+
queries use the CLAP text encoder ("engine revving" finds the shot).
|
|
566
|
+
Results carry scores plus clip/shot/segment context.
|
|
559
567
|
The panel search box gains a `Semantic` toggle when a text backend is
|
|
560
568
|
detected. Vectors live in the per-project DB (schema v10).
|
|
561
569
|
|
|
@@ -714,11 +722,15 @@ clip-count readback plus `brain_edits` rationale rows.
|
|
|
714
722
|
`execute_selects(plan_id)` creates a NEW selects timeline from the plan's
|
|
715
723
|
per-shot source ranges — additive; nothing existing is touched.
|
|
716
724
|
- `plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
|
|
717
|
-
handle_seconds?)` — dead-air lifts from transcript-gap
|
|
718
|
-
timeline item (items without transcripts are reported in
|
|
719
|
-
silently trimmed). `execute_tighten(plan_id)` assembles a
|
|
720
|
-
VARIANT timeline from the plan's keep ranges — true partial
|
|
721
|
-
original timeline is never mutated.
|
|
725
|
+
handle_seconds?, include_audio?)` — dead-air lifts from transcript-gap
|
|
726
|
+
evidence for each timeline item (items without transcripts are reported in
|
|
727
|
+
`skipped`, never silently trimmed). `execute_tighten(plan_id)` assembles a
|
|
728
|
+
tightened VARIANT timeline from the plan's keep ranges — true partial
|
|
729
|
+
trims; the original timeline is never mutated. v2.52.0+: kept ranges mirror
|
|
730
|
+
onto each item's linked audio track(s) so the variant is audible (a
|
|
731
|
+
speech-driven cut was previously silent — #67); pass
|
|
732
|
+
`include_audio=false` for a video-only assembly, and the
|
|
733
|
+
`execute_tighten` readback carries an `audio_accounting` block.
|
|
722
734
|
- `plan_swap(timeline_start_frame | item_name, kind="visual"|"text",
|
|
723
735
|
limit?)` — alternates for one timeline item via the similarity index,
|
|
724
736
|
filtered to shots long enough to fill the slot exactly.
|
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.52.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.52.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.52.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -16592,6 +16592,15 @@ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List
|
|
|
16592
16592
|
pass
|
|
16593
16593
|
except Exception:
|
|
16594
16594
|
pass
|
|
16595
|
+
# Linked audio track indices so the planner can mirror kept ranges
|
|
16596
|
+
# onto sound (see #67 — tighten variants were coming out silent).
|
|
16597
|
+
try:
|
|
16598
|
+
audio_indices, _audio_note = _edit_engine_linked_audio_tracks(
|
|
16599
|
+
tl, item, row["timeline_start_frame"], row["timeline_end_frame"]
|
|
16600
|
+
)
|
|
16601
|
+
row["audio_track_indices"] = audio_indices
|
|
16602
|
+
except Exception:
|
|
16603
|
+
row["audio_track_indices"] = []
|
|
16595
16604
|
rows.append(row)
|
|
16596
16605
|
return rows
|
|
16597
16606
|
|
|
@@ -16732,10 +16741,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16732
16741
|
- execute_selects(plan_id, confirm_token?) — creates a NEW selects timeline
|
|
16733
16742
|
from the plan's clip in/out ranges. Nothing existing is touched.
|
|
16734
16743
|
- plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
|
|
16735
|
-
handle_seconds?) — dead-air lifts from transcript-gap
|
|
16736
|
-
current (or named) timeline.
|
|
16737
|
-
|
|
16738
|
-
|
|
16744
|
+
handle_seconds?, include_audio?) — dead-air lifts from transcript-gap
|
|
16745
|
+
evidence for the current (or named) timeline. Kept ranges mirror onto the
|
|
16746
|
+
items' linked audio tracks by default so the variant is audible; pass
|
|
16747
|
+
include_audio=false for a video-only (silent) assembly.
|
|
16748
|
+
- execute_tighten(plan_id, confirm_token?) — assembles a tightened VARIANT
|
|
16749
|
+
timeline from the plan's keep ranges (video + mirrored audio), never
|
|
16750
|
+
mutating the original. Readback includes an audio_accounting block.
|
|
16739
16751
|
- plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
|
|
16740
16752
|
— alternates for one timeline item via the similarity index.
|
|
16741
16753
|
- execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
|
|
@@ -16807,6 +16819,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16807
16819
|
target_ratio=p.get("target_ratio") or p.get("targetRatio"),
|
|
16808
16820
|
min_pause_seconds=float(p.get("min_pause_seconds") or p.get("minPauseSeconds") or _edit_engine_mod.DEFAULT_MIN_PAUSE_SECONDS),
|
|
16809
16821
|
handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
|
|
16822
|
+
include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
|
|
16810
16823
|
)
|
|
16811
16824
|
|
|
16812
16825
|
if action == "plan_swap":
|
|
@@ -16951,6 +16964,8 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16951
16964
|
keep_ranges = plan.get("keep_ranges") or []
|
|
16952
16965
|
if not keep_ranges:
|
|
16953
16966
|
return _err("plan has no keep_ranges — re-plan with this version")
|
|
16967
|
+
audio_keep_ranges = sum(1 for r in keep_ranges if str(r.get("track_type", "video")).lower() == "audio")
|
|
16968
|
+
video_keep_ranges = len(keep_ranges) - audio_keep_ranges
|
|
16954
16969
|
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
16955
16970
|
return _issue_confirm_token(
|
|
16956
16971
|
action="edit_engine.execute_tighten", params=p,
|
|
@@ -16963,6 +16978,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16963
16978
|
"timeline_name": plan.get("timeline_name"),
|
|
16964
16979
|
"lift_count": len(lifts),
|
|
16965
16980
|
"keep_range_count": len(keep_ranges),
|
|
16981
|
+
"video_keep_range_count": video_keep_ranges,
|
|
16982
|
+
"audio_keep_range_count": audio_keep_ranges,
|
|
16983
|
+
"audio_note": (
|
|
16984
|
+
f"Variant carries audio ({audio_keep_ranges} audio ranges mirror the video cuts)."
|
|
16985
|
+
if audio_keep_ranges
|
|
16986
|
+
else "WARNING: variant is VIDEO-ONLY (silent) — plan has no audio ranges."
|
|
16987
|
+
),
|
|
16966
16988
|
"estimated_removed_seconds": sum(l.get("duration_seconds") or 0 for l in lifts),
|
|
16967
16989
|
},
|
|
16968
16990
|
)
|
|
@@ -17039,6 +17061,23 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
17039
17061
|
else None
|
|
17040
17062
|
),
|
|
17041
17063
|
"structural_diff": structural_diff,
|
|
17064
|
+
"audio_accounting": {
|
|
17065
|
+
"planned_audio_ranges": audio_keep_ranges,
|
|
17066
|
+
"planned_video_ranges": video_keep_ranges,
|
|
17067
|
+
"variant_audio_items": sum(
|
|
17068
|
+
1 for it in (variant.get("items") or [])
|
|
17069
|
+
if (it.get("range") or {}).get("media_type") == 2
|
|
17070
|
+
),
|
|
17071
|
+
"variant_video_items": sum(
|
|
17072
|
+
1 for it in (variant.get("items") or [])
|
|
17073
|
+
if (it.get("range") or {}).get("media_type") == 1
|
|
17074
|
+
),
|
|
17075
|
+
"note": (
|
|
17076
|
+
"Variant carries audio mirrored from the video cuts."
|
|
17077
|
+
if audio_keep_ranges
|
|
17078
|
+
else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
|
|
17079
|
+
),
|
|
17080
|
+
},
|
|
17042
17081
|
},
|
|
17043
17082
|
"plan_id": plan.get("plan_id"),
|
|
17044
17083
|
}
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -348,13 +348,23 @@ def plan_tighten(
|
|
|
348
348
|
target_ratio: Optional[float] = None,
|
|
349
349
|
min_pause_seconds: float = DEFAULT_MIN_PAUSE_SECONDS,
|
|
350
350
|
handle_seconds: float = DEFAULT_HANDLE_SECONDS,
|
|
351
|
+
include_audio: bool = True,
|
|
351
352
|
) -> Dict[str, Any]:
|
|
352
353
|
"""Propose dead-air lifts for a timeline.
|
|
353
354
|
|
|
354
355
|
`items` rows come from the server (Resolve read): each needs
|
|
355
356
|
{timeline_start_frame, timeline_end_frame, source_start_frame,
|
|
356
|
-
media_ref (clip id / path / hash), item_name?}.
|
|
357
|
-
|
|
357
|
+
media_ref (clip id / path / hash), item_name?}. Optionally each row may
|
|
358
|
+
carry {audio_track_indices: [int, ...]} naming the audio tracks that hold
|
|
359
|
+
the item's linked audio. Lifts are returned in timeline frames, latest-first
|
|
360
|
+
ready.
|
|
361
|
+
|
|
362
|
+
When ``include_audio`` (the default), each kept video range is mirrored onto
|
|
363
|
+
matching audio range(s) so the assembled variant carries sound — a
|
|
364
|
+
speech-driven cut would otherwise come out silent (see #67). Audio is
|
|
365
|
+
mirrored to the item's detected ``audio_track_indices``; absent that, it
|
|
366
|
+
falls back to audio track 1, which is where a single linked A/V clip's audio
|
|
367
|
+
lives.
|
|
358
368
|
"""
|
|
359
369
|
if not items:
|
|
360
370
|
return {"success": False, "error": "No timeline items supplied"}
|
|
@@ -475,6 +485,11 @@ def plan_tighten(
|
|
|
475
485
|
cursor = max(cursor, lift_end_sec)
|
|
476
486
|
if spec["src_end_sec"] - cursor > 0.05:
|
|
477
487
|
segments.append((cursor, spec["src_end_sec"]))
|
|
488
|
+
audio_indices: List[int] = []
|
|
489
|
+
if include_audio:
|
|
490
|
+
audio_indices = [int(i) for i in (item.get("audio_track_indices") or []) if int(i) > 0]
|
|
491
|
+
if not audio_indices:
|
|
492
|
+
audio_indices = [1]
|
|
478
493
|
for seg_start, seg_end in segments:
|
|
479
494
|
start_frame = int(round(seg_start * clip_fps))
|
|
480
495
|
end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)) - 1)
|
|
@@ -485,23 +500,40 @@ def plan_tighten(
|
|
|
485
500
|
"track_type": "video",
|
|
486
501
|
"track_index": int(item.get("track_index") or 1),
|
|
487
502
|
})
|
|
503
|
+
# Mirror each kept video range onto its linked audio track(s) with
|
|
504
|
+
# identical source frames so the variant stays frame-locked and
|
|
505
|
+
# audible. mediaType 2 pulls the same media-pool item's audio.
|
|
506
|
+
for audio_index in audio_indices:
|
|
507
|
+
keep_ranges.append({
|
|
508
|
+
"clip_id": spec["resolve_clip_id"],
|
|
509
|
+
"start_frame": start_frame,
|
|
510
|
+
"end_frame": end_frame,
|
|
511
|
+
"track_type": "audio",
|
|
512
|
+
"media_type": 2,
|
|
513
|
+
"track_index": audio_index,
|
|
514
|
+
})
|
|
488
515
|
|
|
489
516
|
removed_frames = sum(l["timeline_end_frame"] - l["timeline_start_frame"] for l in lifts)
|
|
517
|
+
audio_keep_range_count = sum(1 for r in keep_ranges if r.get("track_type") == "audio")
|
|
518
|
+
video_keep_range_count = len(keep_ranges) - audio_keep_range_count
|
|
490
519
|
plan = save_plan(project_root, {
|
|
491
520
|
"kind": "tighten",
|
|
492
521
|
"timeline_name": timeline_name,
|
|
493
522
|
"timeline_fps": fps,
|
|
494
523
|
"lifts": lifts,
|
|
495
524
|
"keep_ranges": keep_ranges,
|
|
525
|
+
"include_audio": bool(include_audio),
|
|
496
526
|
"skipped": skipped,
|
|
497
527
|
"summary": (
|
|
498
528
|
f"{len(lifts)} dead-air lifts, ~{round(removed_frames / fps, 1)}s removed "
|
|
499
|
-
f"from '{timeline_name}' (assembled as a tightened variant
|
|
529
|
+
f"from '{timeline_name}' (assembled as a tightened variant"
|
|
530
|
+
f"{', video + audio' if include_audio else ', video only'})"
|
|
500
531
|
),
|
|
501
532
|
"settings": {
|
|
502
533
|
"target_ratio": target_ratio,
|
|
503
534
|
"min_pause_seconds": min_pause_seconds,
|
|
504
535
|
"handle_seconds": handle_seconds,
|
|
536
|
+
"include_audio": bool(include_audio),
|
|
505
537
|
},
|
|
506
538
|
})
|
|
507
539
|
return {
|
|
@@ -514,11 +546,20 @@ def plan_tighten(
|
|
|
514
546
|
"estimated_removed_seconds": round(removed_frames / fps, 2),
|
|
515
547
|
"lifts": lifts,
|
|
516
548
|
"keep_range_count": len(keep_ranges),
|
|
549
|
+
"video_keep_range_count": video_keep_range_count,
|
|
550
|
+
"audio_keep_range_count": audio_keep_range_count,
|
|
551
|
+
"include_audio": bool(include_audio),
|
|
517
552
|
"skipped": skipped,
|
|
518
553
|
"note": (
|
|
519
554
|
"Dry-run plan. Execute with edit_engine(action='execute_tighten', "
|
|
520
555
|
"params={plan_id}) — a tightened VARIANT timeline is assembled from "
|
|
521
|
-
"the keep ranges; the original timeline is never mutated."
|
|
556
|
+
"the keep ranges; the original timeline is never mutated. "
|
|
557
|
+
+ (
|
|
558
|
+
f"Audio is mirrored onto matching tracks ({audio_keep_range_count} "
|
|
559
|
+
"audio ranges) so the variant is audible."
|
|
560
|
+
if include_audio
|
|
561
|
+
else "include_audio=False: the variant will be VIDEO-ONLY (silent)."
|
|
562
|
+
)
|
|
522
563
|
),
|
|
523
564
|
}
|
|
524
565
|
|
package/src/utils/embeddings.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Embeddings + similarity search (Phase C of the analysis program).
|
|
2
2
|
|
|
3
3
|
Text vectors over clip/shot summaries and transcript segments, CLIP image
|
|
4
|
-
vectors over sampled frames
|
|
5
|
-
whisper pattern):
|
|
4
|
+
vectors over sampled frames, CLAP audio vectors over per-shot audio windows.
|
|
5
|
+
Backends are detected, never installed (the whisper pattern):
|
|
6
6
|
|
|
7
7
|
- text: ollama serving ``nomic-embed-text`` (preferred — works on Apple
|
|
8
8
|
Silicon via Metal), or ``sentence_transformers`` when installed.
|
|
9
9
|
- visual: ``open_clip_torch`` (ViT-B-32) when installed alongside torch.
|
|
10
|
+
- audio: ``transformers`` ClapModel (laion/clap-htsat-unfused; preferred) or
|
|
11
|
+
``laion_clap`` when installed alongside torch; needs ffmpeg. Audio windows
|
|
12
|
+
are piped from the source media as raw PCM — read-only, no temp files.
|
|
10
13
|
|
|
11
14
|
Vectors live in the per-project DB (schema v10 ``embeddings`` table) as
|
|
12
15
|
float32 BLOBs, one row per (entity, kind, model). Similarity is brute-force
|
|
@@ -37,12 +40,17 @@ OLLAMA_TEXT_MODEL = os.environ.get("DAVINCI_RESOLVE_MCP_EMBED_MODEL", "nomic-emb
|
|
|
37
40
|
SENTENCE_TRANSFORMERS_MODEL = "all-MiniLM-L6-v2"
|
|
38
41
|
OPEN_CLIP_MODEL = ("ViT-B-32", "laion2b_s34b_b79k")
|
|
39
42
|
|
|
43
|
+
CLAP_HF_MODEL = "laion/clap-htsat-unfused"
|
|
44
|
+
CLAP_SAMPLE_RATE = 48000
|
|
45
|
+
AUDIO_WINDOW_SECONDS = 10.0 # CLAP works on ~10s windows; longer shots are center-cropped
|
|
46
|
+
|
|
40
47
|
_PROBE_TIMEOUT_SECONDS = 2.0
|
|
41
48
|
_EMBED_TIMEOUT_SECONDS = 120.0
|
|
42
49
|
|
|
43
50
|
# Lazy singletons for heavyweight local models.
|
|
44
51
|
_ST_MODEL = None
|
|
45
52
|
_CLIP_STATE: Optional[Tuple[Any, Any, Any]] = None # (model, preprocess, torch)
|
|
53
|
+
_CLAP_STATE: Optional[Tuple[str, Any]] = None # (backend, state)
|
|
46
54
|
|
|
47
55
|
|
|
48
56
|
def _now() -> str:
|
|
@@ -140,6 +148,26 @@ def detect_embedding_capabilities() -> Dict[str, Any]:
|
|
|
140
148
|
else "pip install torch open_clip_torch"
|
|
141
149
|
)
|
|
142
150
|
|
|
151
|
+
transformers_available = importlib.util.find_spec("transformers") is not None
|
|
152
|
+
laion_clap_available = importlib.util.find_spec("laion_clap") is not None
|
|
153
|
+
ffmpeg_available = bool(shutil.which("ffmpeg"))
|
|
154
|
+
audio_backends: List[str] = []
|
|
155
|
+
if torch_available and ffmpeg_available:
|
|
156
|
+
if transformers_available:
|
|
157
|
+
audio_backends.append("transformers_clap")
|
|
158
|
+
if laion_clap_available:
|
|
159
|
+
audio_backends.append("laion_clap")
|
|
160
|
+
if not audio_backends:
|
|
161
|
+
if not ffmpeg_available:
|
|
162
|
+
guidance["audio"] = "Install ffmpeg (audio windows are extracted with it)."
|
|
163
|
+
else:
|
|
164
|
+
guidance["audio"] = (
|
|
165
|
+
"pip install transformers (CLAP via laion/clap-htsat-unfused), "
|
|
166
|
+
"or pip install laion_clap"
|
|
167
|
+
if torch_available
|
|
168
|
+
else "pip install torch transformers"
|
|
169
|
+
)
|
|
170
|
+
|
|
143
171
|
return {
|
|
144
172
|
"success": True,
|
|
145
173
|
"no_auto_install": True,
|
|
@@ -156,6 +184,12 @@ def detect_embedding_capabilities() -> Dict[str, Any]:
|
|
|
156
184
|
"backends": ["open_clip"] if visual_available else [],
|
|
157
185
|
"model": "-".join(OPEN_CLIP_MODEL) if visual_available else None,
|
|
158
186
|
},
|
|
187
|
+
"audio": {
|
|
188
|
+
"available": bool(audio_backends),
|
|
189
|
+
"backends": audio_backends,
|
|
190
|
+
"model": CLAP_HF_MODEL if audio_backends else None,
|
|
191
|
+
"ffmpeg": ffmpeg_available,
|
|
192
|
+
},
|
|
159
193
|
"install_guidance": guidance,
|
|
160
194
|
}
|
|
161
195
|
|
|
@@ -279,6 +313,148 @@ def embed_text_for_visual_query(text: str) -> Dict[str, Any]:
|
|
|
279
313
|
return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
280
314
|
|
|
281
315
|
|
|
316
|
+
# ── audio (CLAP) ─────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _clap_state() -> Tuple[str, Any]:
|
|
320
|
+
"""Lazy-load the preferred CLAP backend: transformers (HF-cached, kept
|
|
321
|
+
current) over the laion_clap package."""
|
|
322
|
+
global _CLAP_STATE
|
|
323
|
+
if _CLAP_STATE is None:
|
|
324
|
+
caps = detect_embedding_capabilities()["audio"]
|
|
325
|
+
backends = caps["backends"]
|
|
326
|
+
if "transformers_clap" in backends:
|
|
327
|
+
from transformers import ClapModel, ClapProcessor
|
|
328
|
+
|
|
329
|
+
model = ClapModel.from_pretrained(CLAP_HF_MODEL)
|
|
330
|
+
model.eval()
|
|
331
|
+
processor = ClapProcessor.from_pretrained(CLAP_HF_MODEL)
|
|
332
|
+
_CLAP_STATE = ("transformers_clap", (model, processor))
|
|
333
|
+
elif "laion_clap" in backends:
|
|
334
|
+
import laion_clap
|
|
335
|
+
|
|
336
|
+
module = laion_clap.CLAP_Module(enable_fusion=False)
|
|
337
|
+
module.load_ckpt() # default pretrained checkpoint
|
|
338
|
+
_CLAP_STATE = ("laion_clap", module)
|
|
339
|
+
else:
|
|
340
|
+
raise RuntimeError("No audio-embedding backend available")
|
|
341
|
+
return _CLAP_STATE
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _extract_audio_window(file_path: str, start_seconds: float, duration_seconds: float):
|
|
345
|
+
"""Mono 48 kHz float32 samples piped straight from ffmpeg — read-only on
|
|
346
|
+
the source media, no temp files. Returns None when the window is empty
|
|
347
|
+
(e.g. video-only media)."""
|
|
348
|
+
import subprocess
|
|
349
|
+
|
|
350
|
+
import numpy as np
|
|
351
|
+
|
|
352
|
+
command = [
|
|
353
|
+
"ffmpeg", "-v", "error",
|
|
354
|
+
"-ss", f"{max(0.0, float(start_seconds)):.3f}",
|
|
355
|
+
"-t", f"{max(0.1, float(duration_seconds)):.3f}",
|
|
356
|
+
"-i", file_path,
|
|
357
|
+
"-vn", "-ac", "1", "-ar", str(CLAP_SAMPLE_RATE),
|
|
358
|
+
"-f", "f32le", "-",
|
|
359
|
+
]
|
|
360
|
+
try:
|
|
361
|
+
proc = subprocess.run(command, capture_output=True, timeout=120, check=False)
|
|
362
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
363
|
+
return None
|
|
364
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
365
|
+
return None
|
|
366
|
+
samples = np.frombuffer(proc.stdout, dtype=np.float32)
|
|
367
|
+
return samples if samples.size else None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _clap_audio_vectors(waveforms: List[Any]) -> Tuple[List[List[float]], str]:
|
|
371
|
+
backend, state = _clap_state()
|
|
372
|
+
if backend == "transformers_clap":
|
|
373
|
+
import torch
|
|
374
|
+
|
|
375
|
+
model, processor = state
|
|
376
|
+
inputs = processor(audios=waveforms, sampling_rate=CLAP_SAMPLE_RATE, return_tensors="pt", padding=True)
|
|
377
|
+
with torch.no_grad():
|
|
378
|
+
features = model.get_audio_features(**inputs)
|
|
379
|
+
features = features / features.norm(dim=-1, keepdim=True)
|
|
380
|
+
return [[float(x) for x in row.tolist()] for row in features], f"transformers_clap:{CLAP_HF_MODEL}"
|
|
381
|
+
import numpy as np
|
|
382
|
+
|
|
383
|
+
module = state
|
|
384
|
+
vectors = module.get_audio_embedding_from_data(x=np.stack(waveforms), use_tensor=False)
|
|
385
|
+
return [[float(x) for x in row] for row in vectors], "laion_clap:default"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def embed_audio_windows(file_path: str, windows: List[Tuple[float, float]]) -> Dict[str, Any]:
|
|
389
|
+
"""Embed (start_seconds, end_seconds) windows of one media file with CLAP.
|
|
390
|
+
|
|
391
|
+
Windows longer than AUDIO_WINDOW_SECONDS are center-cropped. Vector slots
|
|
392
|
+
are None for windows with no decodable audio.
|
|
393
|
+
"""
|
|
394
|
+
caps = detect_embedding_capabilities()
|
|
395
|
+
if not caps["audio"]["available"]:
|
|
396
|
+
return {
|
|
397
|
+
"success": False,
|
|
398
|
+
"error": "No audio-embedding backend available",
|
|
399
|
+
"install_guidance": caps["install_guidance"].get("audio"),
|
|
400
|
+
}
|
|
401
|
+
if not os.path.isfile(file_path):
|
|
402
|
+
return {"success": False, "error": f"media not found: {file_path}"}
|
|
403
|
+
waveforms: List[Any] = []
|
|
404
|
+
slots: List[Optional[int]] = []
|
|
405
|
+
for start, end in windows:
|
|
406
|
+
duration = max(0.1, float(end) - float(start))
|
|
407
|
+
if duration > AUDIO_WINDOW_SECONDS:
|
|
408
|
+
start = float(start) + (duration - AUDIO_WINDOW_SECONDS) / 2.0
|
|
409
|
+
duration = AUDIO_WINDOW_SECONDS
|
|
410
|
+
samples = _extract_audio_window(file_path, float(start), duration)
|
|
411
|
+
if samples is None:
|
|
412
|
+
slots.append(None)
|
|
413
|
+
continue
|
|
414
|
+
slots.append(len(waveforms))
|
|
415
|
+
waveforms.append(samples)
|
|
416
|
+
if not waveforms:
|
|
417
|
+
return {"success": True, "vectors": [None] * len(windows), "model": None,
|
|
418
|
+
"note": "no decodable audio in any window"}
|
|
419
|
+
try:
|
|
420
|
+
vectors, model = _clap_audio_vectors(waveforms)
|
|
421
|
+
except Exception as exc: # noqa: BLE001 — backend failures surface as data
|
|
422
|
+
return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
423
|
+
return {
|
|
424
|
+
"success": True,
|
|
425
|
+
"vectors": [vectors[slot] if slot is not None else None for slot in slots],
|
|
426
|
+
"model": model,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def embed_text_for_audio_query(text: str) -> Dict[str, Any]:
|
|
431
|
+
"""CLAP text encoder — lets a free-text query ('engine revving') search
|
|
432
|
+
audio vectors."""
|
|
433
|
+
caps = detect_embedding_capabilities()
|
|
434
|
+
if not caps["audio"]["available"]:
|
|
435
|
+
return {
|
|
436
|
+
"success": False,
|
|
437
|
+
"error": "No audio-embedding backend available",
|
|
438
|
+
"install_guidance": caps["install_guidance"].get("audio"),
|
|
439
|
+
}
|
|
440
|
+
try:
|
|
441
|
+
backend, state = _clap_state()
|
|
442
|
+
if backend == "transformers_clap":
|
|
443
|
+
import torch
|
|
444
|
+
|
|
445
|
+
model, processor = state
|
|
446
|
+
inputs = processor(text=[str(text)], return_tensors="pt", padding=True)
|
|
447
|
+
with torch.no_grad():
|
|
448
|
+
features = model.get_text_features(**inputs)
|
|
449
|
+
features = features / features.norm(dim=-1, keepdim=True)
|
|
450
|
+
return {"success": True, "vector": [float(x) for x in features[0].tolist()],
|
|
451
|
+
"model": f"transformers_clap:{CLAP_HF_MODEL}"}
|
|
452
|
+
vectors = state.get_text_embedding([str(text), ""], use_tensor=False)
|
|
453
|
+
return {"success": True, "vector": [float(x) for x in vectors[0]], "model": "laion_clap:default"}
|
|
454
|
+
except Exception as exc: # noqa: BLE001
|
|
455
|
+
return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
456
|
+
|
|
457
|
+
|
|
282
458
|
# ── content builders ─────────────────────────────────────────────────────────
|
|
283
459
|
|
|
284
460
|
|
|
@@ -497,6 +673,78 @@ def build_embeddings(
|
|
|
497
673
|
else:
|
|
498
674
|
result["visual"] = {"success": True, "embedded": 0, "note": "all up to date"}
|
|
499
675
|
|
|
676
|
+
if "audio" in kinds:
|
|
677
|
+
caps = detect_embedding_capabilities()
|
|
678
|
+
if not caps["audio"]["available"]:
|
|
679
|
+
result["audio"] = {
|
|
680
|
+
"success": False,
|
|
681
|
+
"skipped": True,
|
|
682
|
+
"error": "No audio-embedding backend available",
|
|
683
|
+
"install_guidance": caps["install_guidance"].get("audio"),
|
|
684
|
+
}
|
|
685
|
+
result["success"] = False
|
|
686
|
+
return result
|
|
687
|
+
existing = _existing_rows(conn, "audio")
|
|
688
|
+
embedded_count = 0
|
|
689
|
+
missing_media: List[str] = []
|
|
690
|
+
audio_model: Optional[str] = None
|
|
691
|
+
for clip in conn.execute(f"SELECT * FROM clips{where}", args).fetchall():
|
|
692
|
+
clip = dict(clip)
|
|
693
|
+
file_path = str(clip.get("file_path") or "")
|
|
694
|
+
if not file_path or not os.path.isfile(file_path):
|
|
695
|
+
missing_media.append(str(clip.get("clip_name") or clip["clip_uuid"]))
|
|
696
|
+
continue
|
|
697
|
+
shots = [dict(s) for s in conn.execute(
|
|
698
|
+
"SELECT shot_uuid, time_seconds_start, time_seconds_end FROM shots "
|
|
699
|
+
"WHERE clip_uuid = ? ORDER BY shot_index",
|
|
700
|
+
(clip["clip_uuid"],),
|
|
701
|
+
).fetchall()]
|
|
702
|
+
windows: List[Tuple[float, float]] = []
|
|
703
|
+
window_meta: List[Tuple[str, str, str]] = []
|
|
704
|
+
for shot in shots:
|
|
705
|
+
start, end = shot.get("time_seconds_start"), shot.get("time_seconds_end")
|
|
706
|
+
if start is None or end is None or float(end) - float(start) < 0.2:
|
|
707
|
+
continue
|
|
708
|
+
h = _content_hash(f"{file_path}|{start}|{end}")
|
|
709
|
+
if existing.get(("shot", str(shot["shot_uuid"]))) == h:
|
|
710
|
+
continue
|
|
711
|
+
windows.append((float(start), float(end)))
|
|
712
|
+
window_meta.append(("shot", str(shot["shot_uuid"]), h))
|
|
713
|
+
if not windows:
|
|
714
|
+
continue
|
|
715
|
+
embedded = embed_audio_windows(file_path, windows)
|
|
716
|
+
if not embedded.get("success"):
|
|
717
|
+
result["audio"] = embedded
|
|
718
|
+
result["success"] = False
|
|
719
|
+
return result
|
|
720
|
+
if not embedded.get("model"):
|
|
721
|
+
continue # no decodable audio in this clip (e.g. video-only)
|
|
722
|
+
audio_model = str(embedded["model"])
|
|
723
|
+
items: List[Tuple[str, str, str, List[float]]] = []
|
|
724
|
+
clip_vectors: List[List[float]] = []
|
|
725
|
+
for m, vector in zip(window_meta, embedded["vectors"]):
|
|
726
|
+
if vector is None:
|
|
727
|
+
continue
|
|
728
|
+
items.append((m[0], m[1], m[2], vector))
|
|
729
|
+
clip_vectors.append(vector)
|
|
730
|
+
# Clip-level audio vector = mean of its shot windows' (the
|
|
731
|
+
# per-shot-visual pattern).
|
|
732
|
+
if clip_vectors:
|
|
733
|
+
dim = len(clip_vectors[0])
|
|
734
|
+
mean = [sum(v[i] for v in clip_vectors) / len(clip_vectors) for i in range(dim)]
|
|
735
|
+
items.append((
|
|
736
|
+
"clip", str(clip["clip_uuid"]),
|
|
737
|
+
_content_hash(f"{file_path}|clip|{len(clip_vectors)}"), mean,
|
|
738
|
+
))
|
|
739
|
+
embedded_count += _store_vectors(project_root, "audio", audio_model, items)
|
|
740
|
+
result["audio"] = {
|
|
741
|
+
"success": True,
|
|
742
|
+
"embedded": embedded_count,
|
|
743
|
+
"model": audio_model,
|
|
744
|
+
**({"skipped_missing_media": missing_media} if missing_media else {}),
|
|
745
|
+
**({"note": "all up to date"} if not embedded_count and not missing_media else {}),
|
|
746
|
+
}
|
|
747
|
+
|
|
500
748
|
result["wall_clock_ms"] = int((time.time() - started) * 1000)
|
|
501
749
|
counts = conn.execute(
|
|
502
750
|
"SELECT embedding_kind, COUNT(*) AS n FROM embeddings GROUP BY embedding_kind"
|
|
@@ -569,8 +817,8 @@ def find_similar(
|
|
|
569
817
|
|
|
570
818
|
conn = timeline_brain_db.connect(project_root)
|
|
571
819
|
kind = (kind or "text").strip().lower()
|
|
572
|
-
if kind not in ("text", "visual"):
|
|
573
|
-
return {"success": False, "error": f"kind must be 'text' or '
|
|
820
|
+
if kind not in ("text", "visual", "audio"):
|
|
821
|
+
return {"success": False, "error": f"kind must be 'text', 'visual', or 'audio', got {kind!r}"}
|
|
574
822
|
|
|
575
823
|
exclude: Optional[Tuple[str, str]] = None
|
|
576
824
|
query_vector: Optional[List[float]] = None
|
|
@@ -583,6 +831,12 @@ def find_similar(
|
|
|
583
831
|
return embedded
|
|
584
832
|
query_vector = embedded["vectors"][0]
|
|
585
833
|
query_model = str(embedded["model"])
|
|
834
|
+
elif kind == "audio":
|
|
835
|
+
encoded = embed_text_for_audio_query(str(text))
|
|
836
|
+
if not encoded.get("success"):
|
|
837
|
+
return encoded
|
|
838
|
+
query_vector = encoded["vector"]
|
|
839
|
+
query_model = str(encoded["model"])
|
|
586
840
|
else:
|
|
587
841
|
encoded = embed_text_for_visual_query(str(text))
|
|
588
842
|
if not encoded.get("success"):
|
|
@@ -1458,6 +1458,20 @@ TOOL_INSTALL: Dict[str, Dict[str, Any]] = {
|
|
|
1458
1458
|
"verify": "python -c \"import open_clip\"",
|
|
1459
1459
|
"notes": "Needs torch. Model weights (~350 MB) download on first use.",
|
|
1460
1460
|
},
|
|
1461
|
+
"clap_audio": {
|
|
1462
|
+
"label": "CLAP (audio embeddings)",
|
|
1463
|
+
"bundle": "embeddings",
|
|
1464
|
+
"required_for": ["audio similarity (find_similar kind=audio)"],
|
|
1465
|
+
"commands": {
|
|
1466
|
+
"all": "pip install transformers",
|
|
1467
|
+
},
|
|
1468
|
+
"verify": "python -c \"import transformers\"",
|
|
1469
|
+
"notes": (
|
|
1470
|
+
"Needs torch + ffmpeg. Uses laion/clap-htsat-unfused (~600 MB, "
|
|
1471
|
+
"downloads on first use); the laion_clap package works as an "
|
|
1472
|
+
"alternative backend."
|
|
1473
|
+
),
|
|
1474
|
+
},
|
|
1461
1475
|
"whisper_cpp": {
|
|
1462
1476
|
"label": "whisper.cpp",
|
|
1463
1477
|
"bundle": "transcription",
|
|
@@ -1650,6 +1664,11 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
|
1650
1664
|
bool(embedding_caps.get("visual", {}).get("available")),
|
|
1651
1665
|
{"python_module": "open_clip"},
|
|
1652
1666
|
),
|
|
1667
|
+
"clap_audio": _tool_entry(
|
|
1668
|
+
"clap_audio",
|
|
1669
|
+
bool(embedding_caps.get("audio", {}).get("available")),
|
|
1670
|
+
{"backends": embedding_caps.get("audio", {}).get("backends", [])},
|
|
1671
|
+
),
|
|
1653
1672
|
},
|
|
1654
1673
|
"embeddings": embedding_caps,
|
|
1655
1674
|
"transcription": {
|