@gitruck/cli 0.2.13 → 0.2.14

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/README.md CHANGED
@@ -339,10 +339,13 @@ gtrk transcript "D:/素材/采访视频.mp4" --lang zh-CN --out "D:/文字稿/
339
339
  | `--top-k <n>` | 每 query 候选数上限(覆盖派单 shots;服务端上限 50) | 派单值 |
340
340
  | `--material-class <c>` | 素材类型 `real_shot` \| `concept`(仅矩阵成员口;覆盖栏目策略) | 栏目策略 |
341
341
  | `--score-floor <f>` | 填充置信度地板:segment score 低于此值不采纳、留空露主轨 | `0.2` |
342
+ | `--no-black-bed` | 不铺纯黑底垫轨(默认铺一条) | 默认铺 |
342
343
  | `--out <file>` | ad-hoc 模式结果落文件 | stdout |
343
344
  | `--json` | 机读:stdout 只输出结果 JSON | 关 |
344
345
 
345
346
  > preview 代理 url 约 24h 过期,重跑即重签。
347
+ >
348
+ > **纯黑底垫轨**:默认在全部候选轨之下、口播主轨之上垫一条纯黑底轨(`struct_meta.broll.black_track` 记其 `track_index`),按已落成的 beat 包络整条铺满,使 B-roll 期间(含候选轨留空处)不漏出底下的口播画面。字节落 `assets/builtin/solid-000000-<W>x<H>.png`,与客户端内置纯色素材同 id 命名空间、幂等复用。删候选轨时别误删它;换片请拖到候选轨颗粒上、**别拖到黑底条上**——含拖拽保护的客户端会直接拒绝并提示,尚未升级到该版本的客户端会被误拖打出黑底破洞(该处漏口播)。不想要加 `--no-black-bed` 重跑即剥净。
346
349
 
347
350
  ### `gtrk mg` — MG 动态图颗粒(铺轨 / lint / status)
348
351
 
package/dist/index.js CHANGED
@@ -5001,7 +5001,8 @@ function buildLanding(doc, view, opts) {
5001
5001
  dispatch.mg.push({
5002
5002
  beat: beat.id,
5003
5003
  composition_id: compositionId,
5004
- duration: typeof h.duration_hint === "number" ? h.duration_hint : null,
5004
+ duration: r3(track_ed - track_st),
5005
+ duration_hint: typeof h.duration_hint === "number" ? h.duration_hint : null,
5005
5006
  ...h.category !== undefined ? { category: h.category } : {},
5006
5007
  theme: h.theme,
5007
5008
  bg: h.bg,
@@ -5058,7 +5059,8 @@ function buildLanding(doc, view, opts) {
5058
5059
  dispatch.mg.push({
5059
5060
  beat: beat.id,
5060
5061
  composition_id: auxCompositionId,
5061
- duration: ah && typeof ah.duration_hint === "number" ? ah.duration_hint : null,
5062
+ duration: r3(auxTrackEd - auxTrackSt),
5063
+ duration_hint: ah && typeof ah.duration_hint === "number" ? ah.duration_hint : null,
5062
5064
  category: "overlay",
5063
5065
  theme: ah?.theme,
5064
5066
  bg: ah?.bg,
@@ -5130,7 +5132,9 @@ function renderSplitMarkdown(doc, landing, meta) {
5130
5132
  L.push("");
5131
5133
  L.push("## MG Queue");
5132
5134
  for (const r of landing.dispatch.mg) {
5133
- L.push(`- \`${r.beat}\` composition_id=\`${r.composition_id}\`${r.duration != null ? ` · ${r.duration}s` : ""}`);
5135
+ const slot = r.duration != null ? ` · 坑位 ${r.duration}s` : "";
5136
+ const hint = r.duration_hint != null ? ` · hint ${r.duration_hint}s` : "";
5137
+ L.push(`- \`${r.beat}\` composition_id=\`${r.composition_id}\`${slot}${hint}`);
5134
5138
  }
5135
5139
  L.push("");
5136
5140
  L.push("## AI_DRAMA Queue");
@@ -7310,7 +7314,109 @@ async function runLand(splitdocPath, baseDir, gtrkPath, transcriptPath, opts) {
7310
7314
  // src/commands/matrix.ts
7311
7315
  import { resolve as resolve8, join as join19, dirname as dirname7, basename as basename9 } from "node:path";
7312
7316
  import { existsSync as existsSync15 } from "node:fs";
7313
- import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir6 } from "node:fs/promises";
7317
+ import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir6, rename } from "node:fs/promises";
7318
+
7319
+ // src/lib/solid-png.ts
7320
+ import { deflateSync } from "node:zlib";
7321
+ var BLACK_BED_HEX = "000000";
7322
+ var SOLID_MATERIAL_PREFIX = "ex-solid-";
7323
+ var BUILTIN_ASSET_DIR = "assets/builtin";
7324
+ var MAX_BED_DIM = 8192;
7325
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
7326
+ var CRC_TABLE = (() => {
7327
+ const table = new Uint32Array(256);
7328
+ for (let n = 0;n < 256; n++) {
7329
+ let c3 = n;
7330
+ for (let k = 0;k < 8; k++)
7331
+ c3 = c3 & 1 ? 3988292384 ^ c3 >>> 1 : c3 >>> 1;
7332
+ table[n] = c3 >>> 0;
7333
+ }
7334
+ return table;
7335
+ })();
7336
+ function crc32(buf) {
7337
+ let c3 = 4294967295;
7338
+ for (let i = 0;i < buf.length; i++)
7339
+ c3 = (CRC_TABLE[(c3 ^ buf[i]) & 255] ^ c3 >>> 8) >>> 0;
7340
+ return (c3 ^ 4294967295) >>> 0;
7341
+ }
7342
+ function chunk(type, data) {
7343
+ const len = Buffer.alloc(4);
7344
+ len.writeUInt32BE(data.length, 0);
7345
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
7346
+ const crc = Buffer.alloc(4);
7347
+ crc.writeUInt32BE(crc32(body), 0);
7348
+ return Buffer.concat([len, body, crc]);
7349
+ }
7350
+ function normalizeHex(hex) {
7351
+ const h = hex.replace(/^#/, "").toLowerCase();
7352
+ return /^[0-9a-f]{6}$/.test(h) ? h : null;
7353
+ }
7354
+ function solidMaterialId({
7355
+ hex,
7356
+ width,
7357
+ height
7358
+ }) {
7359
+ const h = normalizeHex(hex);
7360
+ if (!h)
7361
+ throw new Error(`非法纯色 hex:${hex}`);
7362
+ return `${SOLID_MATERIAL_PREFIX}${h}-${width}x${height}`;
7363
+ }
7364
+ function solidRelPath({
7365
+ hex,
7366
+ width,
7367
+ height
7368
+ }) {
7369
+ const h = normalizeHex(hex);
7370
+ if (!h)
7371
+ throw new Error(`非法纯色 hex:${hex}`);
7372
+ return `${BUILTIN_ASSET_DIR}/solid-${h}-${width}x${height}.png`;
7373
+ }
7374
+ function isLayoutableCanvas(canvas) {
7375
+ if (!Array.isArray(canvas) || canvas.length !== 2)
7376
+ return false;
7377
+ return canvas.every((v) => typeof v === "number" && Number.isInteger(v) && v > 0 && v <= MAX_BED_DIM);
7378
+ }
7379
+ function encodeSolidPng({
7380
+ hex,
7381
+ width,
7382
+ height
7383
+ }) {
7384
+ const h = normalizeHex(hex);
7385
+ if (!h)
7386
+ throw new Error(`非法纯色 hex:${hex}`);
7387
+ if (!isLayoutableCanvas([width, height])) {
7388
+ throw new Error(`纯色画布尺寸非法或超上限(${width}x${height},上限 ${MAX_BED_DIM})`);
7389
+ }
7390
+ const r = Number.parseInt(h.slice(0, 2), 16);
7391
+ const g2 = Number.parseInt(h.slice(2, 4), 16);
7392
+ const b = Number.parseInt(h.slice(4, 6), 16);
7393
+ const ihdr = Buffer.alloc(13);
7394
+ ihdr.writeUInt32BE(width, 0);
7395
+ ihdr.writeUInt32BE(height, 4);
7396
+ ihdr[8] = 8;
7397
+ ihdr[9] = 2;
7398
+ ihdr[10] = 0;
7399
+ ihdr[11] = 0;
7400
+ ihdr[12] = 0;
7401
+ const stride = 1 + width * 3;
7402
+ const row = Buffer.alloc(stride);
7403
+ row[0] = 0;
7404
+ for (let x = 0;x < width; x++) {
7405
+ const off = 1 + x * 3;
7406
+ row[off] = r;
7407
+ row[off + 1] = g2;
7408
+ row[off + 2] = b;
7409
+ }
7410
+ const raw = Buffer.alloc(stride * height);
7411
+ for (let y = 0;y < height; y++)
7412
+ row.copy(raw, y * stride);
7413
+ return Buffer.concat([
7414
+ PNG_SIGNATURE,
7415
+ chunk("IHDR", ihdr),
7416
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
7417
+ chunk("IEND", Buffer.alloc(0))
7418
+ ]);
7419
+ }
7314
7420
 
7315
7421
  // src/lib/matrix-lay.ts
7316
7422
  var BROLL_PREVIEW_DIR = "assets/broll-preview";
@@ -7320,6 +7426,21 @@ var SHOT_TARGET_DEFAULT = 3;
7320
7426
  var MIN_SHOT_SEC = 1.2;
7321
7427
  var SCORE_FLOOR_DEFAULT = 0.2;
7322
7428
  var MAX_SLOTS_PER_BEAT = 32;
7429
+ var BLACK_BED_MERGE_EPS = 0.001;
7430
+ function mergeBlackBedSegments(envelopes) {
7431
+ const valid = envelopes.filter((e) => Number.isFinite(e.track_st) && Number.isFinite(e.track_ed) && e.track_ed > e.track_st).sort((a, b) => a.track_st - b.track_st);
7432
+ const out = [];
7433
+ for (const e of valid) {
7434
+ const last = out[out.length - 1];
7435
+ if (last && e.track_st - last.track_ed <= BLACK_BED_MERGE_EPS) {
7436
+ if (e.track_ed > last.track_ed)
7437
+ last.track_ed = e.track_ed;
7438
+ continue;
7439
+ }
7440
+ out.push({ track_st: e.track_st, track_ed: e.track_ed });
7441
+ }
7442
+ return out.map((s) => ({ track_st: r33(s.track_st), track_ed: r33(s.track_ed) }));
7443
+ }
7323
7444
  function mergedCandidates(beat) {
7324
7445
  const all = [];
7325
7446
  for (const q of beat.queries)
@@ -7493,6 +7614,8 @@ function planBeatFills(plan, lay, scoreFloor) {
7493
7614
  }
7494
7615
  function layBrollTracks(opts) {
7495
7616
  const { gtrk, plan, lay, fills, downloads } = opts;
7617
+ const blackBedOn = opts.blackBed !== false;
7618
+ const warnings = [];
7496
7619
  const videoTracks = [...gtrk.video_track ?? []];
7497
7620
  const materials = [...gtrk.materials ?? []];
7498
7621
  const structMeta = { ...gtrk.struct_meta ?? {} };
@@ -7504,11 +7627,31 @@ function layBrollTracks(opts) {
7504
7627
  for (const t of removedTracks) {
7505
7628
  for (const c3 of t.track_timeline ?? []) {
7506
7629
  const m = c3.material;
7507
- if (typeof m === "string" && m.startsWith(BROLL_MATERIAL_PREFIX))
7630
+ if (typeof m === "string" && (m.startsWith(BROLL_MATERIAL_PREFIX) || m.startsWith(SOLID_MATERIAL_PREFIX))) {
7508
7631
  removedMaterialIds.add(m);
7632
+ }
7509
7633
  }
7510
7634
  }
7635
+ const stillReferenced = new Set;
7636
+ for (const group of [keptTracks, gtrk.beat_track ?? [], gtrk.audio_track ?? []]) {
7637
+ for (const t of group) {
7638
+ for (const c3 of t.track_timeline ?? []) {
7639
+ if (typeof c3.material === "string")
7640
+ stillReferenced.add(c3.material);
7641
+ }
7642
+ }
7643
+ }
7644
+ for (const id of stillReferenced)
7645
+ removedMaterialIds.delete(id);
7511
7646
  const keptMaterials = materials.filter((m) => !(typeof m.id === "string" && removedMaterialIds.has(m.id)));
7647
+ for (const t of keptTracks) {
7648
+ const clips = t.track_timeline ?? [];
7649
+ if (!clips.length)
7650
+ continue;
7651
+ if (clips.every((c3) => typeof c3.material === "string" && c3.material.startsWith(SOLID_MATERIAL_PREFIX))) {
7652
+ warnings.push(`检测到疑似手工纯色底轨(track_index=${t.track_index}):新候选轨会落到它之下被遮住。建议删除该轨后重跑,或用 --no-black-bed。`);
7653
+ }
7654
+ }
7512
7655
  const canvas = Array.isArray(gtrk.video_size) ? gtrk.video_size : [1920, 1080];
7513
7656
  const baseIndex = keptTracks.reduce((mx, t) => Math.max(mx, typeof t.track_index === "number" ? t.track_index : 0), -1) + 1;
7514
7657
  const candById = new Map;
@@ -7591,24 +7734,71 @@ function layBrollTracks(opts) {
7591
7734
  muted: false,
7592
7735
  track_timeline: clips.sort((a, b) => a.track_st - b.track_st)
7593
7736
  }));
7737
+ let blackTrack = null;
7738
+ let blackTrackObj = null;
7739
+ if (blackBedOn && createdTracks.length > 0) {
7740
+ if (!isLayoutableCanvas([canvas[0], canvas[1]])) {
7741
+ warnings.push(`画布尺寸非法(${canvas[0]}x${canvas[1]}),跳过铺纯黑底轨(候选轨照常铺)。`);
7742
+ } else {
7743
+ const laidBeatEnvelopes = metaBeats.filter((b) => b.laid.length > 0).map((b) => ({ track_st: b.track_st, track_ed: b.track_ed }));
7744
+ const segments = mergeBlackBedSegments(laidBeatEnvelopes);
7745
+ if (segments.length > 0) {
7746
+ const width = canvas[0];
7747
+ const height = canvas[1];
7748
+ const solidId = solidMaterialId({ hex: BLACK_BED_HEX, width, height });
7749
+ newMaterialsById.set(solidId, {
7750
+ id: solidId,
7751
+ path: solidRelPath({ hex: BLACK_BED_HEX, width, height }),
7752
+ video_size: [width, height]
7753
+ });
7754
+ blackTrack = baseIndex + lay;
7755
+ blackTrackObj = {
7756
+ track_index: blackTrack,
7757
+ track_size: [width, height],
7758
+ muted: false,
7759
+ track_timeline: segments.map((s, i) => ({
7760
+ clip_id: `blackbed-${i}`,
7761
+ material: solidId,
7762
+ clip_st: 0,
7763
+ clip_ed: r33(s.track_ed - s.track_st),
7764
+ track_st: s.track_st,
7765
+ track_ed: s.track_ed,
7766
+ duration: r33(s.track_ed - s.track_st)
7767
+ }))
7768
+ };
7769
+ }
7770
+ }
7771
+ }
7772
+ const laidTrackIndices = createdTracks.map((t) => t.track_index);
7594
7773
  const broll = {
7595
7774
  contract_version: "v1",
7596
7775
  generated_at: opts.generatedAt,
7597
7776
  plan_path: opts.planPath,
7598
- lay_tracks: createdTracks.map((t) => t.track_index),
7777
+ lay_tracks: blackTrack === null ? laidTrackIndices : [...laidTrackIndices, blackTrack],
7778
+ black_track: blackTrack,
7599
7779
  confirmed: false,
7600
7780
  beats: metaBeats
7601
7781
  };
7782
+ const mergedMaterials = new Map;
7783
+ for (const m of keptMaterials) {
7784
+ if (typeof m.id === "string")
7785
+ mergedMaterials.set(m.id, m);
7786
+ else
7787
+ mergedMaterials.set(`__anon_${mergedMaterials.size}`, m);
7788
+ }
7789
+ for (const [id, m] of newMaterialsById)
7790
+ mergedMaterials.set(id, m);
7602
7791
  const next = {
7603
7792
  ...gtrk,
7604
- materials: [...keptMaterials, ...newMaterialsById.values()],
7605
- video_track: [...keptTracks, ...createdTracks],
7793
+ materials: [...mergedMaterials.values()],
7794
+ video_track: [...keptTracks, ...createdTracks, ...blackTrackObj ? [blackTrackObj] : []],
7606
7795
  struct_meta: { ...structMeta, broll }
7607
7796
  };
7608
7797
  return {
7609
7798
  next,
7610
- summary: { laidTracks: broll.lay_tracks, laidClips, beatsWithCandidates },
7611
- broll
7799
+ summary: { laidTracks: laidTrackIndices, laidClips, beatsWithCandidates, blackTrack },
7800
+ broll,
7801
+ warnings
7612
7802
  };
7613
7803
  }
7614
7804
 
@@ -7799,7 +7989,7 @@ async function searchOnce(cfg, tier, body) {
7799
7989
 
7800
7990
  // src/commands/matrix.ts
7801
7991
  function registerMatrix(program2) {
7802
- program2.command("matrix [words...]").description('B-roll 检索:无 positional=消费 split/dispatch.json 的 film_broll 队列产候选清单;`matrix search "<query>"`=单条 ad-hoc 检索').option("--project <dir>", "oralcut 产物目录(定位 split/dispatch.json 与产物落点)").option("--dispatch <path>", "显式指定 dispatch.json(非标准布局兜底)").option("--column <id>", "栏目配置 id(缺省取 config defaultColumn,再缺省内置默认栏目)").option("--top-k <n>", "每 query 候选数上限(覆盖派单 shots 翻译;服务端上限 50)").option("--material-class <c>", "素材类型 real_shot|concept(仅矩阵成员口;覆盖栏目 material_class_policy)").option("--lay <n>", "候选铺轨数:下载 preview 代理并在工程里平铺 N 条 B-roll 候选轨(默认 1;0=只出 plan 不铺轨)", "1").option("--score-floor <f>", "填充置信度地板:segment score 低于此值不采纳,槽位留空露主轨(默认 0.2)").option("--out <file>", "ad-hoc 模式:结果落文件(缺省输出 stdout)").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON").action(async (words, opts) => {
7992
+ program2.command("matrix [words...]").description('B-roll 检索:无 positional=消费 split/dispatch.json 的 film_broll 队列产候选清单;`matrix search "<query>"`=单条 ad-hoc 检索').option("--project <dir>", "oralcut 产物目录(定位 split/dispatch.json 与产物落点)").option("--dispatch <path>", "显式指定 dispatch.json(非标准布局兜底)").option("--column <id>", "栏目配置 id(缺省取 config defaultColumn,再缺省内置默认栏目)").option("--top-k <n>", "每 query 候选数上限(覆盖派单 shots 翻译;服务端上限 50)").option("--material-class <c>", "素材类型 real_shot|concept(仅矩阵成员口;覆盖栏目 material_class_policy)").option("--lay <n>", "候选铺轨数:下载 preview 代理并在工程里平铺 N 条 B-roll 候选轨(默认 1;0=只出 plan 不铺轨)", "1").option("--score-floor <f>", "填充置信度地板:segment score 低于此值不采纳,槽位留空露主轨(默认 0.2)").option("--no-black-bed", "不铺纯黑底垫轨(默认铺一条,垫在候选轨之下、口播主轨之上,用于 B-roll 期间遮住口播画面)").option("--out <file>", "ad-hoc 模式:结果落文件(缺省输出 stdout)").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON").action(async (words, opts) => {
7803
7993
  await runMatrix(parseAdhocQuery(words), opts);
7804
7994
  });
7805
7995
  }
@@ -7907,7 +8097,7 @@ async function runPlanMode(cfg, tier, broll, overrides, columnId, opts) {
7907
8097
  const layN = parseLay(opts.lay);
7908
8098
  let laySummary;
7909
8099
  if (layN > 0) {
7910
- laySummary = await layIntoProject(baseDir, plan, layN, parseScoreFloor(opts.scoreFloor));
8100
+ laySummary = await layIntoProject(baseDir, plan, layN, parseScoreFloor(opts.scoreFloor), opts.blackBed ?? true);
7911
8101
  }
7912
8102
  const result = {
7913
8103
  ok: true,
@@ -7944,7 +8134,7 @@ function locateGtrk(baseDir) {
7944
8134
  const cands = [join19(baseDir, "gtrk", "project.gtrk"), join19(baseDir, "project.gtrk")];
7945
8135
  return cands.find((p) => existsSync15(p));
7946
8136
  }
7947
- async function layIntoProject(baseDir, plan, layN, scoreFloor) {
8137
+ async function layIntoProject(baseDir, plan, layN, scoreFloor, blackBed) {
7948
8138
  const gtrkPath = locateGtrk(baseDir);
7949
8139
  if (!gtrkPath) {
7950
8140
  log.warn(`未找到工程文件(${join19(baseDir, "gtrk", "project.gtrk")}),跳过铺轨——plan 已产出,可后续在有工程的目录重跑`);
@@ -8006,22 +8196,57 @@ async function layIntoProject(baseDir, plan, layN, scoreFloor) {
8006
8196
  dlStats.failed++;
8007
8197
  }
8008
8198
  }
8009
- const { next, summary } = layBrollTracks({
8199
+ let { next, summary, warnings } = layBrollTracks({
8010
8200
  gtrk,
8011
8201
  plan,
8012
8202
  lay: layN,
8013
8203
  fills,
8014
8204
  downloads,
8015
8205
  generatedAt: new Date().toISOString(),
8016
- planPath: "split/broll-plan.json"
8206
+ planPath: "split/broll-plan.json",
8207
+ blackBed
8017
8208
  });
8209
+ if (summary.blackTrack !== null) {
8210
+ const canvas = gtrk.video_size;
8211
+ const spec = { hex: BLACK_BED_HEX, width: canvas[0], height: canvas[1] };
8212
+ const rel = solidRelPath(spec);
8213
+ const abs = join19(gtrkDir, ...rel.split("/"));
8214
+ try {
8215
+ if (!existsSync15(abs)) {
8216
+ await mkdir6(dirname7(abs), { recursive: true });
8217
+ const tmp = `${abs}.tmp-${process.pid}`;
8218
+ await writeFile7(tmp, encodeSolidPng(spec));
8219
+ await rename(tmp, abs);
8220
+ }
8221
+ } catch (e) {
8222
+ log.warn(`纯黑底 PNG 落盘失败(${rel}):${e.message} —— 本次不铺黑底垫轨,候选轨照常。`);
8223
+ ({ next, summary, warnings } = layBrollTracks({
8224
+ gtrk,
8225
+ plan,
8226
+ lay: layN,
8227
+ fills,
8228
+ downloads,
8229
+ generatedAt: new Date().toISOString(),
8230
+ planPath: "split/broll-plan.json",
8231
+ blackBed: false
8232
+ }));
8233
+ }
8234
+ }
8018
8235
  writeGtrkAtomic(gtrkPath, next, mtimeMs);
8019
- log.ok(`铺轨完成:${summary.laidTracks.length} 条候选轨(track_index ${summary.laidTracks.join("/") || "-"})· 平铺 ${summary.laidClips} 个颗粒 / ${clipIds.size} 个 clip` + `(代理 ${dlStats.preview} · 原片回落 ${dlStats.raw} · 复用 ${dlStats.reused}${dlStats.failed ? ` · 失败 ${dlStats.failed}` : ""})`);
8236
+ const bedNote = summary.blackTrack !== null ? ` · 纯黑底垫轨 track_index ${summary.blackTrack}` : blackBed ? " · 未铺纯黑底垫轨" : " · 纯黑底垫轨已关闭(--no-black-bed)";
8237
+ log.ok(`铺轨完成:${summary.laidTracks.length} 条候选轨(track_index ${summary.laidTracks.join("/") || "-"})· 平铺 ${summary.laidClips} 个颗粒 / ${clipIds.size} 个 clip` + `(代理 ${dlStats.preview} · 原片回落 ${dlStats.raw} · 复用 ${dlStats.reused}${dlStats.failed ? ` · 失败 ${dlStats.failed}` : ""})${bedNote}`);
8020
8238
  log.info("opencut 打开工程即见候选轨:轨道头小眼睛可开关对比;确认下载原片属挑选 UI(E-P1)。");
8239
+ for (const w of warnings)
8240
+ log.warn(w);
8021
8241
  if (dlStats.raw > 0) {
8022
8242
  log.warn("部分候选无 preview 代理已回落原片(体积较大)——服务端 backfill 后重跑本命令可换回代理。");
8023
8243
  }
8024
- return { laidTracks: summary.laidTracks, laidClips: summary.laidClips, downloads: dlStats };
8244
+ return {
8245
+ laidTracks: summary.laidTracks,
8246
+ laidClips: summary.laidClips,
8247
+ blackTrack: summary.blackTrack,
8248
+ downloads: dlStats
8249
+ };
8025
8250
  }
8026
8251
  async function downloadProxy(cand, absPath, opts = {}) {
8027
8252
  const tryFetch = async (url) => {
@@ -8124,6 +8349,42 @@ var CATEGORY_EXPECTED_OPAQUE2 = {
8124
8349
  "explain-subtitle": false,
8125
8350
  "op-ed-title": true
8126
8351
  };
8352
+ function estimateTimelineSec(html) {
8353
+ const re = /\.\s*(?:to|from|fromTo|set|add)\s*\(([\s\S]*?)\)\s*;/g;
8354
+ const calls = [];
8355
+ let m;
8356
+ while (m = re.exec(html)) {
8357
+ const args = m[1] ?? "";
8358
+ const lastBrace = args.lastIndexOf("}");
8359
+ let pos = null;
8360
+ if (lastBrace >= 0) {
8361
+ const tail = args.slice(lastBrace + 1).replace(/^\s*,\s*/, "").trim();
8362
+ if (tail)
8363
+ pos = tail.replace(/^["']|["']$/g, "");
8364
+ }
8365
+ calls.push({ body: args, pos });
8366
+ }
8367
+ if (!calls.length)
8368
+ return null;
8369
+ let chain = 0;
8370
+ let maxEnd = 0;
8371
+ for (const c3 of calls) {
8372
+ if (/\brepeat\s*:/.test(c3.body) || /\byoyo\s*:\s*true\b/.test(c3.body))
8373
+ return null;
8374
+ if (c3.pos !== null && !/^-?\d+(?:\.\d+)?$/.test(c3.pos))
8375
+ return null;
8376
+ const d = /\bduration\s*:\s*(-?\d+(?:\.\d+)?)/.exec(c3.body);
8377
+ const dur = d ? Number(d[1]) : 0;
8378
+ if (!Number.isFinite(dur) || dur < 0)
8379
+ return null;
8380
+ if (c3.pos !== null)
8381
+ maxEnd = Math.max(maxEnd, Number(c3.pos) + dur);
8382
+ else
8383
+ chain += dur;
8384
+ }
8385
+ const est = Math.max(chain, maxEnd);
8386
+ return est > 0 ? Math.round(est * 1000) / 1000 : null;
8387
+ }
8127
8388
  function lintParticle(html, opts = {}) {
8128
8389
  const v = [];
8129
8390
  const push = (law, fatal, msg) => v.push({ law, fatal, msg });
@@ -8193,6 +8454,12 @@ function lintParticle(html, opts = {}) {
8193
8454
  if (expect !== opaque)
8194
8455
  push("x-category-opaque", false, `category「${opts.category}」期望${expect ? "不透明满屏" : "透明叠加"},但颗粒 HTML 反推为${opaque ? "不透明满屏" : "透明叠加"}(以 HTML 为准落 clip.opaque=${opaque})`);
8195
8456
  }
8457
+ if (typeof opts.slotDuration === "number" && opts.slotDuration > 0) {
8458
+ const est = estimateTimelineSec(html);
8459
+ if (est !== null && est < opts.slotDuration * 0.8) {
8460
+ push("7-fill-slot", false, `时间线静态估长 ~${est}s,短于槽位包络 ${opts.slotDuration}s(铁律⑦:颗粒应占满坑位并终态驻留)——静态估算仅供参考,含 repeat/yoyo/相对定位时不作数`);
8461
+ }
8462
+ }
8196
8463
  return { ok: !v.some((x) => x.fatal), violations: v, opaque, compositionId: cid };
8197
8464
  }
8198
8465
 
@@ -8201,6 +8468,7 @@ var MG_MATERIAL_PREFIX = "mg-";
8201
8468
  var LEGACY_MATERIAL_PREFIX = "rrv-";
8202
8469
  var isOwnMaterialId = (id) => id.startsWith(MG_MATERIAL_PREFIX) || id.startsWith(LEGACY_MATERIAL_PREFIX);
8203
8470
  var r34 = (n) => Math.round(n * 1000) / 1000;
8471
+ var slotEnvelope = (it) => r34(it.track_ed - it.track_st);
8204
8472
  function layTracksOf(prev) {
8205
8473
  return Array.isArray(prev?.lay_tracks) ? prev.lay_tracks.filter((x) => typeof x === "number") : [];
8206
8474
  }
@@ -8233,7 +8501,8 @@ function layMgTracks(opts) {
8233
8501
  ].map((t) => typeof t.track_index === "number" ? t.track_index : 0);
8234
8502
  const newIndex = Math.max(9, ...allIndices) + 1;
8235
8503
  for (const it of items) {
8236
- if (!(it.duration > 0)) {
8504
+ const envelope = slotEnvelope(it);
8505
+ if (!(envelope > 0)) {
8237
8506
  metaBeats.push({ ...toMetaBeat(it), laid: null });
8238
8507
  continue;
8239
8508
  }
@@ -8245,7 +8514,7 @@ function layMgTracks(opts) {
8245
8514
  html_material: materialId,
8246
8515
  opaque: it.opaque,
8247
8516
  track_st: it.track_st,
8248
- duration: it.duration
8517
+ duration: envelope
8249
8518
  });
8250
8519
  metaBeats.push({ ...toMetaBeat(it), laid: { track_index: newIndex } });
8251
8520
  }
@@ -8281,7 +8550,7 @@ function toMetaBeat(it) {
8281
8550
  composition_id: it.composition_id,
8282
8551
  track_st: it.track_st,
8283
8552
  track_ed: r34(it.track_ed),
8284
- duration: it.duration,
8553
+ duration: slotEnvelope(it),
8285
8554
  html_path: it.html_rel,
8286
8555
  ...it.category ? { category: it.category } : {}
8287
8556
  };
@@ -8357,7 +8626,13 @@ async function runLay(opts) {
8357
8626
  }
8358
8627
  const html = await readFile6(srcPath, "utf8");
8359
8628
  const category = typeof q.category === "string" ? q.category : undefined;
8360
- const lint = lintParticle(html, { compositionId: q.composition_id, dispatchIds, category });
8629
+ const slotDuration = Math.round((q.track_ed - q.track_st) * 1000) / 1000;
8630
+ const lint = lintParticle(html, {
8631
+ compositionId: q.composition_id,
8632
+ dispatchIds,
8633
+ category,
8634
+ ...slotDuration > 0 ? { slotDuration } : {}
8635
+ });
8361
8636
  for (const vv of lint.violations)
8362
8637
  (vv.fatal ? log.warn : log.info)(`${q.beat} lint ${vv.fatal ? "✗" : "·"} ${vv.law}: ${vv.msg}`);
8363
8638
  if (!lint.ok) {
@@ -8365,8 +8640,8 @@ async function runLay(opts) {
8365
8640
  log.warn(`${q.beat}:lint 未过,跳过`);
8366
8641
  continue;
8367
8642
  }
8368
- if (typeof q.duration !== "number" || !(q.duration > 0)) {
8369
- skipped.push({ beat: q.beat, reason: "duration 非正数" });
8643
+ if (!(slotDuration > 0)) {
8644
+ skipped.push({ beat: q.beat, reason: "槽位包络非正数" });
8370
8645
  continue;
8371
8646
  }
8372
8647
  srcByComp.set(q.composition_id, srcPath);
@@ -8375,7 +8650,6 @@ async function runLay(opts) {
8375
8650
  composition_id: q.composition_id,
8376
8651
  track_st: q.track_st,
8377
8652
  track_ed: q.track_ed,
8378
- duration: q.duration,
8379
8653
  opaque: lint.opaque,
8380
8654
  html_rel: `${MG_ASSET_DIR}/${q.composition_id}.html`,
8381
8655
  ...category ? { category } : {}
@@ -10726,7 +11000,7 @@ function beatQuantized(natLens, analysis) {
10726
11000
 
10727
11001
  // src/lib/mad/data.ts
10728
11002
  import { createHash as createHash2 } from "node:crypto";
10729
- import { mkdir as mkdir9, readFile as readFile7, rename, rm, writeFile as writeFile9, readdir } from "node:fs/promises";
11003
+ import { mkdir as mkdir9, readFile as readFile7, rename as rename2, rm, writeFile as writeFile9, readdir } from "node:fs/promises";
10730
11004
  import { existsSync as existsSync18 } from "node:fs";
10731
11005
  import { join as join23 } from "node:path";
10732
11006
  function madCacheDir() {
@@ -10754,7 +11028,7 @@ async function fetchWithTimeout(fetchFn, url, timeoutMs) {
10754
11028
  async function atomicWrite(dest, data) {
10755
11029
  const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
10756
11030
  await writeFile9(tmp, data);
10757
- await rename(tmp, dest);
11031
+ await rename2(tmp, dest);
10758
11032
  }
10759
11033
  async function verifyFile(path, sha256) {
10760
11034
  if (!existsSync18(path))
@@ -11264,7 +11538,7 @@ async function runMadInTool(inputArg, opts) {
11264
11538
 
11265
11539
  // src/commands/transcript.ts
11266
11540
  import { existsSync as existsSync21 } from "node:fs";
11267
- import { mkdir as mkdir12, rename as rename2, rm as rm2, stat as stat6, writeFile as writeFile11 } from "node:fs/promises";
11541
+ import { mkdir as mkdir12, rename as rename3, rm as rm2, stat as stat6, writeFile as writeFile11 } from "node:fs/promises";
11268
11542
  import { basename as basename12, dirname as dirname10, extname as extname7, join as join26, resolve as resolve12 } from "node:path";
11269
11543
 
11270
11544
  // src/lib/transcript.ts
@@ -11425,7 +11699,7 @@ async function writeMarkdownAtomic(path, markdown) {
11425
11699
  const temp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
11426
11700
  try {
11427
11701
  await writeFile11(temp, markdown, "utf8");
11428
- await rename2(temp, path);
11702
+ await rename3(temp, path);
11429
11703
  } finally {
11430
11704
  await rm2(temp, { force: true });
11431
11705
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitruck/cli",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "同合云成片流水线 CLI —— agent 驱动云端任务、产物拉回本地、三方工程文件(客户端/剪映/PR)互通。",
5
5
  "license": "MIT",
6
6
  "author": "Gitruck (同合云)",
@@ -48,7 +48,7 @@
48
48
  "build": "bun build ./src/index.ts --target=node --outfile dist/index.js",
49
49
  "typecheck": "tsc --noEmit",
50
50
  "prepublishOnly": "bun run build",
51
- "test": "esbuild src/lib/chunk-upload.ts src/lib/upload-cache.ts src/lib/upload-submit.ts src/lib/render.ts src/lib/cloud.ts src/lib/materialize.ts src/lib/projection.ts src/lib/splitdoc.ts src/lib/gtrk-writeback.ts src/lib/column-config.ts src/lib/matrix.ts src/lib/matrix-lay.ts src/lib/mg-lint.ts src/lib/mg-lay.ts src/lib/tool-descriptors.ts src/lib/tool-runner.ts src/lib/tool-pricing.ts src/lib/transcript.ts --bundle --platform=node --format=esm --outdir=.test-build --out-extension:.js=.mjs && esbuild src/commands/split.ts --bundle --platform=node --format=esm --outdir=.test-build --out-extension:.js=.mjs && esbuild src/commands/matrix.ts --bundle --platform=node --format=esm --outfile=.test-build/matrix-cmd.mjs && esbuild src/commands/mg.ts --bundle --platform=node --format=esm --outfile=.test-build/mg-cmd.mjs && esbuild src/commands/tool.ts --bundle --platform=node --format=esm --outfile=.test-build/tool-cmd.mjs && esbuild src/commands/transcript.ts --bundle --platform=node --format=esm --outfile=.test-build/transcript-cmd.mjs && esbuild src/commands/music-visualizer.ts --bundle --platform=node --format=esm --external:commander --outfile=.test-build/music-visualizer-cmd.mjs && esbuild src/commands/skills.ts --bundle --platform=node --format=esm --outfile=.test-build/skills-cmd.mjs && esbuild src/lib/convert/ir_to_jsx.ts src/lib/convert/ir_to_am.ts src/lib/convert/ir_to_nv.ts src/lib/convert/am_parse.ts src/lib/convert/nv_parse.ts src/lib/convert/parse.ts src/lib/convert/download.ts src/lib/convert/bake_ops.ts --bundle --platform=node --format=esm --outdir=.test-build/convert --out-extension:.js=.mjs && esbuild src/lib/mad/selector.ts src/lib/mad/beat.ts src/lib/mad/scan.ts src/lib/mad/data.ts src/lib/mad/pool.ts src/lib/mad/cloud-beat.ts src/lib/mad/mad.ts --bundle --platform=node --format=esm --outdir=.test-build/mad --out-extension:.js=.mjs && node --test \"test/*.test.mjs\""
51
+ "test": "esbuild src/lib/chunk-upload.ts src/lib/upload-cache.ts src/lib/upload-submit.ts src/lib/render.ts src/lib/cloud.ts src/lib/materialize.ts src/lib/projection.ts src/lib/splitdoc.ts src/lib/gtrk-writeback.ts src/lib/column-config.ts src/lib/matrix.ts src/lib/matrix-lay.ts src/lib/solid-png.ts src/lib/mg-lint.ts src/lib/mg-lay.ts src/lib/tool-descriptors.ts src/lib/tool-runner.ts src/lib/tool-pricing.ts src/lib/transcript.ts --bundle --platform=node --format=esm --outdir=.test-build --out-extension:.js=.mjs && esbuild src/commands/split.ts --bundle --platform=node --format=esm --outdir=.test-build --out-extension:.js=.mjs && esbuild src/commands/matrix.ts --bundle --platform=node --format=esm --outfile=.test-build/matrix-cmd.mjs && esbuild src/commands/mg.ts --bundle --platform=node --format=esm --outfile=.test-build/mg-cmd.mjs && esbuild src/commands/tool.ts --bundle --platform=node --format=esm --outfile=.test-build/tool-cmd.mjs && esbuild src/commands/transcript.ts --bundle --platform=node --format=esm --outfile=.test-build/transcript-cmd.mjs && esbuild src/commands/music-visualizer.ts --bundle --platform=node --format=esm --external:commander --outfile=.test-build/music-visualizer-cmd.mjs && esbuild src/commands/skills.ts --bundle --platform=node --format=esm --outfile=.test-build/skills-cmd.mjs && esbuild src/lib/convert/ir_to_jsx.ts src/lib/convert/ir_to_am.ts src/lib/convert/ir_to_nv.ts src/lib/convert/am_parse.ts src/lib/convert/nv_parse.ts src/lib/convert/parse.ts src/lib/convert/download.ts src/lib/convert/bake_ops.ts --bundle --platform=node --format=esm --outdir=.test-build/convert --out-extension:.js=.mjs && esbuild src/lib/mad/selector.ts src/lib/mad/beat.ts src/lib/mad/scan.ts src/lib/mad/data.ts src/lib/mad/pool.ts src/lib/mad/cloud-beat.ts src/lib/mad/mad.ts --bundle --platform=node --format=esm --outdir=.test-build/mad --out-extension:.js=.mjs && node --test \"test/*.test.mjs\""
52
52
  },
53
53
  "devDependencies": {
54
54
  "@xmldom/xmldom": "^0.9.6",
@@ -46,10 +46,11 @@ description: B-roll 检索铺轨器——成片管线里第一个铺的车道(
46
46
  | 每段多给几个候选 | `--top-k <n>` | 整数 · 派单 `shots` 值(服务端上限 50) | 覆盖派单里每 query 的候选数上限 |
47
47
  | 指定素材类型 | `--material-class <c>` | `real_shot` \| `concept` · 栏目策略 | 仅 internal 矩阵成员口;external 固定 real_shot、传 concept 报错 |
48
48
  | 填充太满/太差调门槛 | `--score-floor <f>` | 浮点 0–1 · `0.2` | segment score 低于此值不采纳、槽位留空露主轨 |
49
+ | 不要那条纯黑底轨 | `--no-black-bed` | 开关 · 默认铺 | 默认在候选轨之下、口播主轨之上垫一条纯黑底轨,按 beat 包络整条铺满,B-roll 期间遮住底下的口播画面(含候选轨留空处);不想要就传这个 |
49
50
  | ad-hoc 结果落文件 | `--out <file>` | 路径 · 缺省 stdout | 仅 `search` 模式 |
50
51
  | 机读(你必带) | `--json` | 开关 · 关 | 人读日志转 stderr,stdout 只出一行结果 JSON |
51
52
 
52
- > **因势象形举例**:「B-roll 多铺几条候选让我挑」→ `--lay 3`;「这段填得太杂、卡严点」→ `--score-floor 0.35`;「每段多给点候选」→ `--top-k 12`;「先只出清单别铺轨」→ `--lay 0`;「单独给『深夜地铁失神』搜一组」→ `gtrk matrix search "exhausted commuter staring blankly on a late night subway" --project <目录> --json`。
53
+ > **因势象形举例**:「B-roll 多铺几条候选让我挑」→ `--lay 3`;「这段填得太杂、卡严点」→ `--score-floor 0.35`;「每段多给点候选」→ `--top-k 12`;「先只出清单别铺轨」→ `--lay 0`;「别给我垫那条黑底」→ `--no-black-bed`;「单独给『深夜地铁失神』搜一组」→ `gtrk matrix search "exhausted commuter staring blankly on a late night subway" --project <目录> --json`。
53
54
 
54
55
  ## 执行(每次都带 `--json`)
55
56
 
@@ -58,7 +59,7 @@ gtrk matrix --project "<split 产物目录>" [--lay N] [--score-floor F] [--top-
58
59
  ```
59
60
 
60
61
  - `--json`:人读日志走 stderr,**成功时 stdout 只有一行结果 JSON**:
61
- `{ ok, mode:"plan", memberType:"internal"|"external", columnId?, planPath, lay:{ laidTracks:[…], laidClips, downloads:{preview,raw,reused,failed} }, counts:{ beats, queries, results, errors } }`
62
+ `{ ok, mode:"plan", memberType:"internal"|"external", columnId?, planPath, lay:{ laidTracks:[…], laidClips, blackTrack, downloads:{preview,raw,reused,failed} }, counts:{ beats, queries, results, errors } }`
62
63
  (`--lay 0` 时无 `lay` 字段;ad-hoc `search` 模式则是 `{ ok, mode:"search", results:[…], counts, outPath? }`)
63
64
  - 产物:候选清单 `<目录>/split/broll-plan.json`(只含引用不含素材,`cover_url` 可预览、签名 url 约 24h 过期,过期重跑即重签),铺轨则把候选轨写回工程 `gtrk/project.gtrk`。
64
65
  - **命令失败**(缺 `--dispatch`/派单、鉴权失败、全部 query 检索失败、参数越界等)→ **进程非 0 退出、报错打到 stderr、stdout 无 JSON**。先看退出码,非 0 就把 stderr 的报错如实回给用户,别当成功。
@@ -70,7 +71,8 @@ gtrk matrix --project "<split 产物目录>" [--lay N] [--score-floor F] [--top-
70
71
 
71
72
  - `counts`:`beats` 个 beat、`queries` 条检索(`errors` 条失败)、`results` 条候选——一句话概括这次铺了多大盘子。
72
73
  - `memberType`:`internal` = 矩阵成员口(栏目偏好/concept 生效);`external` = 通用口(固定 real_shot + 有版权素材)——档位影响命中类型,若用户期望 concept 却是 external,明说「当前身份只能出实拍有版权素材」。
73
- - `lay.laidTracks` / `lay.laidClips`:铺了几条候选轨、共几个颗粒(`--lay 0` 时无此字段,只出了 plan)。
74
+ - `lay.laidTracks` / `lay.laidClips`:铺了几条候选轨、共几个颗粒(`--lay 0` 时无此字段,只出了 plan)。**`laidTracks` 只含候选轨、不含黑底垫轨。**
75
+ - `lay.blackTrack`:纯黑底垫轨的 `track_index`(未铺时 `null`,如传了 `--no-black-bed`、画布尺寸非法、或零候选轨落成)。
74
76
  - `lay.downloads`:`preview` 代理数 / `raw` **原片回落**数(无 preview 代理的候选回落下原片、体积大,服务端 backfill 后重跑本命令可换回代理)/ `reused` 复用 / `failed` 掉的槽位——`raw`/`failed` 非零时提一句。
75
77
  - **单 query 失败是局部化的**(`counts.errors > 0` 但 `ok:true`):个别检索失败不拖垮整盘,如实说哪几段没检到、其余照铺。只有**全部 query 都失败**才会整体非 0 退出。
76
78
  - 工程缺失/非 v1 → 命令会**告警跳过铺轨但仍产 plan**(stderr 有提示)——这时 `lay` 字段缺失,告诉用户 plan 已出、可在有工程的目录重跑铺轨。
@@ -82,6 +84,7 @@ gtrk matrix --project "<split 产物目录>" [--lay N] [--score-floor F] [--top-
82
84
  - **在同合云桌面客户端(opencut / OpenCut Gitruck Edition)里打开这个工程**,B-roll 候选轨已经铺好。
83
85
  - **用轨道头的「小眼睛」开关逐条切换对比**:看哪条候选最贴这段口播的情绪/画面,留下满意的、关掉不要的。
84
86
  - 候选默认是 preview 代理(轻量预览);**下载原片属挑选后的动作**(客户端挑选 UI),确认要哪条再拉原片。
87
+ - **候选轨下方还垫了一条纯黑底轨**(`lay.blackTrack`),按 beat 包络整条铺满,作用是 B-roll 期间遮住底下的口播画面(含候选轨上的留空处)——删多余候选轨时**别误删它**;换片请拖到**候选轨的颗粒**上、**别拖到黑底条上**(含拖拽保护的客户端会直接拒绝并提示;尚未升级到该版本的客户端会被误拖打出黑底破洞、该处漏口播)。不想要黑底就加 `--no-black-bed` 重跑,会自动剥净。
85
88
  - 觉得填充有问题(太杂/太空/漏段)先别急着往下——**回来告诉我**,我按下面「常见情况」调参重铺。
86
89
 
87
90
  **用户明确说「B-roll 就这样、可以了」之后**,才交棒 ④。别自作主张替他拍板往下冲。
@@ -91,6 +94,7 @@ gtrk matrix --project "<split 产物目录>" [--lay N] [--score-floor F] [--top-
91
94
  | 情况 | 怎么做 |
92
95
  |---|---|
93
96
  | 想在多个候选里挑 | `--lay N` 多铺几条候选轨,opencut 小眼睛切换对比(N 越大越占轨、挑完可删多余轨) |
97
+ | 不要那条黑底垫轨 | `--no-black-bed` 重跑,黑轨与黑底素材一并剥净、候选轨层级不受影响 |
94
98
  | 填充太差 / 命中太杂 | 调 `--score-floor`(调高更严、露主轨;调低更满、可能杂)重跑 |
95
99
  | 每段候选太少不够挑 | 调 `--top-k`(每 query 给更多候选)重跑 |
96
100
  | 某段有空槽 / 漏检 / 想补个特定意象 | `gtrk matrix search "<英文长句场景描述>" --project <目录> --json` 单条 ad-hoc 补检,把中意的候选记下、在 opencut 里手动铺进那段 |