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 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
+ }