@dsh-gis/dsh-video-studio-skill 0.8.1 → 1.0.2
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/CHANGELOG.md +582 -0
- package/README.md +287 -304
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +11 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +171 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1066
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +1 -2
- package/skills/video-studio/assets/golden_frames.json +12 -9
- package/skills/video-studio/assets/scenes.py +2207 -1190
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/audio-variation.md +15 -11
- package/skills/video-studio/references/audio.md +36 -34
- package/skills/video-studio/references/change-impact.md +3 -3
- package/skills/video-studio/references/frame-qa.md +2 -2
- package/skills/video-studio/references/proposal.md +177 -0
- package/skills/video-studio/references/quality-gates.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +311 -0
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +107 -168
- package/skills/video-studio/references/v08-quality.md +8 -8
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/references/word-timeline.md +2 -2
- package/skills/video-studio/scripts/designkit.py +2120 -0
- package/skills/video-studio/scripts/filmcheck.py +104 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +619 -0
- package/skills/video-studio/scripts/musicgen.py +2043 -0
- package/skills/video-studio/scripts/preflight.py +10 -3
- package/skills/video-studio/scripts/previewkit.py +849 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +45 -10
- package/skills/video-studio/scripts/qavmaf.py +77 -11
- package/skills/video-studio/scripts/render.py +505 -142
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +851 -115
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +293 -570
- package/skills/video-studio/scripts/voicekit.py +502 -380
- package/skills/video-studio/scripts/webengine.py +15 -14
- package/tools/audio_similarity.py +57 -30
- package/tools/ci_check.py +9 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +1263 -0
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/skills/video-studio/assets/score_demo.json +0 -307
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""grammar.py — 场景语法:这段内容该长成什么构图(v0.9 新增)。
|
|
3
|
+
|
|
4
|
+
为什么单独一个模块
|
|
5
|
+
------------------
|
|
6
|
+
"叙事语气"(kind: hook/body/quote/emphasis/calm/outro)与"画面构图"
|
|
7
|
+
(scene type: hero/list/stat/compare/...)是两个**正交**维度:
|
|
8
|
+
- kind 决定旁白声线、语气、配乐能量(voicekit / scorekit 读它);
|
|
9
|
+
- scene type 决定这段画面长什么样(scenes.py 的 _DRAWERS 读它)。
|
|
10
|
+
v0.8 及以前两者被混成一个 kind,而 scenes.py 只有 hook/body/outro 三个绘制器,
|
|
11
|
+
quote/emphasis/calm 全部回落到 body —— 这就是"8 套风格只是换皮肤"的根因。
|
|
12
|
+
|
|
13
|
+
把"内容 -> 构图"的选择逻辑独立出来,有三个好处:
|
|
14
|
+
1. 纯函数、只依赖标准库:可以单测,不需要 PIL / TTS / 渲染;
|
|
15
|
+
2. 选择理由落盘(work/storyboard.json),用户能看到"为什么这段画成数字卡";
|
|
16
|
+
3. scenes.py 只负责"怎么画",不负责"画什么"——改构图不动选择逻辑,反之亦然。
|
|
17
|
+
|
|
18
|
+
铁律:**不许**用 random / time / hash()(str hash 每进程带随机盐)。
|
|
19
|
+
所有派生量只用 sha1 前 8 位,保证"同一份稿子永远同一张分镜表"。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
# ============================================================================
|
|
27
|
+
# 一、10 类场景(顺序 = 平手时的固定取位顺序,改顺序会让同一稿子的分镜漂移)
|
|
28
|
+
# ============================================================================
|
|
29
|
+
|
|
30
|
+
SCENE_TYPES = ("hero", "chapter", "stat", "chart", "compare",
|
|
31
|
+
"process", "quote", "list", "statement", "outro")
|
|
32
|
+
|
|
33
|
+
SCENE_LABELS = {
|
|
34
|
+
"hero": "封面",
|
|
35
|
+
"chapter": "章节卡",
|
|
36
|
+
"stat": "数字卡",
|
|
37
|
+
"chart": "数据条",
|
|
38
|
+
"compare": "左右对比",
|
|
39
|
+
"process": "流程轴",
|
|
40
|
+
"quote": "引语",
|
|
41
|
+
"list": "要点列表",
|
|
42
|
+
"statement": "金句",
|
|
43
|
+
"outro": "收尾",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# 最短段长(秒):低于它动画演不完,必须按 DOWNGRADE 降级。
|
|
47
|
+
# 数字来源不是拍脑袋:入场动效本身 0.8~1.3s,条目 stagger 3 条约 0.9s,
|
|
48
|
+
# 留 1/3 屏时间给观众读,才撑得住。
|
|
49
|
+
MIN_DUR = {
|
|
50
|
+
"hero": 2.4,
|
|
51
|
+
"chapter": 2.2,
|
|
52
|
+
"stat": 3.2,
|
|
53
|
+
"chart": 4.5,
|
|
54
|
+
"compare": 4.0,
|
|
55
|
+
"process": 4.0,
|
|
56
|
+
"quote": 4.0,
|
|
57
|
+
"list": 3.0,
|
|
58
|
+
"statement": 2.6,
|
|
59
|
+
"outro": 2.4,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# 段太短时的降级目标(键必须都在 SCENE_TYPES 里)
|
|
63
|
+
DOWNGRADE = {
|
|
64
|
+
"hero": "statement",
|
|
65
|
+
"chapter": "statement",
|
|
66
|
+
"stat": "statement",
|
|
67
|
+
"chart": "list",
|
|
68
|
+
"compare": "list",
|
|
69
|
+
"process": "list",
|
|
70
|
+
"quote": "statement",
|
|
71
|
+
"list": "statement",
|
|
72
|
+
"statement": "statement",
|
|
73
|
+
"outro": "statement",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# 连续同类型最多几段(超出换下一个候选,防三连 list 的"复制粘贴感")
|
|
77
|
+
MAX_REPEAT = 2
|
|
78
|
+
|
|
79
|
+
# ============================================================================
|
|
80
|
+
# 二、确定性哈希(与 stylekit._digest 同口径:sha1 前 8 位)
|
|
81
|
+
# ============================================================================
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _digest(*parts):
|
|
85
|
+
raw = "|".join(str(p) for p in parts).encode("utf-8")
|
|
86
|
+
return int(hashlib.sha1(raw).hexdigest()[:8], 16)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hash01(*parts):
|
|
90
|
+
"""0..1 的稳定随机数(替代 random.random())。"""
|
|
91
|
+
return (_digest(*parts) % 100003) / 100003.0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _hash_mod(n, *parts):
|
|
95
|
+
n = max(1, int(n))
|
|
96
|
+
return _digest(*parts) % n
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ============================================================================
|
|
100
|
+
# 三、内容特征(全部是"有没有这类信号",不做语义理解)
|
|
101
|
+
# ============================================================================
|
|
102
|
+
|
|
103
|
+
# 对比词:出现即强烈暗示"左右对照"构图
|
|
104
|
+
COMPARE_WORDS = ("对比", "相比", "相比之", "优劣", "差异", "前后", "以前", "如今",
|
|
105
|
+
"vs", "VS", "而不是", "一边", "另一边", "传统", "新版")
|
|
106
|
+
|
|
107
|
+
# 顺序词:出现即暗示"流程/步骤"构图
|
|
108
|
+
SEQUENCE_WORDS = ("第一步", "第二步", "第三步", "首先", "然后", "接着", "最后",
|
|
109
|
+
"第一步", "步骤", "流程", "环节", "→", "->", "依次", "接下来")
|
|
110
|
+
|
|
111
|
+
# 引语标记:出现即暗示"引语"构图
|
|
112
|
+
QUOTE_MARKS = ("「", "」", "『", "』", "“", "”", "——", "说:", "写道", "认为",
|
|
113
|
+
"曾说过", "有句话", "原话")
|
|
114
|
+
|
|
115
|
+
# 数字 + 单位:数字卡的原料。单位表刻意只收中文视频里真正常见的,
|
|
116
|
+
# 加太宽会把"2024 年"这种年份也当成数据(年份单独排除)。
|
|
117
|
+
_UNIT_RE = re.compile(
|
|
118
|
+
r"(\d+(?:\.\d+)?)\s*(%|%|倍|万|亿|千万|百万|人|名|家|个|次|天|年|月|小时|分钟|秒|元|块|美元|GB|TB|MB|ms|℃|度)"
|
|
119
|
+
)
|
|
120
|
+
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _text_of(line):
|
|
124
|
+
"""把一段里所有可读文字摊平(标题权重最高,放最前)。"""
|
|
125
|
+
chunks = []
|
|
126
|
+
for k in ("big", "text", "sub", "kicker", "chapter"):
|
|
127
|
+
v = line.get(k)
|
|
128
|
+
if v:
|
|
129
|
+
chunks.append(str(v))
|
|
130
|
+
for it in line.get("items") or []:
|
|
131
|
+
chunks.append(str(it.get("text") if isinstance(it, dict) else it))
|
|
132
|
+
return "".join(chunks)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _narrative_of(line):
|
|
136
|
+
"""只取"叙述文字"(标题/正文/副标),**不含 items**。
|
|
137
|
+
|
|
138
|
+
为什么关键词判断必须排除 items:条目是"要点的载荷",不是"这段在讲什么"。
|
|
139
|
+
实测反例:模板片第 3 段正文讲"画质三件事",条目里写的是
|
|
140
|
+
"rawvideo -> libx264 CRF17",其中 "->" 命中了顺序词表,
|
|
141
|
+
于是整段被误判成"流程轴"——画面上出现一条横轴连着四个不相干的要点。
|
|
142
|
+
条目里的箭头/破折号是排版符号,不是叙事顺序。
|
|
143
|
+
"""
|
|
144
|
+
chunks = []
|
|
145
|
+
for k in ("big", "text", "sub", "kicker", "chapter"):
|
|
146
|
+
v = line.get(k)
|
|
147
|
+
if v:
|
|
148
|
+
chunks.append(str(v))
|
|
149
|
+
return "".join(chunks)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _item_texts(line):
|
|
153
|
+
out = []
|
|
154
|
+
for it in line.get("items") or []:
|
|
155
|
+
out.append(str(it.get("text") if isinstance(it, dict) else it))
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def extract_number(text):
|
|
160
|
+
"""从一句里抽第一个"数字 + 单位"。抽不到返回 None。
|
|
161
|
+
|
|
162
|
+
为什么排除年份:'2024 年' 是时间背景,不是"这个数字值得做成巨字卡"的信号;
|
|
163
|
+
真要做数字卡,作者写 stats[] 更明确。
|
|
164
|
+
"""
|
|
165
|
+
if not text:
|
|
166
|
+
return None
|
|
167
|
+
for m in _UNIT_RE.finditer(str(text)):
|
|
168
|
+
raw = m.group(1)
|
|
169
|
+
unit = m.group(2)
|
|
170
|
+
if unit in ("年",) and _YEAR_RE.match(raw):
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
value = float(raw)
|
|
174
|
+
except ValueError:
|
|
175
|
+
continue
|
|
176
|
+
return {"value": value, "raw": raw, "unit": unit}
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _numeric_items(items):
|
|
181
|
+
"""条目里有几条带数字(数据条的原料)。"""
|
|
182
|
+
n = 0
|
|
183
|
+
for it in items:
|
|
184
|
+
txt = it.get("text") if isinstance(it, dict) else it
|
|
185
|
+
if extract_number(txt) or (isinstance(it, dict) and it.get("value") is not None):
|
|
186
|
+
n += 1
|
|
187
|
+
return n
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _hit_any(text, words):
|
|
191
|
+
for w in words:
|
|
192
|
+
if w and w in text:
|
|
193
|
+
return w
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ============================================================================
|
|
198
|
+
# 四、自动选型(打分 + 段长门 + 类型去重)
|
|
199
|
+
# ============================================================================
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def score_types(line, idx, total, dur, chapter, prev_chapter, prev_types):
|
|
203
|
+
"""给 10 类场景打分。返回 [(type, score, reasons)],按分数降序、平手按 SCENE_TYPES 顺序。
|
|
204
|
+
|
|
205
|
+
打分原则:**字段存在性 > 关键词 > 结构位置**。字段是作者明说的意图,关键词是猜的。
|
|
206
|
+
"""
|
|
207
|
+
text = _text_of(line)
|
|
208
|
+
narrative = _narrative_of(line) # 关键词只看叙述,不看条目(见 _narrative_of 注释)
|
|
209
|
+
items = _item_texts(line)
|
|
210
|
+
n_items = len(items)
|
|
211
|
+
big = str(line.get("big") or "")
|
|
212
|
+
kind = str(line.get("kind") or "body")
|
|
213
|
+
first, last = (idx == 0), (idx == total - 1)
|
|
214
|
+
rows = {}
|
|
215
|
+
|
|
216
|
+
def add(t, s, why):
|
|
217
|
+
if t not in SCENE_TYPES:
|
|
218
|
+
return
|
|
219
|
+
cur = rows.setdefault(t, [0.0, []])
|
|
220
|
+
cur[0] += s
|
|
221
|
+
if why:
|
|
222
|
+
cur[1].append(why)
|
|
223
|
+
|
|
224
|
+
# ---- 显式指定:压倒一切 ----
|
|
225
|
+
explicit = str(line.get("type") or "").strip()
|
|
226
|
+
if explicit in SCENE_TYPES:
|
|
227
|
+
add(explicit, 10.0, "film.json 显式 type=%s" % explicit)
|
|
228
|
+
|
|
229
|
+
# ---- 结构位置 ----
|
|
230
|
+
# hero / outro 只在"没有更具体的内容形态"时才该赢:一段开场如果带 5 条要点,
|
|
231
|
+
# 观众要看的是要点,不是封面。所以结构位置给 4~5 分,字段/形态给更高分。
|
|
232
|
+
if kind == "hook" or first:
|
|
233
|
+
add("hero", 4.6, "首段/开场")
|
|
234
|
+
if kind == "outro" or last:
|
|
235
|
+
add("outro", 5.0, "末段/收尾")
|
|
236
|
+
# 章节卡是"纯标题卡":章节变了 + 没有条目 + 标题短 + 段也短,才配得上整屏只有一个编号。
|
|
237
|
+
# 三个条件都是实测加上的:
|
|
238
|
+
# · 有 items 的段用章节卡会把要点吃掉(模板片 5 条要点的 body 曾被选成 chapter);
|
|
239
|
+
# · 带数字/引语的段有更具体的构图,章节卡会把信息量砍掉;
|
|
240
|
+
# · 设计稿规定章节卡 ≤3.4s,长段做章节卡等于"整屏只有一个编号停 6 秒",观众会走。
|
|
241
|
+
if (chapter and chapter != prev_chapter and n_items == 0
|
|
242
|
+
and len(big or text) <= 14 and float(dur) <= 4.0
|
|
243
|
+
and not extract_number(text) and not _hit_any(text, QUOTE_MARKS)):
|
|
244
|
+
add("chapter", 4.2, "章节变化且是纯标题卡")
|
|
245
|
+
|
|
246
|
+
# ---- 字段存在性(作者明说的意图,权重最高) ----
|
|
247
|
+
if isinstance(line.get("stats"), list) and line["stats"]:
|
|
248
|
+
add("stat", 6.0, "有 stats[]")
|
|
249
|
+
if isinstance(line.get("series"), list) and line["series"]:
|
|
250
|
+
add("chart", 6.0, "有 series[]")
|
|
251
|
+
if isinstance(line.get("sides"), dict) and line["sides"]:
|
|
252
|
+
add("compare", 6.0, "有 sides{}")
|
|
253
|
+
if isinstance(line.get("steps"), list) and line["steps"]:
|
|
254
|
+
add("process", 6.0, "有 steps[]")
|
|
255
|
+
if line.get("quote"):
|
|
256
|
+
add("quote", 6.0, "有 quote")
|
|
257
|
+
if n_items >= 3:
|
|
258
|
+
add("list", 5.0, "items>=3")
|
|
259
|
+
|
|
260
|
+
# ---- 关键词 / 内容形态(比字段弱,且互相竞争) ----
|
|
261
|
+
# 关键:关键词命中的类型,必须真的能承载这段内容。比如"含对比词"但只有 2 条 items,
|
|
262
|
+
# 画成左右双栏就是空栏;所以 compare/process/chart 都要求条目数够。
|
|
263
|
+
# 权重排序的理由:list 是"有条目"的通用兜底,所以它只给 5.0;
|
|
264
|
+
# 一旦条目本身带数字(chart 的原料)或全文在讲对比/流程,那些**更具体**的构图
|
|
265
|
+
# 必须压过 list —— 否则"能画"永远赢过"该画",所有带 items 的段都会退化成列表。
|
|
266
|
+
num = extract_number(text)
|
|
267
|
+
if num and n_items < 3:
|
|
268
|
+
add("stat", 3.5, "文本含数字+单位(%s%s)" % (num["raw"], num["unit"]))
|
|
269
|
+
if n_items >= 3 and _numeric_items(items) >= 3:
|
|
270
|
+
add("chart", 5.5, "items 里 >=3 条带数字")
|
|
271
|
+
if _hit_any(narrative, COMPARE_WORDS) and n_items >= 2:
|
|
272
|
+
add("compare", 5.5, "含对比词且条目 >=2")
|
|
273
|
+
if _hit_any(narrative, SEQUENCE_WORDS) and 3 <= n_items <= 6:
|
|
274
|
+
add("process", 5.5, "含顺序词且条目 3~6")
|
|
275
|
+
if _hit_any(narrative, QUOTE_MARKS):
|
|
276
|
+
add("quote", 5.5, "含引语标记")
|
|
277
|
+
if n_items == 0 and len(big or text) <= 16:
|
|
278
|
+
add("statement", 3.5, "无条目且短句")
|
|
279
|
+
|
|
280
|
+
# ---- 段长门:撑不住的类型直接出局(不是降分,是不许选) ----
|
|
281
|
+
floor = float(MIN_DUR.get("statement", 2.6))
|
|
282
|
+
keep = []
|
|
283
|
+
for t, (s, why) in rows.items():
|
|
284
|
+
need = float(MIN_DUR.get(t, floor))
|
|
285
|
+
if float(dur) + 1e-9 < need:
|
|
286
|
+
why = list(why) + ["段长 %.1fs < %.1fs,排除" % (dur, need)]
|
|
287
|
+
s = s - 6.0 # 记下来但排到最后,便于审计
|
|
288
|
+
keep.append((t, s, why))
|
|
289
|
+
|
|
290
|
+
# ---- 去重:上一段已经连着两次同类型,就把这个类型压到最低 ----
|
|
291
|
+
if prev_types and len(prev_types) >= MAX_REPEAT:
|
|
292
|
+
tail = prev_types[-MAX_REPEAT:]
|
|
293
|
+
if len(set(tail)) == 1:
|
|
294
|
+
t_prev = tail[0]
|
|
295
|
+
for i, (t, s, why) in enumerate(keep):
|
|
296
|
+
if t == t_prev and t not in ("chapter", "outro"):
|
|
297
|
+
keep[i] = (t, s - 5.0, list(why) + ["连续 %d 段同型,降权" % MAX_REPEAT])
|
|
298
|
+
|
|
299
|
+
keep.sort(key=lambda r: (-r[1], SCENE_TYPES.index(r[0])))
|
|
300
|
+
return keep
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def choose_type(line, idx, total, dur, chapter, prev_chapter, prev_types):
|
|
304
|
+
"""选一个场景类型。返回 (type, reason[list[str]])。"""
|
|
305
|
+
rows = score_types(line, idx, total, dur, chapter, prev_chapter, prev_types)
|
|
306
|
+
if not rows:
|
|
307
|
+
return "statement", ["无任何特征命中,退到金句构图"]
|
|
308
|
+
t, s, why = rows[0]
|
|
309
|
+
if s < 0:
|
|
310
|
+
# 全被段长门排除:退到最耐短的类型
|
|
311
|
+
return "statement", ["所有候选都被段长门排除,退到金句构图"]
|
|
312
|
+
return t, why
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def downgrade_for(type_name, dur):
|
|
316
|
+
"""段长门兜底:太短就按固定表降级。返回 (最终类型, 是否降级)。"""
|
|
317
|
+
t = type_name if type_name in SCENE_TYPES else "statement"
|
|
318
|
+
if float(dur) + 1e-9 >= float(MIN_DUR.get(t, 2.6)):
|
|
319
|
+
return t, False
|
|
320
|
+
seen = set()
|
|
321
|
+
while t in DOWNGRADE and float(dur) + 1e-9 < float(MIN_DUR.get(t, 2.6)):
|
|
322
|
+
if t in seen: # 防降级表写环(配置错误时不挂死)
|
|
323
|
+
break
|
|
324
|
+
seen.add(t)
|
|
325
|
+
nxt = DOWNGRADE[t]
|
|
326
|
+
if nxt == t:
|
|
327
|
+
break
|
|
328
|
+
t = nxt
|
|
329
|
+
return t, True
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ============================================================================
|
|
333
|
+
# 五、片内变体(防"每屏同一个骨架")
|
|
334
|
+
# ============================================================================
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def variant_of(type_name, chapter, k, prev_variant=None):
|
|
338
|
+
"""本段的版式变体号(0/1/2),确定性。
|
|
339
|
+
|
|
340
|
+
规则:先按 (chapter, type, 第几段同类型) 派生,再强制与上一段不同
|
|
341
|
+
(相邻同类型同变体 = 最刺眼的"复制粘贴")。
|
|
342
|
+
"""
|
|
343
|
+
v = _hash_mod(3, "variant", chapter, type_name, k)
|
|
344
|
+
if prev_variant is not None and v == int(prev_variant):
|
|
345
|
+
v = (v + 1 + _hash_mod(2, "variant-step", chapter)) % 3
|
|
346
|
+
return v
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def decor_of(type_name, chapter, k):
|
|
350
|
+
"""装饰密度档位(0=0.55 / 1=0.75 / 2=1.0)。每 3 段回落到低档,形成呼吸。"""
|
|
351
|
+
base = _hash_mod(3, "decor", chapter, type_name, k)
|
|
352
|
+
return 0 if k % 3 == 2 else base
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def phase_of(idx, chapter):
|
|
356
|
+
"""持续动态的相位偏移(弧度)。相邻段相位不同,防止同频"复印"。"""
|
|
357
|
+
return 6.283185307179586 * _hash01("phase", chapter, idx)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ============================================================================
|
|
361
|
+
# 六、缺省数据推导(作者没写 stats/series/steps/sides 时从 items/text 推)
|
|
362
|
+
# ============================================================================
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def derive_stats(line, limit=3):
|
|
366
|
+
"""数字卡的数据:优先 stats[],否则从 text/sub 抽第一个数字,再用 items 补 micro note。"""
|
|
367
|
+
got = []
|
|
368
|
+
for s in (line.get("stats") or []):
|
|
369
|
+
if isinstance(s, dict) and s.get("value") is not None:
|
|
370
|
+
got.append({"value": s.get("value"), "unit": str(s.get("unit") or ""),
|
|
371
|
+
"label": str(s.get("label") or ""), "note": str(s.get("note") or "")})
|
|
372
|
+
if got:
|
|
373
|
+
return got[:limit]
|
|
374
|
+
hit = extract_number(_text_of(line))
|
|
375
|
+
if not hit:
|
|
376
|
+
return []
|
|
377
|
+
notes = _item_texts(line)[:limit - 1]
|
|
378
|
+
got.append({"value": hit["raw"], "unit": hit["unit"],
|
|
379
|
+
"label": str(line.get("sub") or line.get("big") or ""),
|
|
380
|
+
"note": notes[0] if notes else ""})
|
|
381
|
+
for i, t in enumerate(notes[1:]):
|
|
382
|
+
got.append({"value": "", "unit": "", "label": t, "note": ""})
|
|
383
|
+
return got[:limit]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def derive_series(line, limit=6):
|
|
387
|
+
"""数据条的数据:优先 series[],否则用 items 里的"标签 + 数字"。"""
|
|
388
|
+
got = []
|
|
389
|
+
for s in (line.get("series") or []):
|
|
390
|
+
if isinstance(s, dict) and s.get("label") is not None:
|
|
391
|
+
got.append({"label": str(s.get("label")), "value": s.get("value"),
|
|
392
|
+
"unit": str(s.get("unit") or "")})
|
|
393
|
+
if got:
|
|
394
|
+
return got[:limit]
|
|
395
|
+
for it in (line.get("items") or []):
|
|
396
|
+
if isinstance(it, dict) and it.get("value") is not None:
|
|
397
|
+
got.append({"label": str(it.get("text") or it.get("label") or ""),
|
|
398
|
+
"value": it.get("value"), "unit": str(it.get("unit") or "")})
|
|
399
|
+
continue
|
|
400
|
+
txt = str(it.get("text") if isinstance(it, dict) else it)
|
|
401
|
+
hit = extract_number(txt)
|
|
402
|
+
if hit:
|
|
403
|
+
label = txt.replace(hit["raw"] + hit["unit"], "").strip(" ::,,、-—")
|
|
404
|
+
got.append({"label": label or txt, "value": hit["value"], "unit": hit["unit"]})
|
|
405
|
+
return got[:limit]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def derive_steps(line, limit=5):
|
|
409
|
+
"""流程轴的数据:优先 steps[],否则用 items 顺序当步骤。"""
|
|
410
|
+
got = []
|
|
411
|
+
for s in (line.get("steps") or []):
|
|
412
|
+
if isinstance(s, dict) and s.get("label"):
|
|
413
|
+
got.append({"label": str(s["label"]), "note": str(s.get("note") or "")})
|
|
414
|
+
elif s:
|
|
415
|
+
got.append({"label": str(s), "note": ""})
|
|
416
|
+
if got:
|
|
417
|
+
return got[:limit]
|
|
418
|
+
for t in _item_texts(line)[:limit]:
|
|
419
|
+
got.append({"label": t, "note": ""})
|
|
420
|
+
return got
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def derive_sides(line):
|
|
424
|
+
"""左右对比的数据:优先 sides{},否则把 items 前后对半分。"""
|
|
425
|
+
sides = line.get("sides")
|
|
426
|
+
if isinstance(sides, dict) and sides:
|
|
427
|
+
out = {}
|
|
428
|
+
for k in ("left", "right"):
|
|
429
|
+
blk = sides.get(k) if isinstance(sides.get(k), dict) else {}
|
|
430
|
+
items = []
|
|
431
|
+
for it in (blk.get("items") or []):
|
|
432
|
+
items.append(str(it.get("text") if isinstance(it, dict) else it))
|
|
433
|
+
out[k] = {"head": str(blk.get("head") or ""), "items": items[:3]}
|
|
434
|
+
if out["left"]["items"] or out["right"]["items"]:
|
|
435
|
+
return out
|
|
436
|
+
items = _item_texts(line)
|
|
437
|
+
if len(items) < 2:
|
|
438
|
+
return None
|
|
439
|
+
half = (len(items) + 1) // 2
|
|
440
|
+
return {"left": {"head": "", "items": items[:half][:3]},
|
|
441
|
+
"right": {"head": "", "items": items[half:][:3]}}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def derive_quote(line):
|
|
445
|
+
"""引语:优先 quote 字段,否则从 text 里剥掉引号/出处。"""
|
|
446
|
+
q = line.get("quote")
|
|
447
|
+
if q:
|
|
448
|
+
return str(q), str(line.get("by") or "")
|
|
449
|
+
txt = str(line.get("text") or line.get("big") or "")
|
|
450
|
+
by = str(line.get("by") or "")
|
|
451
|
+
m = re.search(r"[「『“\"](.+?)[」』”\"]", txt)
|
|
452
|
+
if m:
|
|
453
|
+
return m.group(1), by
|
|
454
|
+
for sep in ("——", "—", ":"):
|
|
455
|
+
if sep in txt:
|
|
456
|
+
head, _, tail = txt.partition(sep)
|
|
457
|
+
if len(head) >= 6:
|
|
458
|
+
return head.strip(), (by or tail.strip())
|
|
459
|
+
return txt, by
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ============================================================================
|
|
463
|
+
# 七、分镜表(timeline 的 scenes 上就地补字段)
|
|
464
|
+
# ============================================================================
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def plan_scenes(scenes, script=None, plan=None):
|
|
468
|
+
"""给 timeline 的每一段补 scene_type / scene_variant / scene_decor / scene_layout / scene_reason。
|
|
469
|
+
|
|
470
|
+
scenes: K.build_timeline 产出的段列表(有 id/kind/start/end/dur 与剧本原字段)
|
|
471
|
+
plan: stylekit.resolve_script_style / designkit.design_to_plan 的结果
|
|
472
|
+
(取 chapter_layout / layout_cycle / layout_default 与 style)
|
|
473
|
+
返回 storyboard dict(可直接 json.dump 审计),并把字段就地写进 scenes。
|
|
474
|
+
|
|
475
|
+
scene_layout 的取法(先命中先赢):
|
|
476
|
+
① plan["chapter_layout"] 显式指定(按章名查,查不到用 layout_default);
|
|
477
|
+
② plan["layout_cycle"](≥2 种版式且没有显式 chapter_layout):按章节首次
|
|
478
|
+
出现的顺序循环取位,**同一章内版式固定**——防止"全片一张版式";
|
|
479
|
+
③ 都没有:layout_default(与 v1.0.1 完全一致)。
|
|
480
|
+
|
|
481
|
+
**没有 chapter 的剧本**:按"每段自成一章"处理(轮换照常推进)。
|
|
482
|
+
为什么不把空 chapter 当成"全片同一章":那样 layout_cycle 在常见的不写 chapter
|
|
483
|
+
的剧本上会退化成"永远 cycle[0]",功能写了却完全不生效——正是本仓库反复踩过的
|
|
484
|
+
"号称支持实则失效"。空 chapter 本来就没有"章"的身份可言,逐段轮换既满足
|
|
485
|
+
"同章内稳定"(每段自成一章,平凡成立),又真的防住同骨架。
|
|
486
|
+
"""
|
|
487
|
+
script = script or {}
|
|
488
|
+
plan = plan or {}
|
|
489
|
+
n = len(scenes)
|
|
490
|
+
prev_types = []
|
|
491
|
+
prev_chapter = None
|
|
492
|
+
prev_variant = {}
|
|
493
|
+
chapter_layout = plan.get("chapter_layout") if isinstance(plan.get("chapter_layout"), dict) else {}
|
|
494
|
+
default_layout = plan.get("layout_default") or "left-stack"
|
|
495
|
+
# ---- 片内版式轮换(v1.0.2):design_to_plan 会派生 layout_cycle ----
|
|
496
|
+
# 为什么需要:没有它时一章只能用一种版式(layout_default),8 段片子 8 屏同骨架 ——
|
|
497
|
+
# 正好是用户原则 5 说的"同一视频内部大面积/长时间重复"。
|
|
498
|
+
# 生效条件(两条都不满足就与过去逐字节一致,金帧/既有自检不漂):
|
|
499
|
+
# ① plan 没有显式 chapter_layout(显式指定优先,不能被轮换覆盖);
|
|
500
|
+
# ② layout_cycle 至少 2 种版式(只有一种等于不轮换)。
|
|
501
|
+
# 同一章内必须稳定(观众视角一章是一段整体),换章才换版式:
|
|
502
|
+
# 按「章节首次出现的顺序」依次取 cycle[0], cycle[1], … 循环。
|
|
503
|
+
cycle = plan.get("layout_cycle")
|
|
504
|
+
if isinstance(cycle, (list, tuple)):
|
|
505
|
+
cycle = [str(x) for x in cycle if str(x or "").strip()]
|
|
506
|
+
else:
|
|
507
|
+
cycle = []
|
|
508
|
+
use_cycle = (not chapter_layout) and len(cycle) >= 2
|
|
509
|
+
# key -> 版式(保证同一章内版式不变;无 chapter 时每段自成一章,轮换照常推进)
|
|
510
|
+
cycle_of = {}
|
|
511
|
+
counter = {}
|
|
512
|
+
rows = []
|
|
513
|
+
for i, s in enumerate(scenes):
|
|
514
|
+
chapter = str(s.get("chapter") or "")
|
|
515
|
+
dur = float(s.get("dur") or 0.0)
|
|
516
|
+
raw_type, why = choose_type(s, i, n, dur, chapter, prev_chapter, prev_types)
|
|
517
|
+
stype, downgraded = downgrade_for(raw_type, dur)
|
|
518
|
+
if downgraded:
|
|
519
|
+
why = list(why) + ["段长 %.2fs < %.1fs,降级 %s -> %s"
|
|
520
|
+
% (dur, float(MIN_DUR.get(raw_type, 2.6)), raw_type, stype)]
|
|
521
|
+
k = counter.get(stype, 0)
|
|
522
|
+
counter[stype] = k + 1
|
|
523
|
+
variant = variant_of(stype, chapter, k, prev_variant.get(stype))
|
|
524
|
+
prev_variant[stype] = variant
|
|
525
|
+
decor = decor_of(stype, chapter, k)
|
|
526
|
+
if use_cycle:
|
|
527
|
+
# 空 chapter 用段号做键:每段自成一"章",轮换才真的推进
|
|
528
|
+
# (否则最常见的不写 chapter 的剧本会永远停在 cycle[0],功能形同虚设)。
|
|
529
|
+
key = chapter if chapter else "\x00scene:%d" % i
|
|
530
|
+
if key not in cycle_of:
|
|
531
|
+
# len(cycle_of) = 这是第几个"不同章节"(0 起),按它取轮换位
|
|
532
|
+
cycle_of[key] = cycle[len(cycle_of) % len(cycle)]
|
|
533
|
+
layout = cycle_of[key]
|
|
534
|
+
else:
|
|
535
|
+
layout = chapter_layout.get(chapter) or default_layout
|
|
536
|
+
s["scene_type"] = stype
|
|
537
|
+
s["scene_variant"] = int(variant)
|
|
538
|
+
s["scene_decor"] = int(decor)
|
|
539
|
+
s["scene_layout"] = str(layout)
|
|
540
|
+
s["scene_phase"] = round(phase_of(i, chapter), 4)
|
|
541
|
+
s["scene_reason"] = list(why)
|
|
542
|
+
rows.append({"id": s.get("id"), "kind": s.get("kind") or "body",
|
|
543
|
+
"type": stype, "variant": int(variant), "decor": int(decor),
|
|
544
|
+
"layout": str(layout), "dur": round(dur, 3),
|
|
545
|
+
"reason": list(why)})
|
|
546
|
+
prev_types.append(stype)
|
|
547
|
+
prev_chapter = chapter
|
|
548
|
+
return {
|
|
549
|
+
"version": 1,
|
|
550
|
+
"generator": "grammar 0.9.0",
|
|
551
|
+
"style": plan.get("style"),
|
|
552
|
+
"legacy": bool(plan.get("legacy")),
|
|
553
|
+
"scenes": rows,
|
|
554
|
+
"type_counts": dict(counter),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def describe(storyboard):
|
|
559
|
+
"""人读摘要(--stage layout 打印用)。"""
|
|
560
|
+
if not storyboard:
|
|
561
|
+
return "(无分镜)"
|
|
562
|
+
counts = storyboard.get("type_counts") or {}
|
|
563
|
+
order = [t for t in SCENE_TYPES if t in counts]
|
|
564
|
+
parts = ["%s×%d" % (SCENE_LABELS.get(t, t), counts[t]) for t in order]
|
|
565
|
+
return "%d 段 / %s" % (len(storyboard.get("scenes") or []), " ".join(parts))
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _cli(argv=None):
|
|
569
|
+
"""自测入口:python grammar.py [film.json] —— 只读,不渲染。
|
|
570
|
+
|
|
571
|
+
v1.0.2:加 argparse(在此之前 `--help` 会被当成 film.json 路径,FileNotFoundError,
|
|
572
|
+
与仓库里其它 17 个脚本的 CLI 约定不一致)。不传 film.json 时仍打印用法与 10 类构图,
|
|
573
|
+
退出码 0(保持 v0.9 的行为,便于老 CI 探活)。
|
|
574
|
+
"""
|
|
575
|
+
import argparse
|
|
576
|
+
import json
|
|
577
|
+
|
|
578
|
+
ap = argparse.ArgumentParser(
|
|
579
|
+
prog="grammar.py",
|
|
580
|
+
description="场景语法:按内容推导每段的构图类型(只读,不渲染、不需要 timeline)。",
|
|
581
|
+
epilog="不给 film.json 时只打印用法与 10 类构图说明。")
|
|
582
|
+
ap.add_argument("film", nargs="?", default=None,
|
|
583
|
+
help="film.json 路径;给出时打印该剧本的分镜选型(时长按字数粗估)")
|
|
584
|
+
a = ap.parse_args(argv)
|
|
585
|
+
|
|
586
|
+
if not a.film:
|
|
587
|
+
print("用法: python grammar.py film.json # 打印该剧本的分镜选型(不需要 timeline)")
|
|
588
|
+
print("场景类型:", " / ".join("%s(%s)" % (t, SCENE_LABELS[t]) for t in SCENE_TYPES))
|
|
589
|
+
return 0
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
with open(a.film, encoding="utf-8") as fp:
|
|
593
|
+
script = json.load(fp)
|
|
594
|
+
except OSError as e:
|
|
595
|
+
ap.error("读不了 film.json:%s(%s)" % (a.film, e))
|
|
596
|
+
if not isinstance(script, dict):
|
|
597
|
+
ap.error("film.json 顶层必须是对象:%s" % a.film)
|
|
598
|
+
lines = script.get("lines") or []
|
|
599
|
+
if not isinstance(lines, list):
|
|
600
|
+
ap.error("film.json 的 lines 必须是数组:%s" % a.film)
|
|
601
|
+
# 没跑 TTS 时按字数粗估时长(只用于看选型,不是时间轴权威)
|
|
602
|
+
scenes = []
|
|
603
|
+
t = 0.0
|
|
604
|
+
for ln in lines:
|
|
605
|
+
d = max(2.0, min(12.0, len(str(ln.get("text") or "")) / 4.2 + 1.4))
|
|
606
|
+
row = dict(ln, start=round(t, 3), end=round(t + d, 3), dur=round(d, 3))
|
|
607
|
+
scenes.append(row)
|
|
608
|
+
t += d
|
|
609
|
+
sb = plan_scenes(scenes, script, {})
|
|
610
|
+
print(describe(sb))
|
|
611
|
+
for r in sb["scenes"]:
|
|
612
|
+
print(" %-6s kind=%-9s -> %-9s v%d d%d %.1fs %s"
|
|
613
|
+
% (r["id"], r["kind"], r["type"], r["variant"], r["decor"],
|
|
614
|
+
r["dur"], ";".join(r["reason"][:2])))
|
|
615
|
+
return 0
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
if __name__ == "__main__":
|
|
619
|
+
raise SystemExit(_cli())
|