@gitruck/cli 1.3.7 → 1.3.8

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.
@@ -1,246 +1,248 @@
1
- /* 合成颗粒工具箱 composite-kit@1(html@1 引擎)——`gtrk composite build` 构建时内联进每颗颗粒,颗粒自包含、不外链。
2
- * 随 @gitruck/cli 分发(assets/composite-kit/);改行为即升版本号(颗粒 <meta name="generator"> 与 CP.VERSION 记着它)。
3
- *
4
- * 场景脚本只写一件事:`CP.render = (t) => { ... }`(t = 局部秒)。其余由这里负责:
5
- * - 宿主协议:hello → meta;init → 帧率 / 锚点 / 图层 / 字幕;apply(frame) → 画这一帧并回报槽位几何;lint → 排版自检;
6
- * - 分层:舞台缺省属 back,`data-layer="front"` 的子树属 front(visibility 继承);
7
- * - 槽位几何:`[data-slot]` 的包围盒 + data-z / data-fit / data-shape / data-radius / data-filter / data-crop(上右下左);
8
- * - 装饰跟随:`data-for="<槽位>"` 的元素与槽位同框(data-pad 外扩),用于边框、阴影、角标;
9
- * - 字幕:接管字幕后由颗粒自己排(`CP.caption`),文字来自 init.texts;
10
- * - 逐句换画面与版式切换:`at` / `mix` / `seq` / `place`;
11
- * - 排版自检 `lint`:下层画面的 front 描边压上层画面、front 线条穿过画面、画面越出舞台。
12
- * 纯函数:同一帧号永远画出同一画面;不用 Date / Math.random / requestAnimationFrame / setInterval。
13
- */
14
- (() => {
15
- "use strict";
16
- const META = JSON.parse(document.querySelector("script[data-gtrk-composite]").textContent);
17
- const W = META.stage.width, H = META.stage.height;
18
- const stage = document.getElementById("stage");
19
- let FPS = (META.resolved && META.resolved.video_rate) || 60;
20
- let ANCHORS = (META.resolved && META.resolved.anchors_f) || {};
21
- let LAYER = "all";
22
- let TEXTS = {};
23
-
24
- const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
25
- const lerp = (a, b, p) => a + (b - a) * p;
26
- const ease = {
27
- out: (x) => 1 - Math.pow(1 - x, 3),
28
- inOut: (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
29
- back: (x) => { const c = 1.4; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); },
30
- lin: (x) => x,
31
- };
32
- const prog = (t, t0, dur, e = ease.out) => e(clamp((t - t0) / Math.max(dur, 1e-6)));
33
- /** 窗口内出现:t0 前 0,淡入 fi 秒到 1,t1 起淡出 fo 秒到 0。 */
34
- const span = (t, t0, t1, fi = 0.5, fo = 0.4) => Math.min(prog(t, t0, fi), 1 - prog(t, t1, fo, ease.inOut));
35
- const $ = (id) => document.getElementById(id);
36
- const A = (key) => {
37
- if (!(key in ANCHORS)) throw new Error("锚点「" + key + "」未解析");
38
- return ANCHORS[key] / FPS;
39
- };
40
-
41
- function box(el, x, y, w, h) {
42
- if (typeof el === "string") el = $(el);
43
- el.style.left = x + "px"; el.style.top = y + "px"; el.style.width = w + "px"; el.style.height = h + "px";
44
- return el;
45
- }
46
- /** 透明度 + 位移 / 缩放(不旋转——槽位与其装饰不得旋转)。 */
47
- function look(el, o = 1, dx = 0, dy = 0, s = 1) {
48
- if (typeof el === "string") el = $(el);
49
- el.style.opacity = String(clamp(o));
50
- el.style.transform = dx || dy || s !== 1 ? "translate(" + dx + "px," + dy + "px) scale(" + s + ")" : "";
51
- return el;
52
- }
53
- /** 槽位与跟随它的装饰一起定框;o 同时作用于两者。 */
54
- function slot(id, x, y, w, h, o = 1) {
55
- const el = document.querySelector('[data-slot="' + id + '"]');
56
- box(el, x, y, w, h); el.style.opacity = String(clamp(o));
57
- for (const d of document.querySelectorAll('[data-for="' + id + '"]')) {
58
- const p = Number(d.dataset.pad || 0);
59
- box(d, x - p, y - p, w + 2 * p, h + 2 * p); d.style.opacity = String(clamp(o));
60
- }
61
- }
62
- /** 槽位按矩形 [x, y, w, h] 定框(slot 的数组写法)。 */
63
- const at = (id, r, o = 1) => slot(id, r[0], r[1], r[2], r[3], o);
64
- /** 两个矩形之间插值(版式切换:单画 → 并排)。 */
65
- const mix = (a, b, p) => a.map((v, i) => lerp(v, b[i], p));
66
- /**
67
- * 逐句换画面的透明度:在 a 前 fade 秒淡入、a 时刻已全显;到 b(下一张已全显)之后直接关掉。
68
- * 同一位置叠放的几张画面按 data-z 递增排,下一张盖住上一张再关上一张——任一时刻最多两张在解码。
69
- */
70
- const seq = (t, a, b = null, fade = 0.3) => prog(t, a - fade, fade) * (b == null || t < b + 0.02 ? 1 : 0);
71
- /** 把元素放到 (x, y) 并给透明度(标签、角标跟随画面用)。 */
72
- function place(el, x, y, o = 1) {
73
- if (typeof el === "string") el = $(el);
74
- el.style.left = x + "px"; el.style.top = y + "px"; el.style.opacity = String(clamp(o));
75
- return el;
76
- }
77
- /** 逐字显现(p ∈ [0,1]):未显现的字留占位但透明,排版不跳。 */
78
- function typeOn(el, text, p) {
79
- if (typeof el === "string") el = $(el);
80
- const n = Math.round(clamp(p) * text.length);
81
- const key = text + "|" + n;
82
- if (el.dataset.typed === key) return;
83
- el.dataset.typed = key;
84
- el.innerHTML = "";
85
- const a = document.createElement("span"); a.textContent = text.slice(0, n);
86
- const b = document.createElement("span"); b.textContent = text.slice(n); b.style.opacity = "0";
87
- el.append(a, b);
88
- }
89
- /** 字幕:取文本输入 slot 在第 f 帧的那一条;highlight 里的词加强调色。 */
90
- function cueAt(slotId, t) {
91
- const f = Math.floor(t * FPS + 1e-6);
92
- const list = TEXTS[slotId] || [];
93
- for (const c of list) if (f >= c.f0 && f < c.f1) return c;
94
- return null;
95
- }
96
- /** 显现:淡入 + 上浮(p ∈ [0,1])。 */
97
- const reveal = (el, p, dy = 26) => look(el, p, 0, dy * (1 - p));
98
- /** 从左往右擦出。 */
99
- function wipe(el, p) {
100
- if (typeof el === "string") el = $(el);
101
- el.style.clipPath = "inset(-20% " + (100 - clamp(p) * 100).toFixed(2) + "% -20% 0)";
102
- el.style.opacity = p > 0 ? "1" : "0";
103
- }
104
- /** SVG 路径描边动画。 */
105
- function draw(el, p) {
106
- if (typeof el === "string") el = $(el);
107
- const L = el.getTotalLength();
108
- el.style.strokeDasharray = String(L);
109
- el.style.strokeDashoffset = String(L * (1 - clamp(p)));
110
- }
111
- /** 字幕;hide = [[t0, t1], …] 时段内不显示(屏上排版已经写着同一句话时用)。 */
112
- function caption(el, slotId, t, highlight = [], hide = []) {
113
- if (typeof el === "string") el = $(el);
114
- if (hide.some(([a, b]) => t >= a && t < b)) { el.style.opacity = "0"; return null; }
115
- const c = cueAt(slotId, t);
116
- const text = c ? c.text : "";
117
- const key = text + "|" + highlight.join(",");
118
- if (el.dataset.cue !== key) {
119
- el.dataset.cue = key;
120
- el.innerHTML = "";
121
- let rest = text;
122
- while (rest) {
123
- let hit = null, at = -1;
124
- for (const w of highlight) {
125
- const i = rest.indexOf(w);
126
- if (i >= 0 && (at < 0 || i < at)) { at = i; hit = w; }
127
- }
128
- if (!hit) { el.append(document.createTextNode(rest)); break; }
129
- if (at > 0) el.append(document.createTextNode(rest.slice(0, at)));
130
- const b = document.createElement("b"); b.textContent = hit; el.append(b);
131
- rest = rest.slice(at + hit.length);
132
- }
133
- }
134
- const age = c ? t - c.f0 / FPS : 0;
135
- el.style.opacity = c ? String(prog(age, 0, 0.12)) : "0";
136
- return c;
137
- }
138
-
139
- function applyLayer() {
140
- stage.style.visibility = LAYER === "front" ? "hidden" : "visible";
141
- for (const el of stage.querySelectorAll("[data-layer]")) {
142
- el.style.visibility = LAYER === "all" || el.dataset.layer === LAYER ? "visible" : "hidden";
143
- }
144
- }
145
-
146
- function measure() {
147
- const s = stage.getBoundingClientRect(), k = s.width / W;
148
- const out = [];
149
- for (const el of stage.querySelectorAll("[data-slot]")) {
150
- const r = el.getBoundingClientRect();
151
- const opacity = parseFloat(getComputedStyle(el).opacity);
152
- const crop = (el.dataset.crop || "0,0,0,0").split(",").map(Number);
153
- out.push({
154
- id: el.dataset.slot, z: Number(el.dataset.z || 0),
155
- x: (r.left - s.left) / k, y: (r.top - s.top) / k, w: r.width / k, h: r.height / k,
156
- opacity, visible: opacity > 0.001 && r.width > 0.5 && r.height > 0.5,
157
- shape: el.dataset.shape || "rect", radius: Number(el.dataset.radius || 0),
158
- fit: el.dataset.fit || "cover", filter: el.dataset.filter || "",
159
- crop: crop.length === 4 && crop.every((c) => c >= 0 && c < 1) ? crop : [0, 0, 0, 0],
160
- });
161
- }
162
- return out.sort((a, b) => a.z - b.z);
163
- }
164
-
165
- /**
166
- * 排版自检(只给出片台 / 构建工具用,宿主协议不依赖它)。查三类:
167
- * 1. 两张可见画面重叠,而下面那张有 front 层描边 —— 宿主先画全部画面、再画 front 镜像,
168
- * 下面那张的边框会压在上面那张画面上(同框交替淡入时两框重合,不算);
169
- * 2. front 层的线条(path / line / polyline,按已画出的长度取点)穿过某张可见画面;
170
- * 3. 画面越出舞台(铺满舞台的底图除外)。
171
- */
172
- function lint() {
173
- const s = stage.getBoundingClientRect(), k = s.width / W;
174
- const op = (el) => { let o = 1; for (let e = el; e && e !== stage; e = e.parentElement) o *= parseFloat(getComputedStyle(e).opacity); return o; };
175
- // 铺满舞台的画面是底图,压在它上面的任何东西都是有意叠加,不算穿模
176
- const slots = measure().filter((g) => g.visible && !(g.x <= 1 && g.y <= 1 && g.x + g.w >= W - 1 && g.y + g.h >= H - 1));
177
- const same = (a, b) => ["x", "y", "w", "h"].every((q) => Math.abs(a[q] - b[q]) < 2);
178
- const meet = (a, b) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 2 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 2;
179
- const deco = (id) => [...stage.querySelectorAll('[data-for="' + id + '"][data-layer="front"]')].some((d) => op(d) > 0.01);
180
- const issues = [];
181
- for (const g of measure().filter((g) => g.visible)) {
182
- if (g.x < -1 || g.y < -1 || g.x + g.w > W + 1 || g.y + g.h > H + 1) {
183
- const full = g.x <= 1 && g.y <= 1 && g.x + g.w >= W - 1 && g.y + g.h >= H - 1;
184
- if (!full) issues.push({ kind: "slot-out-of-stage", slot: g.id });
185
- }
186
- }
187
- for (let i = 0; i < slots.length; i++) {
188
- for (let j = i + 1; j < slots.length; j++) {
189
- const a = slots[i], b = slots[j];
190
- if (!meet(a, b) || same(a, b)) continue;
191
- if (deco(a.id)) issues.push({ kind: "border-over-slot", lower: a.id, upper: b.id });
192
- else if (a.z === b.z && deco(b.id)) issues.push({ kind: "same-z-overlap", a: a.id, b: b.id });
193
- }
194
- }
195
- const inside = (g, x, y) => {
196
- if (g.shape === "circle") { const dx = (x - g.x - g.w / 2) / (g.w / 2), dy = (y - g.y - g.h / 2) / (g.h / 2); return dx * dx + dy * dy < 0.96; }
197
- return x > g.x + 3 && x < g.x + g.w - 3 && y > g.y + 3 && y < g.y + g.h - 3;
198
- };
199
- for (const p of stage.querySelectorAll('[data-layer="front"] path, [data-layer="front"] line, [data-layer="front"] polyline')) {
200
- if (op(p) < 0.01 || !p.getTotalLength) continue;
201
- const L = p.getTotalLength();
202
- const cs = getComputedStyle(p);
203
- const drawn = cs.strokeDasharray && cs.strokeDasharray !== "none" ? L - (parseFloat(cs.strokeDashoffset) || 0) : L;
204
- const m = p.getScreenCTM();
205
- const hit = new Set();
206
- for (let d = 0; d <= drawn; d += 6) {
207
- const q = new DOMPoint(p.getPointAtLength(d).x, p.getPointAtLength(d).y).matrixTransform(m);
208
- const x = (q.x - s.left) / k, y = (q.y - s.top) / k;
209
- for (const g of slots) if (!hit.has(g.id) && inside(g, x, y)) hit.add(g.id);
210
- }
211
- for (const id of hit) issues.push({ kind: "stroke-through-slot", stroke: p.id || p.tagName, slot: id });
212
- }
213
- return issues;
214
- }
215
-
216
- function fitStage() { stage.style.transform = "scale(" + innerWidth / W + ")"; }
217
- addEventListener("resize", fitStage); fitStage();
218
-
219
- const CP = (window.CP = {
220
- VERSION: "composite-kit@1",
221
- META, W, H, $, A, has: (k) => k in ANCHORS, clamp, lerp, ease, prog, span, box, look, slot, at, mix, seq, place, typeOn, cueAt, caption,
222
- reveal, wipe, draw, cues: (slotId) => TEXTS[slotId] || [], fps: () => FPS,
223
- render: () => {},
224
- /** init 之后调用一次(字幕等输入已就位):按输入建一次性的 DOM。必须幂等。 */
225
- setup: () => {},
226
- });
227
-
228
- addEventListener("message", (ev) => {
229
- const m = ev.data;
230
- if (!m || m.ch !== "gtrk-composite") return;
231
- const reply = (x) => parent.postMessage(Object.assign({ ch: "gtrk-composite" }, x), "*");
232
- try {
233
- if (m.type === "hello") reply({ type: "meta", meta: META });
234
- else if (m.type === "init") {
235
- FPS = m.fps || FPS; ANCHORS = m.anchors || ANCHORS; LAYER = m.layer || "all"; TEXTS = m.texts || {};
236
- CP.setup(); applyLayer(); reply({ type: "ready", layer: LAYER });
237
- } else if (m.type === "apply") {
238
- CP.render(m.frame / FPS);
239
- reply({ type: "frame", seq: m.seq, frame: m.frame, slots: measure(), layer: LAYER });
240
- } else if (m.type === "lint") {
241
- // 回包类型不能也叫 lint:颗粒作为顶层页面跑(gtrk composite lint)时回包会回到自己的监听里,同名就会无限往返
242
- reply({ type: "lint-result", seq: m.seq, issues: lint() });
243
- }
244
- } catch (e) { reply({ type: "error", seq: m.seq, message: String((e && e.message) || e) }); }
245
- });
246
- })();
1
+ /* 合成颗粒工具箱 composite-kit@1(html@1 引擎)——`gtrk composite build` 构建时内联进每颗颗粒,颗粒自包含、不外链。
2
+ * 随 @gitruck/cli 分发(assets/composite-kit/);改行为即升版本号(颗粒 <meta name="generator"> 与 CP.VERSION 记着它)。
3
+ *
4
+ * 场景脚本只写一件事:`CP.render = (t) => { ... }`(t = 局部秒)。其余由这里负责:
5
+ * - 宿主协议:hello → meta;init → 帧率 / 锚点 / 图层 / 字幕;apply(frame) → 画这一帧并回报槽位几何;lint → 排版自检;
6
+ * - 分层:舞台缺省属 back,`data-layer="front"` 的子树属 front(visibility 继承);
7
+ * - 槽位几何:`[data-slot]` 的包围盒 + data-z / data-fit / data-shape / data-radius / data-filter / data-crop(上右下左);
8
+ * - 装饰跟随:`data-for="<槽位>"` 的元素与槽位同框(data-pad 外扩),用于边框、阴影、角标;
9
+ * - 字幕:接管字幕后由颗粒自己排(`CP.caption`),文字来自 init.texts;
10
+ * - 逐句换画面与版式切换:`at` / `mix` / `seq` / `place`;
11
+ * - 排版自检 `lint`:下层画面的 front 描边压上层画面、front 线条穿过画面、画面越出舞台。
12
+ * 纯函数:同一帧号永远画出同一画面;不用 Date / Math.random / requestAnimationFrame / setInterval。
13
+ */
14
+ (() => {
15
+ "use strict";
16
+ const META = JSON.parse(document.querySelector("script[data-gtrk-composite]").textContent);
17
+ const W = META.stage.width, H = META.stage.height;
18
+ const stage = document.getElementById("stage");
19
+ // 舞台随工程画布(竖屏 / 方形同口径):舞台根按元数据块定尺寸,满幅元素(.bed 等)按百分比随它
20
+ stage.style.width = W + "px"; stage.style.height = H + "px";
21
+ let FPS = (META.resolved && META.resolved.video_rate) || 60;
22
+ let ANCHORS = (META.resolved && META.resolved.anchors_f) || {};
23
+ let LAYER = "all";
24
+ let TEXTS = {};
25
+
26
+ const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
27
+ const lerp = (a, b, p) => a + (b - a) * p;
28
+ const ease = {
29
+ out: (x) => 1 - Math.pow(1 - x, 3),
30
+ inOut: (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
31
+ back: (x) => { const c = 1.4; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); },
32
+ lin: (x) => x,
33
+ };
34
+ const prog = (t, t0, dur, e = ease.out) => e(clamp((t - t0) / Math.max(dur, 1e-6)));
35
+ /** 窗口内出现:t0 前 0,淡入 fi 秒到 1,t1 起淡出 fo 秒到 0。 */
36
+ const span = (t, t0, t1, fi = 0.5, fo = 0.4) => Math.min(prog(t, t0, fi), 1 - prog(t, t1, fo, ease.inOut));
37
+ const $ = (id) => document.getElementById(id);
38
+ const A = (key) => {
39
+ if (!(key in ANCHORS)) throw new Error("锚点「" + key + "」未解析");
40
+ return ANCHORS[key] / FPS;
41
+ };
42
+
43
+ function box(el, x, y, w, h) {
44
+ if (typeof el === "string") el = $(el);
45
+ el.style.left = x + "px"; el.style.top = y + "px"; el.style.width = w + "px"; el.style.height = h + "px";
46
+ return el;
47
+ }
48
+ /** 透明度 + 位移 / 缩放(不旋转——槽位与其装饰不得旋转)。 */
49
+ function look(el, o = 1, dx = 0, dy = 0, s = 1) {
50
+ if (typeof el === "string") el = $(el);
51
+ el.style.opacity = String(clamp(o));
52
+ el.style.transform = dx || dy || s !== 1 ? "translate(" + dx + "px," + dy + "px) scale(" + s + ")" : "";
53
+ return el;
54
+ }
55
+ /** 槽位与跟随它的装饰一起定框;o 同时作用于两者。 */
56
+ function slot(id, x, y, w, h, o = 1) {
57
+ const el = document.querySelector('[data-slot="' + id + '"]');
58
+ box(el, x, y, w, h); el.style.opacity = String(clamp(o));
59
+ for (const d of document.querySelectorAll('[data-for="' + id + '"]')) {
60
+ const p = Number(d.dataset.pad || 0);
61
+ box(d, x - p, y - p, w + 2 * p, h + 2 * p); d.style.opacity = String(clamp(o));
62
+ }
63
+ }
64
+ /** 槽位按矩形 [x, y, w, h] 定框(slot 的数组写法)。 */
65
+ const at = (id, r, o = 1) => slot(id, r[0], r[1], r[2], r[3], o);
66
+ /** 两个矩形之间插值(版式切换:单画 → 并排)。 */
67
+ const mix = (a, b, p) => a.map((v, i) => lerp(v, b[i], p));
68
+ /**
69
+ * 逐句换画面的透明度:在 a 前 fade 秒淡入、a 时刻已全显;到 b(下一张已全显)之后直接关掉。
70
+ * 同一位置叠放的几张画面按 data-z 递增排,下一张盖住上一张再关上一张——任一时刻最多两张在解码。
71
+ */
72
+ const seq = (t, a, b = null, fade = 0.3) => prog(t, a - fade, fade) * (b == null || t < b + 0.02 ? 1 : 0);
73
+ /** 把元素放到 (x, y) 并给透明度(标签、角标跟随画面用)。 */
74
+ function place(el, x, y, o = 1) {
75
+ if (typeof el === "string") el = $(el);
76
+ el.style.left = x + "px"; el.style.top = y + "px"; el.style.opacity = String(clamp(o));
77
+ return el;
78
+ }
79
+ /** 逐字显现(p ∈ [0,1]):未显现的字留占位但透明,排版不跳。 */
80
+ function typeOn(el, text, p) {
81
+ if (typeof el === "string") el = $(el);
82
+ const n = Math.round(clamp(p) * text.length);
83
+ const key = text + "|" + n;
84
+ if (el.dataset.typed === key) return;
85
+ el.dataset.typed = key;
86
+ el.innerHTML = "";
87
+ const a = document.createElement("span"); a.textContent = text.slice(0, n);
88
+ const b = document.createElement("span"); b.textContent = text.slice(n); b.style.opacity = "0";
89
+ el.append(a, b);
90
+ }
91
+ /** 字幕:取文本输入 slot 在第 f 帧的那一条;highlight 里的词加强调色。 */
92
+ function cueAt(slotId, t) {
93
+ const f = Math.floor(t * FPS + 1e-6);
94
+ const list = TEXTS[slotId] || [];
95
+ for (const c of list) if (f >= c.f0 && f < c.f1) return c;
96
+ return null;
97
+ }
98
+ /** 显现:淡入 + 上浮(p ∈ [0,1])。 */
99
+ const reveal = (el, p, dy = 26) => look(el, p, 0, dy * (1 - p));
100
+ /** 从左往右擦出。 */
101
+ function wipe(el, p) {
102
+ if (typeof el === "string") el = $(el);
103
+ el.style.clipPath = "inset(-20% " + (100 - clamp(p) * 100).toFixed(2) + "% -20% 0)";
104
+ el.style.opacity = p > 0 ? "1" : "0";
105
+ }
106
+ /** SVG 路径描边动画。 */
107
+ function draw(el, p) {
108
+ if (typeof el === "string") el = $(el);
109
+ const L = el.getTotalLength();
110
+ el.style.strokeDasharray = String(L);
111
+ el.style.strokeDashoffset = String(L * (1 - clamp(p)));
112
+ }
113
+ /** 字幕;hide = [[t0, t1], …] 时段内不显示(屏上排版已经写着同一句话时用)。 */
114
+ function caption(el, slotId, t, highlight = [], hide = []) {
115
+ if (typeof el === "string") el = $(el);
116
+ if (hide.some(([a, b]) => t >= a && t < b)) { el.style.opacity = "0"; return null; }
117
+ const c = cueAt(slotId, t);
118
+ const text = c ? c.text : "";
119
+ const key = text + "|" + highlight.join(",");
120
+ if (el.dataset.cue !== key) {
121
+ el.dataset.cue = key;
122
+ el.innerHTML = "";
123
+ let rest = text;
124
+ while (rest) {
125
+ let hit = null, at = -1;
126
+ for (const w of highlight) {
127
+ const i = rest.indexOf(w);
128
+ if (i >= 0 && (at < 0 || i < at)) { at = i; hit = w; }
129
+ }
130
+ if (!hit) { el.append(document.createTextNode(rest)); break; }
131
+ if (at > 0) el.append(document.createTextNode(rest.slice(0, at)));
132
+ const b = document.createElement("b"); b.textContent = hit; el.append(b);
133
+ rest = rest.slice(at + hit.length);
134
+ }
135
+ }
136
+ const age = c ? t - c.f0 / FPS : 0;
137
+ el.style.opacity = c ? String(prog(age, 0, 0.12)) : "0";
138
+ return c;
139
+ }
140
+
141
+ function applyLayer() {
142
+ stage.style.visibility = LAYER === "front" ? "hidden" : "visible";
143
+ for (const el of stage.querySelectorAll("[data-layer]")) {
144
+ el.style.visibility = LAYER === "all" || el.dataset.layer === LAYER ? "visible" : "hidden";
145
+ }
146
+ }
147
+
148
+ function measure() {
149
+ const s = stage.getBoundingClientRect(), k = s.width / W;
150
+ const out = [];
151
+ for (const el of stage.querySelectorAll("[data-slot]")) {
152
+ const r = el.getBoundingClientRect();
153
+ const opacity = parseFloat(getComputedStyle(el).opacity);
154
+ const crop = (el.dataset.crop || "0,0,0,0").split(",").map(Number);
155
+ out.push({
156
+ id: el.dataset.slot, z: Number(el.dataset.z || 0),
157
+ x: (r.left - s.left) / k, y: (r.top - s.top) / k, w: r.width / k, h: r.height / k,
158
+ opacity, visible: opacity > 0.001 && r.width > 0.5 && r.height > 0.5,
159
+ shape: el.dataset.shape || "rect", radius: Number(el.dataset.radius || 0),
160
+ fit: el.dataset.fit || "cover", filter: el.dataset.filter || "",
161
+ crop: crop.length === 4 && crop.every((c) => c >= 0 && c < 1) ? crop : [0, 0, 0, 0],
162
+ });
163
+ }
164
+ return out.sort((a, b) => a.z - b.z);
165
+ }
166
+
167
+ /**
168
+ * 排版自检(只给出片台 / 构建工具用,宿主协议不依赖它)。查三类:
169
+ * 1. 两张可见画面重叠,而下面那张有 front 层描边 —— 宿主先画全部画面、再画 front 镜像,
170
+ * 下面那张的边框会压在上面那张画面上(同框交替淡入时两框重合,不算);
171
+ * 2. front 层的线条(path / line / polyline,按已画出的长度取点)穿过某张可见画面;
172
+ * 3. 画面越出舞台(铺满舞台的底图除外)。
173
+ */
174
+ function lint() {
175
+ const s = stage.getBoundingClientRect(), k = s.width / W;
176
+ const op = (el) => { let o = 1; for (let e = el; e && e !== stage; e = e.parentElement) o *= parseFloat(getComputedStyle(e).opacity); return o; };
177
+ // 铺满舞台的画面是底图,压在它上面的任何东西都是有意叠加,不算穿模
178
+ const slots = measure().filter((g) => g.visible && !(g.x <= 1 && g.y <= 1 && g.x + g.w >= W - 1 && g.y + g.h >= H - 1));
179
+ const same = (a, b) => ["x", "y", "w", "h"].every((q) => Math.abs(a[q] - b[q]) < 2);
180
+ const meet = (a, b) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 2 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 2;
181
+ const deco = (id) => [...stage.querySelectorAll('[data-for="' + id + '"][data-layer="front"]')].some((d) => op(d) > 0.01);
182
+ const issues = [];
183
+ for (const g of measure().filter((g) => g.visible)) {
184
+ if (g.x < -1 || g.y < -1 || g.x + g.w > W + 1 || g.y + g.h > H + 1) {
185
+ const full = g.x <= 1 && g.y <= 1 && g.x + g.w >= W - 1 && g.y + g.h >= H - 1;
186
+ if (!full) issues.push({ kind: "slot-out-of-stage", slot: g.id });
187
+ }
188
+ }
189
+ for (let i = 0; i < slots.length; i++) {
190
+ for (let j = i + 1; j < slots.length; j++) {
191
+ const a = slots[i], b = slots[j];
192
+ if (!meet(a, b) || same(a, b)) continue;
193
+ if (deco(a.id)) issues.push({ kind: "border-over-slot", lower: a.id, upper: b.id });
194
+ else if (a.z === b.z && deco(b.id)) issues.push({ kind: "same-z-overlap", a: a.id, b: b.id });
195
+ }
196
+ }
197
+ const inside = (g, x, y) => {
198
+ if (g.shape === "circle") { const dx = (x - g.x - g.w / 2) / (g.w / 2), dy = (y - g.y - g.h / 2) / (g.h / 2); return dx * dx + dy * dy < 0.96; }
199
+ return x > g.x + 3 && x < g.x + g.w - 3 && y > g.y + 3 && y < g.y + g.h - 3;
200
+ };
201
+ for (const p of stage.querySelectorAll('[data-layer="front"] path, [data-layer="front"] line, [data-layer="front"] polyline')) {
202
+ if (op(p) < 0.01 || !p.getTotalLength) continue;
203
+ const L = p.getTotalLength();
204
+ const cs = getComputedStyle(p);
205
+ const drawn = cs.strokeDasharray && cs.strokeDasharray !== "none" ? L - (parseFloat(cs.strokeDashoffset) || 0) : L;
206
+ const m = p.getScreenCTM();
207
+ const hit = new Set();
208
+ for (let d = 0; d <= drawn; d += 6) {
209
+ const q = new DOMPoint(p.getPointAtLength(d).x, p.getPointAtLength(d).y).matrixTransform(m);
210
+ const x = (q.x - s.left) / k, y = (q.y - s.top) / k;
211
+ for (const g of slots) if (!hit.has(g.id) && inside(g, x, y)) hit.add(g.id);
212
+ }
213
+ for (const id of hit) issues.push({ kind: "stroke-through-slot", stroke: p.id || p.tagName, slot: id });
214
+ }
215
+ return issues;
216
+ }
217
+
218
+ function fitStage() { stage.style.transform = "scale(" + innerWidth / W + ")"; }
219
+ addEventListener("resize", fitStage); fitStage();
220
+
221
+ const CP = (window.CP = {
222
+ VERSION: "composite-kit@1",
223
+ META, W, H, $, A, has: (k) => k in ANCHORS, clamp, lerp, ease, prog, span, box, look, slot, at, mix, seq, place, typeOn, cueAt, caption,
224
+ reveal, wipe, draw, cues: (slotId) => TEXTS[slotId] || [], fps: () => FPS,
225
+ render: () => {},
226
+ /** init 之后调用一次(字幕等输入已就位):按输入建一次性的 DOM。必须幂等。 */
227
+ setup: () => {},
228
+ });
229
+
230
+ addEventListener("message", (ev) => {
231
+ const m = ev.data;
232
+ if (!m || m.ch !== "gtrk-composite") return;
233
+ const reply = (x) => parent.postMessage(Object.assign({ ch: "gtrk-composite" }, x), "*");
234
+ try {
235
+ if (m.type === "hello") reply({ type: "meta", meta: META });
236
+ else if (m.type === "init") {
237
+ FPS = m.fps || FPS; ANCHORS = m.anchors || ANCHORS; LAYER = m.layer || "all"; TEXTS = m.texts || {};
238
+ CP.setup(); applyLayer(); reply({ type: "ready", layer: LAYER });
239
+ } else if (m.type === "apply") {
240
+ CP.render(m.frame / FPS);
241
+ reply({ type: "frame", seq: m.seq, frame: m.frame, slots: measure(), layer: LAYER });
242
+ } else if (m.type === "lint") {
243
+ // 回包类型不能也叫 lint:颗粒作为顶层页面跑(gtrk composite lint)时回包会回到自己的监听里,同名就会无限往返
244
+ reply({ type: "lint-result", seq: m.seq, issues: lint() });
245
+ }
246
+ } catch (e) { reply({ type: "error", seq: m.seq, message: String((e && e.message) || e) }); }
247
+ });
248
+ })();
@@ -29,10 +29,21 @@
29
29
  * 每个槽位一个 <video>(同源取帧不共用解码,见客户端 R15);图片素材用 <img>。
30
30
  *
31
31
  * 用法:stageInit({ fps, entries, materials, images?, segs?, tracks }) → renderAt(时间线秒)。全部经 file:// 读本地文件。
32
+ * 舞台随工程画布(adjust-composite-stage-follows-canvas):每颗 `entries[i].stage = [宽, 高]`(缺省 1920×1080),
33
+ * 挂载时页面、两层 iframe 与槽位画布都改成这个尺寸;调用方同时把浏览器视口设成它(截图即这个尺寸)。
32
34
  * `window.SETTLE`:画完后等多少毫秒(缺省 60,lint 静帧用);烘焙置 -1(截帧由 beginFrame / 截图本身推进合成器)。 */
33
35
  let FPS = 60, entries = [], matUrl = new Map(), imageIds = new Set(), segsOf = new Map(), byClip = new Map();
34
36
  const back = document.getElementById("back"), front = document.getElementById("front");
35
37
  const canvas = document.getElementById("slots"), ctx = canvas.getContext("2d");
38
+ let SW = 1920, SH = 1080;
39
+ /** 页面、两层图形 iframe(颗粒按 iframe 宽度缩放舞台)与槽位画布改成舞台尺寸。 */
40
+ function sizeStage(stage) {
41
+ const [w, h] = Array.isArray(stage) ? stage : [1920, 1080];
42
+ if (w === SW && h === SH) return;
43
+ SW = w; SH = h;
44
+ for (const el of [document.documentElement, document.body, back, canvas, front]) { el.style.width = w + "px"; el.style.height = h + "px"; }
45
+ canvas.width = w; canvas.height = h;
46
+ }
36
47
 
37
48
  window.stageInit = (data) => {
38
49
  FPS = data.fps; entries = data.entries;
@@ -66,6 +77,7 @@ const waitFor = (key, ms = 15000) => new Promise((res, rej) => {
66
77
  const load = (frame, src) => new Promise((res) => { frame.onload = () => res(); frame.src = src; });
67
78
  async function mount(entry) {
68
79
  if (current && current.entry.id === entry.id) return current.ready;
80
+ sizeStage(entry.stage);
69
81
  const ready = (async () => {
70
82
  await Promise.all([load(back, entry.url), load(front, entry.url)]);
71
83
  for (const [frame, layer] of [[back, "back"], [front, "front"]]) {
@@ -170,7 +182,7 @@ function applySlotOverrides(g, b) {
170
182
  let out = g;
171
183
  if (b.box) {
172
184
  const s = b.box.scale_pm / 1000, w = g.w * s, h = g.h * s;
173
- const cx = g.x + g.w / 2 + (b.box.dx_pm / 1000) * 1920, cy = g.y + g.h / 2 + (b.box.dy_pm / 1000) * 1080;
185
+ const cx = g.x + g.w / 2 + (b.box.dx_pm / 1000) * SW, cy = g.y + g.h / 2 + (b.box.dy_pm / 1000) * SH;
174
186
  out = { ...out, x: cx - w / 2, y: cy - h / 2, w, h, radius: (g.radius || 0) * s };
175
187
  }
176
188
  if (b.crop) out = { ...out, crop: [b.crop.t_pm / 1000, b.crop.r_pm / 1000, b.crop.b_pm / 1000, b.crop.l_pm / 1000] };
@@ -260,7 +272,7 @@ window.renderAt = async (T) => {
260
272
  return { g, b, v, k: ver.k, tag: at.tag };
261
273
  });
262
274
  const ready = (await Promise.all(jobs)).filter(Boolean);
263
- ctx.clearRect(0, 0, 1920, 1080);
275
+ ctx.clearRect(0, 0, SW, SH);
264
276
  for (const { g: g0, b, v, k, tag, img } of ready) {
265
277
  const g = applySlotOverrides(g0, b);
266
278
  // 中间文件底部的帧号条不进画面:源高度按去掉条带算