@kolbo/mcp 1.112.0 → 1.112.2
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/package.json +1 -1
- package/skill/GENERATED.md +1 -1
- package/skill/SKILL.md +496 -476
- package/skill/VERSION +1 -1
- package/skill/references/models/seedance.md +13 -0
- package/skill/references/models/seedance25.md +8 -0
- package/skill/references/workflows/audio-stems.md +12 -11
- package/skill/references/workflows/cost-and-validation.md +126 -122
- package/skill/references/workflows/flow.md +54 -0
- package/skill/references/workflows/media-library.md +10 -1
- package/skill/references/workflows/micro-drama/cast-locations-voices.md +62 -0
- package/skill/references/workflows/micro-drama/edit-deliver.md +99 -0
- package/skill/references/workflows/micro-drama/render-qa.md +55 -0
- package/skill/references/workflows/micro-drama/series-bible.md +61 -0
- package/skill/references/workflows/micro-drama/shots-prompts.md +76 -0
- package/skill/references/workflows/micro-drama/writing.md +50 -0
- package/skill/references/workflows/micro-drama.md +57 -0
- package/skill/references/workflows/transcription.md +6 -5
- package/skill/references/workflows/troubleshooting.md +4 -0
- package/skill/references/workflows/video-investigation.md +64 -0
- package/skill/scripts/micro-drama/burn.py +121 -0
- package/skill/scripts/micro-drama/cut.py +107 -0
- package/skill/scripts/micro-drama/flash_check.py +20 -0
- package/skill/scripts/micro-drama/media.py +54 -0
- package/skill/scripts/micro-drama/pace_report.py +76 -0
- package/skill/scripts/micro-drama/shot_map.py +38 -0
- package/src/apps/index.js +3 -1
- package/src/index.js +2 -0
- package/src/toolAnnotations.js +6 -2
- package/src/tools/_shared.js +15 -3
- package/src/tools/generate.js +1 -1
- package/src/tools/video_inspection.js +41 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Burn captions (and an optional hook) into a finished cut with libass. Audio is copied untouched.
|
|
2
|
+
|
|
3
|
+
usage:
|
|
4
|
+
python burn.py social <in.mp4> <words.srt> <out.mp4> [--hook "Line one|Line two"] [--hook-dy 0] [--font NAME] [--fontsdir DIR]
|
|
5
|
+
python burn.py film <in.mp4> <words.srt> <out.mp4> [--font NAME] [--fontsdir DIR]
|
|
6
|
+
|
|
7
|
+
words.srt = one-word-per-cue SRT from Kolbo transcribe_audio (word_by_word_srt_url) of THIS file, corrected
|
|
8
|
+
against the script's words (keep the timings, fix only mishears).
|
|
9
|
+
social: word-pop captions, max 3 words per line, the spoken word in yellow, each word one frame early, sitting
|
|
10
|
+
just above the bottom 35% platform UI band; hook = two lines in a white box for exactly 0-3 s, at the waist,
|
|
11
|
+
right above the captions. All text stays inside the centre 82% so the right-side buttons never cover it.
|
|
12
|
+
film: plain subtitles for the full episode page - max 4 words per row, 2 rows, low in frame, soft shadow.
|
|
13
|
+
Also writes <out>.ass next to the output for inspection.
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
|
|
19
|
+
from media import probe, srt_words
|
|
20
|
+
|
|
21
|
+
YELLOW, WHITE, BLACK = "&H003DE2FF", "&H00FFFFFF", "&H00000000"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ts(t):
|
|
25
|
+
t = max(t, 0)
|
|
26
|
+
return f"{int(t // 3600)}:{int(t % 3600 // 60):02d}:{t % 60:05.2f}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def lines_of(words, max_words, pause=0.6):
|
|
30
|
+
out, cur = [], []
|
|
31
|
+
for w in words:
|
|
32
|
+
if cur and (len(cur) >= max_words or w[1] - cur[-1][2] > pause or cur[-1][0][-1:] in ",.?!"):
|
|
33
|
+
out.append(cur)
|
|
34
|
+
cur = []
|
|
35
|
+
cur.append(w)
|
|
36
|
+
return out + ([cur] if cur else [])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build(mode, video, srt, hook, hook_dy, font):
|
|
40
|
+
info = probe(video)
|
|
41
|
+
W, H, frame = info["w"], info["h"], 1 / info["fps"]
|
|
42
|
+
margin = int(W * 0.09)
|
|
43
|
+
cap_y = int(H * 0.65) - int(H * 0.02) # just above the bottom 35% UI band
|
|
44
|
+
words = srt_words(srt)
|
|
45
|
+
head = (f"[Script Info]\nScriptType: v4.00+\nPlayResX: {W}\nPlayResY: {H}\nWrapStyle: 2\n\n[V4+ Styles]\n"
|
|
46
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, "
|
|
47
|
+
"Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, "
|
|
48
|
+
"MarginR, MarginV, Encoding\n")
|
|
49
|
+
ev = "\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
|
50
|
+
if mode == "film":
|
|
51
|
+
size = int(W * 0.063)
|
|
52
|
+
head += (f"Style: Sub,{font},{size},{WHITE},{WHITE},{BLACK},&H80000000,0,0,0,0,100,100,0,0,1,1.5,2,2,"
|
|
53
|
+
f"{margin},{margin},{int(H * 0.08)},1\n")
|
|
54
|
+
for ln in lines_of(words, 8):
|
|
55
|
+
text = " ".join(w[0] for w in ln)
|
|
56
|
+
parts = text.split()
|
|
57
|
+
if len(parts) > 4:
|
|
58
|
+
text = " ".join(parts[:len(parts) // 2 + len(parts) % 2]) + "\\N" + " ".join(parts[len(parts) // 2 + len(parts) % 2:])
|
|
59
|
+
ev += f"Dialogue: 0,{ts(ln[0][1] - frame)},{ts(ln[-1][2] + 0.2)},Sub,,0,0,0,,{text}\n"
|
|
60
|
+
return head + ev
|
|
61
|
+
cap = int(W * 0.07)
|
|
62
|
+
head += (f"Style: Cap,{font},{cap},{WHITE},{WHITE},{BLACK},&H00000000,1,0,0,0,100,100,0,0,1,{cap * 0.12:.1f},0,2,"
|
|
63
|
+
f"{margin},{margin},{H - cap_y},1\n")
|
|
64
|
+
head += (f"Style: Hook,{font},10,{BLACK},{BLACK},{BLACK},{BLACK},1,0,0,0,100,100,0,0,1,0,0,5,0,0,0,1\n"
|
|
65
|
+
f"Style: Box,{font},10,{WHITE},{WHITE},{WHITE},{WHITE},0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n")
|
|
66
|
+
caps = []
|
|
67
|
+
for ln in lines_of(words, 3):
|
|
68
|
+
for k, (w, a, b) in enumerate(ln):
|
|
69
|
+
end = ln[k + 1][1] if k + 1 < len(ln) else b + 0.15
|
|
70
|
+
text = " ".join(("{\\c" + YELLOW + "}" + x + "{\\c" + WHITE + "}") if j == k else x
|
|
71
|
+
for j, (x, _, _) in enumerate(ln))
|
|
72
|
+
caps.append([a - frame, end - frame, text])
|
|
73
|
+
for k in range(len(caps) - 1): # never two caption states on screen at once (libass stacks them)
|
|
74
|
+
caps[k][1] = max(min(caps[k][1], caps[k + 1][0]), caps[k][0] + frame)
|
|
75
|
+
for a, b, text in caps:
|
|
76
|
+
ev += f"Dialogue: 1,{ts(a)},{ts(b)},Cap,,0,0,0,,{text}\n"
|
|
77
|
+
if hook:
|
|
78
|
+
lines = hook.split("|")
|
|
79
|
+
# ponytail: box width estimated at 0.6 x font size per character (bold sans); measure with a font
|
|
80
|
+
# library if a hook font renders much wider.
|
|
81
|
+
size = min(W * 0.068, (W * 0.82 - W * 0.07) / (0.6 * max(len(x) for x in lines)))
|
|
82
|
+
pad, r = size * 0.5, size * 0.45
|
|
83
|
+
bw = 0.6 * size * max(len(x) for x in lines) + 2 * pad
|
|
84
|
+
bh = size * 1.2 * len(lines) + 2 * pad
|
|
85
|
+
bottom = cap_y - cap * 1.3 - hook_dy # sits right above the caption line
|
|
86
|
+
x0, y0 = (W - bw) / 2, bottom - bh
|
|
87
|
+
path = (f"m {r:.0f} 0 l {bw - r:.0f} 0 b {bw - .45 * r:.0f} 0 {bw:.0f} {.45 * r:.0f} {bw:.0f} {r:.0f} "
|
|
88
|
+
f"l {bw:.0f} {bh - r:.0f} b {bw:.0f} {bh - .45 * r:.0f} {bw - .45 * r:.0f} {bh:.0f} {bw - r:.0f} {bh:.0f} "
|
|
89
|
+
f"l {r:.0f} {bh:.0f} b {.45 * r:.0f} {bh:.0f} 0 {bh - .45 * r:.0f} 0 {bh - r:.0f} "
|
|
90
|
+
f"l 0 {r:.0f} b 0 {.45 * r:.0f} {.45 * r:.0f} 0 {r:.0f} 0")
|
|
91
|
+
ev += f"Dialogue: 2,{ts(0)},{ts(3.0)},Box,,0,0,0,,{{\\an7\\pos({x0:.0f},{y0:.0f})\\p1}}{path}{{\\p0}}\n"
|
|
92
|
+
ev += (f"Dialogue: 3,{ts(0)},{ts(3.0)},Hook,,0,0,0,,{{\\an5\\pos({W / 2:.0f},{y0 + bh / 2:.0f})\\fs{size:.0f}}}"
|
|
93
|
+
+ "\\N".join(lines) + "\n")
|
|
94
|
+
return head + ev
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main():
|
|
98
|
+
p = argparse.ArgumentParser()
|
|
99
|
+
p.add_argument("mode", choices=["social", "film"])
|
|
100
|
+
p.add_argument("video")
|
|
101
|
+
p.add_argument("srt")
|
|
102
|
+
p.add_argument("out")
|
|
103
|
+
p.add_argument("--hook", default="")
|
|
104
|
+
p.add_argument("--hook-dy", type=int, default=0, help="move the hook down by N pixels if it covers a face or body")
|
|
105
|
+
p.add_argument("--font", default="Montserrat ExtraBold")
|
|
106
|
+
p.add_argument("--fontsdir", default="")
|
|
107
|
+
a = p.parse_args()
|
|
108
|
+
ass = os.path.abspath(a.out) + ".ass"
|
|
109
|
+
open(ass, "w", encoding="utf-8").write(build(a.mode, a.video, a.srt, a.hook, a.hook_dy, a.font))
|
|
110
|
+
# libass path escaping is fragile on Windows: run from the .ass folder with a bare file name.
|
|
111
|
+
flt = f"subtitles={os.path.basename(ass)}" + (f":fontsdir={a.fontsdir}" if a.fontsdir else "")
|
|
112
|
+
tmp = os.path.abspath(a.out) + ".part.mp4"
|
|
113
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", os.path.abspath(a.video), "-vf", flt, "-c:v", "libx264",
|
|
114
|
+
"-crf", "16", "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart",
|
|
115
|
+
tmp], check=True, cwd=os.path.dirname(ass))
|
|
116
|
+
os.replace(tmp, os.path.abspath(a.out))
|
|
117
|
+
print("->", a.out)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Build the tight cut from final clips with exact frames, L/J-cuts and smooth sound seams. Levels never change.
|
|
2
|
+
|
|
3
|
+
usage: python cut.py <cut.json>
|
|
4
|
+
cut.json:
|
|
5
|
+
{
|
|
6
|
+
"out": "final/ep01-tight.mp4",
|
|
7
|
+
"xfade": 0.3,
|
|
8
|
+
"segments": [
|
|
9
|
+
{"file": "final/ep01-part1.mp4", "start": 0.0, "end": 9.4},
|
|
10
|
+
{"file": "final/ep01-part1.mp4", "start": 11.2, "end": 14.0, "lcut": 0.3},
|
|
11
|
+
{"file": "final/ep01-part2.mp4", "start": 0.2, "end": 6.1, "zoom": 1.5, "y": 0.2, "flip": false, "jcut": 0}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
- start/end: seconds on that file. In/out points within 0.15 s of a shot change are moved 1.5 frames inside the
|
|
15
|
+
shot, so no frame of the neighbouring shot flashes in.
|
|
16
|
+
- lcut: this shot's PICTURE comes in that many seconds early over the end of the previous line (L-cut).
|
|
17
|
+
- jcut: this shot's SOUND starts that many seconds early under the previous picture (J-cut).
|
|
18
|
+
- zoom/y: punch-in (1.5 = 150%) anchored at y (0 top, 0.5 centre) for a jump inside one shot.
|
|
19
|
+
- flip: horizontal mirror to fix a 180-degree break. Check every one-sided detail (scar, parting, ring, text) first.
|
|
20
|
+
- Seams borrow `xfade` s of the incoming shot's own sound and crossfade it, so room tone never jumps.
|
|
21
|
+
Contiguous segments of the same file butt-join with no borrow.
|
|
22
|
+
"""
|
|
23
|
+
import bisect
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
from media import frame_pts, probe, shot_changes
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def snap(s, e, changes, fps):
|
|
33
|
+
for c in changes:
|
|
34
|
+
if abs(e - c) < 0.15:
|
|
35
|
+
e = c - 1.5 / fps
|
|
36
|
+
if abs(s - c) < 0.15:
|
|
37
|
+
s = c + 1.5 / fps
|
|
38
|
+
return s, e
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(spec_path):
|
|
42
|
+
spec = json.load(open(spec_path, encoding="utf-8"))
|
|
43
|
+
base = os.path.dirname(os.path.abspath(spec_path))
|
|
44
|
+
path = lambda x: x if os.path.isabs(x) else os.path.join(base, x)
|
|
45
|
+
X = spec.get("xfade", 0.3)
|
|
46
|
+
cache, segs = {}, []
|
|
47
|
+
for raw in spec["segments"]:
|
|
48
|
+
f = path(raw["file"])
|
|
49
|
+
if f not in cache:
|
|
50
|
+
info = probe(f)
|
|
51
|
+
cache[f] = (info, shot_changes(f), frame_pts(f))
|
|
52
|
+
info, changes, pts = cache[f]
|
|
53
|
+
fps = info["fps"]
|
|
54
|
+
s, e = snap(raw["start"], raw["end"], changes, fps)
|
|
55
|
+
contiguous = bool(segs) and segs[-1]["file"] == f and abs(segs[-1]["raw_end"] - raw["start"]) < 1e-6
|
|
56
|
+
if contiguous: # one shared cut point, mid-frame, so no word repeats
|
|
57
|
+
c = min(changes, key=lambda c: abs(c - raw["start"]), default=raw["start"])
|
|
58
|
+
s = c - 0.5 / fps if abs(c - raw["start"]) < 0.15 else raw["start"]
|
|
59
|
+
segs[-1]["end"] = s
|
|
60
|
+
# Snap to real frame times so audio length == frame count (lip sync never drifts).
|
|
61
|
+
k = bisect.bisect_left(pts, s - 1e-4)
|
|
62
|
+
s = pts[min(k, len(pts) - 1)]
|
|
63
|
+
k = bisect.bisect_left(pts, e - 1e-4)
|
|
64
|
+
e = pts[max(k - 1, 0)] + 1 / fps
|
|
65
|
+
segs.append({"file": f, "start": s, "end": e, "raw_end": raw["end"], "fps": fps, "w": info["w"], "h": info["h"],
|
|
66
|
+
"zoom": raw.get("zoom", 1), "y": raw.get("y", 0.5), "flip": raw.get("flip", False),
|
|
67
|
+
"lcut": round(raw.get("lcut", 0) * fps) / fps, "jcut": round(raw.get("jcut", 0) * fps) / fps,
|
|
68
|
+
"contiguous": contiguous})
|
|
69
|
+
ins, fc, vl = [], "", ""
|
|
70
|
+
for i, sg in enumerate(segs):
|
|
71
|
+
nxt = segs[i + 1] if i + 1 < len(segs) else {"lcut": 0, "jcut": 0}
|
|
72
|
+
ins += ["-i", sg["file"]]
|
|
73
|
+
z = sg["zoom"]
|
|
74
|
+
crop = (f",crop=iw/{z}:ih/{z}:(iw-iw/{z})/2:(ih-ih/{z})*{sg['y']},scale={sg['w']}:{sg['h']}:flags=lanczos"
|
|
75
|
+
if z != 1 else "")
|
|
76
|
+
fc += (f"[{i}:v]trim={sg['start'] - 0.002}:{sg['end'] - nxt['lcut'] - 0.002},setpts=PTS-STARTPTS{crop}"
|
|
77
|
+
f"{',hflip' if sg['flip'] else ''},setsar=1[v{i}];")
|
|
78
|
+
a0 = sg["start"] + sg["lcut"] - sg["jcut"] - (X if i and not sg["contiguous"] else 0)
|
|
79
|
+
if a0 < 0:
|
|
80
|
+
sys.exit(f"segment {i} needs {X + sg['jcut']:.2f}s of sound before its in-point; start it later")
|
|
81
|
+
fc += f"[{i}:a]atrim={a0}:{sg['end'] - nxt['jcut']},asetpts=PTS-STARTPTS[a{i}];"
|
|
82
|
+
vl += f"[v{i}]"
|
|
83
|
+
fc += f"{vl}concat=n={len(segs)}:v=1:a=0[v];"
|
|
84
|
+
prev = "a0"
|
|
85
|
+
for i in range(1, len(segs)):
|
|
86
|
+
join = "concat=n=2:v=0:a=1" if segs[i]["contiguous"] else f"acrossfade=d={X}:c1=tri:c2=tri"
|
|
87
|
+
fc += f"[{prev}][a{i}]{join}[x{i}];"
|
|
88
|
+
prev = f"x{i}"
|
|
89
|
+
out = path(spec["out"])
|
|
90
|
+
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
|
|
91
|
+
tmp = out + ".part.mp4"
|
|
92
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", *ins, "-filter_complex", fc.rstrip(";"), "-map", "[v]",
|
|
93
|
+
"-map", f"[{prev}]", "-c:v", "libx264", "-crf", "16", "-preset", "medium", "-pix_fmt", "yuv420p",
|
|
94
|
+
"-c:a", "aac", "-b:a", "256k", "-movflags", "+faststart", tmp], check=True)
|
|
95
|
+
os.replace(tmp, out)
|
|
96
|
+
t = 0.0
|
|
97
|
+
for i, sg in enumerate(segs):
|
|
98
|
+
nxt = segs[i + 1]["lcut"] if i + 1 < len(segs) else 0
|
|
99
|
+
print(f"{t:7.2f} {os.path.basename(sg['file'])} {sg['start']:.3f}-{sg['end'] - nxt:.3f}"
|
|
100
|
+
+ (f" zoom {sg['zoom']}" if sg["zoom"] != 1 else "") + (" flip" if sg["flip"] else "")
|
|
101
|
+
+ (f" L {sg['lcut']:.2f}" if sg["lcut"] else "") + (f" J {sg['jcut']:.2f}" if sg["jcut"] else ""))
|
|
102
|
+
t += sg["end"] - nxt - sg["start"]
|
|
103
|
+
print(f"total {t:.2f}s -> {out}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main(sys.argv[1])
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Find one-frame flashes and too-short shots in a cut. Exit code 1 if anything is found.
|
|
2
|
+
usage: python flash_check.py <video> [min_shot_s=1.2]"""
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from media import frame_diffs, probe
|
|
8
|
+
|
|
9
|
+
video = sys.argv[1]
|
|
10
|
+
min_shot = float(sys.argv[2]) if len(sys.argv) > 2 else 1.2
|
|
11
|
+
fps = probe(video)["fps"]
|
|
12
|
+
d = frame_diffs(video, fps)
|
|
13
|
+
cuts = [int(i) for i in np.where(d > 18)[0] if i > 0]
|
|
14
|
+
bad = 0
|
|
15
|
+
for a, b in zip(cuts, cuts[1:]):
|
|
16
|
+
if (b - a) / fps < min_shot:
|
|
17
|
+
print(f"short shot {a / fps:.3f}-{b / fps:.3f}s ({b - a} frames)")
|
|
18
|
+
bad += 1
|
|
19
|
+
print(f"{len(cuts)} cuts checked, {bad} problems")
|
|
20
|
+
sys.exit(1 if bad else 0)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shared ffmpeg helpers for the micro-drama edit scripts. Needs ffmpeg/ffprobe on PATH and numpy."""
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def probe(video):
|
|
9
|
+
out = subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
10
|
+
"stream=width,height,avg_frame_rate:format=duration", "-of", "json", video])
|
|
11
|
+
d = json.loads(out)
|
|
12
|
+
s = d["streams"][0]
|
|
13
|
+
num, den = s["avg_frame_rate"].split("/")
|
|
14
|
+
return {"w": s["width"], "h": s["height"], "fps": float(num) / float(den or 1),
|
|
15
|
+
"duration": float(d["format"]["duration"])}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def frame_pts(video):
|
|
19
|
+
"""Real presentation time of every video frame, sorted."""
|
|
20
|
+
out = subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
21
|
+
"packet=pts_time", "-of", "csv=p=0", video], text=True)
|
|
22
|
+
return sorted(float(x) for x in out.split() if x and x != "N/A")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def gray_frames(video, fps):
|
|
26
|
+
raw = subprocess.check_output(["ffmpeg", "-v", "error", "-i", video, "-vf",
|
|
27
|
+
f"fps={fps},scale=90:160,format=gray", "-f", "rawvideo", "-"])
|
|
28
|
+
return np.frombuffer(raw, np.uint8).reshape(-1, 160, 90).astype(np.int16)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def frame_diffs(video, fps):
|
|
32
|
+
f = gray_frames(video, fps)
|
|
33
|
+
return np.concatenate([[0], np.abs(np.diff(f, axis=0)).mean(axis=(1, 2))])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def shot_changes(video, threshold=18):
|
|
37
|
+
"""Time of the first frame of every new shot (hard cuts only)."""
|
|
38
|
+
fps = probe(video)["fps"]
|
|
39
|
+
d = frame_diffs(video, fps)
|
|
40
|
+
return [round(i / fps, 4) for i in np.where(d > threshold)[0] if i > 0]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def srt_words(path):
|
|
44
|
+
"""[(word, start, end)] from a one-word-per-cue SRT (Kolbo transcribe_audio word_by_word_srt_url)."""
|
|
45
|
+
def sec(t):
|
|
46
|
+
h, m, s = t.replace(",", ".").split(":")
|
|
47
|
+
return int(h) * 3600 + int(m) * 60 + float(s)
|
|
48
|
+
words = []
|
|
49
|
+
for block in open(path, encoding="utf-8").read().strip().split("\n\n"):
|
|
50
|
+
lines = block.strip().splitlines()
|
|
51
|
+
if len(lines) >= 3 and "-->" in lines[1]:
|
|
52
|
+
a, b = (x.strip() for x in lines[1].split("-->"))
|
|
53
|
+
words.append((" ".join(lines[2:]).strip(), sec(a), sec(b)))
|
|
54
|
+
return words
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Pace report: find dead air (no speech, little motion) in a joined episode. Candidates only - a human or the
|
|
2
|
+
agent judges each one against the script beat (keep a look that lands, cut a stare that re-establishes nothing).
|
|
3
|
+
|
|
4
|
+
usage: python pace_report.py <joined.mp4> <words.srt> [out.json]
|
|
5
|
+
words.srt = one-word-per-cue SRT from Kolbo transcribe_audio (word_by_word_srt_url).
|
|
6
|
+
Writes proposed keep-segments [[start, end], ...]; a piece whose removed gap has no real shot change gets
|
|
7
|
+
[start, end, 1.5, 0.2] (1.5x punch-in so the jump reads as a new angle).
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from media import frame_diffs, probe, shot_changes, srt_words
|
|
15
|
+
|
|
16
|
+
STEP = 1 / 8 # analysis resolution (s)
|
|
17
|
+
MAX_DEAD = 0.6 # dead air longer than this is a trim candidate
|
|
18
|
+
LEAD_IN = 0.25 # keep before a line
|
|
19
|
+
HOLD = 0.45 # keep after a line (the reaction beat)
|
|
20
|
+
MIN_PIECE = 0.5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(video, srt, out=None):
|
|
24
|
+
dur = probe(video)["duration"]
|
|
25
|
+
n = int(dur / STEP)
|
|
26
|
+
t = np.arange(n) * STEP
|
|
27
|
+
m = frame_diffs(video, 1 / STEP)[:n]
|
|
28
|
+
m = np.pad(m, (0, n - len(m)))
|
|
29
|
+
shots = shot_changes(video)
|
|
30
|
+
for c in shots: # a hard cut is a spike, not action
|
|
31
|
+
m[max(int(c / STEP) - 1, 0):int(c / STEP) + 2] = 0
|
|
32
|
+
on = max(4.0, float(np.percentile(m, 70)))
|
|
33
|
+
keep = np.zeros(n, bool)
|
|
34
|
+
for _, a, b in srt_words(srt):
|
|
35
|
+
keep[(t >= a - LEAD_IN) & (t < b + HOLD)] = True
|
|
36
|
+
keep |= np.convolve(m > on, np.ones(4), "same") > 0
|
|
37
|
+
keep[t < 0.5] = True # hook frame
|
|
38
|
+
keep[t > dur - 1.0] = True # cliff hold
|
|
39
|
+
i = 0
|
|
40
|
+
while i < n: # short gaps are breath, keep them
|
|
41
|
+
if keep[i]:
|
|
42
|
+
i += 1
|
|
43
|
+
continue
|
|
44
|
+
j = i
|
|
45
|
+
while j < n and not keep[j]:
|
|
46
|
+
j += 1
|
|
47
|
+
if (j - i) * STEP <= MAX_DEAD:
|
|
48
|
+
keep[i:j] = True
|
|
49
|
+
i = j
|
|
50
|
+
segs, i = [], 0
|
|
51
|
+
while i < n:
|
|
52
|
+
if not keep[i]:
|
|
53
|
+
i += 1
|
|
54
|
+
continue
|
|
55
|
+
j = i
|
|
56
|
+
while j < n and keep[j]:
|
|
57
|
+
j += 1
|
|
58
|
+
s, e = round(float(t[i]), 3), round(min(float(t[j - 1]) + STEP, dur), 3)
|
|
59
|
+
if e - s >= MIN_PIECE:
|
|
60
|
+
segs.append([s, e])
|
|
61
|
+
i = j
|
|
62
|
+
for k in range(1, len(segs)):
|
|
63
|
+
a, b = segs[k - 1][1], segs[k][0]
|
|
64
|
+
if not any(a - 0.1 <= c <= b + 0.1 for c in shots) and len(segs[k - 1]) == 2:
|
|
65
|
+
segs[k] += [1.5, 0.2]
|
|
66
|
+
new = sum(s[1] - s[0] for s in segs)
|
|
67
|
+
print(f"{video}: {dur:.1f}s -> {new:.1f}s ({len(segs) - 1} trim candidates)")
|
|
68
|
+
for k in range(1, len(segs)):
|
|
69
|
+
a, b = segs[k - 1][1], segs[k][0]
|
|
70
|
+
print(f" trim {a:6.2f}-{b:6.2f} ({b - a:.2f}s){' + 1.5x punch-in' if len(segs[k]) > 2 else ''}")
|
|
71
|
+
if out:
|
|
72
|
+
json.dump(segs, open(out, "w"), indent=1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main(*sys.argv[1:4])
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shot map: list every shot of a joined episode and save one contact sheet (first / middle / last frame per shot).
|
|
2
|
+
|
|
3
|
+
usage: python shot_map.py <joined.mp4> <out_dir>
|
|
4
|
+
Writes <out_dir>/shots.json ([{n, start, end}]) and <out_dir>/shot-NN.jpg strips. Look at every strip and note,
|
|
5
|
+
per shot: who is frame-left / frame-right, where each person looks, shot size. That is the shot map the cut uses.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
|
|
13
|
+
from media import probe, shot_changes
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(video, out_dir):
|
|
17
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
18
|
+
info = probe(video)
|
|
19
|
+
edges = [0.0] + shot_changes(video) + [info["duration"]]
|
|
20
|
+
shots = [{"n": i + 1, "start": round(a, 3), "end": round(b, 3)} for i, (a, b) in enumerate(zip(edges, edges[1:]))]
|
|
21
|
+
|
|
22
|
+
def strip(s):
|
|
23
|
+
a, b = s["start"], s["end"]
|
|
24
|
+
times = [a + 0.05, (a + b) / 2, max(b - 0.08, a + 0.05)]
|
|
25
|
+
inputs = sum((["-ss", f"{t:.3f}", "-i", video] for t in times), [])
|
|
26
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", *inputs, "-filter_complex",
|
|
27
|
+
"".join(f"[{i}:v]scale=270:-2,trim=end_frame=1[s{i}];" for i in range(3)) + "[s0][s1][s2]hstack=3",
|
|
28
|
+
"-frames:v", "1", os.path.join(out_dir, f"shot-{s['n']:02d}.jpg")], check=True)
|
|
29
|
+
|
|
30
|
+
with ThreadPoolExecutor(os.cpu_count()) as pool:
|
|
31
|
+
list(pool.map(strip, shots))
|
|
32
|
+
json.dump(shots, open(os.path.join(out_dir, "shots.json"), "w"), indent=1)
|
|
33
|
+
for s in shots:
|
|
34
|
+
print(f"shot {s['n']:2d} {s['start']:7.2f}-{s['end']:7.2f} ({s['end'] - s['start']:.2f}s)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main(*sys.argv[1:3])
|
package/src/apps/index.js
CHANGED
|
@@ -328,7 +328,9 @@ async function modelCatalog(client) {
|
|
|
328
328
|
? m.supported_aspect_ratios
|
|
329
329
|
: (Array.isArray(m.supportedAspectRatios) ? m.supportedAspectRatios : []);
|
|
330
330
|
const aspectsByType = m.supported_aspect_ratios_by_type || m.supportedAspectRatiosByType || null;
|
|
331
|
-
|
|
331
|
+
// images_per_request: fixed outputs per call (Midjourney = 4, whatever num_images says).
|
|
332
|
+
const ipr = Number(m.images_per_request || m.imagesPerRequest) || 1;
|
|
333
|
+
const info = { icon, eta, id: m.identifier || null, name: m.name || null, types, aspects, aspectsByType, ipr };
|
|
332
334
|
all.push(info);
|
|
333
335
|
// Display names collide across variants ("Nano Banana 2" names both the
|
|
334
336
|
// t2i model and its editing sibling) — on collision keep the model with
|
package/src/index.js
CHANGED
|
@@ -83,6 +83,7 @@ const { registerMusicLibraryTools } = require('./tools/music_library');
|
|
|
83
83
|
const { registerStockLibraryTools } = require('./tools/stock_library');
|
|
84
84
|
const { registerAudioStemTools } = require('./tools/audio_stems');
|
|
85
85
|
const { registerAnalyzeTools } = require('./tools/analyze');
|
|
86
|
+
const { registerVideoInspectionTools } = require('./tools/video_inspection');
|
|
86
87
|
const { registerBlenderTools } = require('./tools/blender');
|
|
87
88
|
const { registerAdobeTools } = require('./tools/adobe');
|
|
88
89
|
const { registerResolveTools } = require('./tools/resolve');
|
|
@@ -200,6 +201,7 @@ function createServer(opts = {}) {
|
|
|
200
201
|
registerTrendTools(server, client);
|
|
201
202
|
registerMorphiousStyleTools(server, client);
|
|
202
203
|
registerAnalyzeTools(server, client, toolOptions);
|
|
204
|
+
registerVideoInspectionTools(server, client);
|
|
203
205
|
registerMediaTools(server, client, toolOptions);
|
|
204
206
|
registerPresetTools(server, client, toolOptions);
|
|
205
207
|
registerArtifactTools(server, client, toolOptions);
|
package/src/toolAnnotations.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const READ_ONLY = [
|
|
12
|
+
'get_video_inspection',
|
|
12
13
|
'get_flow_schema', 'list_flow_sessions', 'get_flow_session', 'validate_flow_session',
|
|
13
14
|
'estimate_flow_run', 'get_flow_run', 'list_flow_runs',
|
|
14
15
|
'list_morphious_styles',
|
|
@@ -40,7 +41,9 @@ const READ_ONLY = [
|
|
|
40
41
|
'create_music_composition_plan',
|
|
41
42
|
];
|
|
42
43
|
|
|
43
|
-
const OPEN_WORLD_READ_ONLY = [
|
|
44
|
+
const OPEN_WORLD_READ_ONLY = [
|
|
45
|
+
// Reads public source media into private ephemeral scratch evidence, no library writes.
|
|
46
|
+
'prepare_video_inspection', 'inspect_video',
|
|
44
47
|
'blender_list_sessions', 'blender_get_scene', 'blender_search_docs',
|
|
45
48
|
'blender_get_command_status',
|
|
46
49
|
'adobe_list_sessions', 'adobe_get_project', 'adobe_get_timeline', 'adobe_get_command_status',
|
|
@@ -100,7 +103,8 @@ const DESTRUCTIVE_WRITE = [
|
|
|
100
103
|
'chat_send_message', 'generate_character_sheet',
|
|
101
104
|
'acquire_clean_music_track', 'import_music_track_to_library',
|
|
102
105
|
'separate_audio_stems', 'clean_dialogue_leftovers', 'separate_ambience',
|
|
103
|
-
'analyze_video',
|
|
106
|
+
'analyze_video',
|
|
107
|
+
'analyze_video_evidence', 'transcribe_video_evidence', 'cancel_video_inspection',
|
|
104
108
|
|
|
105
109
|
// Deletes and whole-value replacement updates are conservatively destructive.
|
|
106
110
|
'delete_voice', 'update_visual_dna', 'delete_visual_dna', 'delete_visual_dna_folder',
|
package/src/tools/_shared.js
CHANGED
|
@@ -54,7 +54,8 @@ const LOCAL_FILE_ROUTING =
|
|
|
54
54
|
// where the model would read it, see no filesystem, and tell the user Kolbo
|
|
55
55
|
// cannot take their file at all. attachFileInputHints() below appends the route
|
|
56
56
|
// that actually works for the current transport, so the refusal never happens.
|
|
57
|
-
const FILE_INPUT_TOOLS = [
|
|
57
|
+
const FILE_INPUT_TOOLS = [
|
|
58
|
+
'prepare_video_inspection',
|
|
58
59
|
'generate_image', 'generate_image_edit', 'generate_creative_director',
|
|
59
60
|
'generate_video', 'generate_video_from_image', 'generate_video_from_video',
|
|
60
61
|
'generate_elements', 'generate_first_last_frame', 'generate_lipsync',
|
|
@@ -901,6 +902,17 @@ function moodboardIds(settings) {
|
|
|
901
902
|
* poll_tool widget-side status tool (default 'get_generation_status')
|
|
902
903
|
* status_args args for poll_tool (default { generation_id, wait: true })
|
|
903
904
|
*/
|
|
905
|
+
// How many outputs the card should expect. A model with a fixed
|
|
906
|
+
// images_per_request (Midjourney = 4) returns that many per generation
|
|
907
|
+
// regardless of num_images, so 2 prompts are 8 images, not "2 images".
|
|
908
|
+
async function outputCount(p) {
|
|
909
|
+
const info = p.model ? await modelInfo(p.client, p.model).catch(() => null) : null;
|
|
910
|
+
const ipr = info && info.ipr > 1 ? info.ipr : 0;
|
|
911
|
+
if (!ipr) return p.count || 1;
|
|
912
|
+
const gens = Array.isArray(p.generation_ids) && p.generation_ids.length ? p.generation_ids.length : 1;
|
|
913
|
+
return ipr * gens;
|
|
914
|
+
}
|
|
915
|
+
|
|
904
916
|
async function uiGenerating(p) {
|
|
905
917
|
// No ETAs anywhere — just a spinner until the poll flips to completed.
|
|
906
918
|
const chip = await modelChipFields(p.client, p.model, p.settings && p.settings.mode);
|
|
@@ -919,7 +931,7 @@ async function uiGenerating(p) {
|
|
|
919
931
|
...chip,
|
|
920
932
|
...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
|
|
921
933
|
prompt: p.prompt,
|
|
922
|
-
count: p
|
|
934
|
+
count: await outputCount(p),
|
|
923
935
|
settings,
|
|
924
936
|
visual_dnas: await resolveVisualDnas(p.client, settings.visual_dna_ids),
|
|
925
937
|
moodboards: await resolveMoodboards(p.client, moodboardIds(settings)),
|
|
@@ -1023,7 +1035,7 @@ async function uiCompleted(p, textPayload, extraContent) {
|
|
|
1023
1035
|
tool: p.tool,
|
|
1024
1036
|
...chip,
|
|
1025
1037
|
prompt: p.prompt,
|
|
1026
|
-
count: p
|
|
1038
|
+
count: await outputCount(p),
|
|
1027
1039
|
// Omitted entirely when the caller has none. A live generation card merges
|
|
1028
1040
|
// an incoming status payload over its own state, so an empty-but-present
|
|
1029
1041
|
// `settings` wiped the resolution / aspect / DNA chips off the finished
|
package/src/tools/generate.js
CHANGED
|
@@ -316,7 +316,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
316
316
|
model: z.string().optional().describe('Model identifier — REQUIRED in practice: pick a specific model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Strong current defaults: "nano-banana-2" (versatile, text rendering, multilingual) or "gpt-image-2" (photoreal, infographics). Call list_models type="text_to_img" to see all options and pick per the user\'s intent.'),
|
|
317
317
|
aspect_ratio: z.string().optional().describe(aspectRatioDescribe('1:1')),
|
|
318
318
|
enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
|
|
319
|
-
num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1.
|
|
319
|
+
num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1. Models with a fixed `images_per_request` in list_models IGNORE this: Midjourney ALWAYS returns 4 images per prompt (one call = 4 variations, billed as one request). So for "4 options" send ONE Midjourney prompt, never 4 calls or 4 entries in `prompts`; 2 prompts = 8 images.'),
|
|
320
320
|
reference_images: z.array(z.string()).optional().describe('STYLE/COMPOSITION inspiration only — does NOT embed reference pixels. Array of image URLs used to guide the look-and-feel of a brand-new generation. The model interprets the references and regenerates approximations conditioned on them. It will NOT copy pixels from these images into the output. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** To embed a specific logo, icon, watermark, or asset pixel-accurately, use generate_image_edit with the asset in source_images. To EDIT an existing image, also use generate_image_edit.'),
|
|
321
321
|
visual_dna_ids: z.array(z.string()).optional().describe('Visual DNA profile IDs (from create_visual_dna / list_visual_dnas) for character / style / product / scene consistency. **Cap: pass at most `max_visual_dna` IDs from list_models — if the field is null/0 or `supports_visual_dna: false`, the model rejects DNA entirely (silently ignored in some paths).** How DNA works: the server fetches the DNA\'s reference images AND always injects its `description` field into the prompt as plaintext (by design — independent of enhance_prompt). Practical implication: do NOT also write physical descriptors of the same subject in your own prompt — they will compete with the DNA description text. For pixel-accurate face anchoring of a specific person, prefer passing the DNA\'s reference image directly via source_images on generate_image_edit and OMIT visual_dna_ids. visual_dna_ids is best for style / scene / product DNAs and for soft consistency across a set.'),
|
|
322
322
|
moodboard_id: z.string().optional().describe('Moodboard ID (from list_moodboards / get_moodboard) whose master_prompt and style_guide should be applied to this generation.'),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { z } = require('zod');
|
|
3
|
+
const project = z.string().optional().describe('Authorized Kolbo project for billing; Act supplies its conversation project.');
|
|
4
|
+
const id = z.string().uuid().describe('Owner-scoped inspection ID returned by prepare_video_inspection.');
|
|
5
|
+
const text = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
|
|
6
|
+
|
|
7
|
+
// Additive tools. Source download and CPU-only measurements are free; provider calls
|
|
8
|
+
// stay explicit and billed. Never put image base64 into the language model's text.
|
|
9
|
+
function registerVideoInspectionTools(server, client) {
|
|
10
|
+
server.tool('prepare_video_inspection',
|
|
11
|
+
'Prepare a private source snapshot for adaptive video/audio investigation, including long recordings up to 2 GB. Streams the source once; returns promptly with preparing status. Poll get_video_inspection until ready. Accepts public direct media file URLs, not YouTube pages. Snapshot expires after two hours. No provider analysis or credits. Reuse the returned ID for frames, cuts and audio; refresh only when the URL content changed.',
|
|
12
|
+
{ video_url: z.string().url(), refresh: z.boolean().optional(), project_id: project },
|
|
13
|
+
async ({ video_url, refresh }) => text(await client.post('/v1/analyze/video-inspections', { video_url, refresh })));
|
|
14
|
+
server.tool('get_video_inspection', 'Check source preparation, duration, streams, dimensions, source version and limits. Do not poll unchanged status in a tight loop.',
|
|
15
|
+
{ inspection_id: id, project_id: project }, async ({ inspection_id }) => text(await client.get(`/v1/analyze/video-inspections/${inspection_id}`)));
|
|
16
|
+
server.tool('cancel_video_inspection', 'Stop preparation/extraction and expire your private snapshot. A paid provider request already accepted may still complete and be charged. Source media in the library is unchanged.',
|
|
17
|
+
{ inspection_id: id, project_id: project }, async ({ inspection_id }) => text(await client.post(`/v1/analyze/video-inspections/${inspection_id}/cancel`, {})));
|
|
18
|
+
server.tool('inspect_video',
|
|
19
|
+
'Extract bounded evidence from a prepared recording. frames returns timestamped contact-sheet IMAGE blocks (up to12 frames,6 per sheet); frame returns one larger IMAGE. Choose explicit timestamps or evenly sampled range/frame_count. cuts measures candidate changes on a640px/8fps proxy and must be verified against nearby frames. audio/clip extracts a private interval for transcribe_video_evidence or analyze_video_evidence. audio_levels measures volume/silence only. Each cut/audio/clip/levels interval is at most120 seconds. Sparse frames are observations, never proof of continuous review. Repeated identical requests reuse evidence. No provider credits.',
|
|
20
|
+
{ inspection_id: id, kind: z.enum(['frames', 'frame', 'cuts', 'audio', 'clip', 'audio_levels']).optional(),
|
|
21
|
+
timestamps: z.array(z.number().nonnegative()).min(1).max(12).optional(),
|
|
22
|
+
start_seconds: z.number().nonnegative().optional(), end_seconds: z.number().positive().optional(),
|
|
23
|
+
frame_count: z.number().int().min(1).max(12).optional(), project_id: project },
|
|
24
|
+
async ({ inspection_id, project_id, ...args }) => {
|
|
25
|
+
const result = await client.post(`/v1/analyze/video-inspections/${inspection_id}/evidence`, args, { timeoutMs: 115000 });
|
|
26
|
+
const { images = [], ...metadata } = result;
|
|
27
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...metadata, images: images.map(({ data, ...entry }) => entry) }) },
|
|
28
|
+
...images.map(img => ({ type: 'image', mimeType: img.mime_type, data: img.data }))] };
|
|
29
|
+
});
|
|
30
|
+
server.tool('analyze_video_evidence',
|
|
31
|
+
'Run paid video, music, speech or sound understanding on a previously extracted interval only. Video focus requires clip evidence; audio focus accepts audio or clip. Useful for motion/continuity, instruments/rhythm/mood, speech context and sound design. For word timestamps/SRT use transcribe_video_evidence. Returned analysis timestamps are clip-relative: add source_offset_seconds. Real model usage is billed, and identical successful requests reuse saved analysis without new charges. An uncertain previous paid request is never automatically resubmitted.',
|
|
32
|
+
{ inspection_id: id, evidence_id: z.string().regex(/^[a-f0-9]{24}$/),
|
|
33
|
+
focus: z.enum(['video', 'speech', 'music', 'sound']).optional(), prompt: z.string().max(2000).optional(),
|
|
34
|
+
quality: z.enum(['standard', 'hq']).optional(), project_id: project },
|
|
35
|
+
async ({ inspection_id, ...args }) => text(await client.post(`/v1/analyze/video-inspections/${inspection_id}/analyze`, args, { timeoutMs: 590000 })));
|
|
36
|
+
server.tool('transcribe_video_evidence',
|
|
37
|
+
'Transcribe an extracted audio interval using speech recognition with speaker labels, word timings and SRT. First inspect_video kind=audio for an interval up to120 seconds. Auto-detects language. Words include source_start/source_end in original recording seconds; SRT is clip-relative. Billed per rounded minute at current subtitle transcription pricing. Identical evidence/project requests reuse the transcript. For long recordings, transcribe selected windows or cover explicit consecutive windows and preserve their source offsets; never claim untranscribed audio was reviewed.',
|
|
38
|
+
{ inspection_id: id, evidence_id: z.string().regex(/^[a-f0-9]{24}$/), project_id: project },
|
|
39
|
+
async ({ inspection_id, ...args }) => text(await client.post(`/v1/analyze/video-inspections/${inspection_id}/analyze`, { ...args, focus: 'transcription' }, { timeoutMs: 150000 })));
|
|
40
|
+
}
|
|
41
|
+
module.exports = { registerVideoInspectionTools };
|