@gitruck/cli 1.3.15 → 1.3.17

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 (36) hide show
  1. package/AGENT.md +40 -10
  2. package/CHANGELOG.md +1262 -1231
  3. package/README.en.md +1 -1
  4. package/README.md +1 -1
  5. package/assets/composite-kit/kit.css +66 -57
  6. package/assets/composite-kit/stage.html +164 -18
  7. package/dist/index.js +653 -343
  8. package/docs/reference.en.md +866 -852
  9. package/docs/reference.md +909 -895
  10. package/docs/workflow.en.md +4 -3
  11. package/docs/workflow.md +4 -3
  12. package/package.json +1 -1
  13. package/skills/gtrk-ai-drama/SKILL.md +7 -7
  14. package/skills/gtrk-composite/SKILL.md +428 -222
  15. package/skills/gtrk-composite/references/fullscreen-card-migration.md +99 -0
  16. package/skills/gtrk-food-recap/SKILL.md +6 -3
  17. package/skills/gtrk-live-slicing/SKILL.md +9 -6
  18. package/skills/gtrk-long2short/SKILL.md +3 -2
  19. package/skills/gtrk-matrix/SKILL.md +9 -9
  20. package/skills/gtrk-mg/SKILL.md +62 -48
  21. package/skills/gtrk-music-visualizer/SKILL.md +1 -1
  22. package/skills/gtrk-narration/SKILL.md +15 -8
  23. package/skills/gtrk-oralcut/SKILL.md +1 -1
  24. package/skills/gtrk-splitter/SKILL.md +116 -24
  25. package/skills/gtrk-splitter/references/example-visual-split.json +34 -13
  26. package/skills/gtrk-splitter/references/example-visual-split.md +28 -5
  27. package/skills/gtrk-splitter/references/field-schema.md +88 -3
  28. package/skills/gtrk-style-maker/SKILL.md +8 -4
  29. package/skills/gtrk-style-maker/references/seeds/seed-ecom-home-goods.md +2 -2
  30. package/skills/gtrk-style-maker/references/seeds/seed-psych-humanities.md +1 -1
  31. package/skills/gtrk-talking-head/SKILL.md +26 -20
  32. package/skills/gtrk-tools/SKILL.md +1 -1
  33. package/skills/gtrk-transcript/SKILL.md +1 -1
  34. package/skills/gtrk-travel-recap/SKILL.md +36 -28
  35. package/skills/gtrk-vlog-docu/SKILL.md +41 -33
  36. package/skills/gtrk-voiceover/SKILL.md +19 -11
package/README.en.md CHANGED
@@ -56,7 +56,7 @@ You need an AI agent that supports Skills and local command execution, plus **No
56
56
  npx @gitruck/cli@latest install
57
57
  ```
58
58
 
59
- The installer sets up gtrk, installs its Skills and guides you through API-key configuration. Get a key from the [Gitruck Cloud dashboard](https://cloud.ai-mcn.tv/zh-CN/dashboard); cloud capabilities require an account and available credit. Open a new agent session afterwards to load the Skills.
59
+ The installer sets up gtrk, installs its Skills and guides you through API-key configuration. To get a key, open [Gitruck Cloud](https://cloud.ai-mcn.tv/zh-CN), click 注册 / 登录 (Sign up / Log in; logging in creates the account), then copy the API key from the [dashboard](https://cloud.ai-mcn.tv/zh-CN/dashboard); cloud capabilities require an account and available credit. Open a new agent session afterwards to load the Skills.
60
60
 
61
61
  Give your agent the footage folder and ask:
62
62
 
package/README.md CHANGED
@@ -56,7 +56,7 @@
56
56
  npx @gitruck/cli@latest install
57
57
  ```
58
58
 
59
- 安装器会配置 gtrk、安装配套 Skills,并引导填写 API Key。Key 在[同合云控制台](https://cloud.ai-mcn.tv/zh-CN/dashboard)获取;调用云端能力需要账户和可用额度。完成后重新打开 Agent 会话,让新 Skills 生效。
59
+ 安装器会配置 gtrk、安装配套 Skills,并引导填写 API Key。Key 的获取方式:打开[同合云官网](https://cloud.ai-mcn.tv/zh-CN),点「注册 / 登录」(登录即开通),进入[「仪表盘」](https://cloud.ai-mcn.tv/zh-CN/dashboard)复制 API Key;调用云端能力需要账户和可用额度。完成后重新打开 Agent 会话,让新 Skills 生效。
60
60
 
61
61
  把素材文件夹交给 Agent,然后说:
62
62
 
@@ -1,57 +1,66 @@
1
- /* 合成颗粒工具箱 composite-kit@1 的公共样式(构建时内联)。
2
- * 中性:配色、字体、纹理一律走变量,由栏目 / 工程风格(composite/theme.css,构建时接在本文件之后)覆盖。
3
- * 变量:--bg 底色 / --bg2 次底色 / --ink 正文 / --muted 次要文字 / --line 分隔线 /
4
- * --accent 强调 / --accent2 / --accent3 辅助色 / --alert 警示 / --font 正文字体 / --font-serif 标题字体。 */
5
- html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
6
- #stage {
7
- position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; transform-origin: 0 0; overflow: hidden; /* 实际尺寸由 kit.js 按舞台写回 */
8
- --bg: #F4F4F2; --bg2: #E6E6E2; --ink: #1D1D1F; --muted: #6B6B70; --line: #CFCFCB;
9
- --accent: #2F6FDE; --accent2: #2E8B57; --accent3: #4A5568; --alert: #D14343;
10
- --font: "Noto Sans SC", "Source Han Sans CN", "Microsoft YaHei", "PingFang SC", sans-serif;
11
- --font-serif: "Noto Serif SC", "Source Han Serif SC", "Source Han Serif", "SimSun", serif;
12
- font-family: var(--font); color: var(--ink);
13
- }
14
- #stage > *, #stage .abs { position: absolute; box-sizing: border-box; }
15
- [data-slot] { visibility: hidden !important; }
16
- .serif { font-family: var(--font-serif); }
17
- .bed { left: 0; top: 0; width: 100%; height: 100%; background: var(--bg); }
18
- .bed.dark { background: radial-gradient(ellipse at 30% 35%, #2A2A2E 0%, #141416 70%); }
19
- /* 槽位装饰:阴影在下(back),描边在上(front)。描边只给最上层的画面——下层画面的 front 描边会压到上层画面上 */
20
- .shadow { border-radius: 18px; box-shadow: 0 18px 50px rgba(0, 0, 0, .22), 0 3px 10px rgba(0, 0, 0, .12); background: #000; }
21
- .shadow.round { border-radius: 50%; }
22
- .frame { border: 3px solid var(--ink); border-radius: 18px; }
23
- .frame.round { border-radius: 50%; }
24
- .frame.thin { border-width: 2px; }
25
- .frame.light { border-color: rgba(255, 255, 255, .85); }
26
- .ring { border: 4px solid var(--ink); border-radius: 50%; }
27
- .ring.light { border-color: rgba(255, 255, 255, .9); }
28
- /* 字幕:颗粒接管字幕后自己排 */
29
- .cap {
30
- left: 160px; width: 1600px; top: 958px; height: 70px; text-align: center; white-space: nowrap;
31
- font-size: 44px; font-weight: 600; letter-spacing: .02em; color: var(--ink); line-height: 70px;
32
- }
33
- .cap b { color: var(--accent); font-weight: 800; }
34
- .cap.dark { color: #fff; text-shadow: 0 2px 12px rgba(0, 0, 0, .55); }
35
- .cap.dark b { color: var(--accent); }
36
- /* 文字 */
37
- .tag { font-size: 26px; letter-spacing: .2em; color: var(--muted); white-space: nowrap; }
38
- .kicker { font-size: 28px; font-weight: 700; letter-spacing: .28em; color: var(--accent); white-space: nowrap; }
39
- .h1 { font-size: 96px; font-weight: 900; line-height: 1.12; white-space: nowrap; }
40
- .h2 { font-size: 64px; font-weight: 900; line-height: 1.2; white-space: nowrap; }
41
- .h3 { font-size: 44px; font-weight: 700; line-height: 1.3; white-space: nowrap; }
42
- .body { font-size: 34px; line-height: 1.5; color: var(--ink); }
43
- .num { font-size: 30px; font-weight: 800; color: var(--accent); letter-spacing: .1em; }
44
- .pill { height: 54px; padding: 0 26px; border-radius: 27px; font-size: 28px; font-weight: 700; line-height: 54px; white-space: nowrap; }
45
- .rule { height: 3px; background: var(--ink); }
46
- .card { background: #FFFFFF; border-radius: 22px; box-shadow: 0 10px 30px rgba(0, 0, 0, .08); }
47
- .stamp { font-size: 72px; font-weight: 900; color: var(--alert); border: 6px solid var(--alert); border-radius: 14px; padding: 6px 26px; white-space: nowrap; }
48
- /* 对话气泡(字幕变成一串对话时用) */
49
- .bubble {
50
- height: 76px; padding: 0 30px; border-radius: 38px 38px 10px 38px; background: #FFFFFF; font-size: 32px; line-height: 76px;
51
- white-space: nowrap; color: var(--ink); box-shadow: 0 6px 18px rgba(0, 0, 0, .08); border: 2px solid transparent;
52
- }
53
- .bubble b { color: var(--accent); font-weight: 700; }
54
- .bubble.now { border-color: var(--accent); }
55
- /* 画面标签:贴在画面左上角,说明这张画面对应台词里的哪个词 */
56
- .ptag { height: 46px; padding: 0 20px; border-radius: 23px; background: rgba(20, 20, 22, .74); color: #fff; font-size: 26px; font-weight: 700; line-height: 46px; letter-spacing: .06em; white-space: nowrap; }
57
- .ptag.warm { background: var(--accent); }
1
+ /* 合成颗粒工具箱的公共样式(构建时内联;随 composite-kit@2 分发——版本号记的是 kit.js 的宿主协议,样式缺省值调整不升版本号)。
2
+ * 中性:配色、字体、纹理一律走变量,由栏目 / 工程风格(composite/theme.css,构建时接在本文件之后)覆盖。
3
+ * 缺省变量写在 :root 上(fix-composite-field-findings §1):theme.css 写同权重的 :root 即后写者胜;写 #stage 或旧写法
4
+ * `:root, #stage` 同样生效。以前写在 #stage 自身上,theme 只写 :root 会被遮住(元素自己的声明胜过从祖先继承的值)。
5
+ * 字号地板(§2):屏上文字缺省 ≥ 32px(社媒有效地板,横竖屏舞台同一口径);出处卡 .credit 是贴图小字,缺省 24px 带深色底条。
6
+ * 变量:--bg 底色 / --bg2 次底色 / --ink 正文 / --muted 次要文字 / --line 分隔线 /
7
+ * --accent 强调 / --accent2 / --accent3 辅助色 / --alert 警示 / --font 正文字体 / --font-serif 标题字体。 */
8
+ html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
9
+ :root {
10
+ --bg: #F4F4F2; --bg2: #E6E6E2; --ink: #1D1D1F; --muted: #6B6B70; --line: #CFCFCB;
11
+ --accent: #2F6FDE; --accent2: #2E8B57; --accent3: #4A5568; --alert: #D14343;
12
+ --font: "Noto Sans SC", "Source Han Sans CN", "Microsoft YaHei", "PingFang SC", sans-serif;
13
+ --font-serif: "Noto Serif SC", "Source Han Serif SC", "Source Han Serif", "SimSun", serif;
14
+ }
15
+ #stage {
16
+ position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; transform-origin: 0 0; overflow: hidden; /* 实际尺寸由 kit.js 按舞台写回 */
17
+ font-family: var(--font); color: var(--ink);
18
+ }
19
+ #stage > *, #stage .abs { position: absolute; box-sizing: border-box; }
20
+ [data-slot] { visibility: hidden !important; }
21
+ .serif { font-family: var(--font-serif); }
22
+ .bed { left: 0; top: 0; width: 100%; height: 100%; background: var(--bg); }
23
+ .bed.dark { background: radial-gradient(ellipse at 30% 35%, #2A2A2E 0%, #141416 70%); }
24
+ /* 槽位装饰:阴影在下(back),描边在上(front)。描边只给最上层的画面——下层画面的 front 描边会压到上层画面上 */
25
+ .shadow { border-radius: 18px; box-shadow: 0 18px 50px rgba(0, 0, 0, .22), 0 3px 10px rgba(0, 0, 0, .12); background: #000; }
26
+ .shadow.round { border-radius: 50%; }
27
+ .frame { border: 3px solid var(--ink); border-radius: 18px; }
28
+ .frame.round { border-radius: 50%; }
29
+ .frame.thin { border-width: 2px; }
30
+ .frame.light { border-color: rgba(255, 255, 255, .85); }
31
+ .ring { border: 4px solid var(--ink); border-radius: 50%; }
32
+ .ring.light { border-color: rgba(255, 255, 255, .9); }
33
+ /* 字幕:颗粒接管字幕后自己排 */
34
+ .cap {
35
+ left: 160px; width: 1600px; top: 958px; height: 70px; text-align: center; white-space: nowrap;
36
+ font-size: 44px; font-weight: 600; letter-spacing: .02em; color: var(--ink); line-height: 70px;
37
+ }
38
+ .cap b { color: var(--accent); font-weight: 800; }
39
+ .cap.dark { color: #fff; text-shadow: 0 2px 12px rgba(0, 0, 0, .55); }
40
+ .cap.dark b { color: var(--accent); }
41
+ /* 文字 */
42
+ .tag { font-size: 32px; letter-spacing: .2em; color: var(--muted); white-space: nowrap; }
43
+ .kicker { font-size: 32px; font-weight: 700; letter-spacing: .28em; color: var(--accent); white-space: nowrap; }
44
+ .h1 { font-size: 96px; font-weight: 900; line-height: 1.12; white-space: nowrap; }
45
+ .h2 { font-size: 64px; font-weight: 900; line-height: 1.2; white-space: nowrap; }
46
+ .h3 { font-size: 44px; font-weight: 700; line-height: 1.3; white-space: nowrap; }
47
+ .body { font-size: 34px; line-height: 1.5; color: var(--ink); }
48
+ .num { font-size: 32px; font-weight: 800; color: var(--accent); letter-spacing: .1em; }
49
+ .pill { height: 60px; padding: 0 28px; border-radius: 30px; font-size: 32px; font-weight: 700; line-height: 60px; white-space: nowrap; }
50
+ .rule { height: 3px; background: var(--ink); }
51
+ .card { background: #FFFFFF; border-radius: 22px; box-shadow: 0 10px 30px rgba(0, 0, 0, .08); }
52
+ .stamp { font-size: 72px; font-weight: 900; color: var(--alert); border: 6px solid var(--alert); border-radius: 14px; padding: 6px 26px; white-space: nowrap; }
53
+ /* 对话气泡(字幕变成一串对话时用) */
54
+ .bubble {
55
+ height: 76px; padding: 0 30px; border-radius: 38px 38px 10px 38px; background: #FFFFFF; font-size: 32px; line-height: 76px;
56
+ white-space: nowrap; color: var(--ink); box-shadow: 0 6px 18px rgba(0, 0, 0, .08); border: 2px solid transparent;
57
+ }
58
+ .bubble b { color: var(--accent); font-weight: 700; }
59
+ .bubble.now { border-color: var(--accent); }
60
+ /* 画面标签:贴在画面左上角,说明这张画面对应台词里的哪个词 */
61
+ .ptag { height: 54px; padding: 0 22px; border-radius: 27px; background: rgba(20, 20, 22, .74); color: #fff; font-size: 32px; font-weight: 700; line-height: 54px; letter-spacing: .06em; white-space: nowrap; }
62
+ .ptag.warm { background: var(--accent); }
63
+ /* 出处卡:贴在图边的小字(联网取回的图必配,文字原样照抄 credit)。按设计是标注不是正文,不抬到 32px 地板:
64
+ * 缺省 24px(1080 高舞台的约 2.2%,与新闻画面里的来源标注同量级),白字配深色半透明底条,压在任何图上都看得清;
65
+ * 栏目风格可改配色,字号别再往下压。 */
66
+ .credit { padding: 4px 14px; border-radius: 8px; background: rgba(20, 20, 22, .72); color: #fff; font-size: 24px; font-weight: 500; line-height: 1.45; letter-spacing: .02em; }
@@ -19,6 +19,7 @@
19
19
  * 槽位取帧(与客户端 `composite-slot-frames.ts` 的 `resolveSlotSource` 同口径):
20
20
  * ref = 片段在时间线该时刻的画面(之前首帧、之后末帧),源时刻 = clip_st + (T − track_st) × speed;
21
21
  * ref-lane = 按 lane_clip_id 找轨,空档定格前一段末帧,窗口前无片段取后一段首帧;
22
+ * 片段的首帧 / 末帧 / 在不在场按帧格判(边吸到第一个不早于它的采样帧,与客户端底轨同一帧,fix-composite-frame-edges);
22
23
  * own = 入点 + (f − starts_at_f) 帧。
23
24
  * 取到的源时刻按「时间戳区间覆盖该时刻的那一帧」取帧(与客户端 VideoCache 同规则):
24
25
  * - 给了中间文件(`segs`:素材 → [{url, off, ed, pts, tag, eof}])时,按中间文件实测的帧时间戳表找覆盖帧、定位到帧中点
@@ -28,11 +29,14 @@
28
29
  * 绘制:先把视频帧 1:1 拷进一张画布,再从画布缩放画进槽位——与客户端同一种绘制源(客户端画的是 VideoCache 的 RGB 画布)。
29
30
  * 每个槽位一个 <video>(同源取帧不共用解码,见客户端 R15);图片素材用 <img>。
30
31
  *
32
+ * 去字幕 / 水印遮罩(add-composite-slot-dewatermark):轨表片段带 `dw`(片段上启用的去水印区域)时,槽位取到的帧按
33
+ * 「哪个素材、源时刻多少秒」套上适用的遮罩再画进槽位,规则与画法见下方 masksFor / dewatermark 的注释。
34
+ *
31
35
  * 用法:stageInit({ fps, entries, materials, images?, segs?, tracks }) → renderAt(时间线秒)。全部经 file:// 读本地文件。
32
36
  * 舞台随工程画布(adjust-composite-stage-follows-canvas):每颗 `entries[i].stage = [宽, 高]`(缺省 1920×1080),
33
37
  * 挂载时页面、两层 iframe 与槽位画布都改成这个尺寸;调用方同时把浏览器视口设成它(截图即这个尺寸)。
34
38
  * `window.SETTLE`:画完后等多少毫秒(缺省 60,lint 静帧用);烘焙置 -1(截帧由 beginFrame / 截图本身推进合成器)。 */
35
- let FPS = 60, entries = [], matUrl = new Map(), imageIds = new Set(), segsOf = new Map(), byClip = new Map();
39
+ let FPS = 60, entries = [], matUrl = new Map(), imageIds = new Set(), segsOf = new Map(), byClip = new Map(), clipsOfMaterial = new Map();
36
40
  const back = document.getElementById("back"), front = document.getElementById("front");
37
41
  const canvas = document.getElementById("slots"), ctx = canvas.getContext("2d");
38
42
  let SW = 1920, SH = 1080;
@@ -51,9 +55,13 @@ window.stageInit = (data) => {
51
55
  imageIds = new Set(data.images || []);
52
56
  segsOf = new Map(Object.entries(data.segs || {}));
53
57
  byClip = new Map();
58
+ clipsOfMaterial = new Map();
54
59
  for (const t of data.tracks) {
55
60
  const clips = t.clips.filter((c) => c.material).sort((a, b) => a.track_st - b.track_st);
56
- for (const c of clips) byClip.set(c.clip_id, { clip: c, clips });
61
+ for (const c of clips) {
62
+ byClip.set(c.clip_id, { clip: c, clips });
63
+ if (c.dw && c.dw.length) clipsOfMaterial.set(c.material, (clipsOfMaterial.get(c.material) || []).concat([c]));
64
+ }
57
65
  }
58
66
  return true;
59
67
  };
@@ -148,24 +156,29 @@ function locate(materialId, sec) {
148
156
  }
149
157
 
150
158
  const speedOf = (c) => (typeof c.speed === "number" && c.speed > 0 ? c.speed : 1);
159
+ // 帧格(fix-composite-frame-edges,与 CLI frame-domain.ts 的 edgeFrame、客户端底轨同一口径):一条边(秒)落在第一个不早于它的
160
+ // 采样帧上;片段在时间线上显示的帧 = [edgeF(track_st), edgeF(track_ed)),首帧、末帧、空档定格的那一帧都按它取
161
+ const edgeF = (sec) => Math.ceil(sec * FPS - 1e-6);
162
+ const firstT = (c) => edgeF(c.track_st) / FPS;
163
+ const endT = (c) => edgeF(c.track_ed) / FPS;
164
+ const lastT = (c) => Math.max(edgeF(c.track_ed) - 1, edgeF(c.track_st)) / FPS;
151
165
  const clipSrc = (c, T) => c.clip_st + (T - c.track_st) * speedOf(c);
152
- const lastFrameT = (c) => c.track_ed - 1 / FPS;
153
166
  function sourceFor(b, T, f) {
154
167
  if (b.kind === "ref") {
155
168
  const hit = byClip.get(b.clip_id); if (!hit) return null;
156
169
  const c = hit.clip;
157
- const tt = Math.min(Math.max(T, c.track_st), lastFrameT(c));
170
+ const tt = Math.min(Math.max(T, firstT(c)), lastT(c));
158
171
  // slip_ms:源时钟上整体前后挪取帧(add-composite-slot-user-overrides);超过素材末尾由 locate 的 eof 段取末帧
159
- return { material: c.material, sec: Math.max(0, clipSrc(c, tt) + (b.slip_ms || 0) / 1000) };
172
+ return { material: c.material, sec: Math.max(0, clipSrc(c, tt) + (b.slip_ms || 0) / 1000), clip: c };
160
173
  }
161
174
  if (b.kind === "ref-lane") {
162
175
  const hit = byClip.get(b.lane_clip_id); if (!hit) return null;
163
- const act = hit.clips.find((c) => T >= c.track_st && T < c.track_ed);
164
- if (act) return { material: act.material, sec: clipSrc(act, T) };
165
- const prev = hit.clips.filter((c) => c.track_ed <= T).pop();
166
- if (prev) return { material: prev.material, sec: clipSrc(prev, lastFrameT(prev)) };
167
- const next = hit.clips.find((c) => c.track_st > T);
168
- return next ? { material: next.material, sec: next.clip_st } : null;
176
+ const act = hit.clips.find((c) => T >= firstT(c) && T < endT(c));
177
+ if (act) return { material: act.material, sec: clipSrc(act, T), clip: act };
178
+ const prev = hit.clips.filter((c) => endT(c) <= T).pop();
179
+ if (prev) return { material: prev.material, sec: clipSrc(prev, lastT(prev)), clip: prev };
180
+ const next = hit.clips.find((c) => firstT(c) > T);
181
+ return next ? { material: next.material, sec: clipSrc(next, firstT(next)), clip: next } : null;
169
182
  }
170
183
  if (b.kind === "own") {
171
184
  const k = Math.min(Math.max(f - b.starts_at_f, 0), b.dur_f - 1);
@@ -173,6 +186,135 @@ function sourceFor(b, T, f) {
173
186
  }
174
187
  return null;
175
188
  }
189
+
190
+ /* ---- 去字幕 / 水印遮罩(add-composite-slot-dewatermark)----------------------------------------------------------
191
+ * 取用(与 CLI `src/lib/composite-dewatermark.ts` 的 slotMasksAt、客户端 `composite-slot-dewatermark.ts` 同一套规则):
192
+ * 遮罩是素材本身的属性——区域按原素材画面 0–1、时间按原素材秒(片段 `dw` = [x, y, w, h, 起, 止, 模糊度, 羽化])。
193
+ * ref = 被引用片段的区域;ref-lane = 该时刻轨上取画面的那个片段的;own = 用同一素材的全部片段,并集去重;
194
+ * 区域在 [起 − 0.1, 止 + 0.1) 源秒内生效(客户端 REGION_TIME_PAD_SEC);图片素材不看时间。
195
+ * 画法 = 客户端时间线的派生图层(同来源 + 高斯模糊 + 区域并集羽化蒙版),按「原素材画面」整体换算,先在源分辨率上糊好、
196
+ * 再按 crop / fit / framing 原样画进槽位(遮罩坐标随裁切、缩放自然对上):
197
+ * σ(源像素)= 模糊度 ÷ 5 × 源短边 ÷ 1080(客户端 blurSigmaFor 在元素显示尺寸上算、换回源像素即此式);
198
+ * 羽化宽 F(源像素)= 羽化 × 舞台短边 ÷ 1080 ÷ min(舞台宽 ÷ 源宽, 舞台高 ÷ 源高)(客户端 featherPx 按画布算、换回源像素);
199
+ * 蒙版 α = smoothstep(−F/2, F/2, d),d = 到对侧最近像素的距离(区域内为正)——与客户端合成器 JFA 羽化同式,这里用精确距离变换;
200
+ * 结果 = 原画 ×(1 − α)+ 模糊 × α;模糊越出源画面的部分按边缘像素外延(客户端 GPU 采样 ClampToEdge)。 */
201
+ const MASK_TIME_PAD = 0.1;
202
+ function masksFor(b, src) {
203
+ if (!src) return [];
204
+ const clips = b.kind === "own" ? (clipsOfMaterial.get(src.material) || []) : src.clip ? [src.clip] : [];
205
+ const timeless = imageIds.has(src.material);
206
+ const seen = new Set(), out = [];
207
+ for (const c of clips) for (const m of c.dw || []) {
208
+ if (!timeless && !(src.sec >= m[4] - MASK_TIME_PAD && src.sec < m[5] + MASK_TIME_PAD)) continue;
209
+ const key = [m[0], m[1], m[2], m[3], m[6], m[7]].join(",");
210
+ if (seen.has(key)) continue;
211
+ seen.add(key); out.push(m);
212
+ }
213
+ return out;
214
+ }
215
+ function maskMetrics(W, H, intensity, feather) {
216
+ const c = Math.min(SW / W, SH / H);
217
+ return { sigma: Math.max(intensity, 0) / 5 * Math.min(W, H) / 1080, feather: Math.max(feather, 0) * Math.min(SW, SH) / 1080 / c };
218
+ }
219
+ const EDT_INF = 1e20;
220
+ /** 一维平方距离变换(Felzenszwalb):f 为 0(种子)或 EDT_INF。 */
221
+ function edt1(f, n, d, v, z) {
222
+ let k = 0; v[0] = 0; z[0] = -EDT_INF; z[1] = EDT_INF;
223
+ for (let q = 1; q < n; q++) {
224
+ let s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
225
+ while (s <= z[k]) { k--; s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); }
226
+ k++; v[k] = q; z[k] = s; z[k + 1] = EDT_INF;
227
+ }
228
+ k = 0;
229
+ for (let q = 0; q < n; q++) { while (z[k + 1] < q) k++; d[q] = (q - v[k]) * (q - v[k]) + f[v[k]]; }
230
+ }
231
+ /** 到最近种子像素的平方距离(两遍一维变换,精确欧氏距离)。 */
232
+ function sqDist(seed, w, h) {
233
+ const n = Math.max(w, h), f = new Float64Array(n), d = new Float64Array(n), v = new Int32Array(n), z = new Float64Array(n + 1);
234
+ const out = new Float64Array(w * h);
235
+ for (let i = 0; i < w * h; i++) out[i] = seed[i] ? 0 : EDT_INF;
236
+ for (let x = 0; x < w; x++) { for (let y = 0; y < h; y++) f[y] = out[y * w + x]; edt1(f, h, d, v, z); for (let y = 0; y < h; y++) out[y * w + x] = d[y]; }
237
+ for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) f[x] = out[y * w + x]; edt1(f, w, d, v, z); for (let x = 0; x < w; x++) out[y * w + x] = d[x]; }
238
+ return out;
239
+ }
240
+ /** 区域并集的羽化蒙版 α(0–255,w × h):像素中心落在任一矩形 [x0, x1) × [y0, y1) 内即「区域内」。 */
241
+ function featherAlpha(w, h, rects, F) {
242
+ const inside = new Uint8Array(w * h);
243
+ for (const [x0, y0, x1, y1] of rects) {
244
+ const i0 = Math.max(0, Math.ceil(x0 - 0.5)), i1 = Math.min(w, Math.ceil(x1 - 0.5));
245
+ const j0 = Math.max(0, Math.ceil(y0 - 0.5)), j1 = Math.min(h, Math.ceil(y1 - 0.5));
246
+ for (let j = j0; j < j1; j++) inside.fill(1, j * w + i0, j * w + Math.max(i1, i0));
247
+ }
248
+ const a = new Uint8ClampedArray(w * h);
249
+ if (!(F > 0)) { for (let i = 0; i < w * h; i++) a[i] = inside[i] ? 255 : 0; return a; }
250
+ const toOut = sqDist(inside.map((x) => 1 - x), w, h), toIn = sqDist(inside, w, h);
251
+ const half = F / 2;
252
+ for (let i = 0; i < w * h; i++) {
253
+ const d = inside[i] ? Math.sqrt(toOut[i]) : -Math.sqrt(toIn[i]);
254
+ const t = Math.min(Math.max((d + half) / F, 0), 1);
255
+ a[i] = Math.round(255 * t * t * (3 - 2 * t));
256
+ }
257
+ return a;
258
+ }
259
+ /** 把源画面 (ox, oy, w, h) 一块画到 ctx 的 (0, 0);越出源画面的部分按边缘像素外延。 */
260
+ function drawClamped(ctx, src, W, H, ox, oy, w, h) {
261
+ const x0 = Math.max(ox, 0), y0 = Math.max(oy, 0), x1 = Math.min(ox + w, W), y1 = Math.min(oy + h, H);
262
+ if (x1 <= x0 || y1 <= y0) return;
263
+ const cols = [[ox, x0, 0, 1], [x0, x1, x0, x1 - x0], [x1, ox + w, W - 1, 1]];
264
+ const rows = [[oy, y0, 0, 1], [y0, y1, y0, y1 - y0], [y1, oy + h, H - 1, 1]];
265
+ for (const [dx0, dx1, sx, sw] of cols) {
266
+ if (dx1 <= dx0) continue;
267
+ for (const [dy0, dy1, sy, sh] of rows) if (dy1 > dy0) ctx.drawImage(src, sx, sy, sw, sh, dx0 - ox, dy0 - oy, dx1 - dx0, dy1 - dy0);
268
+ }
269
+ }
270
+ const sized = (c, w, h) => { if (c.width !== w || c.height !== h) { c.width = w; c.height = h; } return c; };
271
+ /** 每个槽位一套工作画布(结果 / 外延源 / 模糊块)与蒙版缓存(蒙版只随区域集合变,按段换一次)。 */
272
+ const dwWork = new Map();
273
+ function dewatermark(slotKey, src, W, H, masks) {
274
+ let st = dwWork.get(slotKey);
275
+ if (!st) { st = { out: document.createElement("canvas"), b: document.createElement("canvas"), c: document.createElement("canvas"), masks: new Map() }; dwWork.set(slotKey, st); }
276
+ const o = sized(st.out, W, H).getContext("2d");
277
+ o.clearRect(0, 0, W, H);
278
+ o.drawImage(src, 0, 0, W, H, 0, 0, W, H);
279
+ const groups = new Map();
280
+ for (const m of masks) { const k = m[6] + "|" + m[7]; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(m); }
281
+ for (const list of groups.values()) {
282
+ const { sigma, feather: F } = maskMetrics(W, H, list[0][6], list[0][7]);
283
+ if (!(sigma > 0.01)) continue;
284
+ const rects = list.map((m) => [m[0] * W, m[1] * H, (m[0] + m[2]) * W, (m[1] + m[3]) * H]);
285
+ const pad = Math.ceil(F / 2) + 1;
286
+ const bx = Math.max(0, Math.floor(Math.min(...rects.map((r) => r[0]))) - pad), by = Math.max(0, Math.floor(Math.min(...rects.map((r) => r[1]))) - pad);
287
+ const bw = Math.min(W, Math.ceil(Math.max(...rects.map((r) => r[2]))) + pad) - bx, bh = Math.min(H, Math.ceil(Math.max(...rects.map((r) => r[3]))) + pad) - by;
288
+ if (!(bw > 0 && bh > 0)) continue;
289
+ const local = rects.map((r) => [r[0] - bx, r[1] - by, r[2] - bx, r[3] - by]);
290
+ const mkey = [bw, bh, F.toFixed(3), ...local.map((r) => r.map((v) => v.toFixed(2)).join(","))].join("|");
291
+ let mask = st.masks.get(mkey);
292
+ if (!mask) {
293
+ if (st.masks.size >= 8) st.masks.clear();
294
+ mask = sized(document.createElement("canvas"), bw, bh);
295
+ const a = featherAlpha(bw, bh, local, F), img = new ImageData(bw, bh);
296
+ for (let i = 0; i < a.length; i++) { img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = 255; img.data[i * 4 + 3] = a[i]; }
297
+ mask.getContext("2d").putImageData(img, 0, 0);
298
+ st.masks.set(mkey, mask);
299
+ }
300
+ const P = Math.ceil(3 * sigma);
301
+ const bc = sized(st.b, bw + 2 * P, bh + 2 * P).getContext("2d");
302
+ bc.clearRect(0, 0, bw + 2 * P, bh + 2 * P);
303
+ bc.imageSmoothingEnabled = false;
304
+ drawClamped(bc, src, W, H, bx - P, by - P, bw + 2 * P, bh + 2 * P);
305
+ const cc = sized(st.c, bw, bh).getContext("2d");
306
+ cc.globalCompositeOperation = "source-over";
307
+ cc.clearRect(0, 0, bw, bh);
308
+ cc.filter = "blur(" + sigma + "px)";
309
+ cc.drawImage(st.b, -P, -P);
310
+ cc.filter = "none";
311
+ cc.globalCompositeOperation = "destination-in";
312
+ cc.drawImage(mask, 0, 0);
313
+ cc.globalCompositeOperation = "source-over";
314
+ o.drawImage(st.c, bx, by);
315
+ }
316
+ return st.out;
317
+ }
176
318
  /**
177
319
  * 用户覆盖套到颗粒回报的几何上(add-composite-slot-user-overrides,与客户端 `applySlotOverrides` 逐值相同):
178
320
  * box = 以槽位中心缩放 scale_pm/1000 倍、中心位移 dx_px / dy_px 舞台像素(旧写法 dx_pm / dy_pm 为舞台宽高的千分比),圆角同乘缩放;
@@ -283,29 +425,33 @@ window.renderAt = async (T) => {
283
425
  if (!b || b.kind === "text") return null;
284
426
  const src = sourceFor(b, Tf, f);
285
427
  if (!src || !matUrl.has(src.material)) return null;
428
+ const masks = masksFor(b, src);
286
429
  if (imageIds.has(src.material)) {
287
430
  const img = await imageFor(matUrl.get(src.material));
288
- used.push({ slot: g.id, material: src.material, sec: 0 });
289
- return img ? { g, b, img } : null;
431
+ used.push({ slot: g.id, material: src.material, sec: 0, ...(masks.length ? { masks: masks.length } : {}) });
432
+ return img ? { g, b, img, masks } : null;
290
433
  }
291
434
  const at = locate(src.material, src.sec);
292
435
  const v = await seek(videoFor(g.id + "|" + at.url, at.url), at.at);
293
436
  const ver = at.tag ? await drawVerified(v, at) : { k: relayOf(v), waits: 0 };
294
- used.push({ slot: g.id, material: src.material, sec: src.sec, srcFrame: at.srcFrame, viaSeg: at.viaSeg, waits: ver.waits });
295
- return { g, b, v, k: ver.k, tag: at.tag };
437
+ used.push({ slot: g.id, material: src.material, sec: src.sec, srcFrame: at.srcFrame, viaSeg: at.viaSeg, waits: ver.waits, ...(masks.length ? { masks: masks.length } : {}) });
438
+ return { g, b, v, k: ver.k, tag: at.tag, masks };
296
439
  });
297
440
  const ready = (await Promise.all(jobs)).filter(Boolean);
298
441
  ctx.clearRect(0, 0, SW, SH);
299
- for (const { g: g0, b, v, k, tag, img } of ready) {
442
+ for (const { g: g0, b, v, k, tag, img, masks } of ready) {
300
443
  const g = applySlotOverrides(g0, b);
301
444
  // 中间文件底部的帧号条不进画面:源高度按去掉条带算
302
- 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);
445
+ const sw0 = img ? img.naturalWidth : v.videoWidth, sh0 = img ? img.naturalHeight : v.videoHeight - (tag || 0);
446
+ const r = drawRect(g, sw0, sh0, b.fit || g.fit, b.framing);
303
447
  if (!r) continue;
448
+ // 有适用的遮罩:先在源画面上糊好(同尺寸、同坐标),绘制矩形不变
449
+ const source = masks.length ? dewatermark(g.id, img || k, sw0, sh0, masks) : img || k;
304
450
  ctx.save();
305
451
  shape(g); ctx.clip();
306
452
  ctx.globalAlpha = g.opacity;
307
453
  if (g.filter) ctx.filter = g.filter;
308
- ctx.drawImage(img || k, ...r);
454
+ ctx.drawImage(source, ...r);
309
455
  ctx.restore();
310
456
  }
311
457
  const settle = window.SETTLE ?? 60;