@dsh-gis/dsh-video-studio-skill 0.7.2 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +1109 -0
  2. package/README.md +76 -10
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/docs/styles/sheet-8-styles.png +0 -0
  5. package/docs/styles/sheet-before-after.png +0 -0
  6. package/index.js +4 -1
  7. package/package.json +7 -3
  8. package/schemas/film.schema.json +329 -2
  9. package/skills/video-studio/SKILL.md +270 -13
  10. package/skills/video-studio/assets/golden_frames.json +65 -0
  11. package/skills/video-studio/assets/scenes.py +2162 -1190
  12. package/skills/video-studio/assets/web/engine.js +63 -2
  13. package/skills/video-studio/assets/web/film.js +224 -101
  14. package/skills/video-studio/assets/web/index.html +4 -2
  15. package/skills/video-studio/assets/web/qa_text.js +36 -8
  16. package/skills/video-studio/references/audio-variation.md +4 -3
  17. package/skills/video-studio/references/audio.md +2 -2
  18. package/skills/video-studio/references/change-impact.md +1 -1
  19. package/skills/video-studio/references/quality-gates.md +25 -3
  20. package/skills/video-studio/references/scene-grammar.md +306 -0
  21. package/skills/video-studio/references/style-packs.md +108 -3
  22. package/skills/video-studio/references/v08-quality.md +256 -0
  23. package/skills/video-studio/references/web-engine.md +19 -2
  24. package/skills/video-studio/references/word-timeline.md +147 -0
  25. package/skills/video-studio/requirements.txt +14 -0
  26. package/skills/video-studio/scripts/filmcheck.py +218 -0
  27. package/skills/video-studio/scripts/filmkit.py +521 -38
  28. package/skills/video-studio/scripts/gates.py +88 -4
  29. package/skills/video-studio/scripts/grammar.py +559 -0
  30. package/skills/video-studio/scripts/preflight.py +774 -0
  31. package/skills/video-studio/scripts/qa_frames.py +49 -8
  32. package/skills/video-studio/scripts/qaasr.py +1563 -0
  33. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  34. package/skills/video-studio/scripts/render.py +764 -32
  35. package/skills/video-studio/scripts/scorekit.py +16 -30
  36. package/skills/video-studio/scripts/selfcheck.py +483 -0
  37. package/skills/video-studio/scripts/sfxkit.py +130 -14
  38. package/skills/video-studio/scripts/stagewatch.py +19 -6
  39. package/skills/video-studio/scripts/stylekit.py +242 -12
  40. package/skills/video-studio/scripts/voicekit.py +15 -7
  41. package/skills/video-studio/scripts/webengine.py +1119 -72
  42. package/tools/ci_check.py +173 -31
  43. package/tools/estimate.py +130 -19
  44. package/tools/golden_frames.py +664 -0
  45. package/tools/silhouette_qa.py +910 -0
@@ -1,8 +1,12 @@
1
- /* engine.js — Canvas2D 逐帧确定性动画内核(v0.3.0)
1
+ /* engine.js — Canvas2D 逐帧确定性动画内核(v0.8.0)
2
2
  *
3
3
  * 改编自 ai-history-film(MIT)video/web/js/engine.js 的「逐帧 seek + 场景 + cue」思路,
4
4
  * 但去掉 GSAP 与 three.js,改成原生 Canvas2D + 自写缓动,不 vendor 任何三方 JS。
5
5
  *
6
+ * v0.8.0 新增:风格注入(window.__STYLE__)。webengine.open_page(style=...) 在页面加载前用
7
+ * add_init_script 写入它,本文件据此设置画布 w/h、帧率、字体栈与底色;**没有 __STYLE__ 时
8
+ * 所有取值退回 v0.7 常量,像素与旧版逐字节一致**(style=None 的兼容性硬要求)。
9
+ *
6
10
  * 核心约定:
7
11
  * - 每帧从零重画:画面只由「帧号 f」决定,同一帧号永远画同一张图(逐帧渲染可复现)。
8
12
  * - scene(id, start, dur, build):场景 = 时间窗口 + 若干逐帧绘制回调,窗口外不执行。
@@ -13,13 +17,69 @@
13
17
  (function (global) {
14
18
  'use strict';
15
19
 
16
- var W = 1920, H = 1080, FPS = 30, BG = '#0b0e14';
20
+ /* ---------------- v0.8.0:风格包(window.__STYLE__) ----------------
21
+ * 由 webengine 注入(页面自己直接打开时没有)。不注入 = 全部退回下面这行常量。
22
+ * 只读 payload,不在页面里做任何派生计算;同 payload 永远渲染出同一张图。 */
23
+ var ST = (global.__STYLE__ && typeof global.__STYLE__ === 'object') ? global.__STYLE__ : null;
24
+ var ST_THEME = (ST && ST.theme && typeof ST.theme === 'object') ? ST.theme : null;
25
+ var ST_CANVAS = (ST && ST.canvas && typeof ST.canvas === 'object') ? ST.canvas : null;
26
+
27
+ /* 页面在 engine.js 之前自己声明过的帧率(模板不声明 → null)。页面契约说
28
+ * "window.FPS = 页面时间轴帧率,渲染器据此算 -r / -g",所以声明过就不能被默认 30 顶掉
29
+ * —— 否则 webengine 的"页面 FPS 与注入 fps 不一致就报错"永远看不到真实页面帧率。 */
30
+ var DECLARED_FPS = _num(global.FPS, null);
31
+ var W = 1920, H = 1080, BG = '#0b0e14';
32
+ var FPS = (DECLARED_FPS != null && DECLARED_FPS > 0) ? Math.round(DECLARED_FPS) : 30;
17
33
  var DEFAULT_DURATION = 12.0; // 页面没给时长时的兜底;film.js 用它区分"显式时长"与"兜底值"
18
34
  var FAMILY = '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif';
19
35
  var TAU = Math.PI * 2;
20
36
 
37
+ function _num(v, fb) { var n = Number(v); return (v != null && isFinite(n)) ? n : fb; }
38
+ function _txt(v, fb) { return (typeof v === 'string' && v.length > 0) ? v : fb; }
39
+
21
40
  var canvas = document.getElementById('stage');
22
41
  if (!canvas) throw new Error('engine.js: 页面缺少 <canvas id="stage">');
42
+
43
+ /* 画布尺寸:只在注入 style 时改(webengine 侧已校验 16:9 横屏偶数,这里再兜一层)。
44
+ * 没注入时 canvas 属性 / 内联样式一个字都不动 —— 旧行为不变。 */
45
+ if (ST_CANVAS) {
46
+ var _w = Math.round(_num(ST_CANVAS.w, W)), _h = Math.round(_num(ST_CANVAS.h, H));
47
+ if (_w > 0 && _h > 0 && Math.abs(_w / _h - 16 / 9) > 1e-3) {
48
+ throw new Error('engine.js: __STYLE__.canvas 必须是 16:9 横屏,收到 ' + _w + 'x' + _h);
49
+ }
50
+ if (_w > 0 && _h > 0) {
51
+ W = _w; H = _h;
52
+ canvas.width = W;
53
+ canvas.height = H;
54
+ canvas.style.width = W + 'px';
55
+ canvas.style.height = H + 'px';
56
+ document.documentElement.style.width = W + 'px';
57
+ document.documentElement.style.height = H + 'px';
58
+ if (document.body) {
59
+ document.body.style.width = W + 'px';
60
+ document.body.style.height = H + 'px';
61
+ }
62
+ }
63
+ /* 帧率:只有调用方显式给了 fps 才改。帧率是 timeline 契约,不是画风 ——
64
+ * 不能因为注入了 style 就悄悄把页面帧率换掉(那样分块帧号会整体错位)。
65
+ * 页面自己声明过 window.FPS 且与注入值不一致时**不覆盖**:保留页面值,让
66
+ * webengine.open_page 的契约检查报出"页面 window.FPS=X 与注入的 fps=Y 不一致"。
67
+ * 以前这里无条件覆盖 → 页面 window.FPS 被改成注入值,那条检查永远相等,
68
+ * 文档承诺的"fps 不一致就报错"对 engine.js 页面从未真正触发过。 */
69
+ var _fps = _num(ST_CANVAS.fps, null);
70
+ var _declared = (DECLARED_FPS != null && DECLARED_FPS > 0) ? Math.round(DECLARED_FPS) : null;
71
+ if (_fps != null && _fps > 0) {
72
+ if (_declared != null && _declared !== Math.round(_fps)) {
73
+ // 保留页面声明的帧率(FPS 已在上面按 DECLARED_FPS 初始化),交给 webengine 报错
74
+ } else {
75
+ FPS = Math.round(_fps);
76
+ }
77
+ }
78
+ }
79
+
80
+ if (ST_THEME) BG = _txt(ST_THEME.bg, BG);
81
+ if (ST && ST.typography) FAMILY = _txt(ST.typography.font_stack, FAMILY);
82
+
23
83
  var ctx = canvas.getContext('2d', { alpha: false, desynchronized: false });
24
84
 
25
85
  /* ---------------------------------------------------------------- 数学 / 缓动 */
@@ -482,6 +542,7 @@
482
542
  /* ---------------------------------------------------------------- 导出 */
483
543
  var FK = {
484
544
  W: W, H: H, FPS: FPS, BG: BG, TAU: TAU, FAMILY: FAMILY,
545
+ style: ST, // 注入的 __STYLE__(没有就是 null);qa / selftest 可据此断言
485
546
  defaultDuration: DEFAULT_DURATION,
486
547
  canvas: canvas, ctx: ctx,
487
548
  clamp: clamp, lerp: lerp, seg: seg, E: E, hash: hash, rng: rng,
@@ -1,4 +1,4 @@
1
- /* film.js — 场景模板(v0.3.0)
1
+ /* film.js — 场景模板(v0.8.0)
2
2
  *
3
3
  * 3 个场景 / 默认 12 秒(global.DURATION 由场景表推出,见下方 resolveDuration:
4
4
  * 加一幕、改一幕时长都不用手工同步常量;要锁死就写 window.WINDOW_DURATION)。
@@ -9,6 +9,19 @@
9
9
  * ① 逐字入场(0–4s) ② 数字滚动(4–8s) ③ 粒子 / 发光(8–12s)
10
10
  * ④ HUD 标注(贯穿全片) ⑤ cue / event 登记(由 window.getCues()/getEvents() 导出)
11
11
  *
12
+ * v0.8.0 风格跟随(契约)
13
+ * ----------------------
14
+ * 色板 / 版式 / 材质强度 / HUD 装帧优先读 window.__STYLE__(webengine.open_page(style=...)
15
+ * 在页面加载前注入);取不到就用本文件里的 v0.7 常量。
16
+ * - 色板:theme.{bg,bg2,surface,ink,dim,accent,accent2,rule} 决定底色 / 正文 / 强调色;
17
+ * - 版式:layout.default ∈ center-stack / left-stack / split-right 决定标题块的锚点与对齐,
18
+ * hud ∈ chip-left / corner-line / none 决定 HUD 装帧;
19
+ * - 材质:material.vignette / texture_alpha 决定暗角与网格强度;
20
+ * - 动效:motion.entrance_dy 决定标题入场位移;motion.transition=wash 时转场用底色而不是黑;
21
+ * - 画布:canvas.{w,h,fps} 已在 engine.js 里生效;本文件用 X()/Y()/S() 把 1920x1080 设计坐标
22
+ * 等比缩放到实际画布(1280x720 等 16:9 尺寸),1920x1080 时三者原样返回。
23
+ * **没有 __STYLE__ 时**,上面每条取到的都是 v0.7 原值,渲染像素与旧版逐字节一致(自检有证据)。
24
+ *
12
25
  * 纪律:每帧都是「帧号 → 画面」的纯函数——不读上一帧状态、不用 Math.random / Date.now。
13
26
  * 画面里的所有文字都走 FK.g.text(),qaText 才能看到它们的包围盒。
14
27
  * 改编自 ai-history-film(MIT)video/web2 的场景组织方式(逐字入场 / 计数器 / HUD 的写法)。
@@ -20,6 +33,101 @@
20
33
  var W = F.W, H = F.H, g = F.g, E = F.E;
21
34
  var seg = F.seg, lerp = F.lerp, clamp = F.clamp;
22
35
 
36
+ /* ================================================================ 风格包 → 色板 / 版式 */
37
+ var ST = (global.__STYLE__ && typeof global.__STYLE__ === 'object') ? global.__STYLE__ : null;
38
+ var TH = (ST && ST.theme && typeof ST.theme === 'object') ? ST.theme : null;
39
+ var MOTION = (ST && ST.motion && typeof ST.motion === 'object') ? ST.motion : null;
40
+ var MATERIAL = (ST && ST.material && typeof ST.material === 'object') ? ST.material : null;
41
+
42
+ /* 设计坐标 → 实际画布:1920x1080 时 KX = KY = KS = 1,三个函数原样返回(不做任何取整),
43
+ * 所以旧页面(无 style)尺寸路径一个数字都不变。 */
44
+ var KX = W / 1920, KY = H / 1080, KS = Math.min(KX, KY);
45
+ function X(v) { return KX === 1 ? v : Math.round(v * KX); }
46
+ function Y(v) { return KY === 1 ? v : Math.round(v * KY); }
47
+ function S(v) { return KS === 1 ? v : Math.round(v * KS); }
48
+
49
+ /* 颜色归一:接受 "#rrggbb" / [r,g,b];解析不了返回 null(由调用点的兜底值接手)。 */
50
+ function toRgb(c) {
51
+ if (Array.isArray(c) && c.length >= 3) {
52
+ var a = [Number(c[0]) | 0, Number(c[1]) | 0, Number(c[2]) | 0];
53
+ if (a[0] >= 0 && a[0] <= 255 && a[1] >= 0 && a[1] <= 255 && a[2] >= 0 && a[2] <= 255) return a;
54
+ return null;
55
+ }
56
+ if (typeof c === 'string') {
57
+ var s = c.trim();
58
+ var m = /^#([0-9a-fA-F]{6})$/.exec(s);
59
+ if (m) { var n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
60
+ var m2 = /^rgba?\(([^)]+)\)$/.exec(s); // 也认 rgb()/rgba() 字符串
61
+ if (m2) {
62
+ var parts = m2[1].split(',');
63
+ if (parts.length >= 3) {
64
+ return [parseInt(parts[0], 10) | 0, parseInt(parts[1], 10) | 0, parseInt(parts[2], 10) | 0];
65
+ }
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ function P(key, fb) { var a = TH ? toRgb(TH[key]) : null; return a || fb; }
71
+ function rgbStr(a) { return 'rgb(' + a[0] + ',' + a[1] + ',' + a[2] + ')'; }
72
+ function A(c, alpha) {
73
+ var a = toRgb(c) || [255, 255, 255];
74
+ return 'rgba(' + a[0] + ',' + a[1] + ',' + a[2] + ',' + alpha + ')';
75
+ }
76
+
77
+ /* HUD 小字的透明度:深色主题按 v0.7 的 alpha;浅色主题上"淡白字"会消失,统一乘一个系数。
78
+ * 没有 __STYLE__ 时系数 = 1,A() 收到的 alpha 与旧版字面量完全一致。 */
79
+ var HUD_BOOST = (ST && ST.dark === false) ? 1.6 : 1.0;
80
+ function HA(alpha) { return A(C.ink, Math.min(1, alpha * HUD_BOOST)); }
81
+
82
+ /* 底部色板:没有 __STYLE__ 时每一项都是 v0.7 常量(包括字符串写法,便于逐字节对比)。 */
83
+ var C = {
84
+ ink: TH ? rgbStr(P('ink', [234, 242, 255])) : '#eaf2ff',
85
+ dim: TH ? rgbStr(P('dim', [142, 164, 192])) : '#8ea4c0',
86
+ bright: TH ? rgbStr(P('ink', [246, 249, 255])) : '#f6f9ff',
87
+ accent: P('accent', [255, 140, 66]),
88
+ cyan: P('accent2', [90, 210, 255]),
89
+ warm: P('accent2', [255, 196, 120]),
90
+ gridAlpha: (MATERIAL && MATERIAL.texture_alpha != null) ? Number(MATERIAL.texture_alpha) : 0.045,
91
+ vig: (MATERIAL && MATERIAL.vignette != null) ? Number(MATERIAL.vignette) : 0.5,
92
+ /* 白闪:深色主题保持纯白;浅色主题用 ink(白闪在浅底上会"消失",等于闪了一下没有) */
93
+ flash: (ST && ST.dark === false && TH) ? rgbStr(P('ink', [255, 255, 255])) : '#ffffff',
94
+ /* 转场收尾:transition=wash 用底色淡入,其余(含无 style)仍是黑场 */
95
+ wipe: (MOTION && MOTION.transition === 'wash' && TH) ? rgbStr(P('bg', [11, 14, 20])) : '#000000'
96
+ };
97
+
98
+ var BG_STOPS = TH
99
+ ? [[0, P('bg', [11, 14, 20])], [0.55, P('bg2', [14, 22, 36])], [1, P('bg', [10, 13, 19])]]
100
+ : [[0, '#0b0e14'], [0.55, '#0e1624'], [1, '#0a0d13']];
101
+
102
+ /* 版式:没有 __STYLE__ 时 HUD = 'default'(v0.7 原样)、LAYOUT = 'center-stack'。 */
103
+ var LAYOUT = (ST && ST.layout && typeof ST.layout.default === 'string') ? ST.layout.default : 'center-stack';
104
+ var HUD = (ST && typeof ST.hud === 'string') ? ST.hud : 'default';
105
+ var ANCHOR = 'center'; // center-stack 居中;left-stack 左;split-right 右
106
+ if (ST) {
107
+ if (LAYOUT === 'left-stack') ANCHOR = 'left';
108
+ else if (LAYOUT === 'split-right') ANCHOR = 'right';
109
+ }
110
+ var MARGIN = X(168);
111
+ /* 标题入场位移:无 style 时是 v0.7 的 90px;有 style 时按 motion.entrance_dy 放大 4 倍
112
+ * (stylekit 里的单位是 PIL 排版像素,网页坐标要乘 4 才接近原来 90px 的观感)。 */
113
+ var RISE = (MOTION && MOTION.entrance_dy) ? S(Number(MOTION.entrance_dy) * 4) : Y(90);
114
+
115
+ function anchorX(width) { // 逐字排版用的左起 x
116
+ if (ANCHOR === 'left') return MARGIN;
117
+ if (ANCHOR === 'right') return W - MARGIN - width;
118
+ return (W - width) / 2;
119
+ }
120
+ function place() { // 居中/左/右对齐的单行文字锚点
121
+ if (ANCHOR === 'left') return { x: MARGIN, align: 'left' };
122
+ if (ANCHOR === 'right') return { x: W - MARGIN, align: 'right' };
123
+ return { x: W / 2, align: 'center' };
124
+ }
125
+ function blockX(blockWidth) { // 进度条 / 强调线这类"块"的左边界
126
+ if (ANCHOR === 'left') return MARGIN;
127
+ if (ANCHOR === 'right') return W - MARGIN - blockWidth;
128
+ return (W - blockWidth) / 2;
129
+ }
130
+
23
131
  /* 成片秒数(webengine.py / render.py 会读它做分块与校验)。
24
132
  * 默认由场景表推出 = 最后一个场景的 end(加/删/改场景后自动跟着变,不会忘记同步);
25
133
  * 要显式锁死(例如页面里还有场景以外的动画),在 index.html 里先写
@@ -36,125 +144,120 @@
36
144
  return F.timelineEnd();
37
145
  }
38
146
 
39
- var C = {
40
- ink: '#eaf2ff',
41
- dim: '#8ea4c0',
42
- accent: [255, 140, 66],
43
- cyan: [90, 210, 255],
44
- warm: [255, 196, 120]
45
- };
46
-
47
- /* qaText 的「从没露面」检查要先声明 key:必须与 g.text({key}) 对齐 */
147
+ /* qaText 的「从没露面」检查要先声明 key:必须与 g.text({key}) 对齐。
148
+ * hud='none' 的装帧不画品牌/章节文字,就别声明它们(否则 never 会误报)。 */
48
149
  F.watch('open.title', '标题:逐帧确定性');
49
150
  F.watch('open.sub', '副标题:Canvas 2D 极简时间轴');
50
151
  F.watch('data.num', '数字滚动:0 → 1920');
51
152
  F.watch('data.cap', '说明:同一帧号 → 同一张图');
52
153
  F.watch('fx.title', '标题:确定性渲染');
53
154
  F.watch('fx.hud', 'HUD:粒子数 / 帧率标注');
54
- F.watch('hud.ch', 'HUD:章节名');
155
+ if (HUD !== 'none') F.watch('hud.ch', 'HUD:章节名');
55
156
  F.watch('hud.tc', 'HUD:时间码');
56
- F.watch('hud.brand', 'HUD:品牌名');
157
+ if (HUD !== 'none') F.watch('hud.brand', 'HUD:品牌名');
57
158
 
58
159
  /* ================================================================ 背景层(最先注册 → 最先画) */
59
160
  F.always(function (t) {
60
- g.bgGradient([[0, '#0b0e14'], [0.55, '#0e1624'], [1, '#0a0d13']]);
61
- g.grid({ t: t, step: 120, alpha: 0.045, color: C.cyan, drift: 6 });
62
- g.vignette(0.5);
161
+ g.bgGradient(BG_STOPS);
162
+ g.grid({ t: t, step: X(120), alpha: C.gridAlpha, color: C.cyan, drift: S(6) });
163
+ g.vignette(C.vig);
63
164
  });
64
165
 
65
166
  /* ================================================================ 场景 1:逐字入场(0–4s) */
66
- F.scene('open', 0, 4.0, function (S) {
167
+ F.scene('open', 0, 4.0, function (S1) {
67
168
  // ⚠ 命名约定(见 references/frame-qa.md §2.5):
68
169
  // qa_frames 的音画同步**先按 kind == cue 的配方名配对**。
69
170
  // 所以每个"该响一下"的画面事件,kind 要写成**同一个配方名**(ui_blip / whoosh / impact …);
70
171
  // 只有纯装饰性的视觉事件(交界黑场)才用 flash 这类通用名。
71
172
  // 底下每条 S.cue 都配了一条同名 S.event —— 这样同步检查才真的在查,而不是静默全过。
72
- S.cue(0.10, 'ui_blip', { pitch: 2200, gain: -4 });
73
- S.event(0.10, 'ui_blip', { a: 0.5 });
74
- S.cue(0.34, 'ui_blip', { pitch: 2600, gain: -6 });
75
- S.event(0.34, 'ui_blip', { a: 0.5 });
76
- S.cue(1.05, 'whoosh', { dur: 0.55, size: 0.6, gain: -10 });
77
- S.event(1.05, 'whoosh', { size: 0.6 }); // 强调线"画出来"的同时一声掠过
78
- S.event(3.70, 'flash', { a: 0.18 }); // 4s 交界黑场起坡(纯视觉,无对应音效)
173
+ S1.cue(0.10, 'ui_blip', { pitch: 2200, gain: -4 });
174
+ S1.event(0.10, 'ui_blip', { a: 0.5 });
175
+ S1.cue(0.34, 'ui_blip', { pitch: 2600, gain: -6 });
176
+ S1.event(0.34, 'ui_blip', { a: 0.5 });
177
+ S1.cue(1.05, 'whoosh', { dur: 0.55, size: 0.6, gain: -10 });
178
+ S1.event(1.05, 'whoosh', { size: 0.6 }); // 强调线"画出来"的同时一声掠过
179
+ S1.event(3.70, 'flash', { a: 0.18 }); // 4s 交界黑场起坡(纯视觉,无对应音效)
79
180
 
80
181
  var TITLE = '逐帧确定性';
81
- var SIZE = 150;
82
- var TRACK = 8;
182
+ var SIZE = S(150);
183
+ var TRACK = S(8);
83
184
 
84
- S.update(function (t) {
85
- // ① 逐字入场:每个字从下方 90px 升起 + 淡入,错峰 0.075s
185
+ S1.update(function (t) {
186
+ // ① 逐字入场:每个字从下方 RISE 升起 + 淡入,错峰 0.075s(RISE 跟随 style 的 motion.entrance_dy)
86
187
  var lay = g.layoutChars(TITLE, SIZE, '700', null, TRACK);
87
- var x = (W - lay.width) / 2;
88
- var y = 470;
188
+ var x = anchorX(lay.width);
189
+ var y = Y(470);
89
190
  for (var i = 0; i < lay.chars.length; i++) {
90
191
  var p = E.out5(seg(t, 0.16 + i * 0.075, 0.72 + i * 0.075));
91
192
  var a = seg(t, 0.16 + i * 0.075, 0.44 + i * 0.075);
92
- g.text(lay.chars[i].ch, x + lay.chars[i].x, y + (1 - p) * 90, {
193
+ g.text(lay.chars[i].ch, x + lay.chars[i].x, y + (1 - p) * RISE, {
93
194
  size: SIZE, weight: '700', color: C.ink, alpha: a,
94
195
  key: 'open.title.' + i, group: 'open.title'
95
196
  });
96
197
  }
97
- // 标题下方一条「画出来」的强调线
98
- var lw = E.out3(seg(t, 1.15, 1.9)) * 360;
99
- g.rect(W / 2 - lw / 2, y + 118, lw, 4, 'rgba(255,140,66,0.9)');
198
+ // 标题下方一条「画出来」的强调线(跟随版式锚点)
199
+ var lw = E.out3(seg(t, 1.15, 1.9)) * X(360);
200
+ g.rect(blockX(lw), y + Y(118), lw, S(4), A(C.accent, 0.9));
100
201
  // 副标题淡入 + 上浮
101
202
  var a2 = E.out3(seg(t, 1.35, 2.05));
102
- g.text('Canvas 2D · 极简时间轴 · 无 GSAP / three.js', W / 2, 646 + (1 - a2) * 26, {
103
- size: 40, weight: '400', color: C.dim, align: 'center', alpha: a2,
203
+ var ps = place();
204
+ g.text('Canvas 2D · 极简时间轴 · 无 GSAP / three.js', ps.x, Y(646) + (1 - a2) * Y(26), {
205
+ size: S(40), weight: '400', color: C.dim, align: ps.align, alpha: a2,
104
206
  key: 'open.sub', group: 'open.sub'
105
207
  });
106
208
  // 两侧装饰竖线("长出来")
107
209
  var dw = E.out3(seg(t, 1.6, 2.2));
108
210
  if (dw > 0.001) {
109
- g.rect(196, 430 + (1 - dw) * 60, 2, 120 * dw, 'rgba(90,210,255,0.55)');
110
- g.rect(W - 198, 430 + (1 - dw) * 60, 2, 120 * dw, 'rgba(90,210,255,0.55)');
211
+ g.rect(X(196), Y(430) + (1 - dw) * Y(60), S(2), Y(120) * dw, A(C.cyan, 0.55));
212
+ g.rect(W - X(198), Y(430) + (1 - dw) * Y(60), S(2), Y(120) * dw, A(C.cyan, 0.55));
111
213
  }
112
214
  });
113
215
  });
114
216
 
115
217
  /* ================================================================ 场景 2:数字滚动(4–8s) */
116
- F.scene('data', 4.0, 4.0, function (S) {
218
+ F.scene('data', 4.0, 4.0, function (S2) {
117
219
  // 同上:cue 与 event 成对出现、同名,同步检查才有东西可配
118
- S.cue(0.10, 'data_chirp', { gain: -4 });
119
- S.event(0.10, 'data_chirp', {});
120
- S.cue(1.85, 'ui_click', { gain: -5 });
121
- S.event(1.85, 'ui_click', {}); // 数字滚完落定的一声"咔"
122
- S.cue(2.12, 'relay_click', { gain: -12 });
123
- S.event(2.12, 'relay_click', {});
124
-
125
- S.update(function (t) {
220
+ S2.cue(0.10, 'data_chirp', { gain: -4 });
221
+ S2.event(0.10, 'data_chirp', {});
222
+ S2.cue(1.85, 'ui_click', { gain: -5 });
223
+ S2.event(1.85, 'ui_click', {}); // 数字滚完落定的一声"咔"
224
+ S2.cue(2.12, 'relay_click', { gain: -12 });
225
+ S2.event(2.12, 'relay_click', {});
226
+
227
+ S2.update(function (t) {
126
228
  // ② 数字滚动:0 → 1920,走 out5 缓动(声音 chirp 与它同一条时间线)
127
229
  var p = E.out5(seg(t, 0.25, 1.85));
128
230
  var num = Math.round(lerp(0, 1920, p));
129
231
  var s = String(num);
130
232
  while (s.length < 4) s = '0' + s;
131
- g.text(s, W / 2, 470, {
132
- size: 236, weight: '700', align: 'center', color: '#f6f9ff',
233
+ var pn = place();
234
+ g.text(s, pn.x, Y(470), {
235
+ size: S(236), weight: '700', align: pn.align, color: C.bright,
133
236
  key: 'data.num', group: 'data.num'
134
237
  });
135
- g.text('FRAME-STABLE CUTS', W / 2, 606, {
136
- size: 26, weight: '500', align: 'center', color: 'rgba(90,210,255,0.85)',
238
+ g.text('FRAME-STABLE CUTS', pn.x, Y(606), {
239
+ size: S(26), weight: '500', align: pn.align, color: A(C.cyan, 0.85),
137
240
  alpha: E.out3(seg(t, 0.9, 1.4)), key: 'data.kicker', group: 'data.kicker'
138
241
  });
139
242
  // 说明文字
140
243
  var a = E.out3(seg(t, 1.05, 1.75));
141
- g.text('同一帧号 → 同一张图', W / 2, 700 + (1 - a) * 20, {
142
- size: 44, weight: '400', align: 'center', color: C.dim, alpha: a,
244
+ g.text('同一帧号 → 同一张图', pn.x, Y(700) + (1 - a) * Y(20), {
245
+ size: S(44), weight: '400', align: pn.align, color: C.dim, alpha: a,
143
246
  key: 'data.cap', group: 'data.cap'
144
247
  });
145
248
  // 进度条(数字滚动的可视化)
146
- var bw = 720, bx = (W - bw) / 2, by = 792;
147
- g.rect(bx, by, bw, 3, 'rgba(234,242,255,0.12)');
148
- g.rect(bx, by, bw * p, 3, 'rgba(255,140,66,0.95)');
249
+ var bw = X(720), bx = blockX(bw), by = Y(792);
250
+ g.rect(bx, by, bw, S(3), A(C.ink, Math.min(0.5, 0.12 * HUD_BOOST)));
251
+ g.rect(bx, by, bw * p, S(3), A(C.accent, 0.95));
149
252
  // ④ HUD 标注:右上角小徽标
150
253
  var ha = E.out3(seg(t, 0.5, 1.0));
151
254
  if (ha > 0.001) {
152
- g.text('◆ DATA LAYER', W - 64, 206, {
153
- size: 22, weight: '600', align: 'right', color: 'rgba(255,140,66,0.9)',
255
+ g.text('◆ DATA LAYER', W - X(64), Y(206), {
256
+ size: S(22), weight: '600', align: 'right', color: A(C.accent, 0.9),
154
257
  alpha: ha, key: 'data.hud', group: 'data.hud'
155
258
  });
156
- g.text('counter → 1920', W - 64, 238, {
157
- size: 18, weight: '400', align: 'right', color: 'rgba(234,242,255,0.45)',
259
+ g.text('counter → 1920', W - X(64), Y(238), {
260
+ size: S(18), weight: '400', align: 'right', color: HA(0.45),
158
261
  alpha: ha, key: 'data.hud2', group: 'data.hud'
159
262
  });
160
263
  }
@@ -162,50 +265,51 @@
162
265
  });
163
266
 
164
267
  /* ================================================================ 场景 3:粒子 / 发光(8–12s) */
165
- F.scene('fx', 8.0, 4.0, function (S) {
166
- S.event(-0.30, 'flash', { a: 0.18 }); // 8s 交界黑场起坡(本幕负偏移 = 上一幕末帧)
167
- S.cue(0.02, 'reverse_swell', { gain: -4, dur: 1.2 });
168
- S.event(0.02, 'reverse_swell', {}); // 吸气 = 画面从黑里亮起来
169
- S.cue(0.62, 'impact', { size: 1.15, gain: -1 });
170
- S.event(0.62, 'impact', { a: 0.8 }); // 重击:白闪 + 冲击环同一时刻
171
- S.event(0.62, 'ring', { r: 620 }); // 纯装饰(无对应音效)
172
- S.cue(1.55, 'ui_blip', { pitch: 1800, gain: -8 });
173
- S.event(1.55, 'ui_blip', { a: 0.5 });
174
-
175
- S.update(function (t) {
268
+ F.scene('fx', 8.0, 4.0, function (S3) {
269
+ S3.event(-0.30, 'flash', { a: 0.18 }); // 8s 交界黑场起坡(本幕负偏移 = 上一幕末帧)
270
+ S3.cue(0.02, 'reverse_swell', { gain: -4, dur: 1.2 });
271
+ S3.event(0.02, 'reverse_swell', {}); // 吸气 = 画面从黑里亮起来
272
+ S3.cue(0.62, 'impact', { size: 1.15, gain: -1 });
273
+ S3.event(0.62, 'impact', { a: 0.8 }); // 重击:白闪 + 冲击环同一时刻
274
+ S3.event(0.62, 'ring', { r: 620 }); // 纯装饰(无对应音效)
275
+ S3.cue(1.55, 'ui_blip', { pitch: 1800, gain: -8 });
276
+ S3.event(1.55, 'ui_blip', { a: 0.5 });
277
+
278
+ S3.update(function (t) {
176
279
  // ③ 粒子 / 发光:位置全部由 hash(seed,i,*) 决定,同一 t 永远同一张图
177
- g.particles({ n: 150, seed: 20261107, t: t, color: C.accent, alpha: 0.5, speed: 1.0, rmin: 2, rmax: 6, wobble: 30 });
280
+ g.particles({ n: 150, seed: 20261107, t: t, color: C.accent, alpha: 0.5, speed: 1.0, rmin: S(2), rmax: S(6), wobble: Y(30) });
178
281
  // 冲击环:impact 时刻从中心扩散
179
282
  var rp = E.out5(seg(t, 0.62, 2.3));
180
283
  if (rp > 0.001 && rp < 0.999) {
181
- g.ring(W / 2, 520, lerp(40, 640, rp), 'rgba(255,196,120,' + (1 - rp) * 0.55 + ')', 3);
284
+ g.ring(W / 2, Y(520), lerp(S(40), S(640), rp), A(C.warm, (1 - rp) * 0.55), S(3));
182
285
  }
183
286
  // 中心发光:正弦呼吸,相位只由绝对时间 T 决定
184
287
  var T = 8.0 + t;
185
- g.glow(W / 2, 520, 250, C.accent, 0.18 + 0.07 * Math.sin(T * 2.0));
186
- g.glow(W / 2, 520, 130, C.warm, 0.10 + 0.05 * Math.sin(T * 2.6 + 1.2));
288
+ g.glow(W / 2, Y(520), S(250), C.accent, 0.18 + 0.07 * Math.sin(T * 2.0));
289
+ g.glow(W / 2, Y(520), S(130), C.warm, 0.10 + 0.05 * Math.sin(T * 2.6 + 1.2));
187
290
  // 标题逐字入场(复用同一套缓动)
188
- var lay = g.layoutChars('确定性渲染', 132, '700', null, 10);
189
- var x = (W - lay.width) / 2;
190
- var y = 520;
291
+ var lay = g.layoutChars('确定性渲染', S(132), '700', null, S(10));
292
+ var x = anchorX(lay.width);
293
+ var y = Y(520);
191
294
  for (var i = 0; i < lay.chars.length; i++) {
192
295
  var p = E.out5(seg(t, 1.05 + i * 0.07, 1.55 + i * 0.07));
193
- g.text(lay.chars[i].ch, x + lay.chars[i].x, y + (1 - p) * 60, {
194
- size: 132, weight: '700', color: '#ffffff', alpha: p,
296
+ g.text(lay.chars[i].ch, x + lay.chars[i].x, y + (1 - p) * Y(60), {
297
+ size: S(132), weight: '700', color: C.flash, alpha: p,
195
298
  key: 'fx.title.' + i, group: 'fx.title'
196
299
  });
197
300
  }
198
301
  // HUD 标注
199
- g.text('PARTICLES 150 · 30 FPS · 同一帧同一图', W / 2, 660, {
200
- size: 28, weight: '400', align: 'center', color: 'rgba(234,242,255,0.66)',
302
+ var pf = place();
303
+ g.text('PARTICLES 150 · 30 FPS · 同一帧同一图', pf.x, Y(660), {
304
+ size: S(28), weight: '400', align: pf.align, color: HA(0.66),
201
305
  alpha: E.out3(seg(t, 1.3, 1.9)), key: 'fx.hud', group: 'fx.hud'
202
306
  });
203
307
  // 白闪:impact 事件的可视化(event 已登记同一条时间;只从 impact 时刻开始衰减)
204
308
  var fp = seg(t, 0.62, 0.92);
205
- if (t >= 0.62 && fp < 0.999) g.flash(0.55 * (1 - fp), '#ffffff');
206
- // 片尾淡出到黑
309
+ if (t >= 0.62 && fp < 0.999) g.flash(0.55 * (1 - fp), C.flash);
310
+ // 片尾淡出到黑(wash 主题淡到自己的底色)
207
311
  var fo = E.in2(seg(t, 3.35, 4.0));
208
- if (fo > 0.001) g.flash(fo, '#000000');
312
+ if (fo > 0.001) g.flash(fo, C.wipe);
209
313
  });
210
314
  });
211
315
 
@@ -217,30 +321,49 @@
217
321
  /* ================================================================ HUD 层(最后注册 → 最后画,压在所有场景之上) */
218
322
  F.always(function (t) {
219
323
  var dur = Number(global.DURATION) || 12.0;
220
- g.text('DSH VIDEO STUDIO', 64, 60, {
221
- size: 24, weight: '500', color: 'rgba(234,242,255,0.55)', key: 'hud.brand'
222
- });
223
- var chap = t < 4 ? '01 · 逐字入场' : (t < 8 ? '02 · 数字滚动' : '03 · 粒子 / 发光');
224
- g.text(chap, 64, 96, {
225
- size: 22, weight: '500', color: 'rgba(90,210,255,0.8)', key: 'hud.ch', group: 'hud.ch'
226
- });
324
+ // HUD 装帧(只在注入 style 时启用;default = v0.7 原样)
325
+ if (HUD === 'chip-left') {
326
+ g.rrect(X(48), Y(36), X(300), Y(84), S(12), A(P('surface', [26, 42, 64]), 0.55));
327
+ } else if (HUD === 'corner-line') {
328
+ var cl = A(P('rule', [255, 255, 255]), 0.28);
329
+ var m = X(40), ln = X(96), th = S(1);
330
+ g.rect(m, m, ln, th, cl);
331
+ g.rect(m, m, th, ln, cl);
332
+ g.rect(W - m - ln, m, ln, th, cl);
333
+ g.rect(W - m - th, m, th, ln, cl);
334
+ g.rect(m, H - m - th, ln, th, cl);
335
+ g.rect(m, H - m - th, th, ln, cl);
336
+ g.rect(W - m - ln, H - m - th, ln, th, cl);
337
+ g.rect(W - m - th, H - m - th, th, ln, cl);
338
+ }
339
+ if (HUD !== 'none') {
340
+ g.text('DSH VIDEO STUDIO', X(64), Y(60), {
341
+ size: S(24), weight: '500', color: HA(0.55), key: 'hud.brand'
342
+ });
343
+ var chap = t < 4 ? '01 · 逐字入场' : (t < 8 ? '02 · 数字滚动' : '03 · 粒子 / 发光');
344
+ g.text(chap, X(64), Y(96), {
345
+ size: S(22), weight: '500', color: A(C.cyan, 0.8), key: 'hud.ch', group: 'hud.ch'
346
+ });
347
+ }
227
348
  // 时间码每帧都在变 → 也保证「不同帧渲染结果不同」
228
349
  var tc = 'T ' + (t < 10 ? '0' : '') + t.toFixed(2) + ' / ' + dur.toFixed(2);
229
- g.text(tc, W - 64, 60, {
230
- size: 22, weight: '500', align: 'right', color: 'rgba(234,242,255,0.45)',
350
+ g.text(tc, W - X(64), Y(60), {
351
+ size: S(22), weight: '500', align: 'right', color: HA(0.45),
231
352
  key: 'hud.tc', group: 'hud.tc'
232
353
  });
233
- // 顶部进度线
234
- g.rect(64, 122, W - 128, 1, 'rgba(90,210,255,0.16)');
235
- g.rect(64, 122, (W - 128) * clamp(t / dur, 0, 1), 2, 'rgba(255,140,66,0.85)');
236
- // 底部品牌行
237
- g.text('v0.3.0 · Canvas2D 逐帧渲染 · 不使用 GSAP / three.js', 64, H - 52, {
238
- size: 20, weight: '400', color: 'rgba(234,242,255,0.3)', key: 'hud.foot'
354
+ // 顶部进度线(HUD 装帧为 none 时也保留:它是"逐帧在动"的最直观证据)
355
+ g.rect(X(64), Y(122), W - X(128), S(1), A(C.cyan, 0.16));
356
+ g.rect(X(64), Y(122), (W - X(128)) * clamp(t / dur, 0, 1), S(2), A(C.accent, 0.85));
357
+ // 底部品牌行(有 style 时写风格名,便于目检"这条片用的是哪套画风")
358
+ var foot = ST ? ('v0.8.0 · ' + (ST.style_name || ST.style || ST.theme_label || 'style') + ' · Canvas2D 逐帧渲染')
359
+ : 'v0.3.0 · Canvas2D 逐帧渲染 · 不使用 GSAP / three.js';
360
+ g.text(foot, X(64), H - Y(52), {
361
+ size: S(20), weight: '400', color: HA(0.3), key: 'hud.foot'
239
362
  });
240
- // 场景交界黑场「呼吸」(4s / 8s),避免硬切
363
+ // 场景交界黑场「呼吸」(4s / 8s),避免硬切(wash 主题淡到自己的底色)
241
364
  var d = Math.min(Math.abs(t - 4.0), Math.abs(t - 8.0));
242
365
  var dip = clamp(1 - d / 0.3);
243
- if (dip > 0.001) g.flash(0.5 * E.out2(dip), '#000000');
366
+ if (dip > 0.001) g.flash(0.5 * E.out2(dip), C.wipe);
244
367
  // ↑ 这条在 dip 的"起坡帧"会有一个可见的亮度跳变(clamp 从 0 开始斜升)。
245
368
  // 对观众来说它是转场的一部分,但对逐帧质检它长得像单帧闪烁:
246
369
  // 所以把起坡时刻登记成 flash 事件,qa_frames 会把它从 flicker 里排除(见 FLICKER_EXPLAIN_WIN)。
@@ -3,12 +3,14 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=1920, height=1080">
6
- <title>DSH Video Studio · Canvas2D 逐帧渲染模板(v0.3.0)</title>
6
+ <title>DSH Video Studio · Canvas2D 逐帧渲染模板(v0.8.0)</title>
7
7
  <!-- 本地样式与脚本:不引 CDN、不连任何网络 -->
8
8
  <link rel="stylesheet" href="style.css">
9
9
  </head>
10
10
  <body>
11
- <!-- 1920x1080 固定画布:宽高必须与 webengine.py 的 viewport 一致,截图才是 1:1 像素 -->
11
+ <!-- 画布默认 1920x1080,必须与 webengine.py 的 viewport 一致,截图才是 1:1 像素。
12
+ v0.8.0:webengine.open_page(style=...) 注入 window.__STYLE__.canvas 时,engine.js 会把
13
+ canvas 宽高与 CSS 尺寸改成注入值(仍只允许 16:9 横屏);没有 __STYLE__ 时就是下面这组。 -->
12
14
  <canvas id="stage" width="1920" height="1080"></canvas>
13
15
 
14
16
  <script src="engine.js"></script>
@@ -1,11 +1,15 @@
1
- /* qa_text.js — 浏览器端文字质检(v0.3.0)
1
+ /* qa_text.js — 浏览器端文字质检(v0.8.0)
2
2
  *
3
3
  * 逐帧重渲染画布,读回 engine.js 登记的文字包围盒,找出四类问题:
4
4
  * ① overlaps 文字互相压住(同一 group 内的逐字排版不算)
5
- * ② offscreen 出画(包围盒越过 1920x1080 边界 6px)
6
- * ③ brief 一闪而过(可见时长 < 0.6s)
5
+ * ② offscreen 出画(包围盒越过画布边界 6px;边界取 FK.W/FK.H,v0.8 起画布可非 1080p)
6
+ * ③ brief 一闪而过(可见时长 < 0.6s;按采样点数×步长算,不是首末跨度)
7
7
  * ④ never 从没露面(需页面用 FK.watch(key, 标签) 声明)
8
8
  *
9
+ * 深审修复:参数 fail-closed —— step 非正 / >= 0.6s(brief 阈值)或 t1<t0 直接抛错。
10
+ * 以前这三种情况会返回空结果,调用方(CLI textqa)把它当"零缺陷"exit 0:门禁看着是绿的,
11
+ * 其实一行都没查。
12
+ *
9
13
  * 判据改编自 ai-history-film(MIT)video/qa_text.js:
10
14
  * 重叠面积比 0.08、出画容差 6px、短命阈值 0.6s。
11
15
  */
@@ -19,16 +23,35 @@
19
23
  }
20
24
  function nameOf(q) { return (q.group || q.key) + '「' + short(q.text) + '」'; }
21
25
 
26
+ /* 「一闪而过」判据:可见时长 < 0.6 s(与 webengine.QA_BRIEF_S / 文档同口径)。
27
+ * 采样步长必须小于它,否则每段文字最多被采到 1 个点,该判据永远不可能触发 —— 那时
28
+ * 返回一个"零缺陷"的结果等于骗人,所以这里直接抛错(Python 侧也有一道同样的校验,
29
+ * 为的是 CLI 能给 exit 2 + 可读报错)。 */
30
+ var BRIEF_S = 0.6;
31
+
22
32
  async function qaText(t0, t1, step) {
23
33
  var F = global.FK;
24
34
  if (!F) throw new Error('qa_text.js: 找不到 window.FK(engine.js 未加载?)');
25
- var start = Number(t0) || 0;
35
+ var start = (t0 == null) ? 0 : Number(t0);
26
36
  var dur = Number(global.DURATION) || 0;
27
37
  var end = (t1 == null || !isFinite(Number(t1))) ? dur : Number(t1);
28
38
  var fps = Number(global.FPS) || 30;
29
- var dt = Math.max(Number(step) || 0.2, 1 / fps);
30
- var empty = { overlaps: [], offscreen: [], brief: [], never: [], frames: 0, texts: 0 };
31
- if (end < start) return empty;
39
+ var stepN = (step == null) ? 0.2 : Number(step);
40
+ if (!isFinite(start) || !isFinite(end)) {
41
+ throw new Error('qaText: 时间范围必须是有限秒数(t0=' + t0 + ' t1=' + t1 + ')');
42
+ }
43
+ if (!isFinite(stepN) || stepN <= 0) {
44
+ throw new Error('qaText: step 必须是正数秒(收到 ' + step + ')');
45
+ }
46
+ if (stepN >= BRIEF_S) {
47
+ throw new Error('qaText: step=' + stepN + 's 必须小于 ' + BRIEF_S
48
+ + 's(一闪而过阈值);步长 >= 阈值时每段文字最多被采到 1 个点,该判据永远不可能触发');
49
+ }
50
+ if (end < start) {
51
+ throw new Error('qaText: t1=' + end + ' < t0=' + start
52
+ + '(空扫描不是"没有缺陷",不能当通过)');
53
+ }
54
+ var dt = Math.max(stepN, 1 / fps);
32
55
  F.qaReset();
33
56
  var sampled = 0; // 实际扫描的采样帧数(=== frames 字段,与文档一致)
34
57
 
@@ -84,7 +107,12 @@
84
107
 
85
108
  var brief = [];
86
109
  life.forEach(function (L, key) {
87
- var d = L.last - L.first + dt;
110
+ /* 可见时长 = 被采到的点数 × 步长。不能再用 last - first + dt(首末跨度):
111
+ * 同一个 key 如果在两段互不相连的时间各闪 0.1 s(比如 t=0 与 t=5.9 各一次),
112
+ * 跨度会把 0.2 s 的闪烁算成 6.1 s 的可见时长而漏报。连续出现时两种算法等价
113
+ * (点数 = 跨度/dt + 1),所以换成 n*dt 只影响"分散出现"这一类,判据更严格也
114
+ * 更符合文档写的"可见时长"。 */
115
+ var d = L.n * dt;
88
116
  if (d < 0.6) brief.push({ at: r2(L.first), dur: r2(d), el: (L.group || key) + '「' + short(L.text) + '」' });
89
117
  });
90
118