cutnow 0.1.0__py3-none-any.whl
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.
- core/__init__.py +0 -0
- core/action.py +89 -0
- core/analyze.py +109 -0
- core/builtin_ops.py +508 -0
- core/clip.py +98 -0
- core/config.py +106 -0
- core/edl.py +42 -0
- core/ffmpeg.py +34 -0
- core/frames.py +19 -0
- core/ops.py +32 -0
- core/plan.py +177 -0
- core/qc.py +92 -0
- core/render.py +113 -0
- core/runner.py +62 -0
- core/solution.py +88 -0
- cutnow-0.1.0.dist-info/METADATA +224 -0
- cutnow-0.1.0.dist-info/RECORD +32 -0
- cutnow-0.1.0.dist-info/WHEEL +5 -0
- cutnow-0.1.0.dist-info/entry_points.txt +2 -0
- cutnow-0.1.0.dist-info/licenses/LICENSE +21 -0
- cutnow-0.1.0.dist-info/top_level.txt +4 -0
- cutnow.py +374 -0
- solutions/__init__.py +0 -0
- solutions/film-montage.solution.json +130 -0
- solutions/mj-remake.solution.json +121 -0
- solutions/sports-highlight.solution.json +101 -0
- solutions/talk-highlight.solution.json +81 -0
- workbench/__init__.py +0 -0
- workbench/server.py +260 -0
- workbench/static/app.js +158 -0
- workbench/static/index.html +38 -0
- workbench/static/style.css +56 -0
core/__init__.py
ADDED
|
File without changes
|
core/action.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""运动素材的选段信号:音频能量 + 画面运动 + 主体占比 + 场景对齐。"""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from . import analyze as A
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def audio_env(path, hop=0.1, sr=22050):
|
|
7
|
+
"""整段音频的短时能量包络(0-1 归一化)。"""
|
|
8
|
+
x = A.decode_audio(path, sr)
|
|
9
|
+
if x is None or len(x) == 0:
|
|
10
|
+
return None, sr
|
|
11
|
+
n = int(hop * sr)
|
|
12
|
+
frames = len(x) // n
|
|
13
|
+
if frames < 2:
|
|
14
|
+
return None, sr
|
|
15
|
+
e = np.array([float(np.sqrt(np.mean(x[i * n:(i + 1) * n] ** 2))) for i in range(frames)])
|
|
16
|
+
hi = np.percentile(e, 95) or 1.0
|
|
17
|
+
return np.clip(e / hi, 0, 1), hop
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def env_stats(env, hop, s, e):
|
|
21
|
+
if env is None:
|
|
22
|
+
return 0.0, 0.0
|
|
23
|
+
a, b = int(s / hop), int(e / hop)
|
|
24
|
+
seg = env[max(0, a):max(1, b)]
|
|
25
|
+
if len(seg) == 0:
|
|
26
|
+
return 0.0, 0.0
|
|
27
|
+
return float(np.mean(seg)), float(np.percentile(seg, 90))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def scene_candidates(scenes, rows, env, hop, beat, beats=(4, 8, 12), pad=0.15):
|
|
31
|
+
"""在场景内部平铺开窗:切点对齐镜头边界与前一个窗尾,长镜头也能被多次使用。"""
|
|
32
|
+
out = []
|
|
33
|
+
for s, e in scenes:
|
|
34
|
+
span = e - s
|
|
35
|
+
if span < 1.2:
|
|
36
|
+
continue
|
|
37
|
+
for b in beats:
|
|
38
|
+
L = b * beat
|
|
39
|
+
if L > span + 0.45:
|
|
40
|
+
continue
|
|
41
|
+
k = 0
|
|
42
|
+
while True:
|
|
43
|
+
st = s + pad + k * L
|
|
44
|
+
if st + L > e + 0.45:
|
|
45
|
+
break
|
|
46
|
+
k += 1
|
|
47
|
+
rows_in = [r for r in rows if st <= r["t"] < st + L]
|
|
48
|
+
if len(rows_in) < 2:
|
|
49
|
+
continue
|
|
50
|
+
n = len(rows_in)
|
|
51
|
+
avg = lambda key: sum(r.get(key, 0) for r in rows_in) / n
|
|
52
|
+
a_mean, a_p90 = env_stats(env, hop, st, st + L)
|
|
53
|
+
out.append({"start": round(st, 2), "beats": b, "dur": round(L, 3),
|
|
54
|
+
"scene": (s, e),
|
|
55
|
+
"motion": avg("motion"),
|
|
56
|
+
"motion_min": min(r.get("motion", 0) for r in rows_in),
|
|
57
|
+
"human": avg("human_area"), "luma": avg("luma"), "edge": avg("edge"),
|
|
58
|
+
"audio": a_mean, "audio_peak": a_p90})
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def score_action(c, clip_p75_motion=20.0, clip_p60_audio=0.3):
|
|
63
|
+
motion = min(c["motion"] / max(clip_p75_motion * 1.6, 1e-6), 1.0)
|
|
64
|
+
sustain = min(c["motion_min"] / max(clip_p75_motion * 0.6, 1e-6), 1.0)
|
|
65
|
+
audio = min(c["audio_peak"] / max(clip_p60_audio * 1.8, 1e-6), 1.0)
|
|
66
|
+
human = min(c["human"] / 0.10, 1.0)
|
|
67
|
+
dark = 1.0 if c["luma"] >= 35 else max(0.4, c["luma"] / 35.0)
|
|
68
|
+
base = 0.34 * motion + 0.16 * sustain + 0.30 * audio + 0.12 * human + 0.08 * min(c["edge"] / 14.0, 1.0)
|
|
69
|
+
return round(base * dark, 4)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pick_action(cands, target, beat, gap_ratio=1.15):
|
|
73
|
+
"""贪心:分数最高优先,窗口之间至少隔 1.4 个窗长,避免同一段反复用。"""
|
|
74
|
+
chosen, used = [], 0.0
|
|
75
|
+
target_beats = max(2, int(round(target / beat)))
|
|
76
|
+
for c in sorted(cands, key=lambda x: -x.get("score", x.get("action", 0.0))):
|
|
77
|
+
if used >= target_beats:
|
|
78
|
+
break
|
|
79
|
+
if any(abs(c["start"] - o["start"]) < max(c["dur"], o["dur"]) * gap_ratio for o in chosen):
|
|
80
|
+
continue
|
|
81
|
+
same = [o for o in chosen if o["scene"] == c["scene"]]
|
|
82
|
+
span = c["scene"][1] - c["scene"][0]
|
|
83
|
+
cap = max(2, int(span / max(c["dur"], 0.5)))
|
|
84
|
+
if len(same) >= cap:
|
|
85
|
+
continue
|
|
86
|
+
chosen.append(c)
|
|
87
|
+
used += c["beats"]
|
|
88
|
+
chosen.sort(key=lambda c: c["start"])
|
|
89
|
+
return chosen, used
|
core/analyze.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""素材分析:镜头切分、音频解码、节拍估计、逐帧特征聚合。"""
|
|
2
|
+
import json, re, subprocess
|
|
3
|
+
import numpy as np
|
|
4
|
+
from . import ffmpeg as F
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# ---------- 音频 ----------
|
|
8
|
+
|
|
9
|
+
def decode_audio(path: str, sr: int = 22050) -> np.ndarray:
|
|
10
|
+
p = subprocess.run([F.ffmpeg(), "-hide_banner", "-nostdin", "-i", path,
|
|
11
|
+
"-f", "f32le", "-ac", "1", "-ar", str(sr), "-"],
|
|
12
|
+
capture_output=True)
|
|
13
|
+
if p.returncode != 0:
|
|
14
|
+
raise RuntimeError(p.stderr.decode("utf-8", "ignore")[-800:])
|
|
15
|
+
return np.frombuffer(p.stdout, dtype=np.float32)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _stft_mag(x, n_fft=1024, hop=512):
|
|
19
|
+
if len(x) < n_fft:
|
|
20
|
+
x = np.pad(x, (0, n_fft - len(x)))
|
|
21
|
+
n = 1 + (len(x) - n_fft) // hop
|
|
22
|
+
idx = np.arange(n_fft)[None, :] + hop * np.arange(n)[:, None]
|
|
23
|
+
frames = x[idx] * np.hanning(n_fft)[None, :]
|
|
24
|
+
return np.abs(np.fft.rfft(frames, axis=1))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def onset_envelope(x, n_fft=1024, hop=512):
|
|
28
|
+
S = _stft_mag(x, n_fft, hop)
|
|
29
|
+
d = np.diff(S, axis=0, prepend=S[:1])
|
|
30
|
+
flux = np.maximum(d, 0).sum(axis=1)
|
|
31
|
+
flux = flux - flux.mean()
|
|
32
|
+
flux = np.maximum(flux, 0)
|
|
33
|
+
return flux / (flux.max() + 1e-9), hop
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def estimate_beat(x, sr=22050, bpm_range=(70, 190)):
|
|
37
|
+
"""自相关估 tempo + 梳状滤波定相位,返回 (bpm, beat_times_sec)。"""
|
|
38
|
+
env, hop = onset_envelope(x)
|
|
39
|
+
fps = sr / hop
|
|
40
|
+
e = env - env.mean()
|
|
41
|
+
ac = np.correlate(e, e, "full")[len(e) - 1:]
|
|
42
|
+
lo = int(fps * 60 / bpm_range[1]); hi = int(fps * 60 / bpm_range[0])
|
|
43
|
+
lag = lo + int(np.argmax(ac[lo:hi]))
|
|
44
|
+
bpm = 60 * fps / lag
|
|
45
|
+
|
|
46
|
+
# 相位:在 [0, lag) 内找使节拍点能量和最大的偏移
|
|
47
|
+
best, best_off = -1, 0
|
|
48
|
+
for off in range(lag):
|
|
49
|
+
idx = np.arange(off, len(env), lag).astype(int)
|
|
50
|
+
s = env[idx].sum()
|
|
51
|
+
if s > best:
|
|
52
|
+
best, best_off = s, off
|
|
53
|
+
beats = np.arange(best_off, len(env), lag) / fps
|
|
54
|
+
return round(bpm, 2), beats
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def loudest_window(x, sr, dur):
|
|
58
|
+
"""能量最大的 dur 秒窗口起点(用于自动取副歌)。"""
|
|
59
|
+
win = int(sr * dur)
|
|
60
|
+
if len(x) <= win:
|
|
61
|
+
return 0.0
|
|
62
|
+
step = int(sr * 0.5)
|
|
63
|
+
frames = 1 + (len(x) - win) // step
|
|
64
|
+
idx = np.arange(win)[None, :] + step * np.arange(frames)[:, None]
|
|
65
|
+
energy = (x[idx] ** 2).mean(axis=1)
|
|
66
|
+
# 平滑,避免选到单次爆炸音
|
|
67
|
+
k = max(1, int(2 / 0.5))
|
|
68
|
+
sm = np.convolve(energy, np.ones(k) / k, mode="same")
|
|
69
|
+
return float(np.argmax(sm) * step / sr)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------- 视频 ----------
|
|
73
|
+
|
|
74
|
+
def scenes(path: str, threshold: float = 0.3):
|
|
75
|
+
"""ffmpeg scene 检测,返回 [(start, end)]。"""
|
|
76
|
+
p = F.run(["-i", path, "-filter_complex", f"select='gt(scene,{threshold})',showinfo",
|
|
77
|
+
"-f", "null", "-"])
|
|
78
|
+
cuts = [float(m) for m in re.findall(r"pts_time:([0-9.]+)", p.stderr)]
|
|
79
|
+
dur = F.probe(path)["duration"]
|
|
80
|
+
pts = [0.0] + [c for c in cuts if 0.3 < c < dur - 0.3]
|
|
81
|
+
out = []
|
|
82
|
+
for i, s in enumerate(pts):
|
|
83
|
+
e = pts[i + 1] if i + 1 < len(pts) else dur
|
|
84
|
+
if e - s >= 0.35:
|
|
85
|
+
out.append((round(s, 3), round(e, 3)))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_features(path: str):
|
|
90
|
+
rows = []
|
|
91
|
+
with open(path) as fh:
|
|
92
|
+
for line in fh:
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if line:
|
|
95
|
+
rows.append(json.loads(line))
|
|
96
|
+
return rows
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def aggregate(rows, start, end):
|
|
100
|
+
sel = [r for r in rows if start <= r["t"] < end]
|
|
101
|
+
if not sel:
|
|
102
|
+
return {"n": 0, "luma": 0, "motion": 0, "face_area": 0, "faces": 0}
|
|
103
|
+
return {
|
|
104
|
+
"n": len(sel),
|
|
105
|
+
"luma": float(np.mean([r["luma"] for r in sel])),
|
|
106
|
+
"motion": float(np.mean([r["motion"] for r in sel])),
|
|
107
|
+
"face_area": float(np.max([r["face_area"] for r in sel])),
|
|
108
|
+
"faces": float(np.mean([r["faces"] for r in sel])),
|
|
109
|
+
}
|