@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,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()
|