@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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/dist/argv.d.ts +15 -0
  4. package/dist/argv.js +51 -0
  5. package/dist/cli.d.ts +13 -0
  6. package/dist/cli.js +83 -0
  7. package/dist/handlers/gen-image.d.ts +13 -0
  8. package/dist/handlers/gen-image.js +161 -0
  9. package/dist/handlers/gen-voice.d.ts +16 -0
  10. package/dist/handlers/gen-voice.js +110 -0
  11. package/dist/handlers/index.d.ts +13 -0
  12. package/dist/handlers/index.js +14 -0
  13. package/dist/handlers/template-bind.d.ts +15 -0
  14. package/dist/handlers/template-bind.js +149 -0
  15. package/dist/http.d.ts +52 -0
  16. package/dist/http.js +110 -0
  17. package/dist/index.d.ts +9 -0
  18. package/dist/index.js +8 -0
  19. package/dist/manifest.json +260 -0
  20. package/dist/progress.d.ts +23 -0
  21. package/dist/progress.js +15 -0
  22. package/dist/registry.d.ts +33 -0
  23. package/dist/registry.js +72 -0
  24. package/dist/runner.d.ts +17 -0
  25. package/dist/runner.js +63 -0
  26. package/package.json +54 -0
  27. package/skills/export-jianying/SKILL.md +251 -0
  28. package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
  29. package/skills/export-jianying/skill.json +24 -0
  30. package/skills/export-jianying/version.json +7 -0
  31. package/skills/gen-digital-human/SKILL.md +211 -0
  32. package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
  33. package/skills/gen-digital-human/skill.json +23 -0
  34. package/skills/gen-digital-human/version.json +7 -0
  35. package/skills/gen-image/SKILL.md +130 -0
  36. package/skills/gen-image/skill.json +22 -0
  37. package/skills/gen-image/version.json +7 -0
  38. package/skills/gen-script/SKILL.md +283 -0
  39. package/skills/gen-script/scripts/gen_script.py +1208 -0
  40. package/skills/gen-script/skill.json +41 -0
  41. package/skills/gen-script/version.json +7 -0
  42. package/skills/gen-video/SKILL.md +170 -0
  43. package/skills/gen-video/scripts/gen_video.py +591 -0
  44. package/skills/gen-video/skill.json +24 -0
  45. package/skills/gen-video/version.json +7 -0
  46. package/skills/gen-voice/SKILL.md +104 -0
  47. package/skills/gen-voice/skill.json +21 -0
  48. package/skills/gen-voice/version.json +7 -0
  49. package/skills/prepare-video-assets/SKILL.md +192 -0
  50. package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
  51. package/skills/prepare-video-assets/skill.json +23 -0
  52. package/skills/prepare-video-assets/version.json +7 -0
  53. package/skills/render-video/SKILL.md +187 -0
  54. package/skills/render-video/scripts/_chrome_vendor.py +305 -0
  55. package/skills/render-video/scripts/_video_probe.py +174 -0
  56. package/skills/render-video/scripts/_vod_polling.py +86 -0
  57. package/skills/render-video/scripts/remote_renderer_client.py +236 -0
  58. package/skills/render-video/scripts/render_video.py +2288 -0
  59. package/skills/render-video/scripts/upload_video.py +193 -0
  60. package/skills/render-video/skill.json +21 -0
  61. package/skills/render-video/version.json +7 -0
  62. package/skills/template-bind/README.md +63 -0
  63. package/skills/template-bind/SKILL.md +234 -0
  64. package/skills/template-bind/scripts/check_contracts.py +196 -0
  65. package/skills/template-bind/scripts/match_template.py +215 -0
  66. package/skills/template-bind/scripts/registry_loader.py +379 -0
  67. package/skills/template-bind/scripts/render_job_client.py +162 -0
  68. package/skills/template-bind/scripts/sync_registry.py +44 -0
  69. package/skills/template-bind/scripts/template_paths.py +72 -0
  70. package/skills/template-bind/skill.json +20 -0
  71. package/skills/template-bind/version.json +7 -0
  72. package/skills/template-bind/video_dsl/README.md +97 -0
  73. package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
  74. package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
  75. package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
  76. package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
  77. package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
  78. package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
  79. package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
  80. package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
  81. package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
  82. package/skills/video-parser/SKILL.md +238 -0
  83. package/skills/video-parser/scripts/analyze_video.py +1038 -0
  84. package/skills/video-parser/scripts/deconstruct_video.py +602 -0
  85. package/skills/video-parser/scripts/parse_via_render.py +148 -0
  86. package/skills/video-parser/skill.json +21 -0
  87. package/skills/video-parser/version.json +7 -0
  88. package/skills/web-capture/SKILL.md +183 -0
  89. package/skills/web-capture/scripts/web_capture.py +461 -0
  90. package/skills/web-capture/skill.json +29 -0
  91. 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()