@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,825 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""propose.py — 提案器(v1.0):先出 N 个方案,用户确认后才允许出片。
|
|
3
|
+
|
|
4
|
+
为什么单独一个入口而不是塞进 render.py
|
|
5
|
+
--------------------------------------
|
|
6
|
+
render.py 的 13 个 stage 是一条"内容 → 成片"的生产线;提案是**生产之前**的决策环节,
|
|
7
|
+
它的产物(proposal.json)要能被 frames 阶段当成前置门禁来查。混进 STAGES 会把
|
|
8
|
+
"这条 stage 的输入输出是什么"搅乱(stagewatch 的依赖表是按生产链写的)。
|
|
9
|
+
所以:独立入口 + 独立产物 + 由 render.py 在 frames 前**只读校验**。
|
|
10
|
+
|
|
11
|
+
流程
|
|
12
|
+
----
|
|
13
|
+
propose -> work/proposal.json (rev1, status=draft, N 个候选 + 静帧 + 成本)
|
|
14
|
+
confirm -> status=confirmed, chosen="B",并落 work/design.json(冻结)
|
|
15
|
+
revise -> rev2(不覆盖 rev1,带 feedback 原文)
|
|
16
|
+
diff -> 两个 rev 的设计向量差异
|
|
17
|
+
|
|
18
|
+
每次都不一样:同 brief 不传 seed 会得到**不同**的候选(v1.0.1 起,满足"每条视频都不同");
|
|
19
|
+
可复现:显式 --seed 时同 seed 得到同一组候选(designkit 内部 sha1 派生,禁 random)。
|
|
20
|
+
离线:预览只走合成时间轴(previewkit),**不联网、不合成旁白**;--with-audio 才多生成
|
|
21
|
+
12 秒样音(仍全程离线,musicgen/scorekit 任选其一)。
|
|
22
|
+
|
|
23
|
+
确认要绑两道指纹
|
|
24
|
+
----------------
|
|
25
|
+
confirm 除了 designHash(设计指纹),还写 scriptHash(剧本规范化 JSON 的 sha1 前 16 位)。
|
|
26
|
+
只绑设计不够:设计没变而剧本大改,字幕/断句/时长全变,那次确认等于作废。
|
|
27
|
+
proposal_state 因此多一档 stale——剧本对不上就要求重新提案;剧本读不出来(文件搬走、
|
|
28
|
+
不是合法 JSON)时**不判**,因为"算不了"不该变成一条假警报。
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
40
|
+
if HERE not in sys.path:
|
|
41
|
+
sys.path.insert(0, HERE)
|
|
42
|
+
|
|
43
|
+
SCHEMA = 1
|
|
44
|
+
PROPOSAL_FILE = "proposal.json"
|
|
45
|
+
PROPOSALS_DIR = "proposals"
|
|
46
|
+
CANDIDATE_IDS = "ABCDEFGH"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _now():
|
|
50
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _setup_stdio():
|
|
54
|
+
for stream in (sys.stdout, sys.stderr):
|
|
55
|
+
try:
|
|
56
|
+
if stream is not None and not stream.isatty():
|
|
57
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_json(path, default=None):
|
|
63
|
+
try:
|
|
64
|
+
with open(path, encoding="utf-8") as fh:
|
|
65
|
+
return json.load(fh)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
return default
|
|
68
|
+
except json.JSONDecodeError as e:
|
|
69
|
+
raise SystemExit("[proposal] %s 不是合法 JSON:%s" % (path, e)) from e
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _atomic_write(path, text):
|
|
73
|
+
d = os.path.dirname(os.path.abspath(path))
|
|
74
|
+
if d:
|
|
75
|
+
os.makedirs(d, exist_ok=True)
|
|
76
|
+
tmp = path + ".part"
|
|
77
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
|
|
78
|
+
fh.write(text)
|
|
79
|
+
os.replace(tmp, path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _canonical(obj):
|
|
83
|
+
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sha(obj, n=16):
|
|
87
|
+
return hashlib.sha1(_canonical(obj).encode("utf-8")).hexdigest()[:n]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ============================================================================
|
|
92
|
+
# 片间去重:设计台账(v1.0.2)
|
|
93
|
+
# ============================================================================
|
|
94
|
+
#
|
|
95
|
+
# 用户原则 5:**不同视频的整体风格不能大面积重复**。v1.0.1 的"每条视频都不同"
|
|
96
|
+
# 靠的是采样 nonce —— 同一 brief 两次采样确实不同,但**没有记住已经交付过什么**:
|
|
97
|
+
# 连续拍同题材的三条片,仍有概率撞到相近的配色/版式组合(设计空间大,但不是保证)。
|
|
98
|
+
# 台账把"最近交付过的设计"记在用户级目录里,提案时主动避开它们:
|
|
99
|
+
# · 只看**离散维度**(配色/母题/转场/缓动/编号/配乐/声线/情绪/版式/调式),
|
|
100
|
+
# 与 propose 展示给用户的"设计距离"同一口径;
|
|
101
|
+
# · 默认开启,可用 --no-ledger 或环境变量 VIDEO_STUDIO_LEDGER=off 关闭;
|
|
102
|
+
# · 路径可用环境变量覆盖(写文档/做测试时指到临时目录,不要去动用户真实台账)。
|
|
103
|
+
# 为什么默认开启:用户不会主动想起"帮我避开上一部片";默认关闭等于这个功能不存在。
|
|
104
|
+
_LEDGER_ENV = "VIDEO_STUDIO_LEDGER"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ledger_path():
|
|
108
|
+
"""台账文件路径:环境变量优先,否则 ~/.dsh/video-studio/design-ledger.json。"""
|
|
109
|
+
raw = str(os.environ.get(_LEDGER_ENV) or "").strip()
|
|
110
|
+
if raw.lower() in ("off", "0", "false", "no"):
|
|
111
|
+
return ""
|
|
112
|
+
if raw:
|
|
113
|
+
return os.path.abspath(os.path.expanduser(raw))
|
|
114
|
+
home = os.path.expanduser("~")
|
|
115
|
+
return os.path.join(home, ".dsh", "video-studio", "design-ledger.json")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ledger_load(limit=60):
|
|
119
|
+
"""读最近 limit 条设计记录(读不到/写坏都返回空表:台账不该阻塞出片)。"""
|
|
120
|
+
path = ledger_path()
|
|
121
|
+
if not path:
|
|
122
|
+
return []
|
|
123
|
+
try:
|
|
124
|
+
data = json.load(open(path, encoding="utf-8"))
|
|
125
|
+
except (OSError, json.JSONDecodeError):
|
|
126
|
+
return []
|
|
127
|
+
rows = data.get("designs") if isinstance(data, dict) else None
|
|
128
|
+
if not isinstance(rows, list):
|
|
129
|
+
return []
|
|
130
|
+
return [r for r in rows if isinstance(r, dict) and isinstance(r.get("design"), dict)][-int(limit):]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ledger_record(design, work=None, label=""):
|
|
134
|
+
"""把一份已确认的设计记进台账(去重:同一指纹只保留最新一条)。"""
|
|
135
|
+
path = ledger_path()
|
|
136
|
+
if not path:
|
|
137
|
+
return False
|
|
138
|
+
try:
|
|
139
|
+
import designkit as _DK
|
|
140
|
+
fp = _DK.design_fingerprint(design)
|
|
141
|
+
except Exception: # noqa: BLE001
|
|
142
|
+
return False
|
|
143
|
+
rows = ledger_load(limit=500)
|
|
144
|
+
rows = [r for r in rows if r.get("fingerprint") != fp]
|
|
145
|
+
rows.append({"fingerprint": fp, "label": str(label or design.get("label") or ""),
|
|
146
|
+
"at": _now(), "work": os.path.abspath(str(work)) if work else "",
|
|
147
|
+
"design": design})
|
|
148
|
+
rows = rows[-500:]
|
|
149
|
+
try:
|
|
150
|
+
_atomic_write(path, json.dumps({"schema": 1, "designs": rows},
|
|
151
|
+
ensure_ascii=False))
|
|
152
|
+
return True
|
|
153
|
+
except OSError:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ledger_min_distance(designs, recent):
|
|
158
|
+
"""这批候选与"最近交付过的设计"之间的最小设计距离(离散维度数)。"""
|
|
159
|
+
if not recent:
|
|
160
|
+
return None
|
|
161
|
+
import designkit as _DK
|
|
162
|
+
worst = 99
|
|
163
|
+
for d in designs or []:
|
|
164
|
+
for r in recent:
|
|
165
|
+
try:
|
|
166
|
+
n = int(_DK.design_distance(d, r["design"]).get("n", 0))
|
|
167
|
+
except Exception: # noqa: BLE001
|
|
168
|
+
continue
|
|
169
|
+
worst = min(worst, n)
|
|
170
|
+
return None if worst == 99 else worst
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# 与上一部片至少要差这么多维,才算"看得出不是同一套"(与候选两两约束同口径)。
|
|
174
|
+
LEDGER_MIN_GAP = 2
|
|
175
|
+
# 撞车时最多重采几轮(每轮都是一次全新 nonce 采样,成本 = 一次采样,毫秒级)。
|
|
176
|
+
LEDGER_RETRIES = 4
|
|
177
|
+
def proposal_path(work):
|
|
178
|
+
return os.path.join(str(work), PROPOSAL_FILE)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def rev_dir(work, rev):
|
|
182
|
+
return os.path.join(str(work), PROPOSALS_DIR, "rev%d" % int(rev))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _designkit():
|
|
186
|
+
try:
|
|
187
|
+
import designkit
|
|
188
|
+
except ImportError as e:
|
|
189
|
+
raise SystemExit(
|
|
190
|
+
"[proposal] 缺少 designkit.py(%s)——它是设计 DSL 与采样器,必须与 propose.py 同目录"
|
|
191
|
+
% e) from e
|
|
192
|
+
return designkit
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _previewkit():
|
|
196
|
+
try:
|
|
197
|
+
import previewkit
|
|
198
|
+
except ImportError as e:
|
|
199
|
+
raise SystemExit(
|
|
200
|
+
"[proposal] 缺少 previewkit.py(%s)——离线预览要它,必须与 propose.py 同目录"
|
|
201
|
+
% e) from e
|
|
202
|
+
return previewkit
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _script_hash_of(path):
|
|
206
|
+
"""剧本指纹:规范化 JSON 的 sha1 前 16 位;读不出(没有文件/不是 JSON/没有 lines)返回 None。
|
|
207
|
+
|
|
208
|
+
"算不了就不判"是刻意的:剧本被搬走或临时写坏时,把 confirmed 判成 stale 会逼用户
|
|
209
|
+
重新确认一件其实没变的事,久了就没人信这条门禁了。
|
|
210
|
+
"""
|
|
211
|
+
if not path or not os.path.isfile(str(path)):
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
script = _load_json(path, default=None)
|
|
215
|
+
except SystemExit:
|
|
216
|
+
return None
|
|
217
|
+
if not isinstance(script, dict) or not isinstance(script.get("lines"), list):
|
|
218
|
+
return None
|
|
219
|
+
return _sha(script)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _load_script(path):
|
|
223
|
+
script = _load_json(path)
|
|
224
|
+
if not isinstance(script, dict):
|
|
225
|
+
raise SystemExit("[proposal] 剧本 %s 不是 JSON 对象" % path)
|
|
226
|
+
if not script.get("lines"):
|
|
227
|
+
raise SystemExit("[proposal] 剧本 %s 里没有 lines[](提案至少需要一段内容)" % path)
|
|
228
|
+
return script
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------- 提案生成
|
|
232
|
+
_NEGATIVE_WORDS = ("不要", "不想要", "别用", "别", "避开", "排除", "避免", "去掉", "不用")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------- 时长解析(v1.0.2)
|
|
237
|
+
# brief 里最常见的硬要求之一就是时长("做个 2 分钟的""60 秒短视频")。v1.0.1 之前
|
|
238
|
+
# 它**完全不生效**:_brief_of 只填 topic/must_have/avoid,duration_target 恒为 None,
|
|
239
|
+
# 连 design.structure.target_sec 都不会有值。用户以为要求被接住了,成片却按台词长度来。
|
|
240
|
+
# 这里把它解析出来:① 影响候选的段落数(designkit 按目标时长决定 segments);
|
|
241
|
+
# ② 渲染时对账"目标 vs 实测",差超过 15% 当场告警——**不截断旁白**,只把事实说清。
|
|
242
|
+
_CN_DIGITS = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
|
|
243
|
+
"六": 6, "七": 7, "八": 8, "九": 9}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _cn_number(text):
|
|
247
|
+
"""把"九十 / 一百二十 / 半"这类简单中文数字转成 float;认不出返回 None。"""
|
|
248
|
+
s = str(text or "").strip()
|
|
249
|
+
if not s:
|
|
250
|
+
return None
|
|
251
|
+
if s == "半":
|
|
252
|
+
return 0.5
|
|
253
|
+
try:
|
|
254
|
+
return float(s)
|
|
255
|
+
except ValueError:
|
|
256
|
+
pass
|
|
257
|
+
total, section = 0.0, 0
|
|
258
|
+
for ch in s:
|
|
259
|
+
if ch in _CN_DIGITS:
|
|
260
|
+
section = _CN_DIGITS[ch]
|
|
261
|
+
elif ch == "十":
|
|
262
|
+
section = (section or 1) * 10
|
|
263
|
+
elif ch == "百":
|
|
264
|
+
section = (section or 1) * 100
|
|
265
|
+
else:
|
|
266
|
+
return None
|
|
267
|
+
return float(total + section)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def parse_duration(text):
|
|
271
|
+
"""从一句话里解析时长(秒)。认不出返回 None。
|
|
272
|
+
|
|
273
|
+
支持:「90秒」「2分钟」「2.5分钟」「一分半」「3分30秒」「1分30秒」「两分钟」。
|
|
274
|
+
为什么只认这些:宁可比对少认,也不要猜错——猜错会让成片段落数按错误时长排,
|
|
275
|
+
用户更难发现。认不出的句子不会被静默吞掉:整句仍作为 topic 参与题材理解。
|
|
276
|
+
"""
|
|
277
|
+
import re as _re
|
|
278
|
+
s = str(text or "")
|
|
279
|
+
best = None
|
|
280
|
+
m = _re.search(
|
|
281
|
+
r"([0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十百]+)\s*分(?:钟)?\s*"
|
|
282
|
+
r"(半|([0-9]+|[零一二两三四五六七八九十百]+)\s*秒)?", s)
|
|
283
|
+
if m:
|
|
284
|
+
mins = _cn_number(m.group(1))
|
|
285
|
+
if mins is not None:
|
|
286
|
+
secs = 0.0
|
|
287
|
+
if m.group(2) == "半":
|
|
288
|
+
secs = 30.0
|
|
289
|
+
elif m.group(3):
|
|
290
|
+
v = _cn_number(m.group(3))
|
|
291
|
+
secs = float(v) if v is not None else 0.0
|
|
292
|
+
best = mins * 60.0 + secs
|
|
293
|
+
if best is None:
|
|
294
|
+
m = _re.search(r"([0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十百]+)\s*秒", s)
|
|
295
|
+
if m:
|
|
296
|
+
v = _cn_number(m.group(1))
|
|
297
|
+
if v is not None:
|
|
298
|
+
best = float(v)
|
|
299
|
+
if best is None or best <= 0:
|
|
300
|
+
return None
|
|
301
|
+
if best < 3.0 or best > 1800.0:
|
|
302
|
+
return None # 与 designkit 的契约范围一致(3–1800 秒)
|
|
303
|
+
return round(best, 2)
|
|
304
|
+
def _brief_of(text, script):
|
|
305
|
+
"""把用户一句话变成 brief dict。剧本的标题是隐含 topic(避免空 brief)。
|
|
306
|
+
|
|
307
|
+
v1.0.1 修:以前这里把整句话塞进 topic 就完事,**must_have/avoid 恒为空数组**。
|
|
308
|
+
后果是 `--brief "深蓝配色的科技解说"` 里的"深蓝"完全不生效 —— 实测采出来的是
|
|
309
|
+
h087 / h316(橙 / 紫),用户看到的需求与结果无关。designkit 明明有一张
|
|
310
|
+
32 个词的关键词表(_constraint_words),但没人喂给它。
|
|
311
|
+
|
|
312
|
+
现在按句子切开逐个识别:
|
|
313
|
+
· 含否定词的片段 -> avoid("不要放射线" -> avoid 放射);
|
|
314
|
+
· 其余片段 -> must_have("深蓝配色" -> must_have 深蓝);
|
|
315
|
+
· 一句都没认出来的部分原样留着——顶层会告诉用户"这条没生效",绝不静默丢弃。
|
|
316
|
+
"""
|
|
317
|
+
text = str(text or "").strip()
|
|
318
|
+
topic = text or str(script.get("title") or "").strip()
|
|
319
|
+
if not topic:
|
|
320
|
+
raise SystemExit("[proposal] 需要 --brief 或剧本里至少有 title(否则不知道在给什么题材做方案)")
|
|
321
|
+
try:
|
|
322
|
+
import designkit as DK
|
|
323
|
+
probe = DK._constraints_in
|
|
324
|
+
except Exception: # noqa: BLE001
|
|
325
|
+
probe = None
|
|
326
|
+
must, avoid = [], []
|
|
327
|
+
if probe is not None and text:
|
|
328
|
+
# 按标点/连接词切成短句,逐句判定极性
|
|
329
|
+
import re as _re
|
|
330
|
+
for seg in _re.split(r"[,。;、,.;!?!?]+|\s+", text):
|
|
331
|
+
seg = seg.strip()
|
|
332
|
+
if not seg:
|
|
333
|
+
continue
|
|
334
|
+
hits = probe(seg)
|
|
335
|
+
if not hits:
|
|
336
|
+
continue # 认不出来的短句留给 topic,由上层提示
|
|
337
|
+
neg = any(w in seg for w in _NEGATIVE_WORDS)
|
|
338
|
+
(avoid if neg else must).append(seg)
|
|
339
|
+
# v1.0.2:时长要求也要接住(以前恒为 None,用户写了「2 分钟」等于没写)。
|
|
340
|
+
dt = parse_duration(text)
|
|
341
|
+
return {"topic": topic, "duration_target": dt,
|
|
342
|
+
"must_have": must, "avoid": avoid}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _candidate_row(design, script, outdir, n_frames, previewkit, designkit,
|
|
346
|
+
with_audio=False):
|
|
347
|
+
"""跑一个候选的离线预览,返回 proposal.json 里的候选行。"""
|
|
348
|
+
t0 = time.time()
|
|
349
|
+
pv = previewkit.preview_candidate(design, script, outdir, n_frames=n_frames,
|
|
350
|
+
with_audio=with_audio)
|
|
351
|
+
secs = round(time.time() - t0, 2)
|
|
352
|
+
pv["secs"] = secs
|
|
353
|
+
preview = {"sheet": pv.get("sheet"), "frames": pv.get("frames"), "secs": secs}
|
|
354
|
+
# 预览会按合成时长排轴,段太短时 grammar 会降级构图(chart -> list 等);
|
|
355
|
+
# 把降级信息带进提案,人和机器都能看出"哪一段预览里画的和成片不一样"。
|
|
356
|
+
if pv.get("downgrades"):
|
|
357
|
+
preview["downgrades"] = pv["downgrades"]
|
|
358
|
+
if with_audio:
|
|
359
|
+
# preview.audio 只放路径(契约字段);引擎/耗时/失败原因分开写,
|
|
360
|
+
# 是为了让人一眼看出"这份样音是谁出的、等了多久",而不用去翻日志。
|
|
361
|
+
preview["audio"] = pv.get("audio") or ""
|
|
362
|
+
preview["audio_engine"] = pv.get("audio_engine") or ""
|
|
363
|
+
preview["audio_secs"] = pv.get("audio_secs") # 生成耗时
|
|
364
|
+
preview["audio_dur"] = 12.0 if pv.get("audio") else None # 音频长度(秒)
|
|
365
|
+
if pv.get("audio_error"):
|
|
366
|
+
preview["audio_error"] = pv["audio_error"]
|
|
367
|
+
return {
|
|
368
|
+
"id": pv.get("id"),
|
|
369
|
+
"label": design.get("label") or design.get("id") or "",
|
|
370
|
+
"pitch": design.get("pitch") or "",
|
|
371
|
+
"design": design,
|
|
372
|
+
"design_fp": designkit.design_fingerprint(design),
|
|
373
|
+
"preview": preview,
|
|
374
|
+
"cost": {"preview_s": secs},
|
|
375
|
+
"diversity": None, # 生成完之后统一算(需要两两比较)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _fill_diversity(rows, designkit):
|
|
380
|
+
"""两两算设计距离,把"跟谁像/不像"写进每个候选(伪多方案门禁的输入)。"""
|
|
381
|
+
for i, a in enumerate(rows):
|
|
382
|
+
vs = []
|
|
383
|
+
for j, b in enumerate(rows):
|
|
384
|
+
if i == j:
|
|
385
|
+
continue
|
|
386
|
+
d = designkit.design_distance(a["design"], b["design"])
|
|
387
|
+
vs.append({"id": b["id"], "n": d.get("n", 0), "axes": d.get("axes") or []})
|
|
388
|
+
rows[i]["diversity"] = {"vs": vs,
|
|
389
|
+
"min_n": min([v["n"] for v in vs]) if vs else 0}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def make_proposal(brief_text, script, work, n=3, seed=None, n_frames=4, json_out=False,
|
|
393
|
+
with_audio=False, no_ledger=False):
|
|
394
|
+
"""生成一版提案(rev = 已有最大 rev + 1),落盘并打印人读摘要。"""
|
|
395
|
+
designkit = _designkit()
|
|
396
|
+
previewkit = _previewkit()
|
|
397
|
+
brief = _brief_of(brief_text, script)
|
|
398
|
+
|
|
399
|
+
prev = _load_json(proposal_path(work), default=None) or {}
|
|
400
|
+
rev = int(prev.get("rev") or 0) + 1
|
|
401
|
+
parent = prev.get("rev") if prev else None
|
|
402
|
+
feedback = str(brief_text or "").strip() if rev > 1 else ""
|
|
403
|
+
|
|
404
|
+
# v1.0 起唯一的出方案方式是**按 brief 现场采样**:没有预设、没有模板可套。
|
|
405
|
+
#
|
|
406
|
+
# v1.0.2 片间去重:读台账里最近交付过的设计,若这批候选与其中某一份"太像"
|
|
407
|
+
# (离散维度差距 < LEDGER_MIN_GAP),换 nonce 重采一次,最多试 LEDGER_RETRIES 轮,
|
|
408
|
+
# 取"最后一批"里与历史距离最大的那组。重试仍近就**明说**(不静默放过)。
|
|
409
|
+
recent = [] if no_ledger else ledger_load()
|
|
410
|
+
if no_ledger and not json_out:
|
|
411
|
+
print(" 片间去重: 已用 --no-ledger 关闭(本次不与历史设计比对)")
|
|
412
|
+
designs = designkit.sample_designs(brief, n=n, seed=seed)
|
|
413
|
+
ledger_gap = ledger_min_distance(designs, recent)
|
|
414
|
+
if recent and ledger_gap is not None and ledger_gap < LEDGER_MIN_GAP:
|
|
415
|
+
for _try in range(max(1, int(LEDGER_RETRIES))):
|
|
416
|
+
cand = designkit.sample_designs(brief, n=n, seed=seed)
|
|
417
|
+
gap = ledger_min_distance(cand, recent)
|
|
418
|
+
if gap is not None and gap > (ledger_gap or 0):
|
|
419
|
+
designs, ledger_gap = cand, gap
|
|
420
|
+
if ledger_gap is not None and ledger_gap >= LEDGER_MIN_GAP:
|
|
421
|
+
break
|
|
422
|
+
# 认不出来的要求:采样器不会替你"悄悄放宽",但也不会因此失败,而是挂在候选上。
|
|
423
|
+
# 这里汇总一次,人读摘要里必须说出来——用户提的要求没生效,他得知道。
|
|
424
|
+
unmatched = []
|
|
425
|
+
for d in designs:
|
|
426
|
+
for word in (d.get("_unmatched_constraints") or []):
|
|
427
|
+
if word not in unmatched:
|
|
428
|
+
unmatched.append(word)
|
|
429
|
+
if len(designs) < 2:
|
|
430
|
+
raise SystemExit("[proposal] 采样器只给了 %d 个候选(至少要 2 个才能算'多方案')"
|
|
431
|
+
% len(designs))
|
|
432
|
+
|
|
433
|
+
if not json_out and brief.get("duration_target"):
|
|
434
|
+
print(" 目标时长: %.1fs(按 brief 解析;成片时长仍由实测旁白决定,渲染阶段会对账)"
|
|
435
|
+
% float(brief["duration_target"]))
|
|
436
|
+
outdir = rev_dir(work, rev)
|
|
437
|
+
os.makedirs(outdir, exist_ok=True)
|
|
438
|
+
|
|
439
|
+
rows, t_all0 = [], time.time()
|
|
440
|
+
for i, design in enumerate(designs):
|
|
441
|
+
cid = CANDIDATE_IDS[i] if i < len(CANDIDATE_IDS) else "C%d" % (i + 1)
|
|
442
|
+
design = dict(design)
|
|
443
|
+
design.setdefault("id", "design-%s" % cid.lower())
|
|
444
|
+
design["id"] = "%s-%s" % (cid.lower(), design["id"].split("-")[-1]) if "-" in design["id"] else "%s-%s" % (cid.lower(), design["id"])
|
|
445
|
+
cdir = os.path.join(outdir, cid)
|
|
446
|
+
os.makedirs(cdir, exist_ok=True)
|
|
447
|
+
row = _candidate_row(design, script, cdir, n_frames, previewkit, designkit,
|
|
448
|
+
with_audio=with_audio)
|
|
449
|
+
row["id"] = cid
|
|
450
|
+
rows.append(row)
|
|
451
|
+
if not json_out:
|
|
452
|
+
print(" 候选 %s %-22s %d 帧 %.2fs -> %s"
|
|
453
|
+
% (cid, row["label"][:22], len(row["preview"]["frames"] or []),
|
|
454
|
+
row["preview"]["secs"], row["preview"]["sheet"]))
|
|
455
|
+
|
|
456
|
+
_fill_diversity(rows, designkit)
|
|
457
|
+
# 台账距离:< LEDGER_MIN_GAP 说明"这批候选和最近交付的某部片太像"。
|
|
458
|
+
# 不阻断出片(用户可能就是想沿用同一套),但必须在摘要里说出来——
|
|
459
|
+
# 这正是原则 5 要的"不要大面积重复",而用户自己不会去比对上一部片。
|
|
460
|
+
if not json_out and recent:
|
|
461
|
+
print(" 片间去重: 已对照最近 %d 部片(最接近的一对差 %d 维,要求 >=%d;台账 %s)"
|
|
462
|
+
% (len(recent), ledger_gap if ledger_gap is not None else -1,
|
|
463
|
+
LEDGER_MIN_GAP, ledger_path() or "关闭"))
|
|
464
|
+
if ledger_gap is not None and ledger_gap < LEDGER_MIN_GAP:
|
|
465
|
+
print(" ! 这批候选与最近交付过的设计最接近的一对只差 %d 个维度(建议 ≥%d):"
|
|
466
|
+
% (ledger_gap, LEDGER_MIN_GAP))
|
|
467
|
+
print(" 已有 %d 部片的台账:%s" % (len(recent), ledger_path()))
|
|
468
|
+
print(" 想强制避开:--revise \"换个完全不同的方向\";想忽略:--no-ledger")
|
|
469
|
+
wall = round(time.time() - t_all0, 2)
|
|
470
|
+
# 并排对比图必须**无条件**产出:之前它写在 `if not json_out` 分支里,于是
|
|
471
|
+
# `--json` 调用(机读、被 CI 或上层脚本用)永远拿不到 _compare.png,
|
|
472
|
+
# 而"人眼并排看 N 个方案"正是这一步最重要的产物。
|
|
473
|
+
compare = _write_compare_sheet(rows, outdir)
|
|
474
|
+
|
|
475
|
+
payload = {
|
|
476
|
+
"schema": SCHEMA,
|
|
477
|
+
"rev": rev,
|
|
478
|
+
"parentRev": parent,
|
|
479
|
+
"at": _now(),
|
|
480
|
+
"status": "draft",
|
|
481
|
+
"brief": brief,
|
|
482
|
+
"feedback": feedback,
|
|
483
|
+
"chosen": None,
|
|
484
|
+
"seed": seed,
|
|
485
|
+
"wall_s": wall,
|
|
486
|
+
# 片间去重证据:这批候选与"最近交付过的设计"的最小维度距离。
|
|
487
|
+
# None = 没有台账(第一次出片 / 已用 --no-ledger 关闭)。
|
|
488
|
+
"ledger": {"path": ledger_path() or None, "recent": len(recent),
|
|
489
|
+
"min_gap": ledger_gap, "want_gap": LEDGER_MIN_GAP},
|
|
490
|
+
"candidates": rows,
|
|
491
|
+
}
|
|
492
|
+
path = proposal_path(work)
|
|
493
|
+
_atomic_write(path, json.dumps(payload, ensure_ascii=False, indent=1))
|
|
494
|
+
# 每版留一份快照(rev 不覆盖)
|
|
495
|
+
_atomic_write(os.path.join(outdir, "proposal.json"),
|
|
496
|
+
json.dumps(payload, ensure_ascii=False, indent=1))
|
|
497
|
+
|
|
498
|
+
if not json_out:
|
|
499
|
+
print()
|
|
500
|
+
print("== 提案 rev%d:%d 个候选,总耗时 %.2fs" % (rev, len(rows), wall))
|
|
501
|
+
print(" %-3s %-22s %-9s %-8s %s" % ("id", "立意", "静帧", "距离", "状态"))
|
|
502
|
+
for r in rows:
|
|
503
|
+
print(" %-3s %-22s %8.2fs %-8d %s"
|
|
504
|
+
% (r["id"], (r["pitch"] or r["label"])[:22], r["preview"]["secs"],
|
|
505
|
+
r["diversity"]["min_n"],
|
|
506
|
+
"OK" if r["diversity"]["min_n"] >= 2 else "!! 与某候选太像"))
|
|
507
|
+
if with_audio:
|
|
508
|
+
print()
|
|
509
|
+
print(" 样音(12 秒 / 48 kHz 立体声):")
|
|
510
|
+
for r in rows:
|
|
511
|
+
pv = r["preview"]
|
|
512
|
+
if pv.get("audio"):
|
|
513
|
+
print(" %s %-9s 生成 %4.2fs %s"
|
|
514
|
+
% (r["id"], pv.get("audio_engine") or "?",
|
|
515
|
+
pv.get("audio_secs") or 0.0, pv["audio"]))
|
|
516
|
+
else:
|
|
517
|
+
print(" %s 没生成:%s" % (r["id"], pv.get("audio_error") or "未知原因"))
|
|
518
|
+
if unmatched:
|
|
519
|
+
print()
|
|
520
|
+
print(" ! 这些要求没生效(词表里没有,采样器没有照做):%s"
|
|
521
|
+
% "、".join("「%s」" % w for w in unmatched))
|
|
522
|
+
print()
|
|
523
|
+
print(" 联系表(并排对比): %s" % compare)
|
|
524
|
+
print(" 确认其中一个: python propose.py --confirm --candidate %s" % rows[0]["id"])
|
|
525
|
+
print(" 都不满意: python propose.py --revise \"你的要求\"")
|
|
526
|
+
else:
|
|
527
|
+
# 机读调用(CI / 上层脚本)拿不到任何东西的话,--json 就只是"关掉人读输出",
|
|
528
|
+
# 这跟参数名承诺的不是一回事;payload 本身就是这一版提案的全部事实。
|
|
529
|
+
print(json.dumps(payload, ensure_ascii=False, indent=1))
|
|
530
|
+
return payload
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _write_compare_sheet(rows, outdir):
|
|
534
|
+
"""把 N 个候选的联系表纵向拼成一张对比图(人一眼能看全)。"""
|
|
535
|
+
try:
|
|
536
|
+
from PIL import Image
|
|
537
|
+
except ImportError:
|
|
538
|
+
return "(没装 Pillow,跳过并排对比图)"
|
|
539
|
+
paths = [r["preview"]["sheet"] for r in rows if r.get("preview", {}).get("sheet")]
|
|
540
|
+
paths = [p for p in paths if p and os.path.exists(p)]
|
|
541
|
+
if len(paths) < 2:
|
|
542
|
+
return "(候选联系表不足 2 张,跳过并排对比图)"
|
|
543
|
+
from PIL import ImageDraw
|
|
544
|
+
# 中文标签必须用项目自己的字体链(PIL 默认位图字体没有中文字形,会渲成方块)。
|
|
545
|
+
try:
|
|
546
|
+
import filmkit as _K
|
|
547
|
+
label_font = _K.font(19, "reg", "sans")
|
|
548
|
+
except Exception: # noqa: BLE001
|
|
549
|
+
label_font = None
|
|
550
|
+
ims = [Image.open(p).convert("RGB") for p in paths]
|
|
551
|
+
tagged = [r for r in rows if r.get("preview", {}).get("sheet") in paths]
|
|
552
|
+
w = max(i.size[0] for i in ims)
|
|
553
|
+
band = 34 # 每行顶部加一条标签带:不标出来就看不出哪一行是哪个候选
|
|
554
|
+
h = sum(i.size[1] for i in ims) + band * len(ims)
|
|
555
|
+
sheet = Image.new("RGB", (w, h), (12, 12, 14))
|
|
556
|
+
draw = ImageDraw.Draw(sheet)
|
|
557
|
+
y = 0
|
|
558
|
+
for im, r in zip(ims, tagged):
|
|
559
|
+
draw.text((16, y + 7), "%s %s — %s"
|
|
560
|
+
% (r.get("id"), r.get("label") or "", (r.get("pitch") or "")[:60]),
|
|
561
|
+
fill=(226, 232, 240), font=label_font)
|
|
562
|
+
y += band
|
|
563
|
+
sheet.paste(im, (0, y))
|
|
564
|
+
y += im.size[1]
|
|
565
|
+
out = os.path.join(outdir, "_compare.png")
|
|
566
|
+
sheet.save(out)
|
|
567
|
+
return out
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ---------------------------------------------------------------- 确认 / 修订
|
|
571
|
+
def confirm(work, candidate, by="user", note="", json_out=False,
|
|
572
|
+
script=None, script_path=""):
|
|
573
|
+
prev = _load_json(proposal_path(work), default=None)
|
|
574
|
+
if not prev:
|
|
575
|
+
raise SystemExit("[proposal] 没有 %s——先跑一次提案" % proposal_path(work))
|
|
576
|
+
cands = {str(c.get("id")): c for c in (prev.get("candidates") or [])}
|
|
577
|
+
cid = str(candidate or "").strip().upper()
|
|
578
|
+
if cid not in cands:
|
|
579
|
+
raise SystemExit("[proposal] 没有候选 %r;本版有:%s"
|
|
580
|
+
% (candidate, " / ".join(sorted(cands))))
|
|
581
|
+
design = cands[cid]["design"]
|
|
582
|
+
designkit = _designkit()
|
|
583
|
+
bad = designkit.validate_design(design)
|
|
584
|
+
if bad:
|
|
585
|
+
raise SystemExit("[proposal] 候选 %s 的设计没过校验,不能确认:\n - %s"
|
|
586
|
+
% (cid, "\n - ".join(bad)))
|
|
587
|
+
prev["status"] = "confirmed"
|
|
588
|
+
prev["chosen"] = cid
|
|
589
|
+
prev["confirmedAt"] = _now()
|
|
590
|
+
prev["confirmedBy"] = str(by or "user")
|
|
591
|
+
prev["confirmNote"] = str(note or "")
|
|
592
|
+
prev["designHash"] = designkit.design_fingerprint(design)
|
|
593
|
+
# 只绑设计不够:设计没变而剧本大改,断句/时长/字幕全变,那次确认等于作废。
|
|
594
|
+
# 只有调用方**显式**把剧本递进来才绑;没剧本可绑时必须清掉旧值,
|
|
595
|
+
# 否则旧版本留下的指纹会在下次提案后误判成 stale。
|
|
596
|
+
if isinstance(script, dict) and script.get("lines"):
|
|
597
|
+
prev["scriptHash"] = _sha(script)
|
|
598
|
+
prev["scriptPath"] = os.path.abspath(str(script_path)) if script_path else ""
|
|
599
|
+
else:
|
|
600
|
+
prev.pop("scriptHash", None)
|
|
601
|
+
prev.pop("scriptPath", None)
|
|
602
|
+
_atomic_write(proposal_path(work), json.dumps(prev, ensure_ascii=False, indent=1))
|
|
603
|
+
_atomic_write(os.path.join(str(work), "design.json"),
|
|
604
|
+
json.dumps(design, ensure_ascii=False, indent=1))
|
|
605
|
+
# 片间去重台账:**确认**(= 真的要出片了)才记,草稿候选不记。
|
|
606
|
+
# 记的是设计本身(自足 JSON),不是路径;同一指纹只留最新一条。
|
|
607
|
+
ledger_record(design, work=work, label=cands[cid].get("label") or "")
|
|
608
|
+
if not json_out:
|
|
609
|
+
print("== 已确认候选 %s(%s)" % (cid, cands[cid].get("label")))
|
|
610
|
+
if prev.get("scriptHash"):
|
|
611
|
+
print(" 剧本指纹: %s(剧本改了要重新提案)" % prev["scriptHash"])
|
|
612
|
+
print(" 冻结设计: %s" % os.path.join(str(work), "design.json"))
|
|
613
|
+
print(" 现在可以: python render.py --script <film.json> --stage all")
|
|
614
|
+
return prev
|
|
615
|
+
|
|
616
|
+
def revise(work, feedback, script_path, n=3, seed=None, n_frames=4, json_out=False,
|
|
617
|
+
with_audio=False, no_ledger=False):
|
|
618
|
+
"""不满意就重出:新 rev 不覆盖旧 rev,feedback 原文留存(这就是"迭代"的审计)。"""
|
|
619
|
+
if not str(feedback or "").strip():
|
|
620
|
+
raise SystemExit("[proposal] --revise 需要一段要求(例如:更冷一点、女声、节奏快些)")
|
|
621
|
+
prev = _load_json(proposal_path(work), default=None)
|
|
622
|
+
if not prev:
|
|
623
|
+
raise SystemExit("[proposal] 没有提案可改——先跑一次提案")
|
|
624
|
+
script = _load_script(script_path)
|
|
625
|
+
return make_proposal(str(feedback), script, work, n=n, seed=seed,
|
|
626
|
+
n_frames=n_frames, json_out=json_out,
|
|
627
|
+
with_audio=with_audio, no_ledger=no_ledger)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def diff(work, rev_a, rev_b, json_out=False):
|
|
631
|
+
dfk = _designkit()
|
|
632
|
+
pa = _load_json(os.path.join(str(work), PROPOSALS_DIR, "rev%d" % int(rev_a), "proposal.json"),
|
|
633
|
+
default=None)
|
|
634
|
+
pb = _load_json(os.path.join(str(work), PROPOSALS_DIR, "rev%d" % int(rev_b), "proposal.json"),
|
|
635
|
+
default=None)
|
|
636
|
+
if not pa or not pb:
|
|
637
|
+
raise SystemExit("[proposal] 找不到 rev%d 或 rev%d 的快照(work/%s/rev*/proposal.json)"
|
|
638
|
+
% (rev_a, rev_b, PROPOSALS_DIR))
|
|
639
|
+
out = {"revA": int(rev_a), "revB": int(rev_b), "pairs": []}
|
|
640
|
+
for ca in pa.get("candidates") or []:
|
|
641
|
+
row = {"id": ca.get("id"), "label": ca.get("label"), "vs": []}
|
|
642
|
+
for cb in pb.get("candidates") or []:
|
|
643
|
+
d = dfk.design_distance(ca["design"], cb["design"])
|
|
644
|
+
row["vs"].append({"id": cb.get("id"), "n": d.get("n", 0),
|
|
645
|
+
"axes": d.get("axes") or []})
|
|
646
|
+
out["pairs"].append(row)
|
|
647
|
+
if json_out:
|
|
648
|
+
print(json.dumps(out, ensure_ascii=False, indent=1))
|
|
649
|
+
return out
|
|
650
|
+
print("== rev%d vs rev%d 的设计差异(n = 不同的设计维度数)" % (int(rev_a), int(rev_b)))
|
|
651
|
+
for row in out["pairs"]:
|
|
652
|
+
cells = " ".join("%s:n=%d" % (v["id"], v["n"]) for v in row["vs"])
|
|
653
|
+
print(" %s %-20s %s" % (row["id"], (row["label"] or "")[:20], cells))
|
|
654
|
+
return out
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def show(work, which="", json_out=False):
|
|
658
|
+
prev = _load_json(proposal_path(work), default=None)
|
|
659
|
+
if not prev:
|
|
660
|
+
raise SystemExit("[proposal] 没有提案:%s" % proposal_path(work))
|
|
661
|
+
if json_out:
|
|
662
|
+
print(json.dumps(prev, ensure_ascii=False, indent=1))
|
|
663
|
+
return prev
|
|
664
|
+
print("== 提案 rev%d status=%s chosen=%s 时间=%s"
|
|
665
|
+
% (prev.get("rev"), prev.get("status"), prev.get("chosen"), prev.get("at")))
|
|
666
|
+
print(" brief: %s" % (prev.get("brief") or {}).get("topic"))
|
|
667
|
+
if prev.get("scriptHash"):
|
|
668
|
+
print(" 剧本指纹: %s" % prev["scriptHash"])
|
|
669
|
+
if prev.get("feedback"):
|
|
670
|
+
print(" feedback: %s" % prev["feedback"])
|
|
671
|
+
print()
|
|
672
|
+
for r in prev.get("candidates") or []:
|
|
673
|
+
if which and str(r.get("id")).upper() != str(which).upper():
|
|
674
|
+
continue
|
|
675
|
+
print(" [%s] %s" % (r.get("id"), r.get("pitch") or r.get("label")))
|
|
676
|
+
print(" 设计: %s" % (r.get("design_fp") or ""))
|
|
677
|
+
print(" 静帧: %s (%s 帧, %.2fs)"
|
|
678
|
+
% ((r.get("preview") or {}).get("sheet"),
|
|
679
|
+
len((r.get("preview") or {}).get("frames") or []),
|
|
680
|
+
(r.get("preview") or {}).get("secs") or 0))
|
|
681
|
+
pv = r.get("preview") or {}
|
|
682
|
+
if pv.get("audio"):
|
|
683
|
+
print(" 样音: %s (%s, 生成 %.2fs)"
|
|
684
|
+
% (pv["audio"], pv.get("audio_engine") or "?", pv.get("audio_secs") or 0))
|
|
685
|
+
elif pv.get("audio_error"):
|
|
686
|
+
print(" 样音: 没生成(%s)" % pv["audio_error"])
|
|
687
|
+
div = r.get("diversity") or {}
|
|
688
|
+
if div.get("vs"):
|
|
689
|
+
print(" 与其它候选: %s"
|
|
690
|
+
% ", ".join("%s(n=%d)" % (v["id"], v["n"]) for v in div["vs"]))
|
|
691
|
+
return prev
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# ---------------------------------------------------------------- 门禁接口(给 render.py 用)
|
|
695
|
+
def proposal_state(work, script_path=None):
|
|
696
|
+
"""给 render.py 的一行判定:返回 (status, detail)。
|
|
697
|
+
|
|
698
|
+
status ∈ {"absent", "draft", "confirmed", "stale", "broken"}
|
|
699
|
+
absent —— 没提案(未启用,老项目就是这个)
|
|
700
|
+
draft —— 有提案但还没确认
|
|
701
|
+
confirmed —— 已确认,且 design.json 与确认时的 designHash 一致、剧本也没换
|
|
702
|
+
stale —— 确认过,但 design.json 被改过(指纹对不上),或剧本改过了
|
|
703
|
+
broken —— 文件在但坏了
|
|
704
|
+
|
|
705
|
+
script_path 可省:省略时按 confirm 时记下的 scriptPath 找,再退到 work/film.json。
|
|
706
|
+
找不到/读不出剧本就**不判**剧本这一档(见 _script_hash_of),只判设计指纹。
|
|
707
|
+
"""
|
|
708
|
+
p = proposal_path(work)
|
|
709
|
+
data = _load_json(p, default=None)
|
|
710
|
+
if data is None:
|
|
711
|
+
return "absent", "没有 %s" % p
|
|
712
|
+
if not isinstance(data, dict):
|
|
713
|
+
return "broken", "%s 不是 JSON 对象" % p
|
|
714
|
+
if data.get("status") != "confirmed" or not data.get("chosen"):
|
|
715
|
+
return "draft", "rev%s 还没确认(chosen=%r)" % (data.get("rev"), data.get("chosen"))
|
|
716
|
+
design = _load_json(os.path.join(str(work), "design.json"), default=None)
|
|
717
|
+
if design is None:
|
|
718
|
+
return "stale", "confirmed 但 design.json 不存在(重新 --confirm)"
|
|
719
|
+
try:
|
|
720
|
+
dfk = _designkit()
|
|
721
|
+
got = dfk.design_fingerprint(design)
|
|
722
|
+
except SystemExit:
|
|
723
|
+
raise
|
|
724
|
+
if str(data.get("designHash") or "") != str(got):
|
|
725
|
+
return "stale", "design.json 的指纹 %s 与确认时 %s 不一致" % (
|
|
726
|
+
got, data.get("designHash"))
|
|
727
|
+
|
|
728
|
+
# 剧本这一档只在确认时显式传过 script 才存在(scriptHash 有值才判)。
|
|
729
|
+
sh = str(data.get("scriptHash") or "")
|
|
730
|
+
if sh:
|
|
731
|
+
cand_path = str(script_path or data.get("scriptPath") or "")
|
|
732
|
+
if not cand_path:
|
|
733
|
+
fallback = os.path.join(str(work), "film.json")
|
|
734
|
+
cand_path = fallback if os.path.isfile(fallback) else ""
|
|
735
|
+
now = _script_hash_of(cand_path)
|
|
736
|
+
if now is not None and now != sh:
|
|
737
|
+
return "stale", ("剧本改过了(%s 的指纹 %s -> %s),需重新提案"
|
|
738
|
+
% (cand_path, sh, now))
|
|
739
|
+
return "confirmed", "候选 %s(rev%d)" % (data.get("chosen"), data.get("rev"))
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# ---------------------------------------------------------------- CLI
|
|
743
|
+
def main(argv=None):
|
|
744
|
+
_setup_stdio()
|
|
745
|
+
ap = argparse.ArgumentParser(description="video-studio 提案器:先出方案,确认后再出片")
|
|
746
|
+
ap.add_argument("--work", default="work")
|
|
747
|
+
ap.add_argument("--script", default="film.json")
|
|
748
|
+
ap.add_argument("--brief", default="", help="一句话题材/要求(首次提案用)")
|
|
749
|
+
ap.add_argument("--n", type=int, default=3, help="候选数(默认 3,最少 2)")
|
|
750
|
+
ap.add_argument("--seed", default=None,
|
|
751
|
+
help="采样种子:给了就逐字节可复现(调试/回归用);"
|
|
752
|
+
"不给则每次都不同(默认,满足『每条视频都不一样』)")
|
|
753
|
+
ap.add_argument("--n-frames", type=int, default=4, help="每个候选渲几张静帧")
|
|
754
|
+
ap.add_argument("--list", action="store_true", help="列出当前提案")
|
|
755
|
+
ap.add_argument("--show", nargs="?", const="", default=None, help="看单个候选(给候选 id)")
|
|
756
|
+
ap.add_argument("--confirm", action="store_true", help="确认某个候选(配 --candidate)")
|
|
757
|
+
ap.add_argument("--candidate", default="", help="要确认的候选 id(A/B/C…)")
|
|
758
|
+
ap.add_argument("--by", default="user")
|
|
759
|
+
ap.add_argument("--note", default="")
|
|
760
|
+
ap.add_argument("--revise", default=None, help="带一段要求重出方案(新 rev,不覆盖旧的)")
|
|
761
|
+
ap.add_argument("--diff", nargs=2, type=int, metavar=("REV_A", "REV_B"),
|
|
762
|
+
help="看两版设计的差异")
|
|
763
|
+
ap.add_argument("--with-audio", action="store_true",
|
|
764
|
+
help="给每个候选附一段 12 秒样音(离线;配乐由 musicgen 生成)")
|
|
765
|
+
ap.add_argument("--json", action="store_true", help="stdout 只输出 JSON")
|
|
766
|
+
ap.add_argument("--no-ledger", action="store_true",
|
|
767
|
+
help="忽略片间去重台账(不读也不写;默认开启,路径见 --show-ledger)")
|
|
768
|
+
ap.add_argument("--show-ledger", action="store_true",
|
|
769
|
+
help="打印台账路径与最近几条已交付设计(排查为什么这次避开了某方向)")
|
|
770
|
+
a = ap.parse_args(argv)
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
if a.show_ledger:
|
|
774
|
+
path = ledger_path()
|
|
775
|
+
rows = ledger_load(limit=20)
|
|
776
|
+
print("[台账] %s" % (path or "已关闭(--no-ledger / VIDEO_STUDIO_LEDGER=off)"))
|
|
777
|
+
for r in rows:
|
|
778
|
+
print(" %s %-22s %s" % (r.get("at") or "", (r.get("label") or "")[:22],
|
|
779
|
+
r.get("fingerprint") or ""))
|
|
780
|
+
print(" 共 %d 条(最近 20 条)" % len(rows))
|
|
781
|
+
return 0
|
|
782
|
+
if a.diff:
|
|
783
|
+
diff(a.work, a.diff[0], a.diff[1], json_out=a.json)
|
|
784
|
+
return 0
|
|
785
|
+
if a.confirm:
|
|
786
|
+
if not a.candidate:
|
|
787
|
+
raise SystemExit("[proposal] --confirm 需要 --candidate A/B/C")
|
|
788
|
+
# 剧本存在才绑指纹:--confirm 不该因为 film.json 写坏或不在就失败,
|
|
789
|
+
# 但能绑就必须绑,否则"设计没变、剧本改了"会被当成已确认。
|
|
790
|
+
script, spath = None, ""
|
|
791
|
+
try:
|
|
792
|
+
if os.path.isfile(a.script):
|
|
793
|
+
script, spath = _load_script(a.script), a.script
|
|
794
|
+
except SystemExit:
|
|
795
|
+
script, spath = None, ""
|
|
796
|
+
confirm(a.work, a.candidate, by=a.by, note=a.note, json_out=a.json,
|
|
797
|
+
script=script, script_path=spath)
|
|
798
|
+
return 0
|
|
799
|
+
if a.show is not None:
|
|
800
|
+
show(a.work, a.show, json_out=a.json)
|
|
801
|
+
return 0
|
|
802
|
+
if a.list:
|
|
803
|
+
show(a.work, "", json_out=a.json)
|
|
804
|
+
return 0
|
|
805
|
+
if a.revise is not None:
|
|
806
|
+
revise(a.work, a.revise, a.script, n=a.n, seed=a.seed,
|
|
807
|
+
n_frames=a.n_frames, json_out=a.json,
|
|
808
|
+
with_audio=a.with_audio, no_ledger=a.no_ledger)
|
|
809
|
+
return 0
|
|
810
|
+
if a.brief or os.path.exists(a.script):
|
|
811
|
+
script = _load_script(a.script)
|
|
812
|
+
make_proposal(a.brief, script, a.work, n=a.n, seed=a.seed,
|
|
813
|
+
n_frames=a.n_frames, json_out=a.json,
|
|
814
|
+
with_audio=a.with_audio, no_ledger=a.no_ledger)
|
|
815
|
+
return 0
|
|
816
|
+
ap.print_help()
|
|
817
|
+
return 0
|
|
818
|
+
except SystemExit:
|
|
819
|
+
raise
|
|
820
|
+
except Exception as e:
|
|
821
|
+
raise SystemExit("[proposal] %s: %s" % (type(e).__name__, e)) from e
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
if __name__ == "__main__":
|
|
825
|
+
sys.exit(main())
|