@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.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 +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
/* film.js — 场景模板(v0.
|
|
1
|
+
/* film.js — 场景模板(v0.8.0)
|
|
2
2
|
*
|
|
3
|
-
*
|
|
3
|
+
* 3 个场景 / 默认 12 秒(global.DURATION 由场景表推出,见下方 resolveDuration:
|
|
4
|
+
* 加一幕、改一幕时长都不用手工同步常量;要锁死就写 window.WINDOW_DURATION)。
|
|
5
|
+
* 这是**独立的 12 秒演示片**,不是"随便配一份 film.json 就能出全片"的成品页面:
|
|
6
|
+
* 换剧本必须自己重写场景,让画面覆盖到片尾,再按需写 WINDOW_DURATION。
|
|
7
|
+
*
|
|
8
|
+
* 示范 5 件事:
|
|
4
9
|
* ① 逐字入场(0–4s) ② 数字滚动(4–8s) ③ 粒子 / 发光(8–12s)
|
|
5
10
|
* ④ HUD 标注(贯穿全片) ⑤ cue / event 登记(由 window.getCues()/getEvents() 导出)
|
|
6
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
|
+
*
|
|
7
25
|
* 纪律:每帧都是「帧号 → 画面」的纯函数——不读上一帧状态、不用 Math.random / Date.now。
|
|
8
26
|
* 画面里的所有文字都走 FK.g.text(),qaText 才能看到它们的包围盒。
|
|
9
27
|
* 改编自 ai-history-film(MIT)video/web2 的场景组织方式(逐字入场 / 计数器 / HUD 的写法)。
|
|
@@ -15,127 +33,231 @@
|
|
|
15
33
|
var W = F.W, H = F.H, g = F.g, E = F.E;
|
|
16
34
|
var seg = F.seg, lerp = F.lerp, clamp = F.clamp;
|
|
17
35
|
|
|
18
|
-
|
|
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
|
+
}
|
|
19
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 常量(包括字符串写法,便于逐字节对比)。 */
|
|
20
83
|
var C = {
|
|
21
|
-
ink: '#eaf2ff',
|
|
22
|
-
dim: '#8ea4c0',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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'
|
|
26
96
|
};
|
|
27
97
|
|
|
28
|
-
|
|
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
|
+
|
|
131
|
+
/* 成片秒数(webengine.py / render.py 会读它做分块与校验)。
|
|
132
|
+
* 默认由场景表推出 = 最后一个场景的 end(加/删/改场景后自动跟着变,不会忘记同步);
|
|
133
|
+
* 要显式锁死(例如页面里还有场景以外的动画),在 index.html 里先写
|
|
134
|
+
* <script>window.WINDOW_DURATION = 12.0;</script>
|
|
135
|
+
* 或在引入 film.js 之前赋值即可覆盖。使用说明见 references/web-engine.md §3。 */
|
|
136
|
+
function resolveDuration(F) {
|
|
137
|
+
var explicit = Number(global.WINDOW_DURATION);
|
|
138
|
+
if (isFinite(explicit) && explicit > 0) return explicit;
|
|
139
|
+
// 也认"在 film.js 之前直接写 window.DURATION = 秒数"的写法:render.py 的报错就是
|
|
140
|
+
// 教用户改 window.DURATION(页面里的时长必须等于 timeline.total)。
|
|
141
|
+
// engine.js 自己写的兜底值不算显式,否则模板永远锁死在 12 秒,加场景也不生效。
|
|
142
|
+
var pre = Number(global.DURATION);
|
|
143
|
+
if (isFinite(pre) && pre > 0 && pre !== Number(F.defaultDuration)) return pre;
|
|
144
|
+
return F.timelineEnd();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* qaText 的「从没露面」检查要先声明 key:必须与 g.text({key}) 对齐。
|
|
148
|
+
* hud='none' 的装帧不画品牌/章节文字,就别声明它们(否则 never 会误报)。 */
|
|
29
149
|
F.watch('open.title', '标题:逐帧确定性');
|
|
30
150
|
F.watch('open.sub', '副标题:Canvas 2D 极简时间轴');
|
|
31
151
|
F.watch('data.num', '数字滚动:0 → 1920');
|
|
32
152
|
F.watch('data.cap', '说明:同一帧号 → 同一张图');
|
|
33
153
|
F.watch('fx.title', '标题:确定性渲染');
|
|
34
154
|
F.watch('fx.hud', 'HUD:粒子数 / 帧率标注');
|
|
35
|
-
F.watch('hud.ch', 'HUD:章节名');
|
|
155
|
+
if (HUD !== 'none') F.watch('hud.ch', 'HUD:章节名');
|
|
36
156
|
F.watch('hud.tc', 'HUD:时间码');
|
|
37
|
-
F.watch('hud.brand', 'HUD:品牌名');
|
|
157
|
+
if (HUD !== 'none') F.watch('hud.brand', 'HUD:品牌名');
|
|
38
158
|
|
|
39
159
|
/* ================================================================ 背景层(最先注册 → 最先画) */
|
|
40
160
|
F.always(function (t) {
|
|
41
|
-
g.bgGradient(
|
|
42
|
-
g.grid({ t: t, step: 120, alpha:
|
|
43
|
-
g.vignette(
|
|
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);
|
|
44
164
|
});
|
|
45
165
|
|
|
46
166
|
/* ================================================================ 场景 1:逐字入场(0–4s) */
|
|
47
|
-
F.scene('open', 0, 4.0, function (
|
|
167
|
+
F.scene('open', 0, 4.0, function (S1) {
|
|
48
168
|
// ⚠ 命名约定(见 references/frame-qa.md §2.5):
|
|
49
169
|
// qa_frames 的音画同步**先按 kind == cue 的配方名配对**。
|
|
50
170
|
// 所以每个"该响一下"的画面事件,kind 要写成**同一个配方名**(ui_blip / whoosh / impact …);
|
|
51
171
|
// 只有纯装饰性的视觉事件(交界黑场)才用 flash 这类通用名。
|
|
52
172
|
// 底下每条 S.cue 都配了一条同名 S.event —— 这样同步检查才真的在查,而不是静默全过。
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 交界黑场起坡(纯视觉,无对应音效)
|
|
60
180
|
|
|
61
181
|
var TITLE = '逐帧确定性';
|
|
62
|
-
var SIZE = 150;
|
|
63
|
-
var TRACK = 8;
|
|
182
|
+
var SIZE = S(150);
|
|
183
|
+
var TRACK = S(8);
|
|
64
184
|
|
|
65
|
-
|
|
66
|
-
// ① 逐字入场:每个字从下方
|
|
185
|
+
S1.update(function (t) {
|
|
186
|
+
// ① 逐字入场:每个字从下方 RISE 升起 + 淡入,错峰 0.075s(RISE 跟随 style 的 motion.entrance_dy)
|
|
67
187
|
var lay = g.layoutChars(TITLE, SIZE, '700', null, TRACK);
|
|
68
|
-
var x = (
|
|
69
|
-
var y = 470;
|
|
188
|
+
var x = anchorX(lay.width);
|
|
189
|
+
var y = Y(470);
|
|
70
190
|
for (var i = 0; i < lay.chars.length; i++) {
|
|
71
191
|
var p = E.out5(seg(t, 0.16 + i * 0.075, 0.72 + i * 0.075));
|
|
72
192
|
var a = seg(t, 0.16 + i * 0.075, 0.44 + i * 0.075);
|
|
73
|
-
g.text(lay.chars[i].ch, x + lay.chars[i].x, y + (1 - p) *
|
|
193
|
+
g.text(lay.chars[i].ch, x + lay.chars[i].x, y + (1 - p) * RISE, {
|
|
74
194
|
size: SIZE, weight: '700', color: C.ink, alpha: a,
|
|
75
195
|
key: 'open.title.' + i, group: 'open.title'
|
|
76
196
|
});
|
|
77
197
|
}
|
|
78
|
-
//
|
|
79
|
-
var lw = E.out3(seg(t, 1.15, 1.9)) * 360;
|
|
80
|
-
g.rect(
|
|
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));
|
|
81
201
|
// 副标题淡入 + 上浮
|
|
82
202
|
var a2 = E.out3(seg(t, 1.35, 2.05));
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
85
206
|
key: 'open.sub', group: 'open.sub'
|
|
86
207
|
});
|
|
87
208
|
// 两侧装饰竖线("长出来")
|
|
88
209
|
var dw = E.out3(seg(t, 1.6, 2.2));
|
|
89
210
|
if (dw > 0.001) {
|
|
90
|
-
g.rect(196, 430 + (1 - dw) * 60, 2, 120 * dw,
|
|
91
|
-
g.rect(W - 198, 430 + (1 - dw) * 60, 2, 120 * dw,
|
|
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));
|
|
92
213
|
}
|
|
93
214
|
});
|
|
94
215
|
});
|
|
95
216
|
|
|
96
217
|
/* ================================================================ 场景 2:数字滚动(4–8s) */
|
|
97
|
-
F.scene('data', 4.0, 4.0, function (
|
|
218
|
+
F.scene('data', 4.0, 4.0, function (S2) {
|
|
98
219
|
// 同上:cue 与 event 成对出现、同名,同步检查才有东西可配
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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) {
|
|
107
228
|
// ② 数字滚动:0 → 1920,走 out5 缓动(声音 chirp 与它同一条时间线)
|
|
108
229
|
var p = E.out5(seg(t, 0.25, 1.85));
|
|
109
230
|
var num = Math.round(lerp(0, 1920, p));
|
|
110
231
|
var s = String(num);
|
|
111
232
|
while (s.length < 4) s = '0' + s;
|
|
112
|
-
|
|
113
|
-
|
|
233
|
+
var pn = place();
|
|
234
|
+
g.text(s, pn.x, Y(470), {
|
|
235
|
+
size: S(236), weight: '700', align: pn.align, color: C.bright,
|
|
114
236
|
key: 'data.num', group: 'data.num'
|
|
115
237
|
});
|
|
116
|
-
g.text('FRAME-STABLE CUTS',
|
|
117
|
-
size: 26, weight: '500', align:
|
|
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),
|
|
118
240
|
alpha: E.out3(seg(t, 0.9, 1.4)), key: 'data.kicker', group: 'data.kicker'
|
|
119
241
|
});
|
|
120
242
|
// 说明文字
|
|
121
243
|
var a = E.out3(seg(t, 1.05, 1.75));
|
|
122
|
-
g.text('同一帧号 → 同一张图',
|
|
123
|
-
size: 44, weight: '400', align:
|
|
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,
|
|
124
246
|
key: 'data.cap', group: 'data.cap'
|
|
125
247
|
});
|
|
126
248
|
// 进度条(数字滚动的可视化)
|
|
127
|
-
var bw = 720, bx = (
|
|
128
|
-
g.rect(bx, by, bw, 3,
|
|
129
|
-
g.rect(bx, by, bw * p, 3,
|
|
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));
|
|
130
252
|
// ④ HUD 标注:右上角小徽标
|
|
131
253
|
var ha = E.out3(seg(t, 0.5, 1.0));
|
|
132
254
|
if (ha > 0.001) {
|
|
133
|
-
g.text('◆ DATA LAYER', W - 64, 206, {
|
|
134
|
-
size: 22, weight: '600', align: 'right', color:
|
|
255
|
+
g.text('◆ DATA LAYER', W - X(64), Y(206), {
|
|
256
|
+
size: S(22), weight: '600', align: 'right', color: A(C.accent, 0.9),
|
|
135
257
|
alpha: ha, key: 'data.hud', group: 'data.hud'
|
|
136
258
|
});
|
|
137
|
-
g.text('counter → 1920', W - 64, 238, {
|
|
138
|
-
size: 18, weight: '400', align: 'right', color:
|
|
259
|
+
g.text('counter → 1920', W - X(64), Y(238), {
|
|
260
|
+
size: S(18), weight: '400', align: 'right', color: HA(0.45),
|
|
139
261
|
alpha: ha, key: 'data.hud2', group: 'data.hud'
|
|
140
262
|
});
|
|
141
263
|
}
|
|
@@ -143,79 +265,105 @@
|
|
|
143
265
|
});
|
|
144
266
|
|
|
145
267
|
/* ================================================================ 场景 3:粒子 / 发光(8–12s) */
|
|
146
|
-
F.scene('fx', 8.0, 4.0, function (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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) {
|
|
157
279
|
// ③ 粒子 / 发光:位置全部由 hash(seed,i,*) 决定,同一 t 永远同一张图
|
|
158
|
-
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) });
|
|
159
281
|
// 冲击环:impact 时刻从中心扩散
|
|
160
282
|
var rp = E.out5(seg(t, 0.62, 2.3));
|
|
161
283
|
if (rp > 0.001 && rp < 0.999) {
|
|
162
|
-
g.ring(W / 2, 520, lerp(40, 640, rp),
|
|
284
|
+
g.ring(W / 2, Y(520), lerp(S(40), S(640), rp), A(C.warm, (1 - rp) * 0.55), S(3));
|
|
163
285
|
}
|
|
164
286
|
// 中心发光:正弦呼吸,相位只由绝对时间 T 决定
|
|
165
287
|
var T = 8.0 + t;
|
|
166
|
-
g.glow(W / 2, 520, 250, C.accent, 0.18 + 0.07 * Math.sin(T * 2.0));
|
|
167
|
-
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));
|
|
168
290
|
// 标题逐字入场(复用同一套缓动)
|
|
169
|
-
var lay = g.layoutChars('确定性渲染', 132, '700', null, 10);
|
|
170
|
-
var x = (
|
|
171
|
-
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);
|
|
172
294
|
for (var i = 0; i < lay.chars.length; i++) {
|
|
173
295
|
var p = E.out5(seg(t, 1.05 + i * 0.07, 1.55 + i * 0.07));
|
|
174
|
-
g.text(lay.chars[i].ch, x + lay.chars[i].x, y + (1 - p) * 60, {
|
|
175
|
-
size: 132, weight: '700', color:
|
|
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,
|
|
176
298
|
key: 'fx.title.' + i, group: 'fx.title'
|
|
177
299
|
});
|
|
178
300
|
}
|
|
179
301
|
// HUD 标注
|
|
180
|
-
|
|
181
|
-
|
|
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),
|
|
182
305
|
alpha: E.out3(seg(t, 1.3, 1.9)), key: 'fx.hud', group: 'fx.hud'
|
|
183
306
|
});
|
|
184
307
|
// 白闪:impact 事件的可视化(event 已登记同一条时间;只从 impact 时刻开始衰减)
|
|
185
308
|
var fp = seg(t, 0.62, 0.92);
|
|
186
|
-
if (t >= 0.62 && fp < 0.999) g.flash(0.55 * (1 - fp),
|
|
187
|
-
//
|
|
309
|
+
if (t >= 0.62 && fp < 0.999) g.flash(0.55 * (1 - fp), C.flash);
|
|
310
|
+
// 片尾淡出到黑(wash 主题淡到自己的底色)
|
|
188
311
|
var fo = E.in2(seg(t, 3.35, 4.0));
|
|
189
|
-
if (fo > 0.001) g.flash(fo,
|
|
312
|
+
if (fo > 0.001) g.flash(fo, C.wipe);
|
|
190
313
|
});
|
|
191
314
|
});
|
|
192
315
|
|
|
316
|
+
/* ================================================================ 时间轴总长(DURATION) */
|
|
317
|
+
/* 必须在所有 F.scene(...) 注册完之后求值:resolveDuration 读的就是场景表。
|
|
318
|
+
* 放在 HUD 之前、注册之后,这样 HUD 里读 global.DURATION 时它已经是正确值(boot 在最后)。 */
|
|
319
|
+
global.DURATION = resolveDuration(F);
|
|
320
|
+
|
|
193
321
|
/* ================================================================ HUD 层(最后注册 → 最后画,压在所有场景之上) */
|
|
194
322
|
F.always(function (t) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
323
|
+
var dur = Number(global.DURATION) || 12.0;
|
|
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
|
+
}
|
|
202
348
|
// 时间码每帧都在变 → 也保证「不同帧渲染结果不同」
|
|
203
|
-
var tc = 'T ' + (t < 10 ? '0' : '') + t.toFixed(2) + ' /
|
|
204
|
-
g.text(tc, W - 64, 60, {
|
|
205
|
-
size: 22, weight: '500', align: 'right', color:
|
|
349
|
+
var tc = 'T ' + (t < 10 ? '0' : '') + t.toFixed(2) + ' / ' + dur.toFixed(2);
|
|
350
|
+
g.text(tc, W - X(64), Y(60), {
|
|
351
|
+
size: S(22), weight: '500', align: 'right', color: HA(0.45),
|
|
206
352
|
key: 'hud.tc', group: 'hud.tc'
|
|
207
353
|
});
|
|
208
|
-
//
|
|
209
|
-
g.rect(64, 122, W - 128, 1,
|
|
210
|
-
g.rect(64, 122, (W - 128) * clamp(t /
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
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'
|
|
214
362
|
});
|
|
215
|
-
// 场景交界黑场「呼吸」(4s / 8s
|
|
363
|
+
// 场景交界黑场「呼吸」(4s / 8s),避免硬切(wash 主题淡到自己的底色)
|
|
216
364
|
var d = Math.min(Math.abs(t - 4.0), Math.abs(t - 8.0));
|
|
217
365
|
var dip = clamp(1 - d / 0.3);
|
|
218
|
-
if (dip > 0.001) g.flash(0.5 * E.out2(dip),
|
|
366
|
+
if (dip > 0.001) g.flash(0.5 * E.out2(dip), C.wipe);
|
|
219
367
|
// ↑ 这条在 dip 的"起坡帧"会有一个可见的亮度跳变(clamp 从 0 开始斜升)。
|
|
220
368
|
// 对观众来说它是转场的一部分,但对逐帧质检它长得像单帧闪烁:
|
|
221
369
|
// 所以把起坡时刻登记成 flash 事件,qa_frames 会把它从 flicker 里排除(见 FLICKER_EXPLAIN_WIN)。
|
|
@@ -3,15 +3,22 @@
|
|
|
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.
|
|
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
|
|
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>
|
|
17
|
+
<!-- 页面契约(references/web-engine.md §3):DURATION 必须等于成片秒数。
|
|
18
|
+
模板默认由场景表自动推导(film.js 的 resolveDuration);
|
|
19
|
+
要锁死就取消下面这行注释并改成 timeline.json 的 total:
|
|
20
|
+
<script>window.WINDOW_DURATION = 12.0;</script>
|
|
21
|
+
-->
|
|
15
22
|
<script src="film.js"></script>
|
|
16
23
|
<script src="qa_text.js"></script>
|
|
17
24
|
<script>
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
/* qa_text.js — 浏览器端文字质检(v0.
|
|
1
|
+
/* qa_text.js — 浏览器端文字质检(v0.8.0)
|
|
2
2
|
*
|
|
3
3
|
* 逐帧重渲染画布,读回 engine.js 登记的文字包围盒,找出四类问题:
|
|
4
4
|
* ① overlaps 文字互相压住(同一 group 内的逐字排版不算)
|
|
5
|
-
* ② offscreen
|
|
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 =
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|