@dsh-gis/dsh-video-studio-skill 0.8.1 → 0.9.1
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 +158 -0
- package/README.md +37 -14
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/package.json +4 -2
- package/schemas/film.schema.json +167 -0
- package/skills/video-studio/SKILL.md +66 -9
- package/skills/video-studio/assets/golden_frames.json +8 -8
- package/skills/video-studio/assets/scenes.py +2162 -1190
- package/skills/video-studio/references/audio-variation.md +1 -1
- package/skills/video-studio/references/audio.md +1 -1
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/scene-grammar.md +306 -0
- package/skills/video-studio/references/style-packs.md +101 -0
- package/skills/video-studio/scripts/filmcheck.py +97 -0
- package/skills/video-studio/scripts/grammar.py +559 -0
- package/skills/video-studio/scripts/render.py +45 -0
- package/skills/video-studio/scripts/selfcheck.py +209 -0
- package/skills/video-studio/scripts/stylekit.py +199 -0
- package/tools/ci_check.py +3 -1
- package/tools/silhouette_qa.py +910 -0
|
@@ -1407,6 +1407,211 @@ def t_golden_frames():
|
|
|
1407
1407
|
status or "-", p.returncode, (d.get("reason") or "")[:120])
|
|
1408
1408
|
|
|
1409
1409
|
|
|
1410
|
+
# ============================================================================
|
|
1411
|
+
# v0.9:场景语法 + 视觉语言
|
|
1412
|
+
# ============================================================================
|
|
1413
|
+
|
|
1414
|
+
def _load_grammar():
|
|
1415
|
+
"""加载 grammar.py(纯标准库)。找不到返回 None —— CI 里可能只拷了 scripts/ 的一部分。"""
|
|
1416
|
+
try:
|
|
1417
|
+
import grammar
|
|
1418
|
+
return grammar
|
|
1419
|
+
except Exception: # noqa: BLE001
|
|
1420
|
+
cand = os.path.join(HERE, "grammar.py")
|
|
1421
|
+
if not os.path.exists(cand):
|
|
1422
|
+
return None
|
|
1423
|
+
import importlib.util
|
|
1424
|
+
spec = importlib.util.spec_from_file_location("sc_grammar", cand)
|
|
1425
|
+
mod = importlib.util.module_from_spec(spec)
|
|
1426
|
+
spec.loader.exec_module(mod)
|
|
1427
|
+
return mod
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def _fake_scenes(G, lines, per=6.0):
|
|
1431
|
+
"""按固定时长造 timeline 段(不跑 TTS),只用来测选型。"""
|
|
1432
|
+
scenes, t = [], 0.0
|
|
1433
|
+
for ln in lines:
|
|
1434
|
+
row = dict(ln, start=round(t, 3), end=round(t + per, 3), dur=round(per, 3))
|
|
1435
|
+
scenes.append(row)
|
|
1436
|
+
t += per
|
|
1437
|
+
return scenes
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def t_scene_grammar():
|
|
1441
|
+
"""52 场景语法:10 类都能被选中、选型确定、段长门会降级。
|
|
1442
|
+
|
|
1443
|
+
为什么要有这条:v0.9 之前 quote/emphasis/calm 三种 kind 全部落到 body 绘制器,
|
|
1444
|
+
画面永远只有一套骨架。这条断言盯的是"内容 -> 构图"这条链路真的在分流,
|
|
1445
|
+
而不是"代码里写了 10 个函数但没人调用"。
|
|
1446
|
+
"""
|
|
1447
|
+
G = _load_grammar()
|
|
1448
|
+
if G is None:
|
|
1449
|
+
return "skip(没有 grammar.py)"
|
|
1450
|
+
lines = [
|
|
1451
|
+
{"id": "a", "kind": "hook", "chapter": "01", "text": "开场一句。", "big": "开场"},
|
|
1452
|
+
{"id": "b", "kind": "body", "chapter": "02", "text": "我们每天被打断 47 次。", "big": "47 次"},
|
|
1453
|
+
{"id": "c", "kind": "body", "chapter": "02", "text": "对比一下:深度工作四小时,产出是碎片时间的三倍。",
|
|
1454
|
+
"big": "四小时 vs 碎片", "items": [{"text": "深度工作"}, {"text": "碎片时间"}]},
|
|
1455
|
+
{"id": "d", "kind": "body", "chapter": "03", "text": "第一步清空收件箱,然后关掉通知,接着单线程,最后复盘。",
|
|
1456
|
+
"big": "四步", "items": [{"text": "清空"}, {"text": "关通知"}, {"text": "单线程"}, {"text": "复盘"}]},
|
|
1457
|
+
{"id": "e", "kind": "quote", "chapter": "03", "text": "他说:「深度工作稀缺。」", "quote": "深度工作稀缺"},
|
|
1458
|
+
{"id": "f", "kind": "body", "chapter": "04", "text": "各行业占比差异明显。", "big": "各行业占比",
|
|
1459
|
+
"items": [{"text": "A 62%"}, {"text": "B 51%"}, {"text": "C 38%"}]},
|
|
1460
|
+
{"id": "g", "kind": "body", "chapter": "05", "text": "注意力是换来的。", "big": "注意力是换来的"},
|
|
1461
|
+
{"id": "h", "kind": "body", "chapter": "06", "text": "第五章开始了。", "big": "第五章"},
|
|
1462
|
+
{"id": "i", "kind": "body", "chapter": "06", "text": "这里有五条要点要讲清楚。", "big": "五条要点",
|
|
1463
|
+
"items": [{"text": "一"}, {"text": "二"}, {"text": "三"}, {"text": "四"}, {"text": "五"}]},
|
|
1464
|
+
{"id": "j", "kind": "outro", "chapter": "99", "text": "开始就好。", "big": "开始就好"},
|
|
1465
|
+
]
|
|
1466
|
+
scenes = _fake_scenes(G, lines)
|
|
1467
|
+
plan = {"chapter_layout": {}, "layout_default": "left-stack", "style": "tech-deep"}
|
|
1468
|
+
sb1 = G.plan_scenes([dict(s) for s in scenes], {"title": "t"}, plan)
|
|
1469
|
+
got = [r["type"] for r in sb1["scenes"]]
|
|
1470
|
+
# ① 确定性:同一份输入两次结果完全一致(含变体号与理由)
|
|
1471
|
+
sb2 = G.plan_scenes([dict(s) for s in scenes], {"title": "t"}, plan)
|
|
1472
|
+
assert [r["type"] for r in sb2["scenes"]] == got, "同一份稿子两次选型不同(不确定性)"
|
|
1473
|
+
assert [r["variant"] for r in sb2["scenes"]] == [r["variant"] for r in sb1["scenes"]], \
|
|
1474
|
+
"同一份稿子两次变体不同(不确定性)"
|
|
1475
|
+
# ② 分流:至少命中 5 种不同类型(不是全退化成 list/statement)
|
|
1476
|
+
assert len(set(got)) >= 5, "10 段只分出 %d 种构图(%r):选型逻辑没在分流" % (len(set(got)), got)
|
|
1477
|
+
assert "stat" in got and "compare" in got and "quote" in got, \
|
|
1478
|
+
"数字/对比/引语三种特征没有各自分流:%r" % (got,)
|
|
1479
|
+
# ③ 段长门:把 chart 塞进 2 秒段必须降级,且理由里写明
|
|
1480
|
+
short = [{"id": "x", "kind": "body", "chapter": "01", "text": "短。", "big": "短",
|
|
1481
|
+
"type": "chart", "series": [{"label": "A", "value": 1}]}]
|
|
1482
|
+
sb3 = G.plan_scenes([dict(s) for s in _fake_scenes(G, short, per=2.0)],
|
|
1483
|
+
{"title": "t"}, plan)
|
|
1484
|
+
r3 = sb3["scenes"][0]
|
|
1485
|
+
assert r3["type"] != "chart", "2 秒的段仍选了 chart(段长门失效)"
|
|
1486
|
+
assert any("降级" in x or "排除" in x for x in r3["reason"]), \
|
|
1487
|
+
"降级没有留下理由:%r" % (r3["reason"],)
|
|
1488
|
+
# ④ 显式 type 优先
|
|
1489
|
+
ex = [{"id": "y", "kind": "body", "chapter": "01", "text": "随便一句", "big": "随便",
|
|
1490
|
+
"type": "hero"}]
|
|
1491
|
+
sb4 = G.plan_scenes([dict(s) for s in _fake_scenes(G, ex)], {"title": "t"}, plan)
|
|
1492
|
+
assert sb4["scenes"][0]["type"] == "hero", "显式 type=hero 没生效:%r" % (sb4["scenes"][0],)
|
|
1493
|
+
return "10 段 -> %d 种构图;降级 chart->%s;显式 type 生效" % (len(set(got)), r3["type"])
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def t_visual_languages():
|
|
1497
|
+
"""53 视觉语言包:8 套结构签名齐全、过体检、且彼此不是同一套。
|
|
1498
|
+
|
|
1499
|
+
为什么要有这条:v0.5 的"风格"只改配色,8 套风格骨架完全一样(用户抱怨的根因)。
|
|
1500
|
+
这条盯的是"结构签名真的存在且互不相同"——母题/转场/重心偏移任一维度相同不算问题,
|
|
1501
|
+
但**五组签名全同**就说明这套语言只是复制粘贴。
|
|
1502
|
+
"""
|
|
1503
|
+
import stylekit as ST
|
|
1504
|
+
if not hasattr(ST, "VISUAL_LANGUAGES"):
|
|
1505
|
+
return "skip(stylekit 没有 VISUAL_LANGUAGES)"
|
|
1506
|
+
bad = ST.visual_lint_all()
|
|
1507
|
+
assert not bad, "内置视觉语言没过体检: %s" % bad
|
|
1508
|
+
sigs = {}
|
|
1509
|
+
for sid in ST.STYLE_IDS:
|
|
1510
|
+
vl = ST.VISUAL_LANGUAGES[sid]
|
|
1511
|
+
key = (str((vl.get("motif") or {}).get("kind")),
|
|
1512
|
+
str((vl.get("transition") or {}).get("kind")),
|
|
1513
|
+
tuple(sorted((vl.get("bias") or {}).items())))
|
|
1514
|
+
assert vl.get("motion") and vl.get("typography"), "%s 缺 motion/typography 签名" % sid
|
|
1515
|
+
sigs.setdefault(key, []).append(sid)
|
|
1516
|
+
dup = [v for v in sigs.values() if len(v) > 1]
|
|
1517
|
+
assert not dup, "这些风格的母题/转场/重心完全相同(等于没做结构差异): %s" % (dup,)
|
|
1518
|
+
motifs = {str((ST.VISUAL_LANGUAGES[s].get("motif") or {}).get("kind")) for s in ST.STYLE_IDS}
|
|
1519
|
+
assert len(motifs) >= 6, "8 套风格只用了 %d 种装饰母题:%r" % (len(motifs), sorted(motifs))
|
|
1520
|
+
return "8 套 / %d 种母题 / %d 种转场 / 无重复签名" % (
|
|
1521
|
+
len(motifs),
|
|
1522
|
+
len({str((ST.VISUAL_LANGUAGES[s].get("transition") or {}).get("kind"))
|
|
1523
|
+
for s in ST.STYLE_IDS}))
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def t_scene_types_single_truth():
|
|
1527
|
+
"""54 场景类型只有一份"真值集合":4 处定义必须集合相等(顺序可以不同)。
|
|
1528
|
+
|
|
1529
|
+
为什么比集合不比顺序:grammar.SCENE_TYPES 的顺序是"平手取位顺序",改它会改变同一份
|
|
1530
|
+
稿子的选型结果,所以它有独立语义;filmcheck/schema/scenes 只关心"有哪些类型"。
|
|
1531
|
+
顺序不同是设计,集合不同是 bug(写错一个字母 = 某个类型永远画不出来)。
|
|
1532
|
+
"""
|
|
1533
|
+
G = _load_grammar()
|
|
1534
|
+
if G is None:
|
|
1535
|
+
return "skip(没有 grammar.py)"
|
|
1536
|
+
here_scripts = HERE
|
|
1537
|
+
got = {"grammar": set(G.SCENE_TYPES)}
|
|
1538
|
+
# filmcheck.SCENE_TYPES
|
|
1539
|
+
try:
|
|
1540
|
+
import filmcheck as FC
|
|
1541
|
+
got["filmcheck"] = set(FC.SCENE_TYPES)
|
|
1542
|
+
except Exception: # noqa: BLE001
|
|
1543
|
+
pass
|
|
1544
|
+
# assets/scenes.py 的 _DRAWERS 键
|
|
1545
|
+
mod = _load_scenes_mod()
|
|
1546
|
+
if mod is not None and hasattr(mod, "_DRAWERS"):
|
|
1547
|
+
got["scenes"] = set(mod._DRAWERS.keys())
|
|
1548
|
+
# schema 的 enum
|
|
1549
|
+
schema = os.path.join(os.path.dirname(os.path.dirname(here_scripts)),
|
|
1550
|
+
"..", "..", "schemas", "film.schema.json")
|
|
1551
|
+
schema = os.path.normpath(schema)
|
|
1552
|
+
if os.path.exists(schema):
|
|
1553
|
+
try:
|
|
1554
|
+
data = json.load(open(schema, encoding="utf-8"))
|
|
1555
|
+
enum = (data["properties"]["lines"]["items"]["properties"]["type"]["enum"])
|
|
1556
|
+
got["schema"] = set(enum)
|
|
1557
|
+
except Exception: # noqa: BLE001
|
|
1558
|
+
pass
|
|
1559
|
+
if len(got) < 3:
|
|
1560
|
+
return "skip(只找到 %d 处真值:%s)" % (len(got), sorted(got))
|
|
1561
|
+
base_name, base = sorted(got.items())[0]
|
|
1562
|
+
for name, s in got.items():
|
|
1563
|
+
if s != base:
|
|
1564
|
+
raise AssertionError("场景类型集合不一致:%s 与 %s 差 %r / %r"
|
|
1565
|
+
% (name, base_name, sorted(s - base), sorted(base - s)))
|
|
1566
|
+
return "%d 处真值集合一致(%d 类):%s" % (len(got), len(base), "/".join(sorted(got)))
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def t_silhouette_gate():
|
|
1570
|
+
"""55 剪影可区分(G13):8 套风格两两之间构图骨架必须能区分。
|
|
1571
|
+
|
|
1572
|
+
这是 v0.9 的核心验收:如果这条挂了,说明"换风格只是换色"的毛病回来了。
|
|
1573
|
+
工具在 tools/silhouette_qa.py(可选):缺了 skip,不阻塞 CI。
|
|
1574
|
+
"""
|
|
1575
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
|
1576
|
+
tool = os.path.join(root, "tools", "silhouette_qa.py")
|
|
1577
|
+
if not os.path.exists(tool):
|
|
1578
|
+
return "skip(没有 tools/silhouette_qa.py)"
|
|
1579
|
+
p = subprocess.run([sys.executable, tool, "--json"],
|
|
1580
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1581
|
+
cwd=root)
|
|
1582
|
+
out = (p.stdout or "").strip()
|
|
1583
|
+
i = out.find("{")
|
|
1584
|
+
if i < 0:
|
|
1585
|
+
return "skip(silhouette_qa 没输出 JSON:%s)" % ((p.stderr or out) or "")[:120]
|
|
1586
|
+
try:
|
|
1587
|
+
d = json.loads(out[i:])
|
|
1588
|
+
except json.JSONDecodeError as e:
|
|
1589
|
+
return "skip(silhouette_qa JSON 解析失败:%s)" % e
|
|
1590
|
+
pairs = d.get("pairs") or []
|
|
1591
|
+
# 三档语义与工具对齐:status 只有 pass / fail / skip 三种。
|
|
1592
|
+
# 只认 returncode 会把"工具没渲出帧"(skip)也记成 PASS —— 那正是本仓库反复踩的坑。
|
|
1593
|
+
failed = [x for x in pairs if str(x.get("status")) == "fail"]
|
|
1594
|
+
skipped = [x for x in pairs if str(x.get("status")) == "skip"]
|
|
1595
|
+
if p.returncode != 0 or failed:
|
|
1596
|
+
names = ["%s×%s" % (x.get("a"), x.get("b")) for x in failed[:4]]
|
|
1597
|
+
raise AssertionError("剪影不可区分:%d/%d 对失败(%s);换风格仍只是换色"
|
|
1598
|
+
% (len(failed), len(pairs), ", ".join(names)))
|
|
1599
|
+
if skipped:
|
|
1600
|
+
return "skip(%d/%d 对没渲出来:%s)" % (
|
|
1601
|
+
len(skipped), len(pairs),
|
|
1602
|
+
", ".join("%s×%s" % (x.get("a"), x.get("b")) for x in skipped[:3]))
|
|
1603
|
+
# 同明暗族是"保守下界":跨族很多时候靠 ΔB(背景亮度差)就能过,那不是构图证据。
|
|
1604
|
+
# 所以这条断言在报告里必须把同族数字单独说出来,别让 28/28 被读成"8 套风格结构全不同"。
|
|
1605
|
+
same = ((d.get("summary") or {}).get("by_family") or {}).get("same") or {}
|
|
1606
|
+
same_n = same.get("pairs")
|
|
1607
|
+
same_ok = same.get("pass")
|
|
1608
|
+
if same_n and same_ok is not None and int(same_ok) < int(same_n):
|
|
1609
|
+
raise AssertionError("同明暗族只有 %s/%s 对可区分(保守下界不达标)"
|
|
1610
|
+
% (same_ok, same_n))
|
|
1611
|
+
return "%d 对全部可区分(同明暗族 %s/%s,保守下界也过)" % (
|
|
1612
|
+
len(pairs), same_ok if same_ok is not None else "?", same_n if same_n else "?")
|
|
1613
|
+
|
|
1614
|
+
|
|
1410
1615
|
CASES = [
|
|
1411
1616
|
("1 TTS 缓存校验文本", t_cache_key),
|
|
1412
1617
|
("2 sub_window 覆盖长句", t_sub_window),
|
|
@@ -1459,6 +1664,10 @@ CASES = [
|
|
|
1459
1664
|
("49 TTS 后端可替换(接口契约)", t_tts_backend),
|
|
1460
1665
|
("50 web 风格载荷(legacy 必须回落)", t_web_style_payload),
|
|
1461
1666
|
("51 金帧回归(像素基线)", t_golden_frames),
|
|
1667
|
+
("52 场景语法(10 类分流/确定/段长门)", t_scene_grammar),
|
|
1668
|
+
("53 视觉语言包(结构签名互不相同)", t_visual_languages),
|
|
1669
|
+
("54 场景类型单真值(4 处集合一致)", t_scene_types_single_truth),
|
|
1670
|
+
("55 剪影可区分(G13:换风格不能只是换色)", t_silhouette_gate),
|
|
1462
1671
|
]
|
|
1463
1672
|
|
|
1464
1673
|
|
|
@@ -531,6 +531,205 @@ STYLES[LEGACY_STYLE] = {
|
|
|
531
531
|
"pacing": {"lead": 0.55, "tail": 0.85, "min_dur": 3.0, "hold": 0.0},
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
# ============================================================================
|
|
535
|
+
# 四、视觉语言包(VISUAL_LANGUAGES):v0.9
|
|
536
|
+
# ============================================================================
|
|
537
|
+
#
|
|
538
|
+
# 为什么还要一层
|
|
539
|
+
# -------------
|
|
540
|
+
# v0.5 的 THEMES 只管"皮肤":配色、字体族、纹理、颗粒、字距、缓动、HUD、3 种版式几何。
|
|
541
|
+
# 8 套风格共用同一批版式与同一套装饰(开场放射线 + 收尾光点环),所以换风格只是换色,
|
|
542
|
+
# 构图骨架(剪影)没变 —— 这正是"每个视频都差不多"的根因。
|
|
543
|
+
#
|
|
544
|
+
# 这一层补的是**结构签名**,四组:
|
|
545
|
+
# motion 入场怎么进、镜头怎么推、呼吸元素多快
|
|
546
|
+
# motif 这套风格独有的装饰图形语言(直角括角 / 手绘内框 / letterbox / 信号条 …)
|
|
547
|
+
# transition 段与段之间怎么过(dip / wash / 纸卷 / 斜擦 / 硬切 / 放射环)
|
|
548
|
+
# typography 字距、行高、对齐、编号写法、display:body 字号比
|
|
549
|
+
#
|
|
550
|
+
# 设计原则(与 THEMES 一致)
|
|
551
|
+
# 1. 纯数据:不 import PIL / filmkit,scenes.py 反过来读我,成环就炸。
|
|
552
|
+
# 2. 只追加不改顺序:新风格往 STYLE_IDS 末尾追加,老风格的指纹映射不漂。
|
|
553
|
+
# 3. 每条签名都必须"能被剪影看出差别":只改颜色/字重的项不算签名。
|
|
554
|
+
# 4. 全部受 PIL 预算约束:装饰要么预烘焙成静态层,要么是 O(几) 次 ImageDraw 调用;
|
|
555
|
+
# 禁每帧全屏 GaussianBlur(实测 30–60ms)与全屏双线性 resize(29ms),预算 25ms。
|
|
556
|
+
#
|
|
557
|
+
# 剪影判据(tools/silhouette_qa.py 用的口径,也是 P1 的验收标准)
|
|
558
|
+
# 同一段内容在两种风格下渲 3 帧 → 灰度 → 以四角亮度中位数为基准三值化 →
|
|
559
|
+
# 比较 前景占比 F / 前景质心 (Cx,Cy) / 四边覆盖率 B / 左右对称度 S。
|
|
560
|
+
# 五项里至少一项达标才算"这套风格真的不一样":
|
|
561
|
+
# ΔF ≥ 6pp、|ΔCx| ≥ 8pp、|ΔCy| ≥ 8pp、ΔB ≥ 14pp、ΔS ≥ 0.20
|
|
562
|
+
|
|
563
|
+
VISUAL_MOTIFS = ("brackets", "hairlines", "handdrawn", "slabs",
|
|
564
|
+
"minimal", "letterbox", "signal", "rays")
|
|
565
|
+
VISUAL_TRANSITIONS = ("dip", "wash", "paper", "wipe", "cut", "iris")
|
|
566
|
+
NUMBER_STYLES = ("dim", "serif", "cn", "block", "roman", "timecode", "dot", "gilt")
|
|
567
|
+
|
|
568
|
+
VISUAL_LANGUAGES = {}
|
|
569
|
+
|
|
570
|
+
VISUAL_LANGUAGES["tech-deep"] = {
|
|
571
|
+
"motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.04, "breathe_hz": 0.85},
|
|
572
|
+
"motif": {"kind": "brackets", "density": 1.0,
|
|
573
|
+
"detail": {"corner": 24, "rule_step": 120, "scan": True}},
|
|
574
|
+
"transition": {"kind": "dip", "dur": 0.28},
|
|
575
|
+
"typography": {"tracking": 0.0, "line_gap": 1.30, "align": "left",
|
|
576
|
+
"number": "dim", "ratio": 1.50},
|
|
577
|
+
"bias": {"x": 0, "y": 0, "inset": 0},
|
|
578
|
+
"layout_cycle": ("left-stack", "split-right", "center-stack"),
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
VISUAL_LANGUAGES["magazine"] = {
|
|
582
|
+
"motion": {"ease": "out5", "enter": (0.60, 1.10), "push": 0.001, "breathe_hz": 0.50},
|
|
583
|
+
"motif": {"kind": "hairlines", "density": 0.85,
|
|
584
|
+
"detail": {"header_rule": True, "column_rule": True, "pageno": True}},
|
|
585
|
+
"transition": {"kind": "wash", "dur": 0.35},
|
|
586
|
+
"typography": {"tracking": 2.0, "line_gap": 1.34, "align": "right",
|
|
587
|
+
"number": "serif", "ratio": 1.53},
|
|
588
|
+
"bias": {"x": 60, "y": -20, "inset": 0},
|
|
589
|
+
"layout_cycle": ("split-right", "left-stack", "center-stack"),
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
VISUAL_LANGUAGES["paper"] = {
|
|
593
|
+
"motion": {"ease": "inOut3", "enter": (0.55, 1.00), "push": 0.03, "breathe_hz": 0.42},
|
|
594
|
+
"motif": {"kind": "handdrawn", "density": 1.0,
|
|
595
|
+
"detail": {"wobble": 3.0, "seal": True, "tape": True}},
|
|
596
|
+
"transition": {"kind": "paper", "dur": 0.35},
|
|
597
|
+
"typography": {"tracking": 1.0, "line_gap": 1.36, "align": "left",
|
|
598
|
+
"number": "cn", "ratio": 1.53},
|
|
599
|
+
# 重心偏移(px):paper 是"手账"气质——内容整体压在左下、右侧留出大片空白给框与胶带;
|
|
600
|
+
# 这不是配色能表达的差异,是构图差异(剪影 Cx/Cy 直接反映它)。
|
|
601
|
+
"bias": {"x": -150, "y": 80, "inset": 40},
|
|
602
|
+
"layout_cycle": ("left-stack", "center-stack", "split-right"),
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
VISUAL_LANGUAGES["vivid"] = {
|
|
606
|
+
"motion": {"ease": "outBack", "enter": (0.35, 0.70), "push": 0.10, "breathe_hz": 1.10},
|
|
607
|
+
"motif": {"kind": "slabs", "density": 1.0,
|
|
608
|
+
"detail": {"bar_h": 24, "slant": 18, "sticker": True}},
|
|
609
|
+
"transition": {"kind": "wipe", "dur": 0.20},
|
|
610
|
+
"typography": {"tracking": 1.0, "line_gap": 1.26, "align": "center",
|
|
611
|
+
"number": "block", "ratio": 2.97},
|
|
612
|
+
"bias": {"x": 0, "y": 40, "inset": 0},
|
|
613
|
+
"layout_cycle": ("center-stack", "left-stack", "split-right"),
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
VISUAL_LANGUAGES["mono"] = {
|
|
617
|
+
"motion": {"ease": "inOut3", "enter": (0.50, 0.90), "push": 0.02, "breathe_hz": 0.35},
|
|
618
|
+
"motif": {"kind": "minimal", "density": 0.55,
|
|
619
|
+
"detail": {"rule_h": 2, "box_radius": 0}},
|
|
620
|
+
"transition": {"kind": "cut", "dur": 0.0},
|
|
621
|
+
"typography": {"tracking": 6.0, "line_gap": 1.40, "align": "center",
|
|
622
|
+
"number": "roman", "ratio": 3.15},
|
|
623
|
+
"bias": {"x": 0, "y": 0, "inset": 0},
|
|
624
|
+
"layout_cycle": ("center-stack", "center-stack", "center-stack"),
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
VISUAL_LANGUAGES["cinema"] = {
|
|
628
|
+
"motion": {"ease": "inOut3", "enter": (0.70, 1.20), "push": 0.12, "breathe_hz": 0.30},
|
|
629
|
+
"motif": {"kind": "letterbox", "density": 1.0,
|
|
630
|
+
"detail": {"top": 40, "bottom": 44, "flares": True}},
|
|
631
|
+
"transition": {"kind": "dip", "dur": 0.45},
|
|
632
|
+
"typography": {"tracking": 3.0, "line_gap": 1.38, "align": "center",
|
|
633
|
+
"number": "timecode", "ratio": 1.62},
|
|
634
|
+
"bias": {"x": 0, "y": 30, "inset": 0},
|
|
635
|
+
"layout_cycle": ("center-stack", "left-stack", "split-right"),
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
VISUAL_LANGUAGES["lo-fi"] = {
|
|
639
|
+
"motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.05, "breathe_hz": 0.60},
|
|
640
|
+
"motif": {"kind": "signal", "density": 0.90,
|
|
641
|
+
"detail": {"bars": 12, "radius": 18, "note": True}},
|
|
642
|
+
"transition": {"kind": "wash", "dur": 0.30},
|
|
643
|
+
"typography": {"tracking": 1.0, "line_gap": 1.36, "align": "left",
|
|
644
|
+
"number": "dot", "ratio": 1.48},
|
|
645
|
+
# lo-fi 是"桌面便签"气质:整体右移、内容偏上,底部留给信号条与时间戳。
|
|
646
|
+
"bias": {"x": 60, "y": -70, "inset": 0},
|
|
647
|
+
"layout_cycle": ("left-stack", "split-right", "center-stack"),
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
VISUAL_LANGUAGES["epic"] = {
|
|
651
|
+
"motion": {"ease": "out5", "enter": (0.70, 1.20), "push": 0.14, "breathe_hz": 0.28},
|
|
652
|
+
"motif": {"kind": "rays", "density": 1.0,
|
|
653
|
+
"detail": {"rays": 18, "rule_gap": 26, "frame": True}},
|
|
654
|
+
"transition": {"kind": "iris", "dur": 0.40},
|
|
655
|
+
"typography": {"tracking": 4.0, "line_gap": 1.32, "align": "center",
|
|
656
|
+
"number": "gilt", "ratio": 3.18},
|
|
657
|
+
"bias": {"x": 0, "y": -30, "inset": 0},
|
|
658
|
+
"layout_cycle": ("center-stack", "center-stack", "center-stack"),
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
# legacy:v0.4 行为,没有视觉语言(scenes.py 走 _LEGACY_DRAWERS,不会读到这张表)
|
|
662
|
+
VISUAL_LANGUAGES[LEGACY_STYLE] = {
|
|
663
|
+
"motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.04, "breathe_hz": 0.85},
|
|
664
|
+
"motif": {"kind": "brackets", "density": 1.0, "detail": {}},
|
|
665
|
+
"transition": {"kind": "cut", "dur": 0.0},
|
|
666
|
+
"typography": {"tracking": 0.0, "line_gap": 1.30, "align": "left",
|
|
667
|
+
"number": "dim", "ratio": 1.50},
|
|
668
|
+
"layout_cycle": ("left-stack",),
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def visual_language(style_id):
|
|
673
|
+
"""取风格的结构签名;未知 id 退到默认风格(不让拼写错误炸掉渲染)。"""
|
|
674
|
+
sid = str(style_id or "").strip()
|
|
675
|
+
got = VISUAL_LANGUAGES.get(sid)
|
|
676
|
+
if got is not None:
|
|
677
|
+
return got
|
|
678
|
+
return VISUAL_LANGUAGES[DEFAULT_STYLE]
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def visual_lint(style_id):
|
|
682
|
+
"""结构签名体检(--stage style 就拦,不等到渲完 300 帧)。返回问题列表,空 = 过。"""
|
|
683
|
+
bad = []
|
|
684
|
+
sid = str(style_id or "")
|
|
685
|
+
if sid not in VISUAL_LANGUAGES:
|
|
686
|
+
return ["风格 %r 没有视觉语言包" % sid]
|
|
687
|
+
vl = VISUAL_LANGUAGES[sid]
|
|
688
|
+
mo = vl.get("motion") or {}
|
|
689
|
+
mf = vl.get("motif") or {} # 注意:mo = motion,mf = motif,别混用
|
|
690
|
+
tr = vl.get("transition") or {}
|
|
691
|
+
ty = vl.get("typography") or {}
|
|
692
|
+
if str(mf.get("kind")) not in VISUAL_MOTIFS:
|
|
693
|
+
bad.append("motif.kind=%r 不在 %s 里" % (mf.get("kind"), " / ".join(VISUAL_MOTIFS)))
|
|
694
|
+
if str(tr.get("kind")) not in VISUAL_TRANSITIONS:
|
|
695
|
+
bad.append("transition.kind=%r 不在 %s 里"
|
|
696
|
+
% (tr.get("kind"), " / ".join(VISUAL_TRANSITIONS)))
|
|
697
|
+
if not 0.0 <= float(tr.get("dur") or 0.0) <= 0.60:
|
|
698
|
+
# 段界 veil 超过 0.6s 会吃掉 3–6s 节奏下限,也会把两段内容糊在一起
|
|
699
|
+
bad.append("transition.dur=%r 必须在 0~0.60s(段界 veil 上限)" % tr.get("dur"))
|
|
700
|
+
if str(ty.get("number")) not in NUMBER_STYLES:
|
|
701
|
+
bad.append("typography.number=%r 不在 %s 里"
|
|
702
|
+
% (ty.get("number"), " / ".join(NUMBER_STYLES)))
|
|
703
|
+
if not 1.0 <= float(ty.get("ratio") or 0.0) <= 3.6:
|
|
704
|
+
bad.append("typography.ratio=%r 必须在 1.0~3.6(display:body 字号比)" % ty.get("ratio"))
|
|
705
|
+
if not 0.0 <= float(ty.get("tracking") or 0.0) <= 8.0:
|
|
706
|
+
bad.append("typography.tracking=%r 必须在 0~8px" % ty.get("tracking"))
|
|
707
|
+
if not 1.15 <= float(ty.get("line_gap") or 0.0) <= 1.55:
|
|
708
|
+
bad.append("typography.line_gap=%r 必须在 1.15~1.55" % ty.get("line_gap"))
|
|
709
|
+
if not 0.0 <= float(mf.get("density") or 0.0) <= 1.0:
|
|
710
|
+
bad.append("motif.density=%r 必须在 0~1" % mf.get("density"))
|
|
711
|
+
if float(mf.get("density") or 0.0) <= 0.0 and str(mf.get("kind")) != "minimal":
|
|
712
|
+
bad.append("motif.density=0 只允许用于 minimal(否则等于没有装饰语言)")
|
|
713
|
+
enter = mo.get("enter") or (0.45, 0.85)
|
|
714
|
+
if (not isinstance(enter, (list, tuple)) or len(enter) != 2
|
|
715
|
+
or not 0.0 < float(enter[0]) <= float(enter[1]) <= 1.6):
|
|
716
|
+
bad.append("motion.enter=%r 必须是 (起, 止) 且 0 < 起 <= 止 <= 1.6 秒" % (enter,))
|
|
717
|
+
if not 0.0 <= float(mo.get("push") or 0.0) <= 0.16:
|
|
718
|
+
# push > 0.16 时背景层放大后内容会被推出 SAFE(_bg_plate 只裁不缩)
|
|
719
|
+
bad.append("motion.push=%r 必须在 0~0.16(超过会把内容推出安全区)" % mo.get("push"))
|
|
720
|
+
return bad
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def visual_lint_all():
|
|
724
|
+
"""全部内置风格的视觉语言体检,返回 {style_id: [问题]}(只有有问题的才在里面)。"""
|
|
725
|
+
out = {}
|
|
726
|
+
for sid in STYLE_IDS:
|
|
727
|
+
bad = visual_lint(sid)
|
|
728
|
+
if bad:
|
|
729
|
+
out[sid] = bad
|
|
730
|
+
return out
|
|
731
|
+
|
|
732
|
+
|
|
534
733
|
ALL_STYLE_IDS = (LEGACY_STYLE,) + STYLE_IDS
|
|
535
734
|
|
|
536
735
|
|
package/tools/ci_check.py
CHANGED
|
@@ -57,7 +57,9 @@ LOCAL_MODULES = {"filmkit", "sfxkit", "scorekit", "webengine", "qa_frames", "ren
|
|
|
57
57
|
# v0.6.0:门禁账本 / 交付报告 / 签核、阶段新鲜度守卫、film.json 契约校验器
|
|
58
58
|
"gates", "stagewatch", "filmcheck",
|
|
59
59
|
# v0.8.0:渲染前资源闸门、全片 VMAF/SSIM 门禁、ASR 回读门禁
|
|
60
|
-
"preflight", "qavmaf", "qaasr"
|
|
60
|
+
"preflight", "qavmaf", "qaasr",
|
|
61
|
+
# v0.9.0:场景语法(内容 -> 构图选型)。纯标准库,可被 scenes.py 反向 import
|
|
62
|
+
"grammar"}
|
|
61
63
|
MAX_FILE_BYTES = 400 * 1024
|
|
62
64
|
# 体积检查忽略的目录后缀:.team 是本地调研/自测工作区(gitignore),不随包发布;
|
|
63
65
|
# 媒体文件本来就该大(样例片/参考视频),不该拿"能不能塞进上下文"的尺子量。
|