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/render.py ADDED
@@ -0,0 +1,109 @@
1
+ """生成单页浏览器:分镜画面 + B-roll 片段,Tab 切换
2
+
3
+ 产物是单个 HTML,内联全部 CSS/JS,只依赖同目录的图片和视频文件。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import html
8
+ import json
9
+ import os
10
+ import urllib.parse
11
+
12
+ from . import subclean
13
+ from ._assets import CSS, JS
14
+
15
+ CLIP = 48 # 字幕折叠阈值(字符数)
16
+
17
+
18
+ def _q(p: str) -> str:
19
+ return html.escape(urllib.parse.quote(p))
20
+
21
+
22
+ def _card_frames(data: dict, terms: str | None) -> list[str]:
23
+ out = []
24
+ for f in data["frames"]:
25
+ sub = subclean.clean(f.get("subtitle", ""), terms_path=terms)
26
+ out.append(
27
+ f'<div class="card">'
28
+ f'<img src="{_q(f["frame"])}" loading="lazy" data-t="{f["time"]}" alt="#{f["idx"]}">'
29
+ f'<div class="body"><div class="tc">'
30
+ f'<button class="seek" data-t="{f["time"]}">{f["timecode"]}</button>'
31
+ f'<span class="tag">#{f["idx"]} · 停留 {f["span"]}s</span></div>'
32
+ f'<div class="sub" data-clip="{CLIP}" data-raw="{html.escape(sub, quote=True)}"></div>'
33
+ f'</div></div>\n')
34
+ return out
35
+
36
+
37
+ def _card_broll(bdata: dict, terms: str | None) -> list[str]:
38
+ out = []
39
+ for s in bdata["segments"]:
40
+ sub = subclean.clean(s.get("subtitle", ""), terms_path=terms)
41
+ out.append(
42
+ f'<div class="card">'
43
+ f'<video class="clip" src="broll/{_q(s["file"])}" loop muted playsinline '
44
+ f'preload="metadata" data-t="{s["start"]}"></video>'
45
+ f'<div class="body"><div class="tc">'
46
+ f'<button class="seek" data-t="{s["start"]}">{s["timecode"]}</button>'
47
+ f'<span class="tag">#{s["idx"]} · {s["duration"]:.0f}s · '
48
+ f'{s["frames"]}个画面</span></div>'
49
+ f'<div class="sub" data-clip="{CLIP}" data-raw="{html.escape(sub, quote=True)}"></div>'
50
+ f'</div></div>\n')
51
+ return out
52
+
53
+
54
+ def build(outdir: str, terms: str | None = None, log=print) -> str:
55
+ data = json.load(open(os.path.join(outdir, "index.json"), encoding="utf-8"))
56
+ name = data["video"]
57
+
58
+ bpath = os.path.join(outdir, "broll", "broll.json")
59
+ bdata = json.load(open(bpath, encoding="utf-8")) if os.path.exists(bpath) else None
60
+
61
+ fcards = _card_frames(data, terms)
62
+ bcards = _card_broll(bdata, terms) if bdata else []
63
+
64
+ meta = (f'{data["frame_count"]} 个画面 · 采样 {data["fps"]}fps · '
65
+ f'去重阈值 {data["hash_threshold"]}')
66
+ if bdata:
67
+ meta += f' · B-roll {bdata["count"]} 段'
68
+
69
+ tabs = ('<button class="tab active" data-pane="frames">分镜画面'
70
+ f'<span class="n">{len(fcards)}</span></button>')
71
+ if bcards:
72
+ tabs += ('<button class="tab" data-pane="broll">B-roll 片段'
73
+ f'<span class="n">{len(bcards)}</span></button>')
74
+
75
+ broll_pane = (f'<main id="pane-broll" class="pane">\n{"".join(bcards)}</main>'
76
+ if bcards else
77
+ '<main id="pane-broll" class="pane">'
78
+ '<div class="empty">无 B-roll 片段</div></main>')
79
+
80
+ src_rel = urllib.parse.quote(os.path.relpath(data["source"], outdir))
81
+ page = f"""<!doctype html>
82
+ <html lang="zh"><head><meta charset="utf-8">
83
+ <meta name="viewport" content="width=device-width,initial-scale=1">
84
+ <title>{html.escape(name)}</title><style>{CSS}</style></head><body>
85
+ <header>
86
+ <div class="top">
87
+ <video id="player" src="{src_rel}" controls preload="metadata"></video>
88
+ <div class="info">
89
+ <h1>{html.escape(name)}</h1>
90
+ <div class="meta">{html.escape(meta)}</div>
91
+ <div class="tools">
92
+ <input type="search" id="q" placeholder="搜索字幕…" autocomplete="off">
93
+ <button id="auto" class="on hidden">自动播放:开</button>
94
+ <span class="count" id="count"></span>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ <div class="tabs">{tabs}</div>
99
+ </header>
100
+ <main id="pane-frames" class="pane active">
101
+ {"".join(fcards)}</main>
102
+ {broll_pane}
103
+ <script>{JS}</script></body></html>
104
+ """
105
+ out = os.path.join(outdir, "浏览.html")
106
+ open(out, "w", encoding="utf-8").write(page)
107
+ log(f" ✓ 浏览.html ({os.path.getsize(out)//1024} KB, "
108
+ f"分镜 {len(fcards)} + B-roll {len(bcards)})")
109
+ return out
cutmap/subclean.py ADDED
@@ -0,0 +1,61 @@
1
+ """字幕术语归一化 + 口水词精简(纯规则,零 LLM 调用)
2
+
3
+ 这不是语义校对。只修高频、无歧义的固定错误:
4
+ 真正的语法 / 断句 / 低频错词需要过模型。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ from functools import lru_cache
11
+
12
+ DEFAULT_TERMS = os.path.join(os.path.dirname(__file__), "terms.txt")
13
+
14
+ # 句中语气助词,删掉不影响语义
15
+ FILLERS = [
16
+ (r"呢(?=[,。、)]|$)", ""), # 句末的"呢"
17
+ (r"(?<=[了的是在])呢", ""), # 助词后的"呢"
18
+ (r"^(那|然后|就是说|其实呢|那么)", ""),
19
+ (r"这个这个", "这个"),
20
+ (r"就是就是", "就是"),
21
+ (r"我们我们", "我们"),
22
+ ]
23
+
24
+
25
+ @lru_cache(maxsize=8)
26
+ def load_terms(path: str | None = None) -> tuple[tuple[str, str], ...]:
27
+ """读术语表。优先级:显式参数 > CUTMAP_TERMS 环境变量 > 内置表"""
28
+ path = path or os.environ.get("CUTMAP_TERMS") or DEFAULT_TERMS
29
+ rules = []
30
+ with open(path, encoding="utf-8") as f:
31
+ for lineno, line in enumerate(f, 1):
32
+ line = line.strip()
33
+ if not line or line.startswith("#"):
34
+ continue
35
+ if "=>" not in line:
36
+ continue
37
+ pat, rep = line.split("=>", 1)
38
+ pat, rep = pat.strip(), rep.strip()
39
+ try:
40
+ re.compile(pat)
41
+ except re.error as e:
42
+ raise ValueError(f"{path}:{lineno} 正则无效: {pat} ({e})") from e
43
+ rules.append((pat, rep))
44
+ return tuple(rules)
45
+
46
+
47
+ def normalize(text: str, terms_path: str | None = None) -> str:
48
+ for pat, rep in load_terms(terms_path):
49
+ text = re.sub(pat, rep, text, flags=re.I)
50
+ return text
51
+
52
+
53
+ def condense(text: str) -> str:
54
+ for pat, rep in FILLERS:
55
+ text = re.sub(pat, rep, text)
56
+ return re.sub(r"\s{2,}", " ", text).strip()
57
+
58
+
59
+ def clean(text: str, fillers: bool = True, terms_path: str | None = None) -> str:
60
+ t = normalize(text, terms_path)
61
+ return condense(t) if fillers else t
cutmap/terms.txt ADDED
@@ -0,0 +1,54 @@
1
+ # ASR 术语归一化表(默认:AI / 科技类内容)
2
+ #
3
+ # 格式: 正则 => 替换
4
+ # - 每行一条,# 开头为注释
5
+ # - 匹配不区分大小写
6
+ # - 用 \w 边界会在中英混排下失效(中文字符在 Python 里同属 \w),
7
+ # 故一律用 (?<![A-Za-z0-9]) ... (?![A-Za-z0-9]) 作 ASCII 边界
8
+ #
9
+ # 自定义:用 --terms your_terms.txt 指定自己的表(完全替换默认表),
10
+ # 或 CUTMAP_TERMS 环境变量。
11
+
12
+ # ---- 模型 / 产品 ----
13
+ open\s*cloud => OpenClaw
14
+ open\s*claw => OpenClaw
15
+ deep\s*s[iea]*c?k => DeepSeek
16
+ deepseek => DeepSeek
17
+ (?<![A-Za-z0-9])grock(?![A-Za-z0-9]) => Grok
18
+ (?<![A-Za-z0-9])grok(?![A-Za-z0-9]) => Grok
19
+ 詹姆(奶|乃|a|A|i)? => Gemini
20
+ (?<![A-Za-z0-9])gemini(?![A-Za-z0-9]) => Gemini
21
+ (?<![A-Za-z0-9])gbt(?![A-Za-z0-9]) => GPT
22
+ (?<![A-Za-z0-9])gpt(?![A-Za-z0-9]) => GPT
23
+ (?<![A-Za-z0-9])openai(?![A-Za-z0-9]) => OpenAI
24
+ (?<![A-Za-z0-9])deepmind(?![A-Za-z0-9]) => DeepMind
25
+ (?<![A-Za-z0-9])claude(?![A-Za-z0-9]) => Claude
26
+ cod\s*code => Claude Code
27
+ cocold => Claude Code
28
+ (?<![A-Za-z0-9])kimi(?![A-Za-z0-9]) => Kimi
29
+
30
+ # ---- 缩写大小写 ----
31
+ (?<![A-Za-z0-9])ai(?![A-Za-z0-9]) => AI
32
+ (?<![A-Za-z0-9])npu(?![A-Za-z0-9]) => NPU
33
+ (?<![A-Za-z0-9])gpu(?![A-Za-z0-9]) => GPU
34
+ (?<![A-Za-z0-9])cpu(?![A-Za-z0-9]) => CPU
35
+ (?<![A-Za-z0-9])tpu(?![A-Za-z0-9]) => TPU
36
+ (?<![A-Za-z0-9])mcp(?![A-Za-z0-9]) => MCP
37
+ (?<![A-Za-z0-9])hdr(?![A-Za-z0-9]) => HDR
38
+ (?<![A-Za-z0-9])dlss(?![A-Za-z0-9]) => DLSS
39
+ (?<![A-Za-z0-9])esim(?![A-Za-z0-9]) => eSIM
40
+ (?<![A-Za-z0-9])ppt(?![A-Za-z0-9]) => PPT
41
+ (?<![A-Za-z0-9])app(?![A-Za-z0-9]) => App
42
+
43
+ # ---- 设备 / 系统 ----
44
+ (?<![A-Za-z0-9])iphone(?![A-Za-z0-9]) => iPhone
45
+ (?<![A-Za-z0-9])ipad(?![A-Za-z0-9]) => iPad
46
+ (?<![A-Za-z0-9])macbook(?![A-Za-z0-9]) => MacBook
47
+ (?<![A-Za-z0-9])windows(?![A-Za-z0-9]) => Windows
48
+ (?<![A-Za-z0-9])steam(?![A-Za-z0-9]) => Steam
49
+
50
+ # ---- 刻意不收录 ----
51
+ # 有歧义或属于说话人本人用词的,宁可不改:
52
+ # 小龙虾 / 龙虾 某些 UP 主对 OpenClaw 的戏称,是本人措辞
53
+ # 单独的 cloud 可能指 Claude,也可能真指云服务,规则无法区分
54
+ # 克劳德 通用中文译名,保留
cutmap/util.py ADDED
@@ -0,0 +1,140 @@
1
+ """共享工具:外部程序定位、跨平台字体、时间码、SRT 解析"""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+ # ---------------------------------------------------------------- 外部依赖
11
+
12
+
13
+ class MissingDependency(RuntimeError):
14
+ pass
15
+
16
+
17
+ def find_exe(name: str, env_var: str, install_hint: str) -> str:
18
+ """按 环境变量 → PATH 的顺序定位可执行文件"""
19
+ p = os.environ.get(env_var)
20
+ if p and os.path.isfile(p) and os.access(p, os.X_OK):
21
+ return p
22
+ p = shutil.which(name)
23
+ if p:
24
+ return p
25
+ raise MissingDependency(
26
+ f"找不到 {name}。\n{install_hint}\n"
27
+ f"或设置环境变量 {env_var}=/path/to/{name}")
28
+
29
+
30
+ def ffmpeg() -> str:
31
+ return find_exe("ffmpeg", "FFMPEG",
32
+ "安装: brew install ffmpeg (macOS)\n"
33
+ " apt install ffmpeg (Debian/Ubuntu)")
34
+
35
+
36
+ def ffprobe() -> str:
37
+ return find_exe("ffprobe", "FFPROBE", "ffprobe 随 ffmpeg 一起安装")
38
+
39
+
40
+ def bbdown() -> str:
41
+ return find_exe(
42
+ "BBDown", "BBDOWN",
43
+ "BBDown 是 B 站下载器,需单独安装:\n"
44
+ " https://github.com/nilaoda/BBDown/releases\n"
45
+ "下载后放进 PATH,或 brew install bbdown")
46
+
47
+
48
+ def run(cmd, capture=False, check=False):
49
+ return subprocess.run(cmd, capture_output=capture, text=True, check=check)
50
+
51
+
52
+ def probe_duration(path: str) -> float:
53
+ """时长(秒);不可解码返回 0"""
54
+ r = run([ffprobe(), "-v", "error", "-show_entries", "format=duration",
55
+ "-of", "default=nw=1:nk=1", path], capture=True)
56
+ try:
57
+ return float(r.stdout.strip())
58
+ except (ValueError, AttributeError):
59
+ return 0.0
60
+
61
+
62
+ def valid_video(path: str, min_bytes: int = 100_000) -> bool:
63
+ """能否解码。下载器产出损坏文件时未必返回非零退出码,必须实测"""
64
+ if not os.path.exists(path) or os.path.getsize(path) < min_bytes:
65
+ return False
66
+ return probe_duration(path) > 1
67
+
68
+
69
+ # ---------------------------------------------------------------- 字体
70
+
71
+ _FONT_CANDIDATES = [
72
+ # macOS
73
+ "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
74
+ "/System/Library/Fonts/Helvetica.ttc",
75
+ # Linux
76
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
77
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
78
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
79
+ # Windows
80
+ "C:\\Windows\\Fonts\\arialbd.ttf",
81
+ "C:\\Windows\\Fonts\\arial.ttf",
82
+ ]
83
+
84
+
85
+ def load_font(size: int):
86
+ """跨平台加载一个粗体无衬线字体;全部失败则退回 PIL 内置位图字体"""
87
+ from PIL import ImageFont
88
+
89
+ override = os.environ.get("CUTMAP_FONT")
90
+ paths = ([override] if override else []) + _FONT_CANDIDATES
91
+ for p in paths:
92
+ if p and os.path.isfile(p):
93
+ try:
94
+ return ImageFont.truetype(p, size)
95
+ except OSError:
96
+ continue
97
+ return ImageFont.load_default()
98
+
99
+
100
+ # ---------------------------------------------------------------- 时间与字幕
101
+
102
+
103
+ def hhmmss(t: float, sep: str = ":") -> str:
104
+ return f"{int(t//3600):02d}{sep}{int(t%3600//60):02d}{sep}{int(t%60):02d}"
105
+
106
+
107
+ def parse_srt(path: str | None) -> list[tuple[float, float, str]]:
108
+ """→ [(start, end, text), ...];文件不存在返回空表"""
109
+ if not path or not os.path.exists(path):
110
+ return []
111
+ cues = []
112
+ raw = open(path, encoding="utf-8-sig").read()
113
+ for block in re.split(r"\n\s*\n", raw.strip()):
114
+ lines = [ln for ln in block.strip().splitlines() if ln.strip()]
115
+ tc = next((ln for ln in lines if "-->" in ln), None)
116
+ if not tc:
117
+ continue
118
+ text = " ".join(lines[lines.index(tc) + 1:]).strip()
119
+ if not text:
120
+ continue
121
+ try:
122
+ a, b = [_sec(x) for x in tc.split("-->")]
123
+ except ValueError:
124
+ continue
125
+ cues.append((a, b, text))
126
+ return cues
127
+
128
+
129
+ def _sec(t: str) -> float:
130
+ h, m, s = t.strip().replace(",", ".").split(":")
131
+ return int(h) * 3600 + int(m) * 60 + float(s)
132
+
133
+
134
+ def safe_name(name: str) -> str:
135
+ """文件名净化"""
136
+ return re.sub(r'[/\\:*?"<>|]', "_", name).strip()
137
+
138
+
139
+ def eprint(*a):
140
+ print(*a, file=sys.stderr)