@dsh-gis/dsh-video-studio-skill 1.0.2 → 1.0.4
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 +49 -0
- package/README.md +84 -108
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/package.json +1 -1
- package/schemas/design.schema.json +5 -5
- package/schemas/film.schema.json +31 -31
- package/skills/video-studio/SKILL.md +168 -268
- package/skills/video-studio/assets/film.json +2 -3
- package/skills/video-studio/assets/golden_frames.json +11 -11
- package/skills/video-studio/assets/scenes.py +92 -49
- package/skills/video-studio/references/{style-packs.md → appearance.md} +17 -15
- package/skills/video-studio/references/audio-variation.md +73 -96
- package/skills/video-studio/references/audio.md +40 -44
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +9 -11
- package/skills/video-studio/references/frame-qa.md +47 -15
- package/skills/video-studio/references/pipeline.md +4 -4
- package/skills/video-studio/references/pitfalls.md +22 -3
- package/skills/video-studio/references/proposal.md +19 -36
- package/skills/video-studio/references/quality-gates.md +11 -11
- package/skills/video-studio/references/scene-authoring.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +17 -24
- package/skills/video-studio/references/sfx-design.md +7 -5
- package/skills/video-studio/references/web-engine.md +7 -7
- package/skills/video-studio/references/word-timeline.md +7 -7
- package/skills/video-studio/requirements.txt +9 -13
- package/skills/video-studio/scripts/designkit.py +84 -89
- package/skills/video-studio/scripts/filmcheck.py +19 -19
- package/skills/video-studio/scripts/filmkit.py +2949 -2948
- package/skills/video-studio/scripts/gates.py +4 -4
- package/skills/video-studio/scripts/grammar.py +10 -10
- package/skills/video-studio/scripts/musicgen.py +3 -4
- package/skills/video-studio/scripts/preflight.py +5 -5
- package/skills/video-studio/scripts/previewkit.py +11 -12
- package/skills/video-studio/scripts/propose.py +11 -11
- package/skills/video-studio/scripts/qa_frames.py +34 -35
- package/skills/video-studio/scripts/qaasr.py +26 -7
- package/skills/video-studio/scripts/qavmaf.py +19 -19
- package/skills/video-studio/scripts/render.py +137 -141
- package/skills/video-studio/scripts/scorekit.py +21 -23
- package/skills/video-studio/scripts/selfcheck.py +208 -135
- package/skills/video-studio/scripts/sfxkit.py +27 -26
- package/skills/video-studio/scripts/stagewatch.py +4 -4
- package/skills/video-studio/scripts/stylekit.py +77 -282
- package/skills/video-studio/scripts/voicekit.py +43 -44
- package/skills/video-studio/scripts/webengine.py +62 -64
- package/tools/audio_similarity.py +12 -12
- package/tools/ci_check.py +16 -16
- package/tools/estimate.py +24 -25
- package/tools/golden_frames.py +17 -18
- package/tools/silhouette_qa.py +13 -14
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""gates.py — 门禁账本 / 机读交付报告 /
|
|
2
|
+
"""gates.py — 门禁账本 / 机读交付报告 / 人工签核。
|
|
3
3
|
|
|
4
4
|
解决三件事(都是"文档承诺了、代码没做"):
|
|
5
5
|
|
|
@@ -117,7 +117,7 @@ class GateLedger:
|
|
|
117
117
|
self.work = str(work or "work")
|
|
118
118
|
self.items = []
|
|
119
119
|
self.t0 = time.time()
|
|
120
|
-
#
|
|
120
|
+
# run_id 是"这一版"的标签(--run-id)。没给就按时间戳生成,
|
|
121
121
|
# 保证每次运行在 work/runs/<runId>/ 下都有独立记录,而不是互相覆盖。
|
|
122
122
|
self.run_id = str(run_id or "").strip() or time.strftime("r%Y%m%d-%H%M%S")
|
|
123
123
|
self.script = script or {}
|
|
@@ -194,7 +194,7 @@ class GateLedger:
|
|
|
194
194
|
return d
|
|
195
195
|
|
|
196
196
|
def input_fingerprints(self):
|
|
197
|
-
"""
|
|
197
|
+
"""输入指纹快照:剧本 / scenes / 词级 / 本脚本。
|
|
198
198
|
|
|
199
199
|
为什么要单独记:contentHash 是"一个 16 位 hex",能判"变了没",但**说不出
|
|
200
200
|
变了哪个**。批量出片时最常见的问题是"这条用的还是上一版的词级时间戳",
|
|
@@ -219,7 +219,7 @@ class GateLedger:
|
|
|
219
219
|
|
|
220
220
|
|
|
221
221
|
def archive_run(work, ledger, artifacts=None, report_path=None):
|
|
222
|
-
"""把"这一版"的账本快照归档到 work/runs/<runId
|
|
222
|
+
"""把"这一版"的账本快照归档到 work/runs/<runId>/。
|
|
223
223
|
|
|
224
224
|
只归档**小文件**(gates.json / QC-REPORT.md / run.json)。视频/音频/分块体积大,
|
|
225
225
|
就地保留即可——这里要解决的是"跑了几版之后,谁都说不清上一版的门禁结论是什么",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""grammar.py —
|
|
2
|
+
"""grammar.py — 场景语法:这段内容该长成什么构图。
|
|
3
3
|
|
|
4
4
|
为什么单独一个模块
|
|
5
5
|
------------------
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
(scene type: hero/list/stat/compare/...)是两个**正交**维度:
|
|
8
8
|
- kind 决定旁白声线、语气、配乐能量(voicekit / scorekit 读它);
|
|
9
9
|
- scene type 决定这段画面长什么样(scenes.py 的 _DRAWERS 读它)。
|
|
10
|
-
|
|
11
|
-
quote/emphasis/calm 全部回落到 body ——
|
|
10
|
+
两者被混成一个 kind 时,scenes.py 只有 hook/body/outro 三个绘制器,
|
|
11
|
+
quote/emphasis/calm 全部回落到 body —— 画面因此只有三种骨架,换配色也换不掉"每屏一个样"。
|
|
12
12
|
|
|
13
13
|
把"内容 -> 构图"的选择逻辑独立出来,有三个好处:
|
|
14
14
|
1. 纯函数、只依赖标准库:可以单测,不需要 PIL / TTS / 渲染;
|
|
@@ -77,7 +77,7 @@ DOWNGRADE = {
|
|
|
77
77
|
MAX_REPEAT = 2
|
|
78
78
|
|
|
79
79
|
# ============================================================================
|
|
80
|
-
#
|
|
80
|
+
# 二、确定性哈希(sha1 前 8 位;禁 hash():str hash 每次进程带随机盐)
|
|
81
81
|
# ============================================================================
|
|
82
82
|
|
|
83
83
|
|
|
@@ -136,7 +136,7 @@ def _narrative_of(line):
|
|
|
136
136
|
"""只取"叙述文字"(标题/正文/副标),**不含 items**。
|
|
137
137
|
|
|
138
138
|
为什么关键词判断必须排除 items:条目是"要点的载荷",不是"这段在讲什么"。
|
|
139
|
-
|
|
139
|
+
实测反例:夹具片第 3 段正文讲"画质三件事",条目里写的是
|
|
140
140
|
"rawvideo -> libx264 CRF17",其中 "->" 命中了顺序词表,
|
|
141
141
|
于是整段被误判成"流程轴"——画面上出现一条横轴连着四个不相干的要点。
|
|
142
142
|
条目里的箭头/破折号是排版符号,不是叙事顺序。
|
|
@@ -235,7 +235,7 @@ def score_types(line, idx, total, dur, chapter, prev_chapter, prev_types):
|
|
|
235
235
|
add("outro", 5.0, "末段/收尾")
|
|
236
236
|
# 章节卡是"纯标题卡":章节变了 + 没有条目 + 标题短 + 段也短,才配得上整屏只有一个编号。
|
|
237
237
|
# 三个条件都是实测加上的:
|
|
238
|
-
# · 有 items
|
|
238
|
+
# · 有 items 的段用章节卡会把要点吃掉(夹具片 5 条要点的 body 曾被选成 chapter);
|
|
239
239
|
# · 带数字/引语的段有更具体的构图,章节卡会把信息量砍掉;
|
|
240
240
|
# · 设计稿规定章节卡 ≤3.4s,长段做章节卡等于"整屏只有一个编号停 6 秒",观众会走。
|
|
241
241
|
if (chapter and chapter != prev_chapter and n_items == 0
|
|
@@ -476,7 +476,7 @@ def plan_scenes(scenes, script=None, plan=None):
|
|
|
476
476
|
① plan["chapter_layout"] 显式指定(按章名查,查不到用 layout_default);
|
|
477
477
|
② plan["layout_cycle"](≥2 种版式且没有显式 chapter_layout):按章节首次
|
|
478
478
|
出现的顺序循环取位,**同一章内版式固定**——防止"全片一张版式";
|
|
479
|
-
③ 都没有:layout_default
|
|
479
|
+
③ 都没有:layout_default。
|
|
480
480
|
|
|
481
481
|
**没有 chapter 的剧本**:按"每段自成一章"处理(轮换照常推进)。
|
|
482
482
|
为什么不把空 chapter 当成"全片同一章":那样 layout_cycle 在常见的不写 chapter
|
|
@@ -492,7 +492,7 @@ def plan_scenes(scenes, script=None, plan=None):
|
|
|
492
492
|
prev_variant = {}
|
|
493
493
|
chapter_layout = plan.get("chapter_layout") if isinstance(plan.get("chapter_layout"), dict) else {}
|
|
494
494
|
default_layout = plan.get("layout_default") or "left-stack"
|
|
495
|
-
# ----
|
|
495
|
+
# ---- 片内版式轮换:design_to_plan 会派生 layout_cycle ----
|
|
496
496
|
# 为什么需要:没有它时一章只能用一种版式(layout_default),8 段片子 8 屏同骨架 ——
|
|
497
497
|
# 正好是用户原则 5 说的"同一视频内部大面积/长时间重复"。
|
|
498
498
|
# 生效条件(两条都不满足就与过去逐字节一致,金帧/既有自检不漂):
|
|
@@ -568,9 +568,9 @@ def describe(storyboard):
|
|
|
568
568
|
def _cli(argv=None):
|
|
569
569
|
"""自测入口:python grammar.py [film.json] —— 只读,不渲染。
|
|
570
570
|
|
|
571
|
-
|
|
571
|
+
用 argparse 解析(否则 `--help` 会被当成 film.json 路径,FileNotFoundError,
|
|
572
572
|
与仓库里其它 17 个脚本的 CLI 约定不一致)。不传 film.json 时仍打印用法与 10 类构图,
|
|
573
|
-
退出码 0
|
|
573
|
+
退出码 0(便于 CI 探活)。
|
|
574
574
|
"""
|
|
575
575
|
import argparse
|
|
576
576
|
import json
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
2
|
"""musicgen.py — P2 参数化配乐编排器(纯 numpy + stdlib,确定性、可单独 import)。
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
决定,而是由一组可采样参数现场编排:
|
|
4
|
+
配乐不由 style id 决定(本仓库没有曲风表),而是由一组可采样参数现场编排:
|
|
6
5
|
|
|
7
6
|
render(duration, *, bpm=96.0, key='Dm', scale='minor', mood='steady',
|
|
8
7
|
seed=0, sections=None, progressions=None, motif=None,
|
|
@@ -1017,7 +1016,7 @@ def _build_plan(duration, bpm, key, scale, mood, seed, sections, progressions,
|
|
|
1017
1016
|
dtype=np.float64)
|
|
1018
1017
|
# 一小节的能量取"小节内最响的时刻",窗口 **±0.20 小节**(约半小节)。
|
|
1019
1018
|
#
|
|
1020
|
-
#
|
|
1019
|
+
# 取舍:① 只取中心点 -> 段落与小节网格错位时 climax 永远触发不了;
|
|
1021
1020
|
# ② 窗口放宽到 ±0.42 几乎盖满整小节 -> 收尾小节被前一节的高潮污染
|
|
1022
1021
|
# (实测 8 s / 96 BPM 的 outro 被判成 climax)。
|
|
1023
1022
|
# ±0.20 是两头都照顾的取值:段切在中心附近偏差 ≤0.2 小节能被捕获,
|
|
@@ -1284,7 +1283,7 @@ def _arrange(plan):
|
|
|
1284
1283
|
# 都在 C5 以上;250–520 Hz 这个"有厚度的区间"没有持续音。
|
|
1285
1284
|
# 解法不是再开一个合成器,而是让 pad 多叠一层中音区和弦(+12 半音后限幅),
|
|
1286
1285
|
# 用同一套包络与 pan,成本近零、也不会引入新的调性信息。
|
|
1287
|
-
# 实测各层频段分布(12 s
|
|
1286
|
+
# 实测各层频段分布(12 s):pad 全在 250–2000 Hz(120–250 Hz 只有 0.7%)、
|
|
1288
1287
|
# bass 96% 在 20–120 Hz、arp/lead 90%+ 在 500 Hz 以上 —— 所以 120–250 Hz 是挖空的。
|
|
1289
1288
|
# 补法:把和弦**根音再降一个八度**叠一层(MIDI 40–55,即 82–196 Hz 的上半段),
|
|
1290
1289
|
# 它的基频正好落在 120–250 Hz;用 pad 音色与包络,音量压到 pad 的 0.42 倍,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""preflight.py —
|
|
2
|
+
"""preflight.py — 渲染前资源闸门。
|
|
3
3
|
|
|
4
4
|
为什么要有它:缺字形(✓ / emoji / 生僻字)和坏 TTS 音频都是「渲染完才报」的问题——
|
|
5
5
|
filmkit.glyph_missing_chars() 检测本身是准的(正常中文 [],✓/↵/emoji 检出),
|
|
@@ -12,7 +12,7 @@ preflight 不新写检测器,只把现成检测前移,并把结论整理成
|
|
|
12
12
|
2 glyphs 所有上屏文本逐字过 filmkit.glyph_missing_chars;
|
|
13
13
|
字段:text / sub / big / kicker / lead / note / title / end_text /
|
|
14
14
|
chapter / items[].text(含 timeline.scenes 里的同名字段)
|
|
15
|
-
|
|
15
|
+
按夹具实际绘制权重检测:big/kicker=bold、lead=light、sub=reg+light、
|
|
16
16
|
items=reg、title/chapter=reg、text=bold+reg
|
|
17
17
|
3 tts work/tts/*.mp3 若存在,必须能被 ffprobe 解析出音频流;
|
|
18
18
|
文件名要能对上行 id,时长要与台词长度相符(过短=截断/坏缓存)
|
|
@@ -40,7 +40,7 @@ ffmpeg/ffprobe 探测口径与 filmkit 一致:FILMKIT_FFMPEG 优先,找不
|
|
|
40
40
|
"""
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
|
-
__version__ = "0.
|
|
43
|
+
__version__ = "1.0.4"
|
|
44
44
|
|
|
45
45
|
import argparse
|
|
46
46
|
import json
|
|
@@ -70,7 +70,7 @@ FIELD_WEIGHTS = {
|
|
|
70
70
|
"big": ("bold",),
|
|
71
71
|
"kicker": ("bold",),
|
|
72
72
|
"lead": ("light",),
|
|
73
|
-
"note": ("bold",), #
|
|
73
|
+
"note": ("bold",), # 夹具未用;按 QA 的 check_glyphs 口径(bold)兜底
|
|
74
74
|
"title": ("reg", "bold"), # 右下角 brand 画 reg;hook 缺 big 时画 bold
|
|
75
75
|
"end_text": ("bold",),
|
|
76
76
|
"chapter": ("reg",),
|
|
@@ -635,7 +635,7 @@ def _check_web_page(script, engine, errors, warnings, checks):
|
|
|
635
635
|
entry = _web_entry(script)
|
|
636
636
|
if not os.path.isfile(entry):
|
|
637
637
|
tpl = _skill_web_template()
|
|
638
|
-
#
|
|
638
|
+
# assets/web is a demo page for smoke-testing the web path, not a template.
|
|
639
639
|
# This skill ships no templates: web-path films need the user own page (PIL path is zero-copy).
|
|
640
640
|
if tpl:
|
|
641
641
|
hint = ("(web 引擎需要**你自己的页面**:把技能自带的**示例页**拷到 %s 只能跑通链路,"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""previewkit.py —
|
|
2
|
+
"""previewkit.py — 离线方案预览:先看 3 个方案,再出片。
|
|
3
3
|
|
|
4
4
|
解决什么问题
|
|
5
5
|
-------------
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
编码函数)。产出的 PNG 与真实成片的差别只在"哪一帧是哪一句",不在"这一帧画成什么样"——
|
|
17
17
|
后者正是方案预览要回答的问题。
|
|
18
18
|
|
|
19
|
-
12
|
|
19
|
+
12 秒样音
|
|
20
20
|
-----------------
|
|
21
21
|
``render_audio_preview`` 是本文件唯一会"出声"的地方:按设计里的 bpm/key/scale/mood/seed
|
|
22
22
|
生成 12 秒配乐,回答"这一版的情绪对不对"。优先走 musicgen(参数化作曲器);它没就绪时
|
|
23
|
-
退回 scorekit 的 legacy 路径(
|
|
23
|
+
退回 scorekit 的 legacy 路径(scorekit 没有风格表,那条路只保证"还能出声",
|
|
24
24
|
音乐固定为 Dm/120,不能当作方案预览)。两条都不在就返回 ok=False。**样音失败绝不
|
|
25
25
|
打断提案**:提案是决策环节,不该因为配乐引擎没装好就整条断掉。
|
|
26
26
|
|
|
@@ -56,7 +56,7 @@ times 的语义:**全片绝对秒**(0 <= t <= total)。也接受 ``[scene_
|
|
|
56
56
|
"""
|
|
57
57
|
from __future__ import annotations
|
|
58
58
|
|
|
59
|
-
__version__ = "1.
|
|
59
|
+
__version__ = "1.0.4"
|
|
60
60
|
|
|
61
61
|
import argparse
|
|
62
62
|
import hashlib
|
|
@@ -75,11 +75,10 @@ ASSETS = os.path.normpath(os.path.join(HERE, os.pardir, "assets"))
|
|
|
75
75
|
def _default_scenes():
|
|
76
76
|
"""默认 scenes.py:**先看同目录**,再回落到 <skill>/assets/。
|
|
77
77
|
|
|
78
|
-
为什么不能只写死 assets
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
仓库内部使用(脚本仍在 <skill>/scripts/)时,同目录没有 scenes.py,回落到 assets/。
|
|
78
|
+
为什么不能只写死 assets/:用户可能把自己的 scenes.py 放在**剧本同目录**(myfilm/ 下只有
|
|
79
|
+
film.json 与 scenes.py),此时按相对路径推出来的 <myfilm>/../assets/scenes.py 根本不存在——
|
|
80
|
+
实测报 "scenes.py 不存在:<myfilm>/../assets/scenes.py",提案直接跑不起来。
|
|
81
|
+
脚本仍在 <skill>/scripts/ 时,同目录通常没有 scenes.py,于是回落到 assets/。
|
|
83
82
|
"""
|
|
84
83
|
for cand in (os.path.join(HERE, "scenes.py"),
|
|
85
84
|
os.path.join(ASSETS, "scenes.py")):
|
|
@@ -480,7 +479,7 @@ def _musicgen():
|
|
|
480
479
|
def _scorekit():
|
|
481
480
|
"""惰性 import scorekit:它拉起 numpy,import 期就要几百毫秒,默认不该付这笔钱。
|
|
482
481
|
|
|
483
|
-
|
|
482
|
+
scorekit 的配乐风格表已经没有了(零模板),它只剩 legacy 逐样本兼容
|
|
484
483
|
路径(固定 Dm/120、无 style/mood 参数)。这里保留它只是"兜底还能出声":
|
|
485
484
|
musicgen 没就绪时不至于整条样音链路断掉。
|
|
486
485
|
"""
|
|
@@ -522,7 +521,7 @@ def _render_musicgen(params, path, secs):
|
|
|
522
521
|
|
|
523
522
|
|
|
524
523
|
def _render_scorekit(params, path, secs):
|
|
525
|
-
"""兜底:scorekit 的 legacy
|
|
524
|
+
"""兜底:scorekit 的 legacy 路径(没有 style/mood 参数,别传)。"""
|
|
526
525
|
K = _scorekit()
|
|
527
526
|
samples, report = K.render(secs, bpm=params["bpm"], key=params["key"],
|
|
528
527
|
seed=params["seed"])
|
|
@@ -606,7 +605,7 @@ def render_audio_preview(design, out_dir, seconds=AUDIO_PREVIEW_SECONDS):
|
|
|
606
605
|
"""生成一个候选的 12 秒样音(48 kHz 立体声),返回结果 dict,**绝不抛异常**。
|
|
607
606
|
|
|
608
607
|
为什么优先 musicgen:它直接吃设计里的 bpm/key/scale/mood/seed,12 秒一次合成;
|
|
609
|
-
没就绪就退回 scorekit 的 legacy
|
|
608
|
+
没就绪就退回 scorekit 的 legacy 路径(它没有风格表,出的是固定 Dm/120),
|
|
610
609
|
engine 字段会写明是谁出的,调用方据此知道这条样音能不能代表方案。
|
|
611
610
|
两条都不可用时返回 {"ok": False, "reason": ...}——提案是决策环节,配乐引擎缺位不该
|
|
612
611
|
让整条提案断掉,但也不许假装成功(ok=True 却指着一个不存在的文件比报错更糟)。
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""propose.py —
|
|
2
|
+
"""propose.py — 提案器:先出 N 个方案,用户确认后才允许出片。
|
|
3
3
|
|
|
4
4
|
为什么单独一个入口而不是塞进 render.py
|
|
5
5
|
--------------------------------------
|
|
@@ -15,7 +15,7 @@ render.py 的 13 个 stage 是一条"内容 → 成片"的生产线;提案是*
|
|
|
15
15
|
revise -> rev2(不覆盖 rev1,带 feedback 原文)
|
|
16
16
|
diff -> 两个 rev 的设计向量差异
|
|
17
17
|
|
|
18
|
-
每次都不一样:同 brief 不传 seed
|
|
18
|
+
每次都不一样:同 brief 不传 seed 会得到**不同**的候选(满足"每条视频都不同");
|
|
19
19
|
可复现:显式 --seed 时同 seed 得到同一组候选(designkit 内部 sha1 派生,禁 random)。
|
|
20
20
|
离线:预览只走合成时间轴(previewkit),**不联网、不合成旁白**;--with-audio 才多生成
|
|
21
21
|
12 秒样音(仍全程离线,musicgen/scorekit 任选其一)。
|
|
@@ -89,10 +89,10 @@ def _sha(obj, n=16):
|
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
# ============================================================================
|
|
92
|
-
#
|
|
92
|
+
# 片间去重:设计台账
|
|
93
93
|
# ============================================================================
|
|
94
94
|
#
|
|
95
|
-
# 用户原则 5
|
|
95
|
+
# 用户原则 5:**不同视频的整体风格不能大面积重复**。只靠采样 nonce 的"每条视频都不同"
|
|
96
96
|
# 靠的是采样 nonce —— 同一 brief 两次采样确实不同,但**没有记住已经交付过什么**:
|
|
97
97
|
# 连续拍同题材的三条片,仍有概率撞到相近的配色/版式组合(设计空间大,但不是保证)。
|
|
98
98
|
# 台账把"最近交付过的设计"记在用户级目录里,提案时主动避开它们:
|
|
@@ -233,9 +233,9 @@ _NEGATIVE_WORDS = ("不要", "不想要", "别用", "别", "避开", "排除", "
|
|
|
233
233
|
|
|
234
234
|
|
|
235
235
|
|
|
236
|
-
# ----------------------------------------------------------------
|
|
237
|
-
# brief 里最常见的硬要求之一就是时长("做个 2 分钟的""60 秒短视频"
|
|
238
|
-
#
|
|
236
|
+
# ---------------------------------------------------------------- 时长解析
|
|
237
|
+
# brief 里最常见的硬要求之一就是时长("做个 2 分钟的""60 秒短视频")。不解析它
|
|
238
|
+
# 就**完全不生效**:_brief_of 只填 topic/must_have/avoid,duration_target 恒为 None,
|
|
239
239
|
# 连 design.structure.target_sec 都不会有值。用户以为要求被接住了,成片却按台词长度来。
|
|
240
240
|
# 这里把它解析出来:① 影响候选的段落数(designkit 按目标时长决定 segments);
|
|
241
241
|
# ② 渲染时对账"目标 vs 实测",差超过 15% 当场告警——**不截断旁白**,只把事实说清。
|
|
@@ -304,7 +304,7 @@ def parse_duration(text):
|
|
|
304
304
|
def _brief_of(text, script):
|
|
305
305
|
"""把用户一句话变成 brief dict。剧本的标题是隐含 topic(避免空 brief)。
|
|
306
306
|
|
|
307
|
-
|
|
307
|
+
以前这里把整句话塞进 topic 就完事,**must_have/avoid 恒为空数组**。
|
|
308
308
|
后果是 `--brief "深蓝配色的科技解说"` 里的"深蓝"完全不生效 —— 实测采出来的是
|
|
309
309
|
h087 / h316(橙 / 紫),用户看到的需求与结果无关。designkit 明明有一张
|
|
310
310
|
32 个词的关键词表(_constraint_words),但没人喂给它。
|
|
@@ -336,7 +336,7 @@ def _brief_of(text, script):
|
|
|
336
336
|
continue # 认不出来的短句留给 topic,由上层提示
|
|
337
337
|
neg = any(w in seg for w in _NEGATIVE_WORDS)
|
|
338
338
|
(avoid if neg else must).append(seg)
|
|
339
|
-
#
|
|
339
|
+
# 时长要求也要接住(漏了就是恒为 None,用户写了「2 分钟」等于没写)。
|
|
340
340
|
dt = parse_duration(text)
|
|
341
341
|
return {"topic": topic, "duration_target": dt,
|
|
342
342
|
"must_have": must, "avoid": avoid}
|
|
@@ -401,9 +401,9 @@ def make_proposal(brief_text, script, work, n=3, seed=None, n_frames=4, json_out
|
|
|
401
401
|
parent = prev.get("rev") if prev else None
|
|
402
402
|
feedback = str(brief_text or "").strip() if rev > 1 else ""
|
|
403
403
|
|
|
404
|
-
#
|
|
404
|
+
# 唯一的出方案方式是**按 brief 现场采样**:没有预设、没有模板可套。
|
|
405
405
|
#
|
|
406
|
-
#
|
|
406
|
+
# 片间去重:读台账里最近交付过的设计,若这批候选与其中某一份"太像"
|
|
407
407
|
# (离散维度差距 < LEDGER_MIN_GAP),换 nonce 重采一次,最多试 LEDGER_RETRIES 轮,
|
|
408
408
|
# 取"最后一批"里与历史距离最大的那组。重试仍近就**明说**(不静默放过)。
|
|
409
409
|
recent = [] if no_ledger else ledger_load()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""qa_frames.py — 帧级 /
|
|
2
|
+
"""qa_frames.py — 帧级 / 时间轴级深度质检。
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
只看容器、响度、黑帧、锐度的容器级质检看不出"画面内部"的问题:
|
|
5
5
|
一帧白闪、一段近黑空窗、音效 cue 与视觉事件没对上——这些只有逐帧解码才能发现。
|
|
6
6
|
|
|
7
7
|
本模块提供:
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
4. luma_curve / loudness_curve:亮度与响度时间曲线(响度是近似值,见文档)。
|
|
13
13
|
5. static_warnings("_static_warnings",报告里进 warnings):画面长时间"完全没有运动"
|
|
14
14
|
(卡帧 / 忘了加动画)。判据是"最活跃的 8x8 块在 0.5 s 里是否变了 > 1.5 级灰度",
|
|
15
|
-
连续 6 s
|
|
16
|
-
|
|
17
|
-
6. sync_check:cues.json 的音频提示点与视觉事件配对,报 dt
|
|
15
|
+
连续 6 s 全部不动才告警(不用"全帧平均亮度 3 s 波动 < 0.002"这类判据:它对逐字
|
|
16
|
+
入场这类局部动画不敏感,自带模板片会误报 4 段)。
|
|
17
|
+
6. sync_check:cues.json 的音频提示点与视觉事件配对,报 dt 超容差项。支持
|
|
18
18
|
cue 自带 visual_at / visual_t 画面锚点(PIL 路线没有 window.getEvents() 也能自校验),
|
|
19
19
|
并可用 require_sync(环境变量 QA_REQUIRE_SYNC=1 / CLI --require-sync)把
|
|
20
20
|
"同步门禁没执行"从 warning 升级为 error。
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
(亮度曲线 + 响度曲线 + 闪烁点 + 空窗区 + 同步偏差,一眼看出问题位置)。
|
|
23
23
|
|
|
24
24
|
判据与阈值来自 ai-history-film(MIT)的实测经验:
|
|
25
|
-
-
|
|
25
|
+
- 开发日志第十四轮"单帧闪烁":逐帧解码成 160x90、每帧减去前后两帧平均、分块统计,
|
|
26
26
|
找"只亮一帧"的格子。实测真实异常 10–15,正常运动画面 ≤ 6.4,因此默认阈值 6.0。
|
|
27
27
|
- 第五轮"画面空窗":逐帧亮度扫出 ≥ 1 s 近黑片段(保留片头、片尾等刻意黑)。
|
|
28
28
|
- video/qa_sync.js:画面视觉事件(闪白/震屏/冲击环)与音频 cue 对照。
|
|
@@ -39,7 +39,7 @@ FILMKIT_FFMPEG)。正常 import 不做重活、不打印。
|
|
|
39
39
|
"""
|
|
40
40
|
from __future__ import annotations
|
|
41
41
|
|
|
42
|
-
__version__ = "0.
|
|
42
|
+
__version__ = "1.0.4"
|
|
43
43
|
|
|
44
44
|
import argparse
|
|
45
45
|
import json
|
|
@@ -83,7 +83,7 @@ FLICKER_ISO = 1.5 # 孤立性比值:当前帧异常度 >= 1.5 x 前后
|
|
|
83
83
|
FLICKER_RETURN_RATIO = 1.6
|
|
84
84
|
# 符号一致度下限:块内像素"同向"变化的比例。真闪白/闪黑整块同向(0.75–1.0);
|
|
85
85
|
# 文字入场 / 母题动画 / 镜头缓推是有增有减的纹理变化(实测 0.42)。
|
|
86
|
-
# 低于此值不判 error(降级 warning
|
|
86
|
+
# 低于此值不判 error(降级 warning 供人复核)——否则零拷贝首片会被 G9 误杀。
|
|
87
87
|
FLICKER_AGREE_MIN = 0.60
|
|
88
88
|
# 与已登记视觉事件(闪白/震屏/冲击环)重合多久内视为"设计好的重击",不计 error
|
|
89
89
|
FLICKER_EXPLAIN_WIN = 0.2
|
|
@@ -102,18 +102,17 @@ TAIL_SKIP = 1.2 # 片尾多少秒内不算(刻意淡出)
|
|
|
102
102
|
# 音画同步
|
|
103
103
|
SYNC_TOL = 0.06 # dt 容差(秒)
|
|
104
104
|
SYNC_SAME_WIN = 2.0 # 同 kind 配对的最大搜索窗(秒)
|
|
105
|
-
# 异 kind 的"就近配对"
|
|
105
|
+
# 异 kind 的"就近配对"只用于**确认同步**(|dt| <= tol 才认可),没有独立的搜索窗;
|
|
106
106
|
# 这个常量保留给"events 每项带可选 cue 字段、允许跨 kind 显式配对"的下一版(CONTRACT §5.4 兼容位)。
|
|
107
107
|
SYNC_NEAR_WIN = 0.5
|
|
108
108
|
|
|
109
|
-
# 画面"长时间静止"警告(可能卡帧 /
|
|
109
|
+
# 画面"长时间静止"警告(可能卡帧 / 忘了加动画)
|
|
110
110
|
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
# (411/814 个窗口命中),而画面一直在动——这是本轮修掉的假阳性。
|
|
111
|
+
# 不用"全帧平均亮度 3 s 滑动窗内波动 < 0.002"这类判据:它对**局部动画**天生不敏感,
|
|
112
|
+
# 逐字入场、数字滚动、卡片切换只占全屏几个百分点,平均亮度几乎不动。实测自带模板片
|
|
113
|
+
# (30.10 s)会因此报出 4 段假"亮度长时间无变化"(411/814 个窗口命中),而画面一直在动。
|
|
115
114
|
#
|
|
116
|
-
#
|
|
115
|
+
# 本判据看"画面里最活跃的 8x8 块,在 0.5 s 里变了多少级灰度":
|
|
117
116
|
# drift[t] = max over 8x8 blocks of mean |F[t] - F[t-lag]| * 255, lag = 0.5 s * fps
|
|
118
117
|
# 物理含义:把画面切成 8x8 块(160x90 下共 20x11 块,单块占全屏 0.53%),取
|
|
119
118
|
# "0.5 秒内变化最大的那一块的平均变化量"(单位:灰度级 0–255)。
|
|
@@ -376,9 +375,9 @@ def _block_max(diff, block, signed=None) -> tuple:
|
|
|
376
375
|
中值是复核线索:整块一起变亮(真单帧事件)时中值也高;只有小块元素在动时
|
|
377
376
|
均值被少数像素拉高、中值仍接近 0(见 references/frame-qa.md 的 16.933 s 案例)。
|
|
378
377
|
|
|
379
|
-
|
|
378
|
+
符号一致度(signed 传带符号的二阶差分时才算):整块**同向**变亮/变暗
|
|
380
379
|
= 真闪白/闪黑;文字逐字入场、母题动画、镜头缓推产生的只是**有增有减**的纹理变化,
|
|
381
|
-
|
|
380
|
+
符号一致度低。实测(零拷贝首片):t=5.867s 被误报的块 score 51.5 / median 44.5,
|
|
382
381
|
但符号一致度只有 0.42;同一位置人为注入 +50 灰闪白后一致度 0.75。
|
|
383
382
|
"""
|
|
384
383
|
h, w = diff.shape
|
|
@@ -660,10 +659,10 @@ def _src_of(obj) -> str:
|
|
|
660
659
|
def _src_compatible(cue, ev) -> bool:
|
|
661
660
|
"""两个条目能不能做同步配对:只有**双方都明确标了来源且不同**时才认为时间基准不同。
|
|
662
661
|
|
|
663
|
-
|
|
662
|
+
render.py 在 engine=web 时把三路 cue 合并(film.json / scenes.py / 页面),
|
|
664
663
|
其中 scenes.py 的 cue 按 PIL 时间轴、页面事件按页面时间轴——跨基准配对会报假 FAIL
|
|
665
664
|
(实测:cue ui_blip @7.725s 配 页面事件 @9.550s,dt=+1.825s)。
|
|
666
|
-
没标 src 的条目(手写 events /
|
|
665
|
+
没标 src 的条目(手写 events / 缺 src 的 cues.json)一律视作兼容,保持向后兼容。
|
|
667
666
|
"""
|
|
668
667
|
cs, es = _src_of(cue), _src_of(ev)
|
|
669
668
|
return not (cs and es and cs != es)
|
|
@@ -676,7 +675,7 @@ def _compatible_events(cue, events) -> list:
|
|
|
676
675
|
def _match_cue(cue, events, tol) -> tuple[dict | None, float | None, str]:
|
|
677
676
|
"""给一个 cue 找对应视觉事件。
|
|
678
677
|
|
|
679
|
-
|
|
678
|
+
规则:
|
|
680
679
|
1. 同 kind:kind == cue.type,搜索窗 SYNC_SAME_WIN(2 s);
|
|
681
680
|
2. 异 kind:只取时间最近者,且**只有 |dt| <= tol 时才认可**(用作"确实同步了"的佐证)。
|
|
682
681
|
超过容差就不配对——跨 kind 的"最近事件"不是"预期事件",据此判超差会制造假 FAIL。
|
|
@@ -715,10 +714,10 @@ def sync_check(cues, events, tol=SYNC_TOL) -> list[dict]:
|
|
|
715
714
|
|
|
716
715
|
返回 [{"cue","cue_t","event_t","dt"}],dt = event_t - cue_t(秒,3 位小数)。
|
|
717
716
|
配对规则(按优先级):
|
|
718
|
-
0. **cue
|
|
717
|
+
0. **cue 自带视觉锚点**:cue 里写了数值 visual_at / visual_t 时,
|
|
719
718
|
直接拿它当"这条音效对应的画面时刻"自校验,不需要 events。
|
|
720
719
|
这条是给 PIL 路线补的口子——pil 引擎没有 window.getEvents(),events 恒空,
|
|
721
|
-
|
|
720
|
+
没有这条口子 sync 检查在 PIL 路线**永远是空的**,但 SKILL.md §7 把"
|
|
722
721
|
dt ≤ 60 ms"写成通用门禁(文档承诺、代码没做到)。
|
|
723
722
|
用法:scenes.py 的 cues() 钩子里返回
|
|
724
723
|
{"at": "l03+0.35", "type": "impact", "visual_at": "abs:12.42"}
|
|
@@ -728,7 +727,7 @@ def sync_check(cues, events, tol=SYNC_TOL) -> list[dict]:
|
|
|
728
727
|
超过容差就不配对——跨 kind 的"最近事件"不是"预期事件",据此判超差会制造假 FAIL。
|
|
729
728
|
配不上的 cue 视为"没有对应视觉事件",不判超差(很多音效本来就没有画面事件)。
|
|
730
729
|
⚠ 命名约定见 references/frame-qa.md §2.5:events 与 cue 不同名时本检查会静默全过。
|
|
731
|
-
⚠ 来源(src
|
|
730
|
+
⚠ 来源(src)过滤:cue 与事件**双方都标了 src 且不同**时不配对——web 引擎
|
|
732
731
|
三路合并时 PIL 时间轴的 cue 与页面事件基准不同,跨基准配对会报假 FAIL。跳过数量由
|
|
733
732
|
_sync_coverage().src_skipped 计数并在 _load_cues 里转成 warning(不静默)。
|
|
734
733
|
"""
|
|
@@ -872,7 +871,7 @@ def _load_cues(cues_json):
|
|
|
872
871
|
cues = [c for c in (data.get("cues") or []) if isinstance(c, dict)]
|
|
873
872
|
raw_events = [e for e in (data.get("events") or []) if isinstance(e, dict)]
|
|
874
873
|
# 只认"带有限数值 t"的事件:sync_check 内部本来就这么过滤(e.get("t") is not None),
|
|
875
|
-
# 但 _sync_coverage /
|
|
874
|
+
# 但 _sync_coverage / 命名约定守卫必须用**过滤后**的列表 —— 否则 events 里只要有
|
|
876
875
|
# {"kind": "impact"} 这种缺 t 的条目,judged 就会被算成 1,命名约定守卫也被骗过,
|
|
877
876
|
# 结果是"门禁看起来在查、实际一条都没判"且不报警(实测:cues=1/events=[{kind:impact}]
|
|
878
877
|
# -> coverage judged=1,report errors=[] warnings=[])。
|
|
@@ -983,7 +982,7 @@ def _visual_event_times(events) -> list[float]:
|
|
|
983
982
|
def _static_segments(drift, fps, blacks=None) -> list[tuple[float, float]]:
|
|
984
983
|
"""画面长时间"完全没有运动"(卡帧 / 忘了加动画)-> [(t0, t1)] 秒区间。
|
|
985
984
|
|
|
986
|
-
|
|
985
|
+
判据(不用"全帧平均亮度 3 s 波动 < 0.002",理由见下):
|
|
987
986
|
对每个窗口起点 i,看 (i+lag, i+w] 这 w-lag 个 drift 值(drift[t] = "t 与 t-lag
|
|
988
987
|
之间最活跃的 8x8 块平均变了多少级灰度")是否都 <= FLAT_DRIFT_EPS。
|
|
989
988
|
全部命中 → 这 6 秒里画面里任何一个 8x8 块在任意 0.5 s 内都没动过,人眼就是一张定格。
|
|
@@ -1039,7 +1038,7 @@ def _static_warnings(drift, fps, blacks=None) -> list[str]:
|
|
|
1039
1038
|
for t0, t1 in segs:
|
|
1040
1039
|
out.append(
|
|
1041
1040
|
"画面长时间静止(可能卡帧/忘了加动画):%.2f–%.2fs(连续 %.1fs 内最活跃的 8x8 块"
|
|
1042
|
-
"在任意 %.1f s 里的变化都 < %.1f 级灰度;判据见 references/frame-qa.md §2.
|
|
1041
|
+
"在任意 %.1f s 里的变化都 < %.1f 级灰度;判据见 references/frame-qa.md §2.4b)"
|
|
1043
1042
|
% (t0, t1, t1 - t0, FLAT_LAG_S, FLAT_DRIFT_EPS))
|
|
1044
1043
|
return out
|
|
1045
1044
|
|
|
@@ -1062,7 +1061,7 @@ def report(film, *, cues_json=None, expect_duration=None, out_json=None, out_png
|
|
|
1062
1061
|
cues_json 可以是路径或已载入的 dict;events 里闪白/震屏/冲击环时刻会用来排除
|
|
1063
1062
|
"设计好的重击"(记 warning,不记 error)。
|
|
1064
1063
|
|
|
1065
|
-
require_sync
|
|
1064
|
+
require_sync:把"音画同步门禁没能执行"从 warning 升级为 error。
|
|
1066
1065
|
默认 None = 读环境变量 QA_REQUIRE_SYNC(1/true/yes/on 时开启),便于不改进 render.py
|
|
1067
1066
|
就整体开闸(render.py 的 st_qa 调的正是本函数)。
|
|
1068
1067
|
背景:SKILL.md §7 把"音画同步 dt ≤ 60 ms"写成通用门禁,但 --engine pil 时 cues.json
|
|
@@ -1107,10 +1106,10 @@ def report(film, *, cues_json=None, expect_duration=None, out_json=None, out_png
|
|
|
1107
1106
|
msg = ("t=%.3fs score=%.1f(孤立比值 %.2f,块中值 %.2f,异常块 %d,%d–%d,%d,阈值 %.1f)"
|
|
1108
1107
|
% (fl["t"], fl["score"], fl.get("ratio", 0.0), fl.get("median", 0.0),
|
|
1109
1108
|
bb[0], bb[1], bb[2], bb[3], FLICKER_THRESH))
|
|
1110
|
-
#
|
|
1109
|
+
# 符号一致度 = 块内像素是否**同向**变亮/变暗。
|
|
1111
1110
|
# 真闪白/闪黑整块同向(实测 0.75–1.0);文字逐字入场、母题动画、镜头缓推
|
|
1112
|
-
# 是"有增有减"的纹理变化(实测 0.42
|
|
1113
|
-
#
|
|
1111
|
+
# 是"有增有减"的纹理变化(实测 0.42)。不降级的后果是首片就 fail 在 G9
|
|
1112
|
+
# (零拷贝自测实测:t=5.867s 标题逐字入场被误判)。
|
|
1114
1113
|
# 处理:低于阈值不静默放过,降级为 warning 并保留全部数值,交给人工复核。
|
|
1115
1114
|
fl["sign_agree"] = fl.get("agree")
|
|
1116
1115
|
if fl.get("agree") is not None and float(fl["agree"]) < FLICKER_AGREE_MIN:
|
|
@@ -1182,7 +1181,7 @@ def report(film, *, cues_json=None, expect_duration=None, out_json=None, out_png
|
|
|
1182
1181
|
rep = {"flicker": flicker, "black_windows": blacks, "luma": luma, "loudness": loud,
|
|
1183
1182
|
"sync": sync, "errors": errors, "warnings": warnings,
|
|
1184
1183
|
# sync_info:同步门禁到底查了多少(render.py:1172 一直在读 deep["sync_info"]["judged"],
|
|
1185
|
-
#
|
|
1184
|
+
# 缺了这个键 QC 报告的 G9 就永远显示"同步未判")。
|
|
1186
1185
|
"sync_info": dict(sync_info)}
|
|
1187
1186
|
if out_json:
|
|
1188
1187
|
_write_json(rep, out_json)
|
|
@@ -1431,7 +1430,7 @@ def _draw_png(rep, film, fps, duration, events, out_png):
|
|
|
1431
1430
|
"重合(设计重击,不计 error)" % SYNC_TOL)
|
|
1432
1431
|
d.text((PLOT_ML, 788), foot1, fill=gray, font=f_small)
|
|
1433
1432
|
d.text((PLOT_ML, 808), foot2, fill=gray, font=f_small)
|
|
1434
|
-
d.text((W - PLOT_MR, 808), "qa_frames
|
|
1433
|
+
d.text((W - PLOT_MR, 808), "qa_frames", fill=(160, 163, 172), font=f_small,
|
|
1435
1434
|
anchor="ra")
|
|
1436
1435
|
|
|
1437
1436
|
out_png = str(out_png)
|
|
@@ -1889,8 +1888,8 @@ def selftest() -> int:
|
|
|
1889
1888
|
out_json=out_json, out_png=out_png)
|
|
1890
1889
|
keys = {"flicker", "black_windows", "luma", "loudness", "sync",
|
|
1891
1890
|
"errors", "warnings"}
|
|
1892
|
-
# sync_info
|
|
1893
|
-
# 读"同步到底判了几条"
|
|
1891
|
+
# sync_info 是报告里的**增量**字段(render.py 的 G9 从 deep["sync_info"]["judged"]
|
|
1892
|
+
# 读"同步到底判了几条",缺了它 -> QC 报告永远显示"同步未判")。
|
|
1894
1893
|
# 契约 §4.5 的 7 个键必须齐全,允许额外的只读统计键。
|
|
1895
1894
|
if not keys.issubset(set(rep)):
|
|
1896
1895
|
return False, "顶层字段 %s 缺 §4.5 键" % sorted(keys - set(rep))
|
|
@@ -2020,7 +2019,7 @@ def selftest() -> int:
|
|
|
2020
2019
|
def main(argv=None) -> int:
|
|
2021
2020
|
ap = argparse.ArgumentParser(
|
|
2022
2021
|
prog="qa_frames.py",
|
|
2023
|
-
description="帧级 /
|
|
2022
|
+
description="帧级 / 时间轴级深度质检:单帧闪烁、画面空窗、音画同步")
|
|
2024
2023
|
ap.add_argument("film", nargs="?", help="成片路径(.mp4 等)")
|
|
2025
2024
|
ap.add_argument("--cues", default=None,
|
|
2026
2025
|
help="work/cues.json(§4.2:cues 音频提示点 + events 视觉事件)")
|