cutmap 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.
- cutmap/__init__.py +2 -0
- cutmap/_assets.py +149 -0
- cutmap/broll.py +148 -0
- cutmap/cli.py +140 -0
- cutmap/frames.py +156 -0
- cutmap/render.py +109 -0
- cutmap/subclean.py +61 -0
- cutmap/terms.txt +54 -0
- cutmap/util.py +140 -0
- cutmap-0.1.0.dist-info/METADATA +343 -0
- cutmap-0.1.0.dist-info/RECORD +14 -0
- cutmap-0.1.0.dist-info/WHEEL +4 -0
- cutmap-0.1.0.dist-info/entry_points.txt +2 -0
- cutmap-0.1.0.dist-info/licenses/LICENSE +21 -0
cutmap/__init__.py
ADDED
cutmap/_assets.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""浏览页的 CSS / JS(内联,产物为单文件,无外部依赖)"""
|
|
2
|
+
|
|
3
|
+
CSS = """
|
|
4
|
+
:root { --bg:#fff; --fg:#1a1a1a; --sub:#666; --card:#f6f6f7; --line:#e3e3e6; --accent:#0b84ff; }
|
|
5
|
+
@media (prefers-color-scheme: dark) {
|
|
6
|
+
:root { --bg:#141416; --fg:#eaeaec; --sub:#9a9aa0; --card:#1d1d20; --line:#2c2c31; --accent:#4da3ff; }
|
|
7
|
+
}
|
|
8
|
+
:root[data-theme="dark"] {
|
|
9
|
+
--bg:#141416; --fg:#eaeaec; --sub:#9a9aa0; --card:#1d1d20; --line:#2c2c31; --accent:#4da3ff;
|
|
10
|
+
}
|
|
11
|
+
:root[data-theme="light"] {
|
|
12
|
+
--bg:#fff; --fg:#1a1a1a; --sub:#666; --card:#f6f6f7; --line:#e3e3e6; --accent:#0b84ff;
|
|
13
|
+
}
|
|
14
|
+
* { box-sizing:border-box; }
|
|
15
|
+
body { margin:0; background:var(--bg); color:var(--fg);
|
|
16
|
+
font:15px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif; }
|
|
17
|
+
header { position:sticky; top:0; z-index:20; background:var(--bg);
|
|
18
|
+
border-bottom:1px solid var(--line); padding:12px 20px 0; }
|
|
19
|
+
.top { display:flex; gap:14px; align-items:flex-start; }
|
|
20
|
+
#player { width:340px; flex:none; border-radius:8px; background:#000; }
|
|
21
|
+
.info { flex:1; min-width:0; }
|
|
22
|
+
h1 { margin:0 0 4px; font-size:16px; font-weight:650; }
|
|
23
|
+
.meta { color:var(--sub); font-size:13px; }
|
|
24
|
+
.tools { margin-top:10px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
|
25
|
+
input[type=search] { flex:1; min-width:180px; padding:7px 11px; font-size:14px;
|
|
26
|
+
border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg); }
|
|
27
|
+
button { padding:7px 11px; font-size:13px; border:1px solid var(--line); border-radius:8px;
|
|
28
|
+
background:var(--card); color:var(--fg); cursor:pointer; }
|
|
29
|
+
button.on { background:var(--accent); color:#fff; border-color:var(--accent); }
|
|
30
|
+
.count { color:var(--sub); font-size:13px; white-space:nowrap; }
|
|
31
|
+
/* Tab */
|
|
32
|
+
.tabs { display:flex; gap:2px; margin-top:12px; }
|
|
33
|
+
.tab { padding:9px 18px; font-size:14px; font-weight:600; cursor:pointer;
|
|
34
|
+
border:none; background:none; color:var(--sub); border-bottom:2px solid transparent;
|
|
35
|
+
border-radius:0; }
|
|
36
|
+
.tab:hover { color:var(--fg); }
|
|
37
|
+
.tab.active { color:var(--accent); border-bottom-color:var(--accent); }
|
|
38
|
+
.tab .n { font-weight:400; font-size:12px; opacity:.75; margin-left:5px; }
|
|
39
|
+
/* 网格 */
|
|
40
|
+
.pane { display:none; }
|
|
41
|
+
.pane.active { display:grid; }
|
|
42
|
+
.pane { gap:16px; padding:18px 20px 40px; }
|
|
43
|
+
#pane-frames { grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); }
|
|
44
|
+
#pane-broll { grid-template-columns:repeat(auto-fill,minmax(360px,1fr)); }
|
|
45
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; }
|
|
46
|
+
.card img, .card video { width:100%; display:block; background:#000; cursor:pointer; }
|
|
47
|
+
.body { padding:9px 11px 11px; }
|
|
48
|
+
.tc { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:5px; }
|
|
49
|
+
.seek { color:var(--accent); font-weight:600; font-size:13px; cursor:pointer;
|
|
50
|
+
font-variant-numeric:tabular-nums; background:none; border:none; padding:0; }
|
|
51
|
+
.seek:hover { text-decoration:underline; }
|
|
52
|
+
.tag { color:var(--sub); font-size:12px; }
|
|
53
|
+
.sub { font-size:14px; word-break:break-word; cursor:pointer; }
|
|
54
|
+
.sub.clipped .rest { display:none; }
|
|
55
|
+
.sub .more { color:var(--accent); font-size:13px; margin-left:2px; }
|
|
56
|
+
.sub:not(.clipped) .more { display:none; }
|
|
57
|
+
.sub .none { color:var(--sub); font-size:13px; }
|
|
58
|
+
mark { background:#ffd54a; color:#000; border-radius:2px; }
|
|
59
|
+
.hidden { display:none !important; }
|
|
60
|
+
.empty { padding:60px 20px; text-align:center; color:var(--sub); }
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
JS = """
|
|
64
|
+
const player=document.getElementById('player');
|
|
65
|
+
const q=document.getElementById('q');
|
|
66
|
+
const count=document.getElementById('count');
|
|
67
|
+
const autoBtn=document.getElementById('auto');
|
|
68
|
+
let autoPlay=true, active='frames';
|
|
69
|
+
|
|
70
|
+
/* ---- 播放器跳转 ---- */
|
|
71
|
+
function seek(t){
|
|
72
|
+
player.currentTime=t; player.play().catch(()=>{});
|
|
73
|
+
window.scrollTo({top:0,behavior:'smooth'});
|
|
74
|
+
}
|
|
75
|
+
document.addEventListener('click',e=>{
|
|
76
|
+
const el=e.target.closest('[data-t]');
|
|
77
|
+
if(el){ e.preventDefault(); seek(parseFloat(el.dataset.t)); }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/* ---- 字幕折叠 ---- */
|
|
81
|
+
document.addEventListener('click',e=>{
|
|
82
|
+
const s=e.target.closest('.sub');
|
|
83
|
+
if(s && !e.target.closest('[data-t]')) s.classList.toggle('clipped');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/* ---- Tab 切换 ---- */
|
|
87
|
+
document.querySelectorAll('.tab').forEach(t=>{
|
|
88
|
+
t.onclick=()=>{
|
|
89
|
+
active=t.dataset.pane;
|
|
90
|
+
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('active',x===t));
|
|
91
|
+
document.querySelectorAll('.pane').forEach(p=>
|
|
92
|
+
p.classList.toggle('active',p.id==='pane-'+active));
|
|
93
|
+
autoBtn.classList.toggle('hidden', active!=='broll');
|
|
94
|
+
// 切走时暂停所有片段,避免后台解码
|
|
95
|
+
if(active!=='broll') document.querySelectorAll('video.clip').forEach(v=>v.pause());
|
|
96
|
+
else syncPlay();
|
|
97
|
+
render();
|
|
98
|
+
window.scrollTo({top:0});
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/* ---- B-roll 只播视口内 ---- */
|
|
103
|
+
const io=new IntersectionObserver(es=>es.forEach(e=>{
|
|
104
|
+
const v=e.target.querySelector('video.clip'); if(!v) return;
|
|
105
|
+
if(e.isIntersecting && autoPlay && active==='broll') v.play().catch(()=>{}); else v.pause();
|
|
106
|
+
}),{rootMargin:'120px'});
|
|
107
|
+
document.querySelectorAll('#pane-broll .card').forEach(c=>io.observe(c));
|
|
108
|
+
function syncPlay(){
|
|
109
|
+
document.querySelectorAll('#pane-broll .card').forEach(c=>{
|
|
110
|
+
const v=c.querySelector('video.clip'); if(!v) return;
|
|
111
|
+
const r=c.getBoundingClientRect();
|
|
112
|
+
const vis=r.top<innerHeight+120 && r.bottom>-120;
|
|
113
|
+
(autoPlay && vis && active==='broll') ? v.play().catch(()=>{}) : v.pause();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if(autoBtn) autoBtn.onclick=()=>{
|
|
117
|
+
autoPlay=!autoPlay;
|
|
118
|
+
autoBtn.classList.toggle('on',autoPlay);
|
|
119
|
+
autoBtn.textContent='自动播放:'+(autoPlay?'开':'关');
|
|
120
|
+
syncPlay();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/* ---- 搜索 ---- */
|
|
124
|
+
function esc(s){return s.replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]));}
|
|
125
|
+
function paint(el,raw,kw){
|
|
126
|
+
const n=parseInt(el.dataset.clip||'48');
|
|
127
|
+
const hit=s=> kw? esc(s).split(esc(kw)).join('<mark>'+esc(kw)+'</mark>') : esc(s);
|
|
128
|
+
if(!raw){ el.innerHTML='<span class="none">(无字幕)</span>'; el.classList.remove('clipped'); return; }
|
|
129
|
+
if(raw.length<=n){ el.innerHTML=hit(raw); el.classList.remove('clipped'); return; }
|
|
130
|
+
el.innerHTML=hit(raw.slice(0,n))+'<span class="rest">'+hit(raw.slice(n))+
|
|
131
|
+
'</span><span class="more">…展开</span>';
|
|
132
|
+
el.classList.add('clipped');
|
|
133
|
+
}
|
|
134
|
+
function render(){
|
|
135
|
+
const kw=q.value.trim();
|
|
136
|
+
const cards=[...document.querySelectorAll('#pane-'+active+' .card')];
|
|
137
|
+
let n=0;
|
|
138
|
+
cards.forEach(c=>{
|
|
139
|
+
const el=c.querySelector('.sub'); if(!el) return;
|
|
140
|
+
const raw=el.dataset.raw||'';
|
|
141
|
+
if(!kw || raw.includes(kw)){ paint(el,raw,kw); c.classList.remove('hidden'); n++; }
|
|
142
|
+
else c.classList.add('hidden');
|
|
143
|
+
});
|
|
144
|
+
count.textContent = kw ? n+' / '+cards.length : cards.length+(active==='broll'?' 个片段':' 个画面');
|
|
145
|
+
if(active==='broll') syncPlay();
|
|
146
|
+
}
|
|
147
|
+
q.addEventListener('input',render);
|
|
148
|
+
render();
|
|
149
|
+
"""
|
cutmap/broll.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""把视频切成 B-roll 片段,用于研究剪辑手法
|
|
2
|
+
|
|
3
|
+
分类靠三条纯规则,不需要模型:
|
|
4
|
+
1. 主镜头(A-roll) = 贯穿全片反复出现的高度相似画面簇(固定机位)
|
|
5
|
+
2. 转场卡 = 亮度均值极低/极高的纯色帧
|
|
6
|
+
3. B-roll = 其余
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from PIL import Image, ImageStat
|
|
15
|
+
|
|
16
|
+
from . import util
|
|
17
|
+
from .frames import dhash, hamming
|
|
18
|
+
|
|
19
|
+
CLUSTER_TH = 12 # 聚类相似度阈值(汉明距离)
|
|
20
|
+
AROLL_SPAN = 0.5 # 簇的时间跨度占全片比例超过此值,才可能是固定机位
|
|
21
|
+
AROLL_MIN_FRAMES = 8
|
|
22
|
+
SEG_MAX = 45 # 单段上限(秒)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_flat(path: str) -> bool:
|
|
26
|
+
"""黑场 / 白场转场卡
|
|
27
|
+
|
|
28
|
+
不能用标准差判定:这类卡上常烧录着字幕,标准差被文字拉到 23~33,
|
|
29
|
+
远高于"纯色"阈值。改用亮度均值——实测真黑场卡均值 <8。
|
|
30
|
+
阈值也不能放太松:均值 <25 会把偏暗的正常内容误判成转场
|
|
31
|
+
(某片实测 290s vs 实际 20s)。
|
|
32
|
+
"""
|
|
33
|
+
st = ImageStat.Stat(Image.open(path).convert("L"))
|
|
34
|
+
return st.mean[0] < 8 or st.mean[0] > 245
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def classify(index: dict, root: str, seg_max: float = SEG_MAX) -> tuple[list, float]:
|
|
38
|
+
frames = index["frames"]
|
|
39
|
+
total = frames[-1]["time"] + frames[-1]["span"]
|
|
40
|
+
|
|
41
|
+
hs = []
|
|
42
|
+
for f in frames:
|
|
43
|
+
p = os.path.join(root, f["frame"])
|
|
44
|
+
hs.append((f, dhash(p), is_flat(p)))
|
|
45
|
+
|
|
46
|
+
clusters: list[dict] = []
|
|
47
|
+
for f, h, flat in hs:
|
|
48
|
+
for c in clusters:
|
|
49
|
+
if hamming(h, c["h"]) < CLUSTER_TH:
|
|
50
|
+
c["items"].append(f)
|
|
51
|
+
break
|
|
52
|
+
else:
|
|
53
|
+
clusters.append({"h": h, "items": [f], "flat": flat})
|
|
54
|
+
|
|
55
|
+
flat_ids = {id(f) for f, _, flat in hs if flat}
|
|
56
|
+
aroll_ids: set[int] = set()
|
|
57
|
+
for c in clusters:
|
|
58
|
+
if len(c["items"]) < AROLL_MIN_FRAMES or c["flat"]:
|
|
59
|
+
continue
|
|
60
|
+
ts = [x["time"] for x in c["items"]]
|
|
61
|
+
if (max(ts) - min(ts)) / total >= AROLL_SPAN:
|
|
62
|
+
aroll_ids |= {id(x) for x in c["items"]}
|
|
63
|
+
|
|
64
|
+
def kind(f):
|
|
65
|
+
if id(f) in flat_ids:
|
|
66
|
+
return "转场"
|
|
67
|
+
return "主镜头" if id(f) in aroll_ids else "B-roll"
|
|
68
|
+
|
|
69
|
+
segs, cur = [], None
|
|
70
|
+
for f in frames:
|
|
71
|
+
k = kind(f)
|
|
72
|
+
# 同类连续帧合并,但超过 seg_max 强制断开:
|
|
73
|
+
# 全程屏幕演示、没有主镜头作分隔的视频,否则会并成一整坨
|
|
74
|
+
# (实测 390s 的视频被切出单个 280s 的"片段")
|
|
75
|
+
too_long = cur is not None and (f["time"] + f["span"] - cur["start"]) > seg_max
|
|
76
|
+
if cur and cur["kind"] == k and not too_long:
|
|
77
|
+
cur["frames"].append(f)
|
|
78
|
+
cur["end"] = f["time"] + f["span"]
|
|
79
|
+
else:
|
|
80
|
+
if cur:
|
|
81
|
+
segs.append(cur)
|
|
82
|
+
cur = {"kind": k, "frames": [f], "start": f["time"],
|
|
83
|
+
"end": f["time"] + f["span"]}
|
|
84
|
+
if cur:
|
|
85
|
+
segs.append(cur)
|
|
86
|
+
return segs, total
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cut(video: str, start: float, dur: float, out: str, fmt: str) -> None:
|
|
90
|
+
ff = util.ffmpeg()
|
|
91
|
+
common = [ff, "-v", "error", "-ss", f"{start:.2f}", "-t", f"{dur:.2f}", "-i", video]
|
|
92
|
+
if fmt == "mp4":
|
|
93
|
+
cmd = common + ["-an", "-vf", "scale=480:-1", "-c:v", "libx264", "-crf", "28",
|
|
94
|
+
"-preset", "veryfast", "-movflags", "+faststart", "-y", out]
|
|
95
|
+
elif fmt == "webp":
|
|
96
|
+
cmd = common + ["-vf", "fps=12,scale=480:-1", "-loop", "0", "-q:v", "70", "-y", out]
|
|
97
|
+
else: # gif —— 体积约为 mp4 的 24 倍且只有 256 色,仅在需要贴进聊天/笔记时用
|
|
98
|
+
cmd = common + ["-vf", "fps=10,scale=480:-1,split[a][b];[a]palettegen[p];"
|
|
99
|
+
"[b][p]paletteuse", "-y", out]
|
|
100
|
+
subprocess.run(cmd, check=False)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract(outdir: str, *, fmt: str = "mp4", min_duration: float = 1.5,
|
|
104
|
+
seg_max: float = SEG_MAX, log=print) -> dict:
|
|
105
|
+
"""从已有的 index.json 切 B-roll,写入 outdir/broll/"""
|
|
106
|
+
index = json.load(open(os.path.join(outdir, "index.json"), encoding="utf-8"))
|
|
107
|
+
video = index["source"]
|
|
108
|
+
segs, total = classify(index, outdir, seg_max)
|
|
109
|
+
|
|
110
|
+
stat: dict[str, list] = {}
|
|
111
|
+
for s in segs:
|
|
112
|
+
e = stat.setdefault(s["kind"], [0, 0.0])
|
|
113
|
+
e[0] += 1
|
|
114
|
+
e[1] += s["end"] - s["start"]
|
|
115
|
+
for k, (n, t) in sorted(stat.items(), key=lambda x: -x[1][1]):
|
|
116
|
+
log(f" {k:>7}: {n:3d} 段, 累计 {t:5.0f}s ({t/total*100:4.1f}%)")
|
|
117
|
+
|
|
118
|
+
picks = [s for s in segs
|
|
119
|
+
if s["kind"] == "B-roll" and s["end"] - s["start"] >= min_duration]
|
|
120
|
+
bdir = os.path.join(outdir, "broll")
|
|
121
|
+
os.makedirs(bdir, exist_ok=True)
|
|
122
|
+
log(f" 导出 {len(picks)} 个 B-roll 片段 ({fmt}) ...")
|
|
123
|
+
|
|
124
|
+
meta = []
|
|
125
|
+
for i, s in enumerate(picks, 1):
|
|
126
|
+
dur = s["end"] - s["start"]
|
|
127
|
+
name = (f"{i:03d}_{int(s['start']//60):02d}-{int(s['start']%60):02d}"
|
|
128
|
+
f"_{dur:.0f}s.{fmt}")
|
|
129
|
+
_cut(video, s["start"], dur, os.path.join(bdir, name), fmt)
|
|
130
|
+
# 用独占字段拼接(相邻帧窗口不重叠),并去掉连续重复句作为兜底
|
|
131
|
+
parts, prev = [], None
|
|
132
|
+
for f in s["frames"]:
|
|
133
|
+
t = f.get("subtitle_own") or ""
|
|
134
|
+
if t and t != prev:
|
|
135
|
+
parts.append(t)
|
|
136
|
+
prev = t
|
|
137
|
+
meta.append({
|
|
138
|
+
"idx": i, "file": name, "start": round(s["start"], 2),
|
|
139
|
+
"timecode": util.hhmmss(s["start"]), "duration": round(dur, 2),
|
|
140
|
+
"frames": len(s["frames"]), "subtitle": "".join(parts),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
data = {"video": index["video"], "format": fmt, "count": len(meta), "segments": meta}
|
|
144
|
+
json.dump(data, open(os.path.join(bdir, "broll.json"), "w", encoding="utf-8"),
|
|
145
|
+
ensure_ascii=False, indent=2)
|
|
146
|
+
size = sum(os.path.getsize(os.path.join(bdir, m["file"])) for m in meta)
|
|
147
|
+
log(f" ✓ {len(meta)} 个片段, 合计 {size/1048576:.1f} MB")
|
|
148
|
+
return data
|
cutmap/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""cutmap 命令行入口"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import broll as broll_mod
|
|
11
|
+
from . import frames as frames_mod
|
|
12
|
+
from . import render, util
|
|
13
|
+
|
|
14
|
+
# 下载能力是可选覆盖模块:公开发行版不包含 download.py。
|
|
15
|
+
# 缺失时 URL 输入会给出明确提示,本地文件分析不受影响。
|
|
16
|
+
try:
|
|
17
|
+
from . import download as _dl
|
|
18
|
+
except ImportError: # pragma: no cover
|
|
19
|
+
_dl = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _step(n: int, total: int, msg: str) -> None:
|
|
23
|
+
print(f"\n\033[1m[{n}/{total}] {msg}\033[0m", flush=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve(target: str, srt_opt: str | None):
|
|
27
|
+
"""输入 → (视频路径, 字幕路径或None, 工作目录)"""
|
|
28
|
+
if os.path.isdir(target):
|
|
29
|
+
mp4 = os.path.join(target, "源片.mp4")
|
|
30
|
+
if not os.path.exists(mp4):
|
|
31
|
+
cand = glob.glob(os.path.join(target, "*.mp4"))
|
|
32
|
+
if not cand:
|
|
33
|
+
sys.exit(f"目录里没有 mp4: {target}")
|
|
34
|
+
mp4 = max(cand, key=os.path.getsize)
|
|
35
|
+
srt = srt_opt or os.path.join(target, "字幕.srt")
|
|
36
|
+
return mp4, (srt if os.path.exists(srt) else None), target
|
|
37
|
+
|
|
38
|
+
if os.path.isfile(target):
|
|
39
|
+
stem = os.path.splitext(target)[0]
|
|
40
|
+
srt = srt_opt or stem + ".srt"
|
|
41
|
+
outdir = stem # 与视频同名的目录
|
|
42
|
+
os.makedirs(outdir, exist_ok=True)
|
|
43
|
+
return target, (srt if os.path.exists(srt) else None), outdir
|
|
44
|
+
|
|
45
|
+
return None, None, None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
has_dl = _dl is not None
|
|
50
|
+
desc = "视频分镜分析:抽画面 + 切 B-roll + 生成可浏览页面"
|
|
51
|
+
if has_dl:
|
|
52
|
+
desc = "B站视频分析:下载 + " + desc.split(":", 1)[1]
|
|
53
|
+
p = argparse.ArgumentParser(prog="cutmap", description=desc)
|
|
54
|
+
p.add_argument("target",
|
|
55
|
+
help="视频文件 / 素材目录" + ("/ B站 URL 或 BV号" if has_dl else ""))
|
|
56
|
+
p.add_argument("--srt", help="字幕文件(默认在同目录按同名查找)")
|
|
57
|
+
p.add_argument("--terms", help="自定义术语表(默认用内置 AI/科技词表)")
|
|
58
|
+
p.add_argument("--threshold", type=int, default=10,
|
|
59
|
+
help="去重阈值,越小画面越密:6密 / 10默认 / 14疏")
|
|
60
|
+
p.add_argument("--fps", type=float, default=2, help="密集采样帧率(默认2)")
|
|
61
|
+
p.add_argument("--cols", type=int, default=4, help="图墙列数")
|
|
62
|
+
p.add_argument("--rows", type=int, default=4, help="图墙行数")
|
|
63
|
+
p.add_argument("--thumb-width", type=int, default=480, help="缩略图宽度 px")
|
|
64
|
+
p.add_argument("--seg-max", type=float, default=45, help="B-roll 单段上限秒数")
|
|
65
|
+
p.add_argument("--clip-format", default="mp4", choices=["mp4", "gif", "webp"],
|
|
66
|
+
help="片段格式;gif 体积约为 mp4 的 24 倍")
|
|
67
|
+
p.add_argument("--no-broll", action="store_true", help="跳过 B-roll 切片")
|
|
68
|
+
p.add_argument("--no-frames", action="store_true",
|
|
69
|
+
help="不保留单帧,只留图墙(省空间)")
|
|
70
|
+
if has_dl:
|
|
71
|
+
p.add_argument("--out", default=".", help="下载输出根目录(默认当前目录)")
|
|
72
|
+
p.add_argument("--download-only", action="store_true", help="只下载不分析")
|
|
73
|
+
p.add_argument("--redownload", action="store_true", help="强制重新下载")
|
|
74
|
+
p.add_argument("-v", "--verbose", action="store_true", help="下载器完整输出")
|
|
75
|
+
return p
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv=None) -> int:
|
|
79
|
+
a = build_parser().parse_args(argv)
|
|
80
|
+
target = a.target
|
|
81
|
+
|
|
82
|
+
# ---- 可选:下载 ----
|
|
83
|
+
if not os.path.exists(target):
|
|
84
|
+
if _dl is None:
|
|
85
|
+
sys.exit(
|
|
86
|
+
f"找不到: {target}\n"
|
|
87
|
+
"本版本不含下载功能,请传入本地视频文件或素材目录。\n"
|
|
88
|
+
"B站视频可用 BBDown 自行下载:https://github.com/nilaoda/BBDown")
|
|
89
|
+
try:
|
|
90
|
+
got = _dl.acquire(target, a.out, a.redownload, a.verbose)
|
|
91
|
+
except util.MissingDependency as e:
|
|
92
|
+
sys.exit(f"\n✗ 缺少依赖:\n{e}")
|
|
93
|
+
if not got:
|
|
94
|
+
return 1
|
|
95
|
+
target = got
|
|
96
|
+
if a.download_only:
|
|
97
|
+
print(f"\n\033[1m=== 仅下载,已完成 ===\033[0m\n{target}")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
mp4, srt, outdir = resolve(target, a.srt)
|
|
101
|
+
if mp4 is None:
|
|
102
|
+
sys.exit(f"找不到: {target}")
|
|
103
|
+
|
|
104
|
+
total = 3 if not a.no_broll else 2
|
|
105
|
+
name = os.path.basename(outdir.rstrip("/"))
|
|
106
|
+
print(f"\033[1m{name}\033[0m")
|
|
107
|
+
print(f" 视频: {os.path.basename(mp4)} ({os.path.getsize(mp4)/1048576:.0f} MB)")
|
|
108
|
+
print(f" 字幕: {'✓ ' + os.path.basename(srt) if srt else '⚠ 无(图文对照将为空)'}")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
_step(1, total, "抽画面 + 图墙")
|
|
112
|
+
data = frames_mod.extract(
|
|
113
|
+
mp4, outdir, srt, threshold=a.threshold, fps=a.fps,
|
|
114
|
+
cols=a.cols, rows=a.rows, thumb_width=a.thumb_width,
|
|
115
|
+
keep_frames=not a.no_frames)
|
|
116
|
+
data["video"] = name
|
|
117
|
+
json.dump(data, open(os.path.join(outdir, "index.json"), "w", encoding="utf-8"),
|
|
118
|
+
ensure_ascii=False, indent=2)
|
|
119
|
+
|
|
120
|
+
n = 2
|
|
121
|
+
if not a.no_broll:
|
|
122
|
+
_step(2, total, "切 B-roll 片段")
|
|
123
|
+
if a.no_frames:
|
|
124
|
+
print(" 跳过:B-roll 分类需要单帧,不能与 --no-frames 同用")
|
|
125
|
+
else:
|
|
126
|
+
broll_mod.extract(outdir, fmt=a.clip_format, seg_max=a.seg_max)
|
|
127
|
+
n = 3
|
|
128
|
+
|
|
129
|
+
_step(n, total, "生成浏览页")
|
|
130
|
+
page = render.build(outdir, a.terms)
|
|
131
|
+
except util.MissingDependency as e:
|
|
132
|
+
sys.exit(f"\n✗ 缺少依赖:\n{e}")
|
|
133
|
+
|
|
134
|
+
print(f"\n\033[1m=== 完成 ===\033[0m\n{outdir}")
|
|
135
|
+
print(f"\n打开: open '{page}'")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
sys.exit(main())
|
cutmap/frames.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""密集采样 + dHash 感知去重 → 分镜画面 + contact sheet + index.json
|
|
2
|
+
|
|
3
|
+
为什么不用 ffmpeg 的场景检测(select='gt(scene,X)'):
|
|
4
|
+
它只识别硬切,抓不到
|
|
5
|
+
① 白底页面之间的低对比度切换(整体亮度结构相近,差异分低于阈值)
|
|
6
|
+
② 镜头内的内容演进(文字逐行出现、图表数值增长、表格高亮移动)
|
|
7
|
+
实测同一视频:场景检测得 104 个画面,感知去重得 608 个。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
from PIL import Image, ImageDraw
|
|
18
|
+
|
|
19
|
+
from . import util
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dhash(path: str, size: int = 8) -> int:
|
|
23
|
+
"""差值哈希:缩到 (size+1)×size 灰度,比较每行相邻像素亮度,得 size² 位指纹"""
|
|
24
|
+
im = Image.open(path).convert("L").resize((size + 1, size), Image.LANCZOS)
|
|
25
|
+
px = list(im.getdata())
|
|
26
|
+
bits = 0
|
|
27
|
+
for r in range(size):
|
|
28
|
+
row = r * (size + 1)
|
|
29
|
+
for c in range(size):
|
|
30
|
+
bits = (bits << 1) | (1 if px[row + c] > px[row + c + 1] else 0)
|
|
31
|
+
return bits
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def hamming(a: int, b: int) -> int:
|
|
35
|
+
return bin(a ^ b).count("1")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def subtitle_own(cues, t0: float, t1: float) -> str:
|
|
39
|
+
"""起始于 [t0, t1) 的字幕 —— 每条只归属一帧,用于跨帧拼接
|
|
40
|
+
|
|
41
|
+
严格半开区间、无回看。带回看会让相邻帧认领同一条字幕,
|
|
42
|
+
拼接 B-roll 段落时出现整句重复。
|
|
43
|
+
"""
|
|
44
|
+
return "".join(c[2] for c in cues if t0 <= c[0] < t1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def subtitle_at(cues, t: float) -> str:
|
|
48
|
+
"""该时刻正在说的话 —— 用于单帧显示,允许与相邻帧相同"""
|
|
49
|
+
for a, b, txt in cues:
|
|
50
|
+
if a <= t < b:
|
|
51
|
+
return txt
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _label(path: str, idx: int, t: float) -> None:
|
|
56
|
+
im = Image.open(path).convert("RGB")
|
|
57
|
+
d = ImageDraw.Draw(im, "RGBA")
|
|
58
|
+
font = util.load_font(max(14, int(im.width * 0.042)))
|
|
59
|
+
txt = f"#{idx} {util.hhmmss(t)} {t:.1f}s"
|
|
60
|
+
bb = d.textbbox((4, 4), txt, font=font)
|
|
61
|
+
d.rectangle([bb[0] - 3, bb[1] - 2, bb[2] + 3, bb[3] + 2], fill=(0, 0, 0, 170))
|
|
62
|
+
d.text((4, 4), txt, font=font, fill=(255, 235, 60))
|
|
63
|
+
im.save(path, quality=92)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _sheets(frames_dir: str, outdir: str, cols: int, rows: int, thumb_w: int) -> int:
|
|
67
|
+
per = cols * rows
|
|
68
|
+
names = sorted(os.listdir(frames_dir))
|
|
69
|
+
n = 0
|
|
70
|
+
for s in range(0, len(names), per):
|
|
71
|
+
lst = os.path.join(outdir, f"_l{s}.txt")
|
|
72
|
+
with open(lst, "w") as f:
|
|
73
|
+
for c in names[s:s + per]:
|
|
74
|
+
# concat 的相对路径按列表文件所在目录解析,故写绝对路径
|
|
75
|
+
f.write(f"file '{os.path.abspath(os.path.join(frames_dir, c))}'\n")
|
|
76
|
+
subprocess.run(
|
|
77
|
+
[util.ffmpeg(), "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", lst,
|
|
78
|
+
"-vf", f"scale={thumb_w}:-1,tile={cols}x{rows}:padding=5:margin=5:color=white",
|
|
79
|
+
"-frames:v", "1", "-q:v", "3",
|
|
80
|
+
os.path.join(outdir, f"sheet_{s//per + 1:02d}.jpg")],
|
|
81
|
+
check=False)
|
|
82
|
+
os.remove(lst)
|
|
83
|
+
n += 1
|
|
84
|
+
return n
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract(video: str, outdir: str, srt: str | None = None, *,
|
|
88
|
+
threshold: int = 10, fps: float = 2, cols: int = 4, rows: int = 4,
|
|
89
|
+
thumb_width: int = 480, keep_frames: bool = True,
|
|
90
|
+
log=print) -> dict:
|
|
91
|
+
"""抽分镜画面,写入 outdir,返回 index 数据"""
|
|
92
|
+
cues = util.parse_srt(srt)
|
|
93
|
+
frames_dir = os.path.join(outdir, "frames")
|
|
94
|
+
os.makedirs(frames_dir, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
log(f" 字幕: {len(cues)} 条" if cues else " 字幕: 无")
|
|
97
|
+
log(f" 密集采样 {fps}fps ...")
|
|
98
|
+
|
|
99
|
+
# 临时目录放系统盘:几千个小文件写外置盘(exFAT 簇 1MB)既慢又浪费
|
|
100
|
+
with tempfile.TemporaryDirectory() as td:
|
|
101
|
+
subprocess.run(
|
|
102
|
+
[util.ffmpeg(), "-v", "error", "-i", video, "-vf",
|
|
103
|
+
f"fps={fps},scale={thumb_width}:-1", "-q:v", "3",
|
|
104
|
+
os.path.join(td, "%06d.jpg")], check=True)
|
|
105
|
+
raw = sorted(os.listdir(td))
|
|
106
|
+
if not raw:
|
|
107
|
+
raise RuntimeError(f"未能从视频抽出任何帧: {video}")
|
|
108
|
+
log(f" {len(raw)} 帧 → dHash 去重 (阈值 {threshold}) ...")
|
|
109
|
+
|
|
110
|
+
kept, last = [], None
|
|
111
|
+
for i, f in enumerate(raw):
|
|
112
|
+
p = os.path.join(td, f)
|
|
113
|
+
h = dhash(p)
|
|
114
|
+
if last is not None and hamming(h, last) < threshold:
|
|
115
|
+
continue
|
|
116
|
+
last = h
|
|
117
|
+
kept.append((i / fps, p))
|
|
118
|
+
log(f" 保留 {len(kept)} 帧 (压缩率 {len(kept)/len(raw)*100:.1f}%)")
|
|
119
|
+
|
|
120
|
+
index = []
|
|
121
|
+
for n, (t, src) in enumerate(kept, 1):
|
|
122
|
+
nxt = kept[n][0] if n < len(kept) else t + 1 / fps
|
|
123
|
+
dst = os.path.join(frames_dir, f"{n:04d}_{util.hhmmss(t,'-')}.jpg")
|
|
124
|
+
shutil.copy(src, dst)
|
|
125
|
+
_label(dst, n, t)
|
|
126
|
+
index.append({
|
|
127
|
+
"idx": n,
|
|
128
|
+
"time": round(t, 2),
|
|
129
|
+
"timecode": util.hhmmss(t),
|
|
130
|
+
"span": round(nxt - t, 2),
|
|
131
|
+
"frame": os.path.relpath(dst, outdir),
|
|
132
|
+
"subtitle": subtitle_at(cues, t),
|
|
133
|
+
"subtitle_own": subtitle_own(cues, t, nxt),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
data = {
|
|
137
|
+
"video": os.path.basename(outdir.rstrip("/")),
|
|
138
|
+
# 存绝对路径:后续 broll 直接拿它喂 ffmpeg,
|
|
139
|
+
# 存相对路径会导致换个 cwd 跑就找不到文件
|
|
140
|
+
"source": os.path.abspath(video),
|
|
141
|
+
"method": "dense-sample + dHash dedup",
|
|
142
|
+
"fps": fps, "hash_threshold": threshold,
|
|
143
|
+
"frame_count": len(index), "frames": index,
|
|
144
|
+
}
|
|
145
|
+
json.dump(data, open(os.path.join(outdir, "index.json"), "w", encoding="utf-8"),
|
|
146
|
+
ensure_ascii=False, indent=2)
|
|
147
|
+
|
|
148
|
+
log(" 生成 contact sheet ...")
|
|
149
|
+
n_sheets = _sheets(frames_dir, outdir, cols, rows, thumb_width)
|
|
150
|
+
if not keep_frames:
|
|
151
|
+
shutil.rmtree(frames_dir, ignore_errors=True)
|
|
152
|
+
|
|
153
|
+
spans = [x["span"] for x in index]
|
|
154
|
+
log(f" ✓ 画面 {len(index)} 个 / 图墙 {n_sheets} 张 / "
|
|
155
|
+
f"平均间隔 {sum(spans)/len(spans):.1f}s")
|
|
156
|
+
return data
|