@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,1038 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 解构产物 → 视频结构分析报告
4
+
5
+ 从 deconstruction.json(video-parser 的输出)提取钩子、叙事结构、文案节奏、
6
+ 场景编排等关键元素,输出结构化的分析报告 analysis.json。
7
+
8
+ 用法:
9
+ python analyze_video.py -i ./deconstructed_xxx/deconstruction.json
10
+ python analyze_video.py -i ./deconstructed_xxx/ -o ./reports/
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+
19
+ QUIET = False
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Logging
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def log(msg: str) -> None:
27
+ if not QUIET:
28
+ print(msg, flush=True)
29
+
30
+
31
+ def log_err(msg: str) -> None:
32
+ print(msg, file=sys.stderr, flush=True)
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Load
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def load_deconstruction(input_path: str):
40
+ """Load deconstruction.json, return (dict, input_dir)."""
41
+ if os.path.isdir(input_path):
42
+ json_path = os.path.join(input_path, "deconstruction.json")
43
+ else:
44
+ json_path = input_path
45
+
46
+ if not os.path.isfile(json_path):
47
+ log_err(f"找不到 deconstruction.json: {json_path}")
48
+ sys.exit(1)
49
+
50
+ with open(json_path, "r", encoding="utf-8") as f:
51
+ data = json.load(f)
52
+
53
+ input_dir = os.path.dirname(os.path.abspath(json_path))
54
+ return data, input_dir
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Language detection
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def detect_language(text: str) -> str:
62
+ """Simple heuristic: if >30% CJK chars, treat as zh-CN, else en."""
63
+ if not text:
64
+ return "zh-CN"
65
+ cjk_count = sum(1 for ch in text if '\u4e00' <= ch <= '\u9fff')
66
+ return "zh-CN" if cjk_count > len(text) * 0.3 else "en"
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Utterance ↔ scene matching
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def match_utterances_to_scenes(scenes: list, utterances: list) -> dict:
74
+ """Assign ASR utterances to scenes by time overlap.
75
+
76
+ Returns {scene_index: [utterance, ...]}
77
+ """
78
+ result = {s["index"]: [] for s in scenes}
79
+ for utt in utterances:
80
+ utt_start = utt.get("startTime", 0) / 1000.0
81
+ utt_end = utt.get("endTime", 0) / 1000.0
82
+ best_idx, best_overlap = None, 0.0
83
+ for s in scenes:
84
+ overlap = max(0, min(utt_end, s["endTimeSec"]) - max(utt_start, s["startTimeSec"]))
85
+ if overlap > best_overlap:
86
+ best_overlap = overlap
87
+ best_idx = s["index"]
88
+ if best_idx is not None:
89
+ result[best_idx].append(utt)
90
+ return result
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Scene roles
95
+ # ---------------------------------------------------------------------------
96
+
97
+ def infer_scene_roles(scene_count: int) -> list:
98
+ """Infer role labels from scene count.
99
+
100
+ Returns list like ["opening", "point", ..., "ending"].
101
+ """
102
+ if scene_count <= 0:
103
+ return ["opening"]
104
+ if scene_count == 1:
105
+ return ["opening"]
106
+ if scene_count == 2:
107
+ return ["opening", "ending"]
108
+ if scene_count == 3:
109
+ return ["opening", "point", "ending"]
110
+ roles = ["opening"]
111
+ for _ in range(scene_count - 2):
112
+ roles.append("point")
113
+ roles.append("ending")
114
+ return roles
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Hook extraction
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def analyze_hook(scenes: list, utterance_map: dict, asr: dict) -> dict:
122
+ """Extract hook from the first 3 seconds of the first scene."""
123
+ if not scenes:
124
+ return {"text": "", "durationSec": 0.0, "strategy": "none"}
125
+
126
+ first_scene = scenes[0]
127
+ hook_end = first_scene["startTimeSec"] + 3.0
128
+
129
+ # Collect words that fall within the hook window.
130
+ # Space tokens (startTime == -1) are included between valid words
131
+ # to preserve natural text spacing.
132
+ hook_words = []
133
+ utterances = asr.get("utterances", [])
134
+ exceeded = False
135
+ for utt in utterances:
136
+ if exceeded:
137
+ break
138
+ for w in utt.get("words", []):
139
+ w_start = w.get("startTime", -1) / 1000.0
140
+ if w_start < 0:
141
+ # Space/punctuation token — include if we haven't exceeded the window
142
+ hook_words.append(w["text"])
143
+ continue
144
+ if w_start >= hook_end:
145
+ exceeded = True
146
+ break
147
+ hook_words.append(w["text"])
148
+
149
+ hook_text = "".join(hook_words).strip()
150
+ if not hook_text:
151
+ # Fallback: use the first utterance text in the first scene
152
+ first_utts = utterance_map.get(first_scene["index"], [])
153
+ if first_utts:
154
+ hook_text = first_utts[0].get("text", "")
155
+
156
+ # Determine hook duration (end of last word in hook window)
157
+ hook_duration = 3.0
158
+ for utt in utterances:
159
+ for w in utt.get("words", []):
160
+ w_start = w.get("startTime", -1) / 1000.0
161
+ w_end = w.get("endTime", -1) / 1000.0
162
+ if 0 <= w_start < hook_end and w_end > 0:
163
+ hook_duration = min(3.0, w_end)
164
+
165
+ # Classify hook strategy
166
+ strategy = "none"
167
+ if hook_text:
168
+ stripped = hook_text.strip()
169
+ if stripped.endswith("?") or stripped.endswith("?"):
170
+ strategy = "question"
171
+ elif any(kw in stripped.lower() for kw in ["you", "your", "你", "你们", "想不想", "是不是"]):
172
+ strategy = "question"
173
+ else:
174
+ strategy = "direct-content"
175
+
176
+ # Word count: non-space characters for CJK, space-separated tokens for English
177
+ hook_word_count = len(hook_text.replace(" ", "")) if any(
178
+ '\u4e00' <= ch <= '\u9fff' for ch in hook_text
179
+ ) else len(hook_text.split())
180
+
181
+ return {
182
+ "text": hook_text,
183
+ "durationSec": round(hook_duration, 1),
184
+ "strategy": strategy,
185
+ "wordCount": hook_word_count,
186
+ }
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Narrative structure
191
+ # ---------------------------------------------------------------------------
192
+
193
+ def build_narrative_structure(scenes: list, utterance_map: dict, total_duration: float) -> list:
194
+ """Build per-scene narrative structure entries."""
195
+ roles = infer_scene_roles(len(scenes))
196
+ structure = []
197
+ for i, scene in enumerate(scenes):
198
+ duration = round(scene["endTimeSec"] - scene["startTimeSec"], 3)
199
+ pct = round(duration / total_duration * 100, 1) if total_duration > 0 else 0.0
200
+
201
+ utts = utterance_map.get(scene["index"], [])
202
+ text = " ".join(u.get("text", "") for u in utts).strip()
203
+ text_len = len(text.replace(" ", ""))
204
+ density = round(text_len / duration, 1) if duration > 0 else 0.0
205
+
206
+ structure.append({
207
+ "sceneIndex": scene["index"],
208
+ "role": roles[i] if i < len(roles) else "point",
209
+ "durationSec": round(duration, 1),
210
+ "durationPct": pct,
211
+ "text": text,
212
+ "textDensity": density,
213
+ "keyframe": scene.get("keyframe", ""),
214
+ })
215
+ return structure
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Pacing analysis
220
+ # ---------------------------------------------------------------------------
221
+
222
+ def analyze_pacing(scenes: list, total_duration: float) -> dict:
223
+ """Analyze scene pacing / rhythm."""
224
+ durations = []
225
+ for s in scenes:
226
+ d = round(s["endTimeSec"] - s["startTimeSec"], 1)
227
+ durations.append(d)
228
+
229
+ if not durations:
230
+ return {
231
+ "avgSceneDuration": 0.0,
232
+ "minSceneDuration": 0.0,
233
+ "maxSceneDuration": 0.0,
234
+ "rhythm": "uniform",
235
+ "sceneDurations": [],
236
+ }
237
+
238
+ avg = round(sum(durations) / len(durations), 1)
239
+ mn = min(durations)
240
+ mx = max(durations)
241
+
242
+ # Determine rhythm
243
+ if len(durations) == 1:
244
+ rhythm = "uniform"
245
+ elif mx - mn < 1.0:
246
+ rhythm = "uniform"
247
+ else:
248
+ first_half = sum(durations[:len(durations) // 2])
249
+ second_half = sum(durations[len(durations) // 2:])
250
+ if len(durations) == 2:
251
+ ratio = durations[0] / durations[1] if durations[1] > 0 else 1
252
+ if ratio > 2:
253
+ rhythm = "front-heavy"
254
+ elif ratio < 0.5:
255
+ rhythm = "back-heavy"
256
+ else:
257
+ rhythm = "varied"
258
+ elif first_half > second_half * 1.5:
259
+ rhythm = "front-heavy"
260
+ elif second_half > first_half * 1.5:
261
+ rhythm = "back-heavy"
262
+ else:
263
+ cv = (mx - mn) / avg if avg > 0 else 0
264
+ rhythm = "varied" if cv > 0.5 else "uniform"
265
+
266
+ return {
267
+ "avgSceneDuration": avg,
268
+ "minSceneDuration": mn,
269
+ "maxSceneDuration": mx,
270
+ "rhythm": rhythm,
271
+ "sceneDurations": durations,
272
+ }
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Keywords & silence detection
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def extract_keywords(words: list, full_text: str, language: str) -> list:
280
+ """Extract meaningful keywords from ASR data.
281
+
282
+ For English: use word-level tokens, filter stopwords and punctuation.
283
+ For Chinese: split full text on punctuation into phrases, then extract
284
+ multi-character content tokens (simple segmentation by punctuation boundaries).
285
+ """
286
+ if language.startswith("zh"):
287
+ return _extract_keywords_zh(full_text)
288
+ return _extract_keywords_en(words)
289
+
290
+
291
+ # Common Chinese stopwords (function words with little semantic value)
292
+ _ZH_STOPWORDS = {
293
+ "的", "了", "在", "是", "我", "们", "有", "和", "就", "不", "人", "都",
294
+ "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会", "着",
295
+ "没有", "看", "好", "自己", "这", "他", "她", "它", "吗", "什么", "那",
296
+ "没", "把", "那个", "这个", "啊", "呢", "吧", "哦", "嗯", "哈", "呀",
297
+ "被", "从", "对", "而", "但", "但是", "如果", "因为", "所以", "或者",
298
+ "还", "还是", "之", "与", "及", "等", "让", "给", "跟", "比", "为",
299
+ "能", "可以", "这样", "那样", "其", "于", "以", "来", "出", "里",
300
+ }
301
+
302
+
303
+ def _extract_keywords_zh(full_text: str) -> list:
304
+ """Extract Chinese keywords by splitting on punctuation and filtering."""
305
+ # Split on common punctuation to get phrase segments
306
+ segments = re.split(r'[,,。、!?!?.…::;;\-—–\s\'\"()()\[\]【】]+', full_text)
307
+
308
+ keywords = []
309
+ seen = set()
310
+ for seg in segments:
311
+ seg = seg.strip()
312
+ if not seg:
313
+ continue
314
+ # Extract multi-char tokens from each segment using a simple greedy approach:
315
+ # scan for runs of CJK characters (2+ chars) and non-CJK tokens (e.g. "1V1", "3500")
316
+ tokens = re.findall(r'[\u4e00-\u9fff]{2,}|[A-Za-z0-9]+(?:[A-Za-z0-9]+)*', seg)
317
+ for tok in tokens:
318
+ lower = tok.lower()
319
+ if lower in seen:
320
+ continue
321
+ if tok in _ZH_STOPWORDS:
322
+ continue
323
+ # Skip very short CJK tokens that are likely function words
324
+ if len(tok) == 2 and all('\u4e00' <= c <= '\u9fff' for c in tok) and tok in _ZH_STOPWORDS:
325
+ continue
326
+ seen.add(lower)
327
+ keywords.append(tok)
328
+ return keywords
329
+
330
+
331
+ def _extract_keywords_en(words: list) -> list:
332
+ """Extract English keywords from word-level ASR data."""
333
+ stop_pattern = re.compile(r'^[\s,,。、!?!?.…::;;\-—–\'\"()()\[\]【】]+$')
334
+ en_stopwords = {
335
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
336
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
337
+ "should", "may", "might", "shall", "can", "to", "of", "in", "for",
338
+ "on", "with", "at", "by", "from", "as", "into", "through", "during",
339
+ "before", "after", "and", "but", "or", "nor", "not", "so", "yet",
340
+ "both", "either", "neither", "each", "every", "all", "any", "few",
341
+ "more", "most", "other", "some", "such", "no", "only", "own", "same",
342
+ "than", "too", "very", "just", "because", "if", "when", "where",
343
+ "how", "what", "which", "who", "whom", "this", "that", "these",
344
+ "those", "i", "me", "my", "we", "our", "you", "your", "he", "him",
345
+ "his", "she", "her", "it", "its", "they", "them", "their",
346
+ }
347
+ keywords = []
348
+ seen = set()
349
+ for w in words:
350
+ text = w.get("text", "").strip()
351
+ if not text or stop_pattern.match(text):
352
+ continue
353
+ if w.get("startTime", -1) < 0:
354
+ continue
355
+ lower = text.lower()
356
+ if lower in en_stopwords:
357
+ continue
358
+ if lower not in seen:
359
+ seen.add(lower)
360
+ keywords.append(text)
361
+ return keywords
362
+
363
+
364
+ def detect_silence_segments(utterances: list, total_duration: float) -> list:
365
+ """Find gaps between utterances longer than 1 second."""
366
+ if not utterances:
367
+ return []
368
+
369
+ segments = []
370
+ # Gap before first utterance
371
+ first_start = utterances[0].get("startTime", 0) / 1000.0
372
+ if first_start > 1.0:
373
+ segments.append({
374
+ "startSec": 0.0,
375
+ "endSec": round(first_start, 1),
376
+ "durationSec": round(first_start, 1),
377
+ })
378
+
379
+ # Gaps between utterances
380
+ for i in range(len(utterances) - 1):
381
+ cur_end = utterances[i].get("endTime", 0) / 1000.0
382
+ next_start = utterances[i + 1].get("startTime", 0) / 1000.0
383
+ gap = next_start - cur_end
384
+ if gap > 1.0:
385
+ segments.append({
386
+ "startSec": round(cur_end, 1),
387
+ "endSec": round(next_start, 1),
388
+ "durationSec": round(gap, 1),
389
+ })
390
+
391
+ # Gap after last utterance
392
+ last_end = utterances[-1].get("endTime", 0) / 1000.0
393
+ if total_duration - last_end > 1.0:
394
+ segments.append({
395
+ "startSec": round(last_end, 1),
396
+ "endSec": round(total_duration, 1),
397
+ "durationSec": round(total_duration - last_end, 1),
398
+ })
399
+
400
+ return segments
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Text structure
405
+ # ---------------------------------------------------------------------------
406
+
407
+ def analyze_text_structure(asr: dict, scenes: list, utterance_map: dict, total_duration: float) -> dict:
408
+ """Analyze text structure: speech rate, silence segments, keywords."""
409
+ full_text = asr.get("text", "")
410
+ utterances = asr.get("utterances", [])
411
+
412
+ # Collect all words from all utterances
413
+ all_words = []
414
+ for utt in utterances:
415
+ all_words.extend(utt.get("words", []))
416
+
417
+ # Speech duration (sum of utterance durations)
418
+ speech_duration = 0.0
419
+ for utt in utterances:
420
+ s = utt.get("startTime", 0) / 1000.0
421
+ e = utt.get("endTime", 0) / 1000.0
422
+ speech_duration += max(0, e - s)
423
+
424
+ # Text length (non-space characters)
425
+ text_len = len(full_text.replace(" ", ""))
426
+
427
+ # Speech rate: chars per second of speech
428
+ speech_rate = round(text_len / speech_duration, 1) if speech_duration > 0 else 0.0
429
+
430
+ # Silence segments
431
+ silence_segments = detect_silence_segments(utterances, total_duration)
432
+
433
+ # Keywords
434
+ keywords = extract_keywords(all_words, full_text, detect_language(full_text))
435
+
436
+ return {
437
+ "fullText": full_text,
438
+ "speechRate": speech_rate,
439
+ "silenceSegments": silence_segments,
440
+ "keywords": keywords,
441
+ }
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # Audio pattern
446
+ # ---------------------------------------------------------------------------
447
+
448
+ def analyze_audio_pattern(asr: dict, audio: dict, total_duration: float) -> dict:
449
+ """Analyze audio pattern: narration presence, speech/silence ratio."""
450
+ utterances = asr.get("utterances", [])
451
+ has_narration = len(utterances) > 0
452
+
453
+ narration_url = audio.get("url", "")
454
+
455
+ # Total speech duration
456
+ speech_duration = 0.0
457
+ for utt in utterances:
458
+ s = utt.get("startTime", 0) / 1000.0
459
+ e = utt.get("endTime", 0) / 1000.0
460
+ speech_duration += max(0, e - s)
461
+
462
+ speech_ratio = round(speech_duration / total_duration, 2) if total_duration > 0 else 0.0
463
+ silence_ratio = round(1.0 - speech_ratio, 2)
464
+
465
+ return {
466
+ "hasNarration": has_narration,
467
+ "narrationUrl": narration_url,
468
+ "speechRatio": speech_ratio,
469
+ "silenceRatio": silence_ratio,
470
+ }
471
+
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # CTA detection
475
+ # ---------------------------------------------------------------------------
476
+
477
+ CTA_KEYWORDS = [
478
+ # English
479
+ "subscribe", "like", "follow", "share", "comment", "click", "link",
480
+ "sign up", "buy now", "check out", "tap", "swipe up", "join",
481
+ # Chinese — verbs / phrases commonly used in CTA
482
+ "关注", "点赞", "转发", "评论", "订阅", "分享", "点击", "链接",
483
+ "扫码", "加入", "报名", "下单", "购买", "赶紧", "抓紧", "安排",
484
+ "左下角", "右下角", "小黄车", "橱窗", "主页", "私信", "留言",
485
+ ]
486
+
487
+
488
+ def analyze_cta(scenes: list, utterance_map: dict, utterances: list) -> dict:
489
+ """Detect call-to-action in the ending portion of the video.
490
+
491
+ When there are multiple scenes, check the last scene's text.
492
+ When there is only one scene, check the last utterance(s) to avoid
493
+ treating the entire video text as the ending.
494
+ """
495
+ if not scenes:
496
+ return {"hasExplicitCta": False, "endingText": "", "endingDurationSec": 0.0}
497
+
498
+ last_scene = scenes[-1]
499
+ scene_duration = round(last_scene["endTimeSec"] - last_scene["startTimeSec"], 1)
500
+
501
+ # Determine ending text: use last scene's utterances,
502
+ # but if there's only one scene, narrow down to the last utterance(s)
503
+ utts = utterance_map.get(last_scene["index"], [])
504
+ if len(scenes) == 1 and len(utts) > 1:
505
+ # Single-scene video: take the last 1-2 utterances as ending
506
+ ending_utts = utts[-2:] if len(utts) >= 2 else utts[-1:]
507
+ ending_text = " ".join(u.get("text", "") for u in ending_utts).strip()
508
+ # Approximate ending duration from last utterance timestamps
509
+ first_end_utt = ending_utts[0]
510
+ ending_duration = round(
511
+ (last_scene["endTimeSec"] - first_end_utt.get("startTime", 0) / 1000.0), 1
512
+ )
513
+ else:
514
+ ending_text = " ".join(u.get("text", "") for u in utts).strip()
515
+ ending_duration = scene_duration
516
+
517
+ # Keyword matching
518
+ lower_text = ending_text.lower()
519
+ matched_count = sum(1 for kw in CTA_KEYWORDS if kw in lower_text)
520
+ has_cta = matched_count > 0
521
+
522
+ # Strength: none / weak / strong
523
+ if matched_count == 0:
524
+ strength = "none"
525
+ elif matched_count == 1:
526
+ strength = "weak"
527
+ else:
528
+ strength = "strong"
529
+
530
+ return {
531
+ "hasExplicitCta": has_cta,
532
+ "endingText": ending_text,
533
+ "endingDurationSec": ending_duration,
534
+ "strength": strength,
535
+ }
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Utterance metrics
540
+ # ---------------------------------------------------------------------------
541
+
542
+ def analyze_utterance_metrics(utterances: list) -> dict:
543
+ """Compute per-utterance statistics: char counts, durations, density."""
544
+ if not utterances:
545
+ return {
546
+ "count": 0,
547
+ "avgCharCount": 0.0,
548
+ "minCharCount": 0,
549
+ "maxCharCount": 0,
550
+ "avgDurationSec": 0.0,
551
+ "minDurationSec": 0.0,
552
+ "maxDurationSec": 0.0,
553
+ "avgDensity": 0.0,
554
+ "utterances": [],
555
+ }
556
+
557
+ items = []
558
+ for idx, utt in enumerate(utterances):
559
+ text = utt.get("text", "")
560
+ char_count = len(text.replace(" ", ""))
561
+ start = utt.get("startTime", 0) / 1000.0
562
+ end = utt.get("endTime", 0) / 1000.0
563
+ dur = max(0.0, end - start)
564
+ density = round(char_count / dur, 1) if dur > 0 else 0.0
565
+ items.append({
566
+ "index": idx,
567
+ "text": text,
568
+ "durationSec": round(dur, 1),
569
+ "charCount": char_count,
570
+ "density": density,
571
+ })
572
+
573
+ char_counts = [it["charCount"] for it in items]
574
+ durations = [it["durationSec"] for it in items]
575
+ densities = [it["density"] for it in items]
576
+
577
+ return {
578
+ "count": len(items),
579
+ "avgCharCount": round(sum(char_counts) / len(char_counts), 1),
580
+ "minCharCount": min(char_counts),
581
+ "maxCharCount": max(char_counts),
582
+ "avgDurationSec": round(sum(durations) / len(durations), 1),
583
+ "minDurationSec": min(durations),
584
+ "maxDurationSec": max(durations),
585
+ "avgDensity": round(sum(densities) / len(densities), 1),
586
+ "utterances": items,
587
+ }
588
+
589
+
590
+ # ---------------------------------------------------------------------------
591
+ # Speech rhythm
592
+ # ---------------------------------------------------------------------------
593
+
594
+ def analyze_speech_rhythm(utterances: list) -> dict:
595
+ """Analyze word-level micro-rhythm: gaps, pauses, tempo profile."""
596
+ # Collect all words with valid timestamps across all utterances
597
+ all_words = []
598
+ for utt in utterances:
599
+ for w in utt.get("words", []):
600
+ if w.get("startTime", -1) >= 0:
601
+ all_words.append(w)
602
+
603
+ if len(all_words) < 2:
604
+ return {
605
+ "avgWordGapMs": 0,
606
+ "maxWordGapMs": 0,
607
+ "intentionalPauses": [],
608
+ "tempoProfile": "steady",
609
+ }
610
+
611
+ # Sort words by start time
612
+ all_words.sort(key=lambda w: w["startTime"])
613
+
614
+ gaps = []
615
+ pauses = []
616
+ for i in range(1, len(all_words)):
617
+ prev_end = all_words[i - 1].get("endTime", 0)
618
+ cur_start = all_words[i].get("startTime", 0)
619
+ gap = cur_start - prev_end
620
+ if gap < 0:
621
+ continue
622
+ gaps.append(gap)
623
+ if gap > 500:
624
+ pauses.append({
625
+ "afterWord": all_words[i - 1].get("text", "").strip(),
626
+ "gapMs": int(gap),
627
+ "timestampSec": round(prev_end / 1000.0, 1),
628
+ })
629
+
630
+ avg_gap = int(sum(gaps) / len(gaps)) if gaps else 0
631
+ max_gap = int(max(gaps)) if gaps else 0
632
+
633
+ # Tempo profile: compare first-half vs second-half avg gap
634
+ if len(gaps) < 4:
635
+ tempo = "steady"
636
+ else:
637
+ mid = len(gaps) // 2
638
+ first_avg = sum(gaps[:mid]) / mid
639
+ second_avg = sum(gaps[mid:]) / (len(gaps) - mid)
640
+ if first_avg > 0 and second_avg / first_avg < 0.7:
641
+ tempo = "accelerating"
642
+ elif second_avg > 0 and first_avg / second_avg < 0.7:
643
+ tempo = "decelerating"
644
+ elif max_gap > avg_gap * 3 and len(pauses) >= 2:
645
+ tempo = "varied"
646
+ else:
647
+ tempo = "steady"
648
+
649
+ return {
650
+ "avgWordGapMs": avg_gap,
651
+ "maxWordGapMs": max_gap,
652
+ "intentionalPauses": pauses,
653
+ "tempoProfile": tempo,
654
+ }
655
+
656
+
657
+ # ---------------------------------------------------------------------------
658
+ # Repetition analysis
659
+ # ---------------------------------------------------------------------------
660
+
661
+ def analyze_repetition(full_text: str, words: list, language: str) -> dict:
662
+ """Detect repeated words/phrases and compute repetition ratio."""
663
+ from collections import Counter
664
+
665
+ if language.startswith("zh"):
666
+ # Chinese: tokenize via punctuation-split + CJK/alphanum extraction (no dedup)
667
+ segments = re.split(r'[,,。、!?!?.…::;;\-—–\s\'\"()()\[\]【】]+', full_text)
668
+ tokens = []
669
+ for seg in segments:
670
+ seg = seg.strip()
671
+ if not seg:
672
+ continue
673
+ toks = re.findall(r'[\u4e00-\u9fff]{2,}|[A-Za-z0-9]+(?:[A-Za-z0-9]+)*', seg)
674
+ for tok in toks:
675
+ if tok not in _ZH_STOPWORDS:
676
+ tokens.append(tok)
677
+ else:
678
+ # English: use word-level tokens, filter stopwords
679
+ en_stopwords = {
680
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
681
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
682
+ "should", "may", "might", "shall", "can", "to", "of", "in", "for",
683
+ "on", "with", "at", "by", "from", "as", "into", "through", "during",
684
+ "before", "after", "and", "but", "or", "nor", "not", "so", "yet",
685
+ "both", "either", "neither", "each", "every", "all", "any", "few",
686
+ "more", "most", "other", "some", "such", "no", "only", "own", "same",
687
+ "than", "too", "very", "just", "because", "if", "when", "where",
688
+ "how", "what", "which", "who", "whom", "this", "that", "these",
689
+ "those", "i", "me", "my", "we", "our", "you", "your", "he", "him",
690
+ "his", "she", "her", "it", "its", "they", "them", "their",
691
+ }
692
+ tokens = []
693
+ for w in words:
694
+ text = w.get("text", "").strip()
695
+ if not text or w.get("startTime", -1) < 0:
696
+ continue
697
+ lower = text.lower()
698
+ if lower in en_stopwords:
699
+ continue
700
+ if re.match(r'^[\s,,。、!?!?.…::;;\-—–\'\"()()\[\]【】]+$', text):
701
+ continue
702
+ tokens.append(lower)
703
+
704
+ counter = Counter(tokens)
705
+ top_repeated = [
706
+ {"word": word, "count": cnt}
707
+ for word, cnt in counter.most_common()
708
+ if cnt >= 2
709
+ ][:10]
710
+
711
+ total_words = len(tokens)
712
+ unique_words = len(counter)
713
+ ratio = round(1 - unique_words / total_words, 2) if total_words > 0 else 0.0
714
+
715
+ return {
716
+ "topRepeated": top_repeated,
717
+ "totalUniqueWords": unique_words,
718
+ "totalWords": total_words,
719
+ "repetitionRatio": ratio,
720
+ }
721
+
722
+
723
+ # ---------------------------------------------------------------------------
724
+ # Sentence type classification
725
+ # ---------------------------------------------------------------------------
726
+
727
+ _IMPERATIVE_STARTERS_ZH = [
728
+ "点", "看", "赶紧", "抓紧", "快", "记得", "一定", "别忘",
729
+ "关注", "点赞", "转发", "评论", "订阅", "分享", "点击",
730
+ "扫码", "加入", "报名", "下单", "购买", "安排", "留言",
731
+ ]
732
+ _IMPERATIVE_STARTERS_EN = [
733
+ "click", "tap", "subscribe", "like", "follow", "share", "comment",
734
+ "check", "go", "try", "watch", "listen", "join", "sign", "buy",
735
+ "get", "grab", "hit", "swipe", "make", "let", "don't forget",
736
+ "remember",
737
+ ]
738
+
739
+
740
+ def analyze_sentence_types(full_text: str, language: str) -> dict:
741
+ """Classify sentences by type: declarative, interrogative, imperative, exclamatory."""
742
+ # Split into sentences by sentence-ending punctuation
743
+ sentences = re.split(r'[。!?!?.]', full_text)
744
+ sentences = [s.strip() for s in sentences if s.strip()]
745
+
746
+ if not sentences:
747
+ return {
748
+ "declarative": 0,
749
+ "interrogative": 0,
750
+ "imperative": 0,
751
+ "exclamatory": 0,
752
+ "distribution": {
753
+ "declarative": 0.0,
754
+ "interrogative": 0.0,
755
+ "imperative": 0.0,
756
+ "exclamatory": 0.0,
757
+ },
758
+ }
759
+
760
+ # To classify we need the original ending punctuation, so re-scan full_text
761
+ # Build list of (sentence_text, ending_punctuation)
762
+ parts = re.findall(r'([^。!?!?.]+)([。!?!?.])', full_text)
763
+ # If regex didn't capture all, fallback to simple classification
764
+ if not parts:
765
+ parts = [(s, "。") for s in sentences]
766
+
767
+ counts = {"declarative": 0, "interrogative": 0, "imperative": 0, "exclamatory": 0}
768
+
769
+ imp_starters = _IMPERATIVE_STARTERS_ZH if language.startswith("zh") else _IMPERATIVE_STARTERS_EN
770
+
771
+ for text, punct in parts:
772
+ text = text.strip()
773
+ if not text:
774
+ continue
775
+ if punct in ("?", "?"):
776
+ counts["interrogative"] += 1
777
+ elif punct in ("!", "!"):
778
+ counts["exclamatory"] += 1
779
+ elif any(text.lower().startswith(kw) for kw in imp_starters):
780
+ counts["imperative"] += 1
781
+ else:
782
+ counts["declarative"] += 1
783
+
784
+ total = sum(counts.values())
785
+ dist = {}
786
+ for k, v in counts.items():
787
+ dist[k] = round(v / total, 3) if total > 0 else 0.0
788
+
789
+ return {
790
+ **counts,
791
+ "distribution": dist,
792
+ }
793
+
794
+
795
+ # ---------------------------------------------------------------------------
796
+ # Assets summary
797
+ # ---------------------------------------------------------------------------
798
+
799
+ def build_assets_summary(decon: dict) -> dict:
800
+ """Build assets summary from deconstruction data."""
801
+ audio = decon.get("audio", {})
802
+ keyframes = decon.get("keyframes", [])
803
+
804
+ audio_info = {}
805
+ if audio.get("localPath") or audio.get("url"):
806
+ audio_info = {
807
+ "localPath": audio.get("localPath", ""),
808
+ "url": audio.get("url", ""),
809
+ }
810
+
811
+ kf_list = []
812
+ for kf in keyframes:
813
+ kf_list.append({
814
+ "index": kf["index"],
815
+ "localPath": kf.get("localPath", ""),
816
+ "timestampSec": kf.get("timestampSec", 0.0),
817
+ })
818
+
819
+ result = {}
820
+ if audio_info:
821
+ result["audio"] = audio_info
822
+ if kf_list:
823
+ result["keyframes"] = kf_list
824
+ return result
825
+
826
+
827
+ # ---------------------------------------------------------------------------
828
+ # Main
829
+ # ---------------------------------------------------------------------------
830
+
831
+ def main():
832
+ parser = argparse.ArgumentParser(
833
+ description="Generate a video structural analysis report (analysis.json) from a deconstruction artifact",
834
+ formatter_class=argparse.RawDescriptionHelpFormatter,
835
+ epilog="""
836
+ Examples:
837
+ python analyze_video.py -i ./deconstructed_xxx/deconstruction.json
838
+ python analyze_video.py -i ./deconstructed_xxx/ -o ./reports/
839
+ python analyze_video.py -i ./deconstructed_xxx/ --json-output
840
+ """,
841
+ )
842
+
843
+ parser.add_argument(
844
+ "-i", "--input",
845
+ required=True,
846
+ help="Path to deconstruction.json or its parent directory",
847
+ )
848
+ parser.add_argument(
849
+ "-o", "--output-dir",
850
+ default=None,
851
+ help="Output directory (default: same as input)",
852
+ )
853
+ parser.add_argument(
854
+ "--json-output",
855
+ action="store_true",
856
+ default=False,
857
+ help="Only print JSON to stdout (pipeline mode)",
858
+ )
859
+
860
+ args = parser.parse_args()
861
+
862
+ global QUIET
863
+ QUIET = args.json_output
864
+
865
+ # ------------------------------------------------------------------
866
+ # [1] Load deconstruction
867
+ # ------------------------------------------------------------------
868
+ log("[1/13] Loading deconstruction data...")
869
+ decon, input_dir = load_deconstruction(args.input)
870
+ log(f" loaded: {input_dir}/deconstruction.json")
871
+
872
+ output_dir = args.output_dir or input_dir
873
+ os.makedirs(output_dir, exist_ok=True)
874
+
875
+ # ------------------------------------------------------------------
876
+ # [2] Basic info
877
+ # ------------------------------------------------------------------
878
+ log("[2/13] Basic analysis...")
879
+ source = decon.get("source", {})
880
+ duration_ms = source.get("durationMs", 0)
881
+ duration_sec = duration_ms / 1000.0
882
+
883
+ scenes = decon.get("scenes", [])
884
+ asr = decon.get("asr", {})
885
+ audio = decon.get("audio", {})
886
+ utterances = asr.get("utterances", [])
887
+ full_text = asr.get("text", "")
888
+ keyframes = decon.get("keyframes", [])
889
+
890
+ language = detect_language(full_text)
891
+ asset_types = []
892
+ if keyframes:
893
+ asset_types.append("image")
894
+ if audio.get("url") or audio.get("localPath"):
895
+ asset_types.append("audio")
896
+
897
+ source_info = {
898
+ "url": source.get("url", ""),
899
+ "durationMs": duration_ms,
900
+ "durationSec": round(duration_sec, 1),
901
+ }
902
+
903
+ overview = {
904
+ "language": language,
905
+ "sceneCount": len(scenes),
906
+ "totalTextLength": len(full_text.replace(" ", "")),
907
+ "assetTypes": asset_types,
908
+ }
909
+
910
+ log(f" duration: {duration_sec:.1f}s, scenes: {len(scenes)}, language: {language}")
911
+
912
+ # ------------------------------------------------------------------
913
+ # [3] Scene structure (utterance mapping)
914
+ # ------------------------------------------------------------------
915
+ log("[3/13] Scene-structure analysis...")
916
+ utterance_map = match_utterances_to_scenes(scenes, utterances) if scenes and utterances else {}
917
+ narrative = build_narrative_structure(scenes, utterance_map, duration_sec)
918
+ for ns in narrative:
919
+ log(f" scene {ns['sceneIndex']}: {ns['role']} ({ns['durationSec']}s, {ns['durationPct']}%)")
920
+
921
+ # ------------------------------------------------------------------
922
+ # [4] Hook extraction
923
+ # ------------------------------------------------------------------
924
+ log("[4/13] Hook extraction...")
925
+ hook = analyze_hook(scenes, utterance_map, asr)
926
+ log(f" strategy: {hook['strategy']}, word count: {hook['wordCount']}, text: {hook['text'][:40]}...")
927
+
928
+ # ------------------------------------------------------------------
929
+ # [5] Pacing analysis
930
+ # ------------------------------------------------------------------
931
+ log("[5/13] Pacing analysis...")
932
+ pacing = analyze_pacing(scenes, duration_sec)
933
+ log(f" rhythm: {pacing['rhythm']}, scene durations: {pacing['sceneDurations']}")
934
+
935
+ # ------------------------------------------------------------------
936
+ # [6] Text structure
937
+ # ------------------------------------------------------------------
938
+ log("[6/13] Copy-structure analysis...")
939
+ text_structure = analyze_text_structure(asr, scenes, utterance_map, duration_sec)
940
+ log(f" speech rate: {text_structure['speechRate']} chars/sec, "
941
+ f"关键词: {len(text_structure['keywords'])} 个, "
942
+ f"静默段: {len(text_structure['silenceSegments'])} 处")
943
+
944
+ # ------------------------------------------------------------------
945
+ # [7] Audio pattern
946
+ # ------------------------------------------------------------------
947
+ log("[7/13] Audio-pattern analysis...")
948
+ audio_pattern = analyze_audio_pattern(asr, audio, duration_sec)
949
+ log(f" speech ratio: {audio_pattern['speechRatio']}, "
950
+ f"静默比例: {audio_pattern['silenceRatio']}")
951
+
952
+ # ------------------------------------------------------------------
953
+ # [8] CTA detection
954
+ # ------------------------------------------------------------------
955
+ log("[8/13] CTA detection...")
956
+ cta = analyze_cta(scenes, utterance_map, utterances)
957
+ log(f" explicit CTA: {cta['hasExplicitCta']}, "
958
+ f"强度: {cta['strength']}, "
959
+ f"结尾文案: {cta['endingText'][:30]}")
960
+
961
+ # ------------------------------------------------------------------
962
+ # [9] Utterance metrics
963
+ # ------------------------------------------------------------------
964
+ log("[9/13] Sentence statistics...")
965
+ utterance_metrics = analyze_utterance_metrics(utterances)
966
+ log(f" sentences: {utterance_metrics['count']}, "
967
+ f"平均字数: {utterance_metrics['avgCharCount']}, "
968
+ f"平均时长: {utterance_metrics['avgDurationSec']}s")
969
+
970
+ # ------------------------------------------------------------------
971
+ # [10] Speech rhythm
972
+ # ------------------------------------------------------------------
973
+ log("[10/13] Speech rhythm...")
974
+ speech_rhythm = analyze_speech_rhythm(utterances)
975
+ log(f" avg word gap: {speech_rhythm['avgWordGapMs']}ms, "
976
+ f"有意停顿: {len(speech_rhythm['intentionalPauses'])} 处, "
977
+ f"节奏: {speech_rhythm['tempoProfile']}")
978
+
979
+ # ------------------------------------------------------------------
980
+ # [11] Repetition
981
+ # ------------------------------------------------------------------
982
+ log("[11/13] Repetition patterns...")
983
+ all_words = []
984
+ for utt in utterances:
985
+ all_words.extend(utt.get("words", []))
986
+ repetition = analyze_repetition(full_text, all_words, language)
987
+ top3 = ", ".join(f"{r['word']}({r['count']})" for r in repetition["topRepeated"][:3])
988
+ log(f" repetition ratio: {repetition['repetitionRatio']}, "
989
+ f"高频词: {top3 or '无'}")
990
+
991
+ # ------------------------------------------------------------------
992
+ # [12] Sentence types
993
+ # ------------------------------------------------------------------
994
+ log("[12/13] Sentence-type distribution...")
995
+ sentence_types = analyze_sentence_types(full_text, language)
996
+ log(f" declarative: {sentence_types['declarative']}, "
997
+ f"疑问: {sentence_types['interrogative']}, "
998
+ f"祈使: {sentence_types['imperative']}, "
999
+ f"感叹: {sentence_types['exclamatory']}")
1000
+
1001
+ # ------------------------------------------------------------------
1002
+ # [13] Output
1003
+ # ------------------------------------------------------------------
1004
+ log("[13/13] Writing analysis report...")
1005
+ assets = build_assets_summary(decon)
1006
+
1007
+ analysis = {
1008
+ "source": source_info,
1009
+ "overview": overview,
1010
+ "hook": hook,
1011
+ "narrativeStructure": narrative,
1012
+ "pacing": pacing,
1013
+ "textStructure": text_structure,
1014
+ "audioPattern": audio_pattern,
1015
+ "cta": cta,
1016
+ "utteranceMetrics": utterance_metrics,
1017
+ "speechRhythm": speech_rhythm,
1018
+ "repetition": repetition,
1019
+ "sentenceTypes": sentence_types,
1020
+ "assets": assets,
1021
+ }
1022
+
1023
+ if args.json_output:
1024
+ print(json.dumps(analysis, ensure_ascii=False, indent=2))
1025
+ else:
1026
+ analysis_path = os.path.join(output_dir, "analysis.json")
1027
+ with open(analysis_path, "w", encoding="utf-8") as f:
1028
+ json.dump(analysis, f, ensure_ascii=False, indent=2)
1029
+ log(f"\nAnalysis complete!")
1030
+ log(f" report: {analysis_path}")
1031
+ log(f" duration: {source_info['durationSec']}s")
1032
+ log(f" scenes: {overview['sceneCount']}")
1033
+ log(f" rhythm: {pacing['rhythm']}")
1034
+ log(f" hook: {hook['strategy']}")
1035
+
1036
+
1037
+ if __name__ == "__main__":
1038
+ main()