@kolbo/mcp 1.112.1 → 1.112.4
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/presets.md +6 -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/references/workflows/visual-dna.md +12 -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/theme.js +10 -1
- package/src/apps/widgets/generation.js +24 -10
- package/src/tools/generate.js +25 -14
|
@@ -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/theme.js
CHANGED
|
@@ -152,7 +152,16 @@ body {
|
|
|
152
152
|
align-items: center; justify-content: center; font-family: inherit;
|
|
153
153
|
}
|
|
154
154
|
.k-chip img.k-voice-thumb { width: 18px; height: 18px; border-radius: 999px; margin-left: -3px; }
|
|
155
|
-
.k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
|
|
155
|
+
.k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
|
|
156
|
+
.k-input-section { flex-basis: 100%; min-width: 0; margin-top: 6px; }
|
|
157
|
+
.k-input-label { display: block; color: var(--muted); font-size: 11px; margin-bottom: 6px; }
|
|
158
|
+
.k-input-media { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
159
|
+
.k-input-media .k-ref-thumb { width: 80px; height: 80px; object-fit: contain; background: var(--panel); }
|
|
160
|
+
.k-ref-audio { display: flex; flex-direction: column; gap: 6px; max-width: 100%; font-size: 11px; }
|
|
161
|
+
.k-ref-audio audio { width: 240px; max-width: 100%; height: 32px; }
|
|
162
|
+
.k-template { display: flex; align-items: center; gap: 12px; font-size: 12px; }
|
|
163
|
+
.k-template-thumb, .k-template-placeholder { width: 104px; height: 80px; border-radius: 8px; object-fit: contain; background: var(--panel); border: 1px solid var(--border-strong); }
|
|
164
|
+
.k-template-placeholder { display: flex; align-items: center; justify-content: center; color: var(--muted); }
|
|
156
165
|
.k-peek-hit { cursor: zoom-in; }
|
|
157
166
|
.k-peek {
|
|
158
167
|
position: absolute; inset: 0; z-index: 20;
|
|
@@ -385,7 +385,7 @@ function renderChips(sc) {
|
|
|
385
385
|
if (s.mode) h += chip(esc(editLabel(s.mode)));
|
|
386
386
|
if (Array.isArray(s.details)) s.details.forEach(function (d) { if (d) h += chip(esc(String(d))); });
|
|
387
387
|
if (many && !kind) h += chip(sc.count + ' items');
|
|
388
|
-
h += referenceHTML(sc);
|
|
388
|
+
h += templateHTML(sc) + referenceHTML(sc);
|
|
389
389
|
el('chips').innerHTML = h;
|
|
390
390
|
}
|
|
391
391
|
|
|
@@ -429,7 +429,7 @@ function referenceHTML(sc) {
|
|
|
429
429
|
h += '<video class="k-ref-thumb k-peek-hit" src="' + url + '#t=0.1" muted playsinline preload="metadata" title="'
|
|
430
430
|
+ title + '"' + peek + ' onerror="this.style.display=\\'none\\'"></video>';
|
|
431
431
|
} else if (refs[i].kind === 'audio') {
|
|
432
|
-
h += '<span class="k-
|
|
432
|
+
h += '<span class="k-ref-audio" title="' + title + '">' + ICONS.sound + ' audio ref<audio controls preload="none" src="' + url + '"></audio></span>';
|
|
433
433
|
} else {
|
|
434
434
|
h += '<img class="k-ref-thumb k-peek-hit" src="' + url + '" alt="" loading="lazy" title="' + title + '"'
|
|
435
435
|
+ peek + ' onerror="this.style.display=\\'none\\'">';
|
|
@@ -438,12 +438,23 @@ function referenceHTML(sc) {
|
|
|
438
438
|
// Local files the caller passed by path: the iframe cannot load them and they
|
|
439
439
|
// are only uploaded during the run, so name them instead of showing nothing.
|
|
440
440
|
// The finished card carries the uploaded thumbnails and drops these.
|
|
441
|
-
if (sc.phase !== 'completed' && Array.isArray(sc.local_references)) {
|
|
442
|
-
sc.local_references.
|
|
441
|
+
if (sc.phase !== 'completed' && Array.isArray(sc.local_references) && !refs.length) {
|
|
442
|
+
sc.local_references.forEach(function (name) {
|
|
443
443
|
h += '<span class="k-chip" title="' + esc(name) + '">' + ICONS.image + ' ' + esc(String(name).slice(0, 24)) + '</span>';
|
|
444
444
|
});
|
|
445
445
|
}
|
|
446
|
-
return h;
|
|
446
|
+
return h ? '<div class="k-input-section"><span class="k-input-label">References</span><div class="k-input-media">' + h + '</div></div>' : '';
|
|
447
|
+
}
|
|
448
|
+
function templateHTML(sc) {
|
|
449
|
+
var s = sc.settings || {};
|
|
450
|
+
if (!s.preset_id && !s.preset_name && !s.preset_thumbnail) return '';
|
|
451
|
+
var name = s.preset_name || 'Selected template';
|
|
452
|
+
var url = s.preset_thumbnail;
|
|
453
|
+
var media = url ? '<img class="k-template-thumb k-peek-hit" src="' + esc(url) + '" alt="' + esc(name)
|
|
454
|
+
+ '" loading="lazy" data-peek="' + esc(url) + '" data-peek-kind="image" data-peek-cap="' + esc(name) + '">'
|
|
455
|
+
: '<span class="k-template-placeholder">' + ICONS.image + '</span>';
|
|
456
|
+
return '<div class="k-input-section"><span class="k-input-label">Template</span><div class="k-template">'
|
|
457
|
+
+ media + '<span>' + esc(name) + '</span></div></div>';
|
|
447
458
|
}
|
|
448
459
|
// Which characters/looks are locked into this generation — by face and name,
|
|
449
460
|
// resolved from visual_dna_ids server-side. "1 Visual DNA" told the user nothing
|
|
@@ -830,6 +841,7 @@ function poll(sc) {
|
|
|
830
841
|
|
|
831
842
|
var done = Object.assign({}, sc, r, {
|
|
832
843
|
phase: 'completed',
|
|
844
|
+
settings: Object.assign({}, sc.settings || {}, r.settings || {}),
|
|
833
845
|
urls: r.urls || st.urls || [],
|
|
834
846
|
credits_used: st.credits_used != null ? st.credits_used : sc.credits_used,
|
|
835
847
|
// Status structuredContent used to set open_url:undefined and wipe the
|
|
@@ -1462,7 +1474,7 @@ function kindFromTool(tool, sc) {
|
|
|
1462
1474
|
function liveFromTimedOut(sc) {
|
|
1463
1475
|
if (!sc || !sc.generation_id || isTerminal(sc.state) || Array.isArray(sc.urls)) return null;
|
|
1464
1476
|
var ids = Array.isArray(sc.generation_ids) && sc.generation_ids.length > 1 ? sc.generation_ids : null;
|
|
1465
|
-
var live = {
|
|
1477
|
+
var live = Object.assign({}, preRefSc(originTool, originArgs), {
|
|
1466
1478
|
widget: 'generation',
|
|
1467
1479
|
phase: 'generating',
|
|
1468
1480
|
tool: originTool,
|
|
@@ -1477,7 +1489,8 @@ function liveFromTimedOut(sc) {
|
|
|
1477
1489
|
quality: originArgs.quality,
|
|
1478
1490
|
mode: originArgs.operation,
|
|
1479
1491
|
visual_dna_ids: originArgs.visual_dna_ids,
|
|
1480
|
-
moodboard_id: originArgs.moodboard_id
|
|
1492
|
+
moodboard_id: originArgs.moodboard_id,
|
|
1493
|
+
preset_id: originArgs.preset_id
|
|
1481
1494
|
}, originArgs),
|
|
1482
1495
|
count: ids ? ids.length : (originArgs.num_images || 1),
|
|
1483
1496
|
generation_id: sc.generation_id,
|
|
@@ -1485,14 +1498,14 @@ function liveFromTimedOut(sc) {
|
|
|
1485
1498
|
status_args: ids ? { generation_ids: ids, wait: true } : { generation_id: sc.generation_id, wait: true },
|
|
1486
1499
|
session_id: sc.session_id || originArgs.session_id,
|
|
1487
1500
|
project_id: sc.project_id || originArgs.project_id
|
|
1488
|
-
};
|
|
1501
|
+
});
|
|
1489
1502
|
if (ids) { live.generation_ids = ids; live.prompts = originArgs.prompts || []; }
|
|
1490
1503
|
return live;
|
|
1491
1504
|
}
|
|
1492
1505
|
|
|
1493
1506
|
function completedFromPlain(sc) {
|
|
1494
1507
|
if (!sc || (!Array.isArray(sc.urls) && !Array.isArray(sc.scenes))) return null;
|
|
1495
|
-
return Object.assign({}, sc, {
|
|
1508
|
+
return Object.assign({}, preRefSc(originTool, originArgs), sc, {
|
|
1496
1509
|
widget: 'generation',
|
|
1497
1510
|
phase: 'completed',
|
|
1498
1511
|
tool: originTool,
|
|
@@ -1505,7 +1518,8 @@ function completedFromPlain(sc) {
|
|
|
1505
1518
|
resolution: originArgs.draft === true ? '480p-draft' : originArgs.draft === false ? String(originArgs.resolution || '').replace(/-draft$/i, '') : originArgs.resolution,
|
|
1506
1519
|
aspect_ratio: originArgs.aspect_ratio,
|
|
1507
1520
|
quality: originArgs.quality,
|
|
1508
|
-
mode: originArgs.operation
|
|
1521
|
+
mode: originArgs.operation,
|
|
1522
|
+
preset_id: originArgs.preset_id
|
|
1509
1523
|
}, originArgs),
|
|
1510
1524
|
urls: sc.urls || [],
|
|
1511
1525
|
session_id: sc.session_id || originArgs.session_id,
|
package/src/tools/generate.js
CHANGED
|
@@ -110,7 +110,7 @@ async function submitBatch(rawItems, submitOne) {
|
|
|
110
110
|
// multi-id branch, same fix: always ship structuredContent via the shared
|
|
111
111
|
// kind:'status' grid, which already renders any mix of completed/processing
|
|
112
112
|
// items correctly.
|
|
113
|
-
async function pollBatch(client, batch, { interval, timeout }, toolName, submittedModel) {
|
|
113
|
+
async function pollBatch(client, batch, { interval, timeout }, toolName, submittedModel, inputs = {}) {
|
|
114
114
|
const polls = await Promise.all(batch.ids.map((id) => pollOrTimedOut(client, id, { interval, timeout })));
|
|
115
115
|
const generations = polls.map((p, i) => p.timedOut
|
|
116
116
|
? { prompt: batch.ok[i].prompt, generation_id: batch.ids[i], status: 'processing', note: 'Still running — call get_generation_status with wait=true to collect it.' }
|
|
@@ -126,8 +126,9 @@ async function pollBatch(client, batch, { interval, timeout }, toolName, submitt
|
|
|
126
126
|
return uiCompleted({
|
|
127
127
|
tool: toolName, kind: 'status', client,
|
|
128
128
|
model: modelsRan.length === 1 ? modelsRan[0] : (submittedModel || 'Generations'),
|
|
129
|
-
gen: { generation_id: batch.ids[0], session_id: batch.ok[0].gen.session_id },
|
|
130
|
-
settings: {},
|
|
129
|
+
gen: { generation_id: batch.ids[0], session_id: batch.ok[0].gen.session_id },
|
|
130
|
+
settings: {},
|
|
131
|
+
...inputs,
|
|
131
132
|
items: generations.map(g => ({
|
|
132
133
|
id: g.generation_id,
|
|
133
134
|
state: g.status,
|
|
@@ -338,10 +339,11 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
338
339
|
if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
|
|
339
340
|
model = await canonicalModelId(client, model, 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
340
341
|
aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'text_to_img');
|
|
341
|
-
const shared = {
|
|
342
|
-
model, aspect_ratio, enhance_prompt, font_ids,
|
|
343
|
-
reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, background, output_format, output_compression, moderation, mask_image_url, preset_id, cinematic, skip_color_palette, project_id, session_id
|
|
344
|
-
};
|
|
342
|
+
const shared = await rehostLocalPaths(client, {
|
|
343
|
+
model, aspect_ratio, enhance_prompt, font_ids,
|
|
344
|
+
reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, background, output_format, output_compression, moderation, mask_image_url, preset_id, cinematic, skip_color_palette, project_id, session_id
|
|
345
|
+
}, { allowLocalFiles: !options.remote });
|
|
346
|
+
reference_images = [...(shared.reference_images || []), shared.mask_image_url].filter(Boolean);
|
|
345
347
|
|
|
346
348
|
// Batch mode: N different prompts, one widget owning all generation ids.
|
|
347
349
|
if (prompts && prompts.length) {
|
|
@@ -354,7 +356,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
354
356
|
status_args: { generation_ids: batch.ids, wait: true },
|
|
355
357
|
reference_images
|
|
356
358
|
});
|
|
357
|
-
return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 150000 }, 'generate_image', model);
|
|
359
|
+
return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 150000 }, 'generate_image', model, { settings: imageSettings(shared), reference_images });
|
|
358
360
|
}
|
|
359
361
|
|
|
360
362
|
const gen = await client.post('/v1/generate/image', { ...shared, prompt, num_images });
|
|
@@ -429,10 +431,12 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
429
431
|
if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
|
|
430
432
|
model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
431
433
|
aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'image_editing');
|
|
432
|
-
const shared = {
|
|
433
|
-
model, source_images, reference_images, aspect_ratio, enhance_prompt, font_ids,
|
|
434
|
-
visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, background, output_format, output_compression, moderation, mask_image_url, preset_id, cinematic, skip_color_palette, project_id, session_id
|
|
435
|
-
};
|
|
434
|
+
const shared = await rehostLocalPaths(client, {
|
|
435
|
+
model, source_images, reference_images, aspect_ratio, enhance_prompt, font_ids,
|
|
436
|
+
visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, background, output_format, output_compression, moderation, mask_image_url, preset_id, cinematic, skip_color_palette, project_id, session_id
|
|
437
|
+
}, { allowLocalFiles: !options.remote });
|
|
438
|
+
source_images = shared.source_images;
|
|
439
|
+
reference_images = [...(shared.reference_images || []), shared.mask_image_url].filter(Boolean);
|
|
436
440
|
const settings = imageSettings(shared);
|
|
437
441
|
|
|
438
442
|
// Batch mode: N different edit instructions against the SAME source
|
|
@@ -449,7 +453,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
449
453
|
status_args: { generation_ids: batch.ids, wait: true },
|
|
450
454
|
reference_images: [...(source_images || []), ...(reference_images || [])]
|
|
451
455
|
});
|
|
452
|
-
return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 150000 }, 'generate_image_edit', model);
|
|
456
|
+
return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 150000 }, 'generate_image_edit', model, { settings, reference_images: [...(source_images || []), ...reference_images] });
|
|
453
457
|
}
|
|
454
458
|
|
|
455
459
|
const gen = await client.post('/v1/generate/image-edit', { ...shared, prompt, num_images });
|
|
@@ -1441,7 +1445,14 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1441
1445
|
// the live card merges this payload over its submit-time state, so
|
|
1442
1446
|
// the finished card shows every reference, including server-side
|
|
1443
1447
|
// additions (DNA stills, merged sources) the tool call never named.
|
|
1444
|
-
reference_images: res.reference_images,
|
|
1448
|
+
reference_images: res.reference_images,
|
|
1449
|
+
reference_videos: res.reference_videos,
|
|
1450
|
+
reference_audio: res.reference_audio,
|
|
1451
|
+
...(res.preset ? { settings: {
|
|
1452
|
+
preset_id: res.preset.id,
|
|
1453
|
+
preset_name: res.preset.name,
|
|
1454
|
+
preset_thumbnail: res.preset.thumbnail_url,
|
|
1455
|
+
} } : {}),
|
|
1445
1456
|
// Transcription results ride the same status tool as media
|
|
1446
1457
|
// generations; without these the transcript widget merges a payload
|
|
1447
1458
|
// with no transcript in it.
|