@gitruck/cli 1.3.4 → 1.3.6

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 (51) hide show
  1. package/AGENT.md +25 -0
  2. package/CHANGELOG.md +27 -0
  3. package/README.en.md +50 -50
  4. package/README.md +50 -50
  5. package/assets/composite-kit/kit.css +57 -0
  6. package/assets/composite-kit/kit.js +246 -0
  7. package/assets/composite-kit/stage.html +282 -0
  8. package/assets/readme/wordmark.svg +6 -6
  9. package/contracts/README.md +16 -16
  10. package/contracts/gsap-emit-v1.md +163 -163
  11. package/contracts/handoff-contracts.json +10 -10
  12. package/dist/index.js +440 -286
  13. package/docs/development.en.md +47 -47
  14. package/docs/development.md +47 -47
  15. package/docs/reference.en.md +59 -15
  16. package/docs/reference.md +59 -15
  17. package/docs/workflow.en.md +72 -72
  18. package/docs/workflow.md +72 -72
  19. package/package.json +2 -2
  20. package/skills/gtrk-ai-drama/SKILL.md +10 -10
  21. package/skills/gtrk-ai-drama/references/sparse-key-scenes.md +38 -38
  22. package/skills/gtrk-ai-drama/scripts/validate-storyboard.mjs +119 -119
  23. package/skills/gtrk-composite/SKILL.md +194 -0
  24. package/skills/gtrk-cover/SKILL.md +3 -3
  25. package/skills/gtrk-cover/references/layout-recipes.md +337 -337
  26. package/skills/gtrk-cover/references/prompt-templates.md +119 -119
  27. package/skills/gtrk-food-recap/SKILL.md +9 -9
  28. package/skills/gtrk-live-slicing/SKILL.md +2 -1
  29. package/skills/gtrk-long2short/SKILL.md +2 -1
  30. package/skills/gtrk-matrix/SKILL.md +20 -10
  31. package/skills/gtrk-mg/SKILL.md +391 -391
  32. package/skills/gtrk-music-visualizer/SKILL.md +3 -3
  33. package/skills/gtrk-narration/SKILL.md +23 -23
  34. package/skills/gtrk-oralcut/SKILL.md +5 -4
  35. package/skills/gtrk-splitter/SKILL.md +298 -298
  36. package/skills/gtrk-splitter/references/example-visual-split.json +645 -645
  37. package/skills/gtrk-splitter/references/example-visual-split.md +439 -439
  38. package/skills/gtrk-splitter/references/field-schema.md +171 -171
  39. package/skills/gtrk-style-maker/SKILL.md +1 -1
  40. package/skills/gtrk-style-maker/references/ammo.md +16 -16
  41. package/skills/gtrk-style-maker/references/contracts-ref.md +9 -9
  42. package/skills/gtrk-style-maker/references/seeds/README.md +4 -4
  43. package/skills/gtrk-style-maker/references/seeds/seed-ecom-home-goods.md +37 -37
  44. package/skills/gtrk-style-maker/references/seeds/seed-psych-humanities.md +35 -35
  45. package/skills/gtrk-talking-head/SKILL.md +15 -7
  46. package/skills/gtrk-tools/SKILL.md +3 -2
  47. package/skills/gtrk-transcript/SKILL.md +4 -4
  48. package/skills/gtrk-transcript/agents/openai.yaml +4 -4
  49. package/skills/gtrk-travel-recap/SKILL.md +52 -52
  50. package/skills/gtrk-vlog-docu/SKILL.md +6 -6
  51. package/skills/gtrk-voiceover/SKILL.md +2 -2
@@ -0,0 +1,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
+ 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
+ })();
@@ -0,0 +1,282 @@
1
+ <!doctype html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>合成颗粒出片台</title>
6
+ <style>
7
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
8
+ .layer { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; border: 0; }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <iframe id="back" class="layer" title="back"></iframe>
13
+ <canvas id="slots" class="layer" width="1920" height="1080"></canvas>
14
+ <iframe id="front" class="layer" title="front"></iframe>
15
+ <script>
16
+ /* 合成颗粒出片台(composite-kit@1;`gtrk composite lint --stills` 与 `gtrk composite bake` 共用):与客户端预览同一套规则——
17
+ * 两层图形 iframe(back / front)夹一块槽位画布。颗粒走宿主协议(init / apply → frame)。
18
+ *
19
+ * 槽位取帧(与客户端 `composite-slot-frames.ts` 的 `resolveSlotSource` 同口径):
20
+ * ref = 片段在时间线该时刻的画面(之前首帧、之后末帧),源时刻 = clip_st + (T − track_st) × speed;
21
+ * ref-lane = 按 lane_clip_id 找轨,空档定格前一段末帧,窗口前无片段取后一段首帧;
22
+ * own = 入点 + (f − starts_at_f) 帧。
23
+ * 取到的源时刻按「时间戳区间覆盖该时刻的那一帧」取帧(与客户端 VideoCache 同规则):
24
+ * - 给了中间文件(`segs`:素材 → [{url, off, ed, pts, tag, eof}])时,按中间文件实测的帧时间戳表找覆盖帧、定位到帧中点
25
+ * (不假设首帧时间戳为 0,也不靠帧率推算——可变帧率同样成立);画完读回中间文件底部的帧号条,核对确是那一帧
26
+ * (`seeked` 之后新帧未必已可画:满载时会画出上一帧或空帧),不对就等、必要时重新定位,始终不对则报错;
27
+ * - 没给时直接定位原素材(lint 静帧)。
28
+ * 绘制:先把视频帧 1:1 拷进一张画布,再从画布缩放画进槽位——与客户端同一种绘制源(客户端画的是 VideoCache 的 RGB 画布)。
29
+ * 每个槽位一个 <video>(同源取帧不共用解码,见客户端 R15);图片素材用 <img>。
30
+ *
31
+ * 用法:stageInit({ fps, entries, materials, images?, segs?, tracks }) → renderAt(时间线秒)。全部经 file:// 读本地文件。
32
+ * `window.SETTLE`:画完后等多少毫秒(缺省 60,lint 静帧用);烘焙置 -1(截帧由 beginFrame / 截图本身推进合成器)。 */
33
+ let FPS = 60, entries = [], matUrl = new Map(), imageIds = new Set(), segsOf = new Map(), byClip = new Map();
34
+ const back = document.getElementById("back"), front = document.getElementById("front");
35
+ const canvas = document.getElementById("slots"), ctx = canvas.getContext("2d");
36
+
37
+ window.stageInit = (data) => {
38
+ FPS = data.fps; entries = data.entries;
39
+ matUrl = new Map(Object.entries(data.materials));
40
+ imageIds = new Set(data.images || []);
41
+ segsOf = new Map(Object.entries(data.segs || {}));
42
+ byClip = new Map();
43
+ for (const t of data.tracks) {
44
+ const clips = t.clips.filter((c) => c.material).sort((a, b) => a.track_st - b.track_st);
45
+ for (const c of clips) byClip.set(c.clip_id, { clip: c, clips });
46
+ }
47
+ return true;
48
+ };
49
+
50
+ let current = null;
51
+ const waiters = new Map();
52
+ addEventListener("message", (ev) => {
53
+ const m = ev.data;
54
+ if (!m || m.ch !== "gtrk-composite") return;
55
+ const layer = ev.source === back.contentWindow ? "back" : ev.source === front.contentWindow ? "front" : null;
56
+ if (!layer) return;
57
+ if (m.type === "error") { console.error("[composite]", layer, m.message); window.__scriptErrors = (window.__scriptErrors || []).concat(m.message); }
58
+ const key = layer + ":" + (m.type === "frame" ? "frame:" + m.seq : m.type);
59
+ const w = waiters.get(key);
60
+ if (w) { waiters.delete(key); w(m); }
61
+ });
62
+ const waitFor = (key, ms = 15000) => new Promise((res, rej) => {
63
+ waiters.set(key, res);
64
+ setTimeout(() => { if (waiters.delete(key)) rej(new Error("超时:" + key)); }, ms);
65
+ });
66
+ const load = (frame, src) => new Promise((res) => { frame.onload = () => res(); frame.src = src; });
67
+ async function mount(entry) {
68
+ if (current && current.entry.id === entry.id) return current.ready;
69
+ const ready = (async () => {
70
+ await Promise.all([load(back, entry.url), load(front, entry.url)]);
71
+ for (const [frame, layer] of [[back, "back"], [front, "front"]]) {
72
+ const p = waitFor(layer + ":ready");
73
+ frame.contentWindow.postMessage({ ch: "gtrk-composite", type: "init", layer, fps: FPS, anchors: entry.anchors_f, texts: entry.texts }, "*");
74
+ await p;
75
+ }
76
+ })();
77
+ current = { entry, ready };
78
+ return ready;
79
+ }
80
+
81
+ const pool = new Map();
82
+ function videoFor(key, url) {
83
+ let v = pool.get(key);
84
+ if (!v || v.dataset.url !== url) {
85
+ v = document.createElement("video");
86
+ v.muted = true; v.preload = "auto"; v.dataset.url = url; v.src = url;
87
+ pool.set(key, v);
88
+ }
89
+ return v;
90
+ }
91
+ const images = new Map();
92
+ function imageFor(url) {
93
+ let p = images.get(url);
94
+ if (!p) {
95
+ p = new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => res(null); i.src = url; });
96
+ images.set(url, p);
97
+ }
98
+ return p;
99
+ }
100
+ const once = (el, ev) => new Promise((res) => el.addEventListener(ev, res, { once: true }));
101
+ async function seek(v, sec, force = false) {
102
+ if (v.readyState < 1) await once(v, "loadedmetadata");
103
+ const target = Math.min(Math.max(sec, 0), Math.max(v.duration - 0.001, 0));
104
+ // 强制重定位:先挪到同一帧内略早的位置再回来(目标是帧中点,±2 毫秒仍在该帧内),逼解码器重出这一帧
105
+ if (force) { const d = once(v, "seeked"); v.currentTime = Math.max(target - 0.002, 0); await d; }
106
+ if (force || Math.abs(v.currentTime - target) > 1e-4 || v.readyState < 2) {
107
+ const done = once(v, "seeked");
108
+ v.currentTime = target;
109
+ await done;
110
+ }
111
+ if (v.readyState < 2) await once(v, "loadeddata");
112
+ return v;
113
+ }
114
+
115
+ /** 有序帧时间戳表里「≤ t 的最后一帧」的下标(二分)。 */
116
+ function coverIndex(pts, t) {
117
+ let lo = 0, hi = pts.length - 1;
118
+ if (t < pts[0]) return 0;
119
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (pts[mid] <= t + 1e-6) lo = mid; else hi = mid - 1; }
120
+ return lo;
121
+ }
122
+ /** 源时刻 → { url, at(定位目标), srcFrame(该帧对应的源时刻,供核对)}。 */
123
+ function locate(materialId, sec) {
124
+ const segs = segsOf.get(materialId);
125
+ if (segs) {
126
+ // eof:中间文件转到了素材结尾,更晚的源时刻(片段出点超出素材长度)取它的末帧
127
+ const s = segs.find((x) => sec >= x.off - 1e-6 && (sec < x.ed || x.eof)) || null;
128
+ if (s) {
129
+ const local = s.pts[0] + (sec - s.off);
130
+ const i = coverIndex(s.pts, local);
131
+ const next = i + 1 < s.pts.length ? s.pts[i + 1] : s.pts[i] + (i > 0 ? s.pts[i] - s.pts[i - 1] : 1 / FPS);
132
+ return { url: s.url, at: (s.pts[i] + next) / 2, srcFrame: s.off + (s.pts[i] - s.pts[0]), viaSeg: true, idx: i, tag: s.tag || 0 };
133
+ }
134
+ }
135
+ return { url: matUrl.get(materialId), at: sec, srcFrame: sec, viaSeg: false, idx: -1, tag: 0 };
136
+ }
137
+
138
+ const speedOf = (c) => (typeof c.speed === "number" && c.speed > 0 ? c.speed : 1);
139
+ const clipSrc = (c, T) => c.clip_st + (T - c.track_st) * speedOf(c);
140
+ const lastFrameT = (c) => c.track_ed - 1 / FPS;
141
+ function sourceFor(b, T, f) {
142
+ if (b.kind === "ref") {
143
+ const hit = byClip.get(b.clip_id); if (!hit) return null;
144
+ const c = hit.clip;
145
+ const tt = Math.min(Math.max(T, c.track_st), lastFrameT(c));
146
+ // slip_ms:源时钟上整体前后挪取帧(add-composite-slot-user-overrides);超过素材末尾由 locate 的 eof 段取末帧
147
+ return { material: c.material, sec: Math.max(0, clipSrc(c, tt) + (b.slip_ms || 0) / 1000) };
148
+ }
149
+ if (b.kind === "ref-lane") {
150
+ const hit = byClip.get(b.lane_clip_id); if (!hit) return null;
151
+ const act = hit.clips.find((c) => T >= c.track_st && T < c.track_ed);
152
+ if (act) return { material: act.material, sec: clipSrc(act, T) };
153
+ const prev = hit.clips.filter((c) => c.track_ed <= T).pop();
154
+ if (prev) return { material: prev.material, sec: clipSrc(prev, lastFrameT(prev)) };
155
+ const next = hit.clips.find((c) => c.track_st > T);
156
+ return next ? { material: next.material, sec: next.clip_st } : null;
157
+ }
158
+ if (b.kind === "own") {
159
+ const k = Math.min(Math.max(f - b.starts_at_f, 0), b.dur_f - 1);
160
+ return { material: b.material, sec: b.src_in_ms / 1000 + k / FPS };
161
+ }
162
+ return null;
163
+ }
164
+ /**
165
+ * 用户覆盖套到颗粒回报的几何上(add-composite-slot-user-overrides,与客户端 `applySlotOverrides` 逐值相同):
166
+ * box = 以槽位中心缩放 scale_pm/1000 倍、中心位移舞台宽高的 dx_pm / dy_pm 千分比,圆角同乘缩放;
167
+ * crop = 替换颗粒的 data-crop([上, 右, 下, 左] 比例)。
168
+ */
169
+ function applySlotOverrides(g, b) {
170
+ let out = g;
171
+ if (b.box) {
172
+ 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;
174
+ out = { ...out, x: cx - w / 2, y: cy - h / 2, w, h, radius: (g.radius || 0) * s };
175
+ }
176
+ 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] };
177
+ return out;
178
+ }
179
+ /** fit / framing / crop 合成的绘制矩形(与客户端 `slotDrawRect` 逐值相同;源或槽位尺寸为 0 时返回 null,不画)。 */
180
+ function drawRect(g, sw0, sh0, fit, framing) {
181
+ const [top, right, bottom, left] = g.crop;
182
+ const sx = sw0 * left, sy = sh0 * top, sw = sw0 * (1 - left - right), sh = sh0 * (1 - top - bottom);
183
+ if (!(sw > 0 && sh > 0 && g.w > 0 && g.h > 0)) return null;
184
+ const base = fit === "contain" ? Math.min(g.w / sw, g.h / sh) : Math.max(g.w / sw, g.h / sh);
185
+ const scale = base * (Math.max((framing && framing.scale_pm) || 1000, 1000) / 1000);
186
+ const dw = sw * scale, dh = sh * scale;
187
+ const cx = g.x + g.w / 2 + ((framing && framing.x_pm) || 0) / 1000 * g.w;
188
+ const cy = g.y + g.h / 2 + ((framing && framing.y_pm) || 0) / 1000 * g.h;
189
+ return [sx, sy, sw, sh, cx - dw / 2, cy - dh / 2, dw, dh];
190
+ }
191
+ function shape(g) {
192
+ ctx.beginPath();
193
+ if (g.shape === "circle") ctx.ellipse(g.x + g.w / 2, g.y + g.h / 2, g.w / 2, g.h / 2, 0, 0, Math.PI * 2);
194
+ else if (g.shape === "roundrect" && g.radius > 0) ctx.roundRect(g.x, g.y, g.w, g.h, Math.min(g.radius, g.w / 2, g.h / 2));
195
+ else ctx.rect(g.x, g.y, g.w, g.h);
196
+ }
197
+ /** 视频帧 1:1 拷进的中转画布(每个 <video> 一张,复用)。 */
198
+ const relay = new Map();
199
+ function relayOf(v) {
200
+ let k = relay.get(v);
201
+ if (!k) { k = document.createElement("canvas"); relay.set(v, k); }
202
+ if (k.width !== v.videoWidth || k.height !== v.videoHeight) { k.width = v.videoWidth; k.height = v.videoHeight; }
203
+ const c = k.getContext("2d", { willReadFrequently: true });
204
+ c.clearRect(0, 0, k.width, k.height);
205
+ c.drawImage(v, 0, 0);
206
+ return k;
207
+ }
208
+ /** 读中转画布底部帧号条:16 位黑白块(块宽 floor(宽/32)*2,与 CLI 的 drawbox 同式);没画上(透明)返回 -1。 */
209
+ function readTag(k, tag) {
210
+ const bw = Math.floor(k.width / 32) * 2;
211
+ const row = k.getContext("2d", { willReadFrequently: true }).getImageData(0, k.height - Math.ceil(tag / 2), k.width, 1).data;
212
+ let n = 0;
213
+ for (let b = 0; b < 16; b++) {
214
+ const o = (b * bw + (bw >> 1)) * 4;
215
+ if (row[o + 3] < 255) return -1;
216
+ if (row[o] > 128) n |= 1 << b;
217
+ }
218
+ return n;
219
+ }
220
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
221
+ /** 画到中转画布并核对帧号;不对就等新帧到位,每 40 次重新定位一次,约 3 秒仍不对即报错(该段会被重试)。 */
222
+ async function drawVerified(v, at) {
223
+ for (let i = 0; ; i++) {
224
+ const k = relayOf(v);
225
+ const got = readTag(k, at.tag);
226
+ if (got === (at.idx & 0xffff)) return { k, waits: i };
227
+ if (i >= 400) throw new Error("取帧校验失败:要第 " + at.idx + " 帧,画出的是 " + got + "(" + at.url + ")");
228
+ if (i % 40 === 39) await seek(v, at.at, true);
229
+ else await sleep(i < 20 ? 2 : 8);
230
+ }
231
+ }
232
+
233
+ let seq = 0;
234
+ window.renderAt = async (T) => {
235
+ const F = Math.round(T * FPS);
236
+ const entry = entries.find((p) => F >= p.window_f[0] && F < p.window_f[1]);
237
+ if (!entry) throw new Error("该时刻没有合成颗粒:" + T);
238
+ await mount(entry);
239
+ const f = Math.min(Math.max(F - entry.window_f[0], 0), entry.window_f[1] - entry.window_f[0] - 1);
240
+ const s = ++seq;
241
+ const pf = waitFor("front:frame:" + s), pb = waitFor("back:frame:" + s);
242
+ for (const fr of [back, front]) fr.contentWindow.postMessage({ ch: "gtrk-composite", type: "apply", seq: s, frame: f }, "*");
243
+ const [geo] = await Promise.all([pf, pb]);
244
+ const Tf = (entry.window_f[0] + f) / FPS;
245
+ const used = [];
246
+ const jobs = geo.slots.filter((g) => g.visible).map(async (g) => {
247
+ const b = entry.bindings[g.id];
248
+ if (!b || b.kind === "text") return null;
249
+ const src = sourceFor(b, Tf, f);
250
+ if (!src || !matUrl.has(src.material)) return null;
251
+ if (imageIds.has(src.material)) {
252
+ const img = await imageFor(matUrl.get(src.material));
253
+ used.push({ slot: g.id, material: src.material, sec: 0 });
254
+ return img ? { g, b, img } : null;
255
+ }
256
+ const at = locate(src.material, src.sec);
257
+ const v = await seek(videoFor(g.id + "|" + at.url, at.url), at.at);
258
+ const ver = at.tag ? await drawVerified(v, at) : { k: relayOf(v), waits: 0 };
259
+ used.push({ slot: g.id, material: src.material, sec: src.sec, srcFrame: at.srcFrame, viaSeg: at.viaSeg, waits: ver.waits });
260
+ return { g, b, v, k: ver.k, tag: at.tag };
261
+ });
262
+ const ready = (await Promise.all(jobs)).filter(Boolean);
263
+ ctx.clearRect(0, 0, 1920, 1080);
264
+ for (const { g: g0, b, v, k, tag, img } of ready) {
265
+ const g = applySlotOverrides(g0, b);
266
+ // 中间文件底部的帧号条不进画面:源高度按去掉条带算
267
+ const r = img ? drawRect(g, img.naturalWidth, img.naturalHeight, b.fit || g.fit, b.framing) : drawRect(g, v.videoWidth, v.videoHeight - (tag || 0), b.fit || g.fit, b.framing);
268
+ if (!r) continue;
269
+ ctx.save();
270
+ shape(g); ctx.clip();
271
+ ctx.globalAlpha = g.opacity;
272
+ if (g.filter) ctx.filter = g.filter;
273
+ ctx.drawImage(img || k, ...r);
274
+ ctx.restore();
275
+ }
276
+ const settle = window.SETTLE ?? 60;
277
+ if (settle > 0) await new Promise((r) => setTimeout(r, settle));
278
+ return { id: entry.id, f, used };
279
+ };
280
+ </script>
281
+ </body>
282
+ </html>
@@ -1,6 +1,6 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="560" height="170" viewBox="0 0 560 170" role="img" aria-labelledby="title">
2
- <title id="title">gtrk CLI</title>
3
- <text x="84" y="128" fill="#f43b48" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="150" font-weight="750" letter-spacing="-9">gtrk</text>
4
- <path d="M403 63v61" stroke="#f43b48" stroke-opacity=".3" stroke-width="2"/>
5
- <text x="425" y="106" fill="#888888" font-family="Consolas, monospace" font-size="28" letter-spacing="3">CLI</text>
6
- </svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="560" height="170" viewBox="0 0 560 170" role="img" aria-labelledby="title">
2
+ <title id="title">gtrk CLI</title>
3
+ <text x="84" y="128" fill="#f43b48" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="150" font-weight="750" letter-spacing="-9">gtrk</text>
4
+ <path d="M403 63v61" stroke="#f43b48" stroke-opacity=".3" stroke-width="2"/>
5
+ <text x="425" y="106" fill="#888888" font-family="Consolas, monospace" font-size="28" letter-spacing="3">CLI</text>
6
+ </svg>
@@ -1,16 +1,16 @@
1
- # 框架契约库(handoff contracts)
2
-
3
- 同合云管线的 handoff 契约**正本**,随 `@gitruck/cli` npm 包分发。创作 skill 以「契约名 + 版本」引用(如 `gsap-emit v1`);`handoff-contracts.json` 是「handoff 类型 → 契约」映射表,消费方查表、不硬编码。
4
-
5
- ## 收录双轴边界(handoff-contract-library spec,扩项须走 change 过审)
6
-
7
- - **轴一(管线性,审美零预设)**:契约只约束**机器可判定的管线消费属性**(封装/注册/确定性/依赖可达/产物格式)。**不得**规定颜色、字体、构图、红点数量等任何视觉取值或栏目词表词条;凡涉视觉取值处一律参数化/中性占位。「全片最多一个红点」这类审美铁律属栏目 skill 侧,**不入库**。
8
- - **轴二(脱敏)**:只收**自有管线**的合规契约——渲染引擎合规、`.gtrk` 契约、派单格式(封闭列举)。外部供应商/外部平台适配内容(AI 视频平台差异、文生图平台参数等)**永不收录**,那属于用户自己的 skill。
9
-
10
- ## 现有契约
11
-
12
- | 契约 | 版本 | 适用 handoff | 文档 |
13
- |---|---|---|---|
14
- | gsap-emit(HTML 动画颗粒逐帧 seek 合规) | v1 | MG(去品牌化前 RRV_MG,消费方查表前归一) | [gsap-emit-v1.md](gsap-emit-v1.md) |
15
-
16
- 维护者证据附录:[`gsap-emit-v1-evidence.md`](gsap-emit-v1-evidence.md)。该文件不属于 agent 执行时的必读契约。
1
+ # 框架契约库(handoff contracts)
2
+
3
+ 同合云管线的 handoff 契约**正本**,随 `@gitruck/cli` npm 包分发。创作 skill 以「契约名 + 版本」引用(如 `gsap-emit v1`);`handoff-contracts.json` 是「handoff 类型 → 契约」映射表,消费方查表、不硬编码。
4
+
5
+ ## 收录双轴边界(handoff-contract-library spec,扩项须走 change 过审)
6
+
7
+ - **轴一(管线性,审美零预设)**:契约只约束**机器可判定的管线消费属性**(封装/注册/确定性/依赖可达/产物格式)。**不得**规定颜色、字体、构图、红点数量等任何视觉取值或栏目词表词条;凡涉视觉取值处一律参数化/中性占位。「全片最多一个红点」这类审美铁律属栏目 skill 侧,**不入库**。
8
+ - **轴二(脱敏)**:只收**自有管线**的合规契约——渲染引擎合规、`.gtrk` 契约、派单格式(封闭列举)。外部供应商/外部平台适配内容(AI 视频平台差异、文生图平台参数等)**永不收录**,那属于用户自己的 skill。
9
+
10
+ ## 现有契约
11
+
12
+ | 契约 | 版本 | 适用 handoff | 文档 |
13
+ |---|---|---|---|
14
+ | gsap-emit(HTML 动画颗粒逐帧 seek 合规) | v1 | MG(去品牌化前 RRV_MG,消费方查表前归一) | [gsap-emit-v1.md](gsap-emit-v1.md) |
15
+
16
+ 维护者证据附录:[`gsap-emit-v1-evidence.md`](gsap-emit-v1-evidence.md)。该文件不属于 agent 执行时的必读契约。