@remixmate/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/argv.d.ts +15 -0
- package/dist/argv.js +51 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +83 -0
- package/dist/handlers/gen-image.d.ts +13 -0
- package/dist/handlers/gen-image.js +161 -0
- package/dist/handlers/gen-voice.d.ts +16 -0
- package/dist/handlers/gen-voice.js +110 -0
- package/dist/handlers/index.d.ts +13 -0
- package/dist/handlers/index.js +14 -0
- package/dist/handlers/template-bind.d.ts +15 -0
- package/dist/handlers/template-bind.js +149 -0
- package/dist/http.d.ts +52 -0
- package/dist/http.js +110 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/manifest.json +260 -0
- package/dist/progress.d.ts +23 -0
- package/dist/progress.js +15 -0
- package/dist/registry.d.ts +33 -0
- package/dist/registry.js +72 -0
- package/dist/runner.d.ts +17 -0
- package/dist/runner.js +63 -0
- package/package.json +54 -0
- package/skills/export-jianying/SKILL.md +251 -0
- package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
- package/skills/export-jianying/skill.json +24 -0
- package/skills/export-jianying/version.json +7 -0
- package/skills/gen-digital-human/SKILL.md +211 -0
- package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
- package/skills/gen-digital-human/skill.json +23 -0
- package/skills/gen-digital-human/version.json +7 -0
- package/skills/gen-image/SKILL.md +130 -0
- package/skills/gen-image/skill.json +22 -0
- package/skills/gen-image/version.json +7 -0
- package/skills/gen-script/SKILL.md +283 -0
- package/skills/gen-script/scripts/gen_script.py +1208 -0
- package/skills/gen-script/skill.json +41 -0
- package/skills/gen-script/version.json +7 -0
- package/skills/gen-video/SKILL.md +170 -0
- package/skills/gen-video/scripts/gen_video.py +591 -0
- package/skills/gen-video/skill.json +24 -0
- package/skills/gen-video/version.json +7 -0
- package/skills/gen-voice/SKILL.md +104 -0
- package/skills/gen-voice/skill.json +21 -0
- package/skills/gen-voice/version.json +7 -0
- package/skills/prepare-video-assets/SKILL.md +192 -0
- package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
- package/skills/prepare-video-assets/skill.json +23 -0
- package/skills/prepare-video-assets/version.json +7 -0
- package/skills/render-video/SKILL.md +187 -0
- package/skills/render-video/scripts/_chrome_vendor.py +305 -0
- package/skills/render-video/scripts/_video_probe.py +174 -0
- package/skills/render-video/scripts/_vod_polling.py +86 -0
- package/skills/render-video/scripts/remote_renderer_client.py +236 -0
- package/skills/render-video/scripts/render_video.py +2288 -0
- package/skills/render-video/scripts/upload_video.py +193 -0
- package/skills/render-video/skill.json +21 -0
- package/skills/render-video/version.json +7 -0
- package/skills/template-bind/README.md +63 -0
- package/skills/template-bind/SKILL.md +234 -0
- package/skills/template-bind/scripts/check_contracts.py +196 -0
- package/skills/template-bind/scripts/match_template.py +215 -0
- package/skills/template-bind/scripts/registry_loader.py +379 -0
- package/skills/template-bind/scripts/render_job_client.py +162 -0
- package/skills/template-bind/scripts/sync_registry.py +44 -0
- package/skills/template-bind/scripts/template_paths.py +72 -0
- package/skills/template-bind/skill.json +20 -0
- package/skills/template-bind/version.json +7 -0
- package/skills/template-bind/video_dsl/README.md +97 -0
- package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
- package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
- package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
- package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
- package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
- package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
- package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
- package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
- package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
- package/skills/video-parser/SKILL.md +238 -0
- package/skills/video-parser/scripts/analyze_video.py +1038 -0
- package/skills/video-parser/scripts/deconstruct_video.py +602 -0
- package/skills/video-parser/scripts/parse_via_render.py +148 -0
- package/skills/video-parser/skill.json +21 -0
- package/skills/video-parser/version.json +7 -0
- package/skills/web-capture/SKILL.md +183 -0
- package/skills/web-capture/scripts/web_capture.py +461 -0
- package/skills/web-capture/skill.json +29 -0
- package/skills/web-capture/version.json +7 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
视频解构脚本 - 将视频拆解为可复用的内容资产
|
|
4
|
+
自动提取:音频、ASR 文本(含逐字时间戳)、场景切分、关键帧图片。
|
|
5
|
+
输出结构化资产清单 JSON。
|
|
6
|
+
|
|
7
|
+
依赖:本地须安装 ffmpeg(brew install ffmpeg)
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python deconstruct_video.py --url "https://example.com/video.mp4"
|
|
11
|
+
python deconstruct_video.py --local /path/to/video.mp4
|
|
12
|
+
python deconstruct_video.py --url "https://example.com/video.mp4" --skip-asr
|
|
13
|
+
python deconstruct_video.py --url "https://example.com/video.mp4" --json-output
|
|
14
|
+
|
|
15
|
+
环境变量:
|
|
16
|
+
MM_API_BASE_URL - 后端 API 地址(默认: http://localhost:3001/api)
|
|
17
|
+
PRIV_TOKEN - PrivToken 认证令牌
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
|
|
30
|
+
API_BASE_URL = os.environ.get("MM_API_BASE_URL", "http://localhost:3001/api")
|
|
31
|
+
|
|
32
|
+
PRIVATE_TOKEN = "" # 在 main() 中通过 resolve_token() 初始化
|
|
33
|
+
SKILL_NAME = "video-parser"
|
|
34
|
+
AGENT_NAME = os.environ.get("AGENT_NAME", "")
|
|
35
|
+
|
|
36
|
+
# 是否静默模式(--json-output 时抑制进度输出)
|
|
37
|
+
QUIET = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def log(msg: str) -> None:
|
|
41
|
+
"""打印进度信息(静默模式下不输出)"""
|
|
42
|
+
if not QUIET:
|
|
43
|
+
print(msg, flush=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def log_err(msg: str) -> None:
|
|
47
|
+
"""打印错误信息到 stderr(始终输出)"""
|
|
48
|
+
print(msg, file=sys.stderr, flush=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_token(cli_token: str = None) -> str:
|
|
52
|
+
"""优先从 CLI 参数读取 token,其次环境变量,最后报错退出"""
|
|
53
|
+
if cli_token:
|
|
54
|
+
return cli_token
|
|
55
|
+
token = os.environ.get("PRIV_TOKEN", "")
|
|
56
|
+
if not token:
|
|
57
|
+
if sys.stdin.isatty():
|
|
58
|
+
token = input("请输入 PrivToken: ").strip()
|
|
59
|
+
else:
|
|
60
|
+
print("❌ PRIV_TOKEN env var not configured; cannot authenticate", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
return token
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_headers() -> dict:
|
|
66
|
+
"""构建统一请求头(X-Priv-Token 认证头)"""
|
|
67
|
+
headers = {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"X-Priv-Token": PRIVATE_TOKEN,
|
|
70
|
+
"x-invoke-skill": SKILL_NAME,
|
|
71
|
+
}
|
|
72
|
+
if AGENT_NAME:
|
|
73
|
+
headers["x-invoke-agent"] = AGENT_NAME
|
|
74
|
+
return headers
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# 前置检查
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def check_ffmpeg() -> None:
|
|
82
|
+
"""检查 ffmpeg 是否已安装"""
|
|
83
|
+
try:
|
|
84
|
+
subprocess.run(
|
|
85
|
+
["ffmpeg", "-version"],
|
|
86
|
+
stdout=subprocess.DEVNULL,
|
|
87
|
+
stderr=subprocess.DEVNULL,
|
|
88
|
+
check=True,
|
|
89
|
+
)
|
|
90
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
91
|
+
log_err("ffmpeg 未安装。请先安装:brew install ffmpeg")
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# 步骤 1:获取视频到本地
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def download_video(url: str, output_dir: str) -> str:
|
|
100
|
+
"""下载远程视频到本地,返回本地文件路径"""
|
|
101
|
+
# 从 URL 推断文件扩展名
|
|
102
|
+
url_path = url.split("?")[0]
|
|
103
|
+
ext = os.path.splitext(url_path)[1] or ".mp4"
|
|
104
|
+
local_path = os.path.join(output_dir, f"source_video{ext}")
|
|
105
|
+
|
|
106
|
+
log(f"[1/6] 正在下载视频: {url[:80]}{'...' if len(url) > 80 else ''}")
|
|
107
|
+
try:
|
|
108
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
109
|
+
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
110
|
+
with open(local_path, "wb") as f:
|
|
111
|
+
while True:
|
|
112
|
+
chunk = resp.read(1024 * 1024) # 1MB chunks
|
|
113
|
+
if not chunk:
|
|
114
|
+
break
|
|
115
|
+
f.write(chunk)
|
|
116
|
+
file_size = os.path.getsize(local_path)
|
|
117
|
+
log(f" 下载完成: {local_path} ({file_size / 1024 / 1024:.1f} MB)")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
log_err(f"下载视频失败: {e}")
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
return local_path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_video_duration_ms(video_path: str) -> int:
|
|
126
|
+
"""使用 ffprobe 获取视频时长(毫秒)"""
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
[
|
|
130
|
+
"ffprobe", "-v", "error",
|
|
131
|
+
"-show_entries", "format=duration",
|
|
132
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
133
|
+
video_path,
|
|
134
|
+
],
|
|
135
|
+
capture_output=True, text=True, check=True,
|
|
136
|
+
)
|
|
137
|
+
duration_sec = float(result.stdout.strip())
|
|
138
|
+
return int(duration_sec * 1000)
|
|
139
|
+
except Exception:
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# 步骤 2:提取音频
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def extract_audio(video_path: str, output_dir: str) -> str:
|
|
148
|
+
"""从视频中提取音频为 MP3,返回音频文件路径"""
|
|
149
|
+
audio_path = os.path.join(output_dir, "audio.mp3")
|
|
150
|
+
log("[2/6] 正在提取音频...")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
subprocess.run(
|
|
154
|
+
[
|
|
155
|
+
"ffmpeg", "-y", "-i", video_path,
|
|
156
|
+
"-vn", "-acodec", "libmp3lame", "-q:a", "2",
|
|
157
|
+
audio_path,
|
|
158
|
+
],
|
|
159
|
+
stdout=subprocess.DEVNULL,
|
|
160
|
+
stderr=subprocess.DEVNULL,
|
|
161
|
+
check=True,
|
|
162
|
+
)
|
|
163
|
+
except subprocess.CalledProcessError as e:
|
|
164
|
+
log_err(f"音频提取失败: {e}")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
file_size = os.path.getsize(audio_path)
|
|
168
|
+
log(f" 音频已保存: {audio_path} ({file_size / 1024:.1f} KB)")
|
|
169
|
+
return audio_path
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# 步骤 3:上传音频到 OSS
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def upload_audio(audio_path: str) -> str:
|
|
177
|
+
"""上传音频文件到 OSS(POST /file/uploadMediaFile,multipart),返回文件 URL"""
|
|
178
|
+
log("[3/6] 正在上传音频到 OSS...")
|
|
179
|
+
|
|
180
|
+
url = f"{API_BASE_URL}/file/uploadMediaFile"
|
|
181
|
+
boundary = f"----PythonBoundary{int(time.time() * 1000)}"
|
|
182
|
+
filename = os.path.basename(audio_path)
|
|
183
|
+
|
|
184
|
+
with open(audio_path, "rb") as f:
|
|
185
|
+
file_data = f.read()
|
|
186
|
+
|
|
187
|
+
# 构建 multipart/form-data body
|
|
188
|
+
body = b""
|
|
189
|
+
# file 字段
|
|
190
|
+
body += f"--{boundary}\r\n".encode()
|
|
191
|
+
body += f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode()
|
|
192
|
+
body += b"Content-Type: audio/mpeg\r\n\r\n"
|
|
193
|
+
body += file_data
|
|
194
|
+
body += b"\r\n"
|
|
195
|
+
# title 字段
|
|
196
|
+
body += f"--{boundary}\r\n".encode()
|
|
197
|
+
body += b'Content-Disposition: form-data; name="title"\r\n\r\n'
|
|
198
|
+
body += filename.encode()
|
|
199
|
+
body += b"\r\n"
|
|
200
|
+
# 结束
|
|
201
|
+
body += f"--{boundary}--\r\n".encode()
|
|
202
|
+
|
|
203
|
+
headers = {
|
|
204
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
205
|
+
"X-Priv-Token": PRIVATE_TOKEN,
|
|
206
|
+
"x-invoke-skill": SKILL_NAME,
|
|
207
|
+
}
|
|
208
|
+
if AGENT_NAME:
|
|
209
|
+
headers["x-invoke-agent"] = AGENT_NAME
|
|
210
|
+
|
|
211
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
215
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
216
|
+
except urllib.error.HTTPError as e:
|
|
217
|
+
err_body = e.read().decode("utf-8")
|
|
218
|
+
log_err(f"上传失败 (HTTP {e.code}): {err_body}")
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
except urllib.error.URLError as e:
|
|
221
|
+
log_err(f"上传网络错误: {e.reason}")
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
if result.get("code") != 0:
|
|
225
|
+
msg = result.get("msg") or result.get("message") or "未知错误"
|
|
226
|
+
log_err(f"上传 API 返回错误: {msg}")
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
|
|
229
|
+
file_url = (result.get("data") or {}).get("fileUrl", "")
|
|
230
|
+
if not file_url:
|
|
231
|
+
log_err(f"上传返回中无 fileUrl: {json.dumps(result, ensure_ascii=False)}")
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
log(f" 上传完成: {file_url[:80]}...")
|
|
235
|
+
return file_url
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# 步骤 4:ASR 语音识别
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def run_asr(audio_url: str) -> dict:
|
|
243
|
+
"""调用 ASR 接口(POST /tool/asr),返回识别结果"""
|
|
244
|
+
log("[4/6] 正在进行语音识别 (ASR)...")
|
|
245
|
+
|
|
246
|
+
url = f"{API_BASE_URL}/tool/asr"
|
|
247
|
+
payload = {"audioUrl": audio_url}
|
|
248
|
+
|
|
249
|
+
data = json.dumps(payload).encode("utf-8")
|
|
250
|
+
req = urllib.request.Request(
|
|
251
|
+
url, data=data, headers=_build_headers(), method="POST",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
256
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
257
|
+
except urllib.error.HTTPError as e:
|
|
258
|
+
err_body = e.read().decode("utf-8")
|
|
259
|
+
log_err(f"ASR 请求失败 (HTTP {e.code}): {err_body}")
|
|
260
|
+
sys.exit(1)
|
|
261
|
+
except urllib.error.URLError as e:
|
|
262
|
+
log_err(f"ASR 网络错误: {e.reason}")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
|
|
265
|
+
if result.get("code") != 0:
|
|
266
|
+
msg = result.get("msg") or result.get("message") or "未知错误"
|
|
267
|
+
log_err(f"ASR API 返回错误: {msg}")
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
asr_data = result.get("data") or {}
|
|
271
|
+
text = asr_data.get("text", "")
|
|
272
|
+
duration = asr_data.get("duration", 0)
|
|
273
|
+
utterances = asr_data.get("utterances", [])
|
|
274
|
+
|
|
275
|
+
log(f" 识别完成: {len(text)} 字, {len(utterances)} 句, 时长 {duration}ms")
|
|
276
|
+
return {
|
|
277
|
+
"text": text,
|
|
278
|
+
"duration": duration,
|
|
279
|
+
"utterances": utterances,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# 步骤 5:场景切变检测 & 关键帧提取
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def detect_scenes(video_path: str, threshold: float) -> list:
|
|
288
|
+
"""
|
|
289
|
+
使用 ffmpeg select 滤镜检测场景切变,返回切点时间戳列表(秒)。
|
|
290
|
+
始终包含 0.0 作为第一个切点。
|
|
291
|
+
"""
|
|
292
|
+
log(f"[5/6] 正在检测场景切变 (阈值={threshold})...")
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
result = subprocess.run(
|
|
296
|
+
[
|
|
297
|
+
"ffmpeg", "-i", video_path,
|
|
298
|
+
"-vf", f"select='gt(scene,{threshold})',showinfo",
|
|
299
|
+
"-vsync", "vfr",
|
|
300
|
+
"-f", "null", "-",
|
|
301
|
+
],
|
|
302
|
+
capture_output=True, text=True,
|
|
303
|
+
)
|
|
304
|
+
# 从 stderr 解析 pts_time
|
|
305
|
+
output = result.stderr
|
|
306
|
+
except Exception as e:
|
|
307
|
+
log_err(f"场景检测失败: {e}")
|
|
308
|
+
return [0.0]
|
|
309
|
+
|
|
310
|
+
timestamps = []
|
|
311
|
+
for match in re.finditer(r"pts_time:\s*([\d.]+)", output):
|
|
312
|
+
ts = float(match.group(1))
|
|
313
|
+
timestamps.append(ts)
|
|
314
|
+
|
|
315
|
+
# 去重并排序,始终包含 0.0
|
|
316
|
+
timestamps = sorted(set(timestamps))
|
|
317
|
+
if not timestamps or timestamps[0] != 0.0:
|
|
318
|
+
timestamps.insert(0, 0.0)
|
|
319
|
+
|
|
320
|
+
log(f" 检测到 {len(timestamps)} 个场景切点")
|
|
321
|
+
return timestamps
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def extract_keyframes(video_path: str, timestamps: list, output_dir: str) -> list:
|
|
325
|
+
"""按时间戳列表从视频截取关键帧,返回关键帧信息列表"""
|
|
326
|
+
keyframes_dir = os.path.join(output_dir, "keyframes")
|
|
327
|
+
os.makedirs(keyframes_dir, exist_ok=True)
|
|
328
|
+
|
|
329
|
+
log(f"[6/6] 正在提取 {len(timestamps)} 张关键帧...")
|
|
330
|
+
|
|
331
|
+
keyframes = []
|
|
332
|
+
for i, ts in enumerate(timestamps):
|
|
333
|
+
frame_name = f"frame_{i + 1:03d}.jpg"
|
|
334
|
+
frame_path = os.path.join(keyframes_dir, frame_name)
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
subprocess.run(
|
|
338
|
+
[
|
|
339
|
+
"ffmpeg", "-y",
|
|
340
|
+
"-ss", str(ts),
|
|
341
|
+
"-i", video_path,
|
|
342
|
+
"-frames:v", "1",
|
|
343
|
+
"-q:v", "2",
|
|
344
|
+
frame_path,
|
|
345
|
+
],
|
|
346
|
+
stdout=subprocess.DEVNULL,
|
|
347
|
+
stderr=subprocess.DEVNULL,
|
|
348
|
+
check=True,
|
|
349
|
+
)
|
|
350
|
+
keyframes.append({
|
|
351
|
+
"index": i,
|
|
352
|
+
"timestampSec": round(ts, 3),
|
|
353
|
+
"localPath": f"keyframes/{frame_name}",
|
|
354
|
+
})
|
|
355
|
+
except subprocess.CalledProcessError:
|
|
356
|
+
log(f" 警告: 无法提取帧 @ {ts:.3f}s,跳过")
|
|
357
|
+
|
|
358
|
+
log(f" 已提取 {len(keyframes)} 张关键帧")
|
|
359
|
+
return keyframes
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# 步骤 6:汇总输出
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def build_scenes(timestamps: list, video_duration_sec: float, keyframes: list) -> list:
|
|
367
|
+
"""根据切点时间戳构建场景分段列表"""
|
|
368
|
+
scenes = []
|
|
369
|
+
keyframe_map = {kf["index"]: kf["localPath"] for kf in keyframes}
|
|
370
|
+
|
|
371
|
+
for i, start in enumerate(timestamps):
|
|
372
|
+
end = timestamps[i + 1] if i + 1 < len(timestamps) else video_duration_sec
|
|
373
|
+
scenes.append({
|
|
374
|
+
"index": i,
|
|
375
|
+
"startTimeSec": round(start, 3),
|
|
376
|
+
"endTimeSec": round(end, 3),
|
|
377
|
+
"keyframe": keyframe_map.get(i, None),
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
return scenes
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def build_result(
|
|
384
|
+
source_url: str,
|
|
385
|
+
local_video_path: str,
|
|
386
|
+
video_duration_ms: int,
|
|
387
|
+
audio_local_path: str,
|
|
388
|
+
audio_oss_url: str,
|
|
389
|
+
asr_result: dict,
|
|
390
|
+
scenes: list,
|
|
391
|
+
keyframes: list,
|
|
392
|
+
) -> dict:
|
|
393
|
+
"""汇总所有数据为最终资产清单"""
|
|
394
|
+
result = {
|
|
395
|
+
"source": {
|
|
396
|
+
"url": source_url or None,
|
|
397
|
+
"localPath": local_video_path,
|
|
398
|
+
"durationMs": video_duration_ms,
|
|
399
|
+
},
|
|
400
|
+
"audio": {
|
|
401
|
+
"localPath": audio_local_path,
|
|
402
|
+
"url": audio_oss_url,
|
|
403
|
+
},
|
|
404
|
+
"asr": asr_result,
|
|
405
|
+
"scenes": scenes,
|
|
406
|
+
"keyframes": keyframes,
|
|
407
|
+
}
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# Main
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
def main():
|
|
416
|
+
parser = argparse.ArgumentParser(
|
|
417
|
+
description="Video deconstruction tool — split a video into reusable content assets",
|
|
418
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
419
|
+
epilog="""
|
|
420
|
+
Examples:
|
|
421
|
+
# Deconstruct from URL
|
|
422
|
+
python deconstruct_video.py --url "https://example.com/video.mp4"
|
|
423
|
+
|
|
424
|
+
# Deconstruct from a local file
|
|
425
|
+
python deconstruct_video.py --local /path/to/video.mp4
|
|
426
|
+
|
|
427
|
+
# Custom output directory and scene threshold
|
|
428
|
+
python deconstruct_video.py --url "https://example.com/video.mp4" \\
|
|
429
|
+
--output-dir ./my_output --scene-threshold 0.4
|
|
430
|
+
|
|
431
|
+
# Skip ASR, only extract keyframes
|
|
432
|
+
python deconstruct_video.py --local video.mp4 --skip-asr
|
|
433
|
+
|
|
434
|
+
# JSON pipeline mode
|
|
435
|
+
python deconstruct_video.py --url "https://example.com/video.mp4" --json-output
|
|
436
|
+
""",
|
|
437
|
+
)
|
|
438
|
+
parser.add_argument(
|
|
439
|
+
"--url",
|
|
440
|
+
default=None,
|
|
441
|
+
help="Remote video URL (mutually exclusive with --local)",
|
|
442
|
+
)
|
|
443
|
+
parser.add_argument(
|
|
444
|
+
"--local",
|
|
445
|
+
default=None,
|
|
446
|
+
help="Local video path (mutually exclusive with --url)",
|
|
447
|
+
)
|
|
448
|
+
parser.add_argument(
|
|
449
|
+
"-o", "--output-dir",
|
|
450
|
+
default=None,
|
|
451
|
+
help="Output directory (default: ./deconstructed_<timestamp>)",
|
|
452
|
+
)
|
|
453
|
+
parser.add_argument(
|
|
454
|
+
"--scene-threshold",
|
|
455
|
+
type=float,
|
|
456
|
+
default=0.3,
|
|
457
|
+
help="Scene-change threshold 0.0-1.0 (default: 0.3; lower = more sensitive)",
|
|
458
|
+
)
|
|
459
|
+
parser.add_argument(
|
|
460
|
+
"--priv-token",
|
|
461
|
+
metavar="TOKEN",
|
|
462
|
+
default=None,
|
|
463
|
+
help="PrivToken auth credential (priority: this flag > env var > interactive prompt)",
|
|
464
|
+
)
|
|
465
|
+
parser.add_argument(
|
|
466
|
+
"--json-output",
|
|
467
|
+
action="store_true",
|
|
468
|
+
default=False,
|
|
469
|
+
help="Print only JSON to stdout (pipeline mode, suppresses progress)",
|
|
470
|
+
)
|
|
471
|
+
parser.add_argument(
|
|
472
|
+
"--skip-asr",
|
|
473
|
+
action="store_true",
|
|
474
|
+
default=False,
|
|
475
|
+
help="Skip the ASR step",
|
|
476
|
+
)
|
|
477
|
+
parser.add_argument(
|
|
478
|
+
"--skip-keyframes",
|
|
479
|
+
action="store_true",
|
|
480
|
+
default=False,
|
|
481
|
+
help="Skip the keyframe-extraction step",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
args = parser.parse_args()
|
|
485
|
+
|
|
486
|
+
# 校验输入
|
|
487
|
+
if not args.url and not args.local:
|
|
488
|
+
log_err("请提供 --url 或 --local 参数指定视频来源")
|
|
489
|
+
parser.print_help()
|
|
490
|
+
sys.exit(1)
|
|
491
|
+
if args.url and args.local:
|
|
492
|
+
log_err("--url 和 --local 不能同时使用,请选择其一")
|
|
493
|
+
sys.exit(1)
|
|
494
|
+
if args.local and not os.path.isfile(args.local):
|
|
495
|
+
log_err(f"本地视频文件不存在: {args.local}")
|
|
496
|
+
sys.exit(1)
|
|
497
|
+
if not (0.0 <= args.scene_threshold <= 1.0):
|
|
498
|
+
log_err("--scene-threshold 范围为 0.0-1.0")
|
|
499
|
+
sys.exit(1)
|
|
500
|
+
|
|
501
|
+
# 静默模式
|
|
502
|
+
global QUIET
|
|
503
|
+
QUIET = args.json_output
|
|
504
|
+
|
|
505
|
+
# 初始化 token(如果需要 ASR,则必须有 token)
|
|
506
|
+
global PRIVATE_TOKEN
|
|
507
|
+
if not args.skip_asr:
|
|
508
|
+
PRIVATE_TOKEN = resolve_token(args.priv_token)
|
|
509
|
+
else:
|
|
510
|
+
# 即使跳过 ASR,如果提供了 token 也保存(可能用于其他扩展)
|
|
511
|
+
PRIVATE_TOKEN = args.priv_token or os.environ.get("PRIV_TOKEN", "")
|
|
512
|
+
|
|
513
|
+
# 前置检查
|
|
514
|
+
check_ffmpeg()
|
|
515
|
+
|
|
516
|
+
# 输出目录
|
|
517
|
+
output_dir = args.output_dir or f"./deconstructed_{int(time.time())}"
|
|
518
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
519
|
+
log(f"输出目录: {os.path.abspath(output_dir)}")
|
|
520
|
+
|
|
521
|
+
# -----------------------------------------------------------------------
|
|
522
|
+
# [1] 获取视频到本地
|
|
523
|
+
# -----------------------------------------------------------------------
|
|
524
|
+
source_url = args.url or ""
|
|
525
|
+
if args.url:
|
|
526
|
+
video_path = download_video(args.url, output_dir)
|
|
527
|
+
else:
|
|
528
|
+
video_path = os.path.abspath(args.local)
|
|
529
|
+
log(f"[1/6] 使用本地视频: {video_path}")
|
|
530
|
+
|
|
531
|
+
# 获取视频时长
|
|
532
|
+
video_duration_ms = get_video_duration_ms(video_path)
|
|
533
|
+
video_duration_sec = video_duration_ms / 1000.0 if video_duration_ms > 0 else 0.0
|
|
534
|
+
log(f" 视频时长: {video_duration_sec:.1f}s ({video_duration_ms}ms)")
|
|
535
|
+
|
|
536
|
+
# -----------------------------------------------------------------------
|
|
537
|
+
# [2] 提取音频
|
|
538
|
+
# -----------------------------------------------------------------------
|
|
539
|
+
audio_path = extract_audio(video_path, output_dir)
|
|
540
|
+
|
|
541
|
+
# -----------------------------------------------------------------------
|
|
542
|
+
# [3-4] 上传音频 + ASR
|
|
543
|
+
# -----------------------------------------------------------------------
|
|
544
|
+
audio_oss_url = ""
|
|
545
|
+
asr_result = {"text": "", "duration": 0, "utterances": []}
|
|
546
|
+
|
|
547
|
+
if not args.skip_asr:
|
|
548
|
+
audio_oss_url = upload_audio(audio_path)
|
|
549
|
+
asr_result = run_asr(audio_oss_url)
|
|
550
|
+
else:
|
|
551
|
+
log("[3/6] 跳过音频上传(--skip-asr)")
|
|
552
|
+
log("[4/6] 跳过 ASR(--skip-asr)")
|
|
553
|
+
|
|
554
|
+
# -----------------------------------------------------------------------
|
|
555
|
+
# [5-6] 场景检测 + 关键帧提取
|
|
556
|
+
# -----------------------------------------------------------------------
|
|
557
|
+
scenes = []
|
|
558
|
+
keyframes = []
|
|
559
|
+
|
|
560
|
+
if not args.skip_keyframes:
|
|
561
|
+
timestamps = detect_scenes(video_path, args.scene_threshold)
|
|
562
|
+
keyframes = extract_keyframes(video_path, timestamps, output_dir)
|
|
563
|
+
scenes = build_scenes(timestamps, video_duration_sec, keyframes)
|
|
564
|
+
else:
|
|
565
|
+
log("[5/6] 跳过场景检测(--skip-keyframes)")
|
|
566
|
+
log("[6/6] 跳过关键帧提取(--skip-keyframes)")
|
|
567
|
+
|
|
568
|
+
# -----------------------------------------------------------------------
|
|
569
|
+
# 汇总输出
|
|
570
|
+
# -----------------------------------------------------------------------
|
|
571
|
+
final = build_result(
|
|
572
|
+
source_url=source_url,
|
|
573
|
+
local_video_path=video_path,
|
|
574
|
+
video_duration_ms=video_duration_ms,
|
|
575
|
+
audio_local_path=audio_path,
|
|
576
|
+
audio_oss_url=audio_oss_url,
|
|
577
|
+
asr_result=asr_result,
|
|
578
|
+
scenes=scenes,
|
|
579
|
+
keyframes=keyframes,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# 写入 JSON
|
|
583
|
+
json_path = os.path.join(output_dir, "deconstruction.json")
|
|
584
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
585
|
+
json.dump(final, f, ensure_ascii=False, indent=2)
|
|
586
|
+
|
|
587
|
+
if args.json_output:
|
|
588
|
+
# 管道模式:仅输出 JSON 到 stdout
|
|
589
|
+
print(json.dumps(final, ensure_ascii=False, indent=2))
|
|
590
|
+
else:
|
|
591
|
+
log(f"\n资产清单已保存: {json_path}")
|
|
592
|
+
log(f"音频文件: {audio_path}")
|
|
593
|
+
if keyframes:
|
|
594
|
+
log(f"关键帧: {len(keyframes)} 张 → {output_dir}/keyframes/")
|
|
595
|
+
if asr_result.get("text"):
|
|
596
|
+
text_preview = asr_result["text"][:60]
|
|
597
|
+
log(f"ASR 文本: {text_preview}{'...' if len(asr_result['text']) > 60 else ''}")
|
|
598
|
+
log("\n视频解构完成!")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
if __name__ == "__main__":
|
|
602
|
+
main()
|