@gitruck/cli 0.2.3 → 0.2.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.
package/dist/index.js CHANGED
@@ -4196,8 +4196,8 @@ var {
4196
4196
  } = import__.default;
4197
4197
 
4198
4198
  // src/index.ts
4199
- import { readFileSync as readFileSync3 } from "node:fs";
4200
- import { join as join15 } from "node:path";
4199
+ import { readFileSync as readFileSync5 } from "node:fs";
4200
+ import { join as join20 } from "node:path";
4201
4201
 
4202
4202
  // src/lib/paths.ts
4203
4203
  import { dirname, join } from "node:path";
@@ -4245,7 +4245,7 @@ function migrateLegacyHome() {
4245
4245
  // src/commands/skills.ts
4246
4246
  import { homedir as homedir2 } from "node:os";
4247
4247
  import { join as join2 } from "node:path";
4248
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, copyFileSync } from "node:fs";
4248
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, cpSync as cpSync2 } from "node:fs";
4249
4249
 
4250
4250
  // src/lib/log.ts
4251
4251
  var c = {
@@ -4274,35 +4274,40 @@ var log = {
4274
4274
  };
4275
4275
 
4276
4276
  // src/commands/skills.ts
4277
- var SKILL_NAME = "gtrk-oralcut";
4278
- var SRC = join2(packageRoot(), "skills", SKILL_NAME, "SKILL.md");
4277
+ var SKILL_NAMES = ["gtrk-oralcut", "gtrk-splitter", "gtrk-style-maker"];
4279
4278
  function installSkill(opts = {}) {
4280
- if (!existsSync2(SRC)) {
4281
- log.warn(`找不到打包的 skill 源:${SRC}(跳过 skill 安装,不影响命令行)`);
4282
- return false;
4283
- }
4284
- const dest = join2(opts.dir ?? join2(homedir2(), ".claude", "skills"), SKILL_NAME);
4285
- try {
4286
- mkdirSync2(dest, { recursive: true });
4287
- copyFileSync(SRC, join2(dest, "SKILL.md"));
4288
- log.ok(`已安装 /${SKILL_NAME} → ${join2(dest, "SKILL.md")}`);
4289
- log.info("在 Claude Code 里打 /gtrk-oralcut,或直接说「帮我剪个口播」即可触发(可能需重载会话)。");
4290
- return true;
4291
- } catch (e) {
4292
- log.warn(`skill 安装失败(不影响命令行使用):${e instanceof Error ? e.message : String(e)}`);
4293
- return false;
4279
+ const destRoot = opts.dir ?? join2(homedir2(), ".claude", "skills");
4280
+ let allOk = true;
4281
+ for (const name of SKILL_NAMES) {
4282
+ const src = join2(packageRoot(), "skills", name);
4283
+ if (!existsSync2(join2(src, "SKILL.md"))) {
4284
+ log.warn(`找不到打包的 skill 源:${join2(src, "SKILL.md")}(跳过 ${name},不影响命令行)`);
4285
+ allOk = false;
4286
+ continue;
4287
+ }
4288
+ const dest = join2(destRoot, name);
4289
+ try {
4290
+ mkdirSync2(dest, { recursive: true });
4291
+ cpSync2(src, dest, { recursive: true });
4292
+ log.ok(`已安装 /${name} → ${join2(dest, "SKILL.md")}`);
4293
+ } catch (e) {
4294
+ allOk = false;
4295
+ log.warn(`skill 安装失败(${name},不影响命令行使用):${e instanceof Error ? e.message : String(e)}`);
4296
+ }
4294
4297
  }
4298
+ log.info("在 Claude Code 里打 /gtrk-oralcut、/gtrk-splitter 或 /gtrk-style-maker,也可直接说「帮我剪个口播 / 拆个分镜 / 造我栏目的风格 skill」触发(可能需重载会话)。");
4299
+ return allOk;
4295
4300
  }
4296
4301
  function registerSkills(program2) {
4297
4302
  const skills = program2.command("skills").description("管理 agent skill(安装到 Claude Code)");
4298
- skills.command("install").description(`把 /${SKILL_NAME} 安装到 ~/.claude/skills(对标飞书 skills add)`).option("--dir <dir>", "自定义 skills 目录(缺省 ~/.claude/skills)").action((opts) => {
4303
+ skills.command("install").description("把 /gtrk-oralcut 与 /gtrk-splitter 安装到 ~/.claude/skills(对标飞书 skills add)").option("--dir <dir>", "自定义 skills 目录(缺省 ~/.claude/skills)").action((opts) => {
4299
4304
  installSkill({ dir: opts.dir });
4300
4305
  });
4301
4306
  }
4302
4307
 
4303
4308
  // src/commands/init.ts
4304
- import { join as join7, resolve as resolve2 } from "node:path";
4305
- import { existsSync as existsSync7 } from "node:fs";
4309
+ import { join as join9, resolve as resolve2 } from "node:path";
4310
+ import { existsSync as existsSync8 } from "node:fs";
4306
4311
 
4307
4312
  // src/lib/user-config.ts
4308
4313
  import { join as join3 } from "node:path";
@@ -4461,12 +4466,566 @@ async function promptConfirm(message, defaultYes = true) {
4461
4466
  }
4462
4467
 
4463
4468
  // src/commands/doctor.ts
4464
- import { existsSync as existsSync6 } from "node:fs";
4469
+ import { existsSync as existsSync7 } from "node:fs";
4470
+ import { join as join8 } from "node:path";
4471
+
4472
+ // src/lib/column-config.ts
4473
+ import { join as join5 } from "node:path";
4474
+ import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
4475
+
4476
+ // src/lib/splitdoc.ts
4477
+ var BASE_TRACKS = ["真人出镜", "口播继续", "旁白主导"];
4478
+ var LANES = ["A_ROLL", "RRV_MG", "AI_DRAMA", "FILM_BROLL"];
4479
+ var NARRATIVES = [
4480
+ "mirror-hook",
4481
+ "demolition",
4482
+ "container-translation",
4483
+ "abyssal-fall",
4484
+ "holding",
4485
+ "reversal-elevation",
4486
+ "callback-closure",
4487
+ "typography-emphasis"
4488
+ ];
4489
+ var CONTAINER_STAGES = [
4490
+ "none",
4491
+ "seed",
4492
+ "expand",
4493
+ "translate",
4494
+ "rupture",
4495
+ "flip",
4496
+ "callback"
4497
+ ];
4498
+ var IRREPLACEABILITY = ["必须真人出镜", "优先 MG", "可被 B-roll 替代", "可降级处理"];
4499
+ var AUX_TYPES = [
4500
+ "quote-card",
4501
+ "term-callout",
4502
+ "network-diagram",
4503
+ "archive-caption",
4504
+ "pause-card",
4505
+ "data-annotation",
4506
+ "timeline-tag"
4507
+ ];
4508
+ function isNonEmptyStr(v) {
4509
+ return typeof v === "string" && v.trim().length > 0;
4510
+ }
4511
+ function enumOk(v, list) {
4512
+ return typeof v === "string" && list.includes(v);
4513
+ }
4514
+ function validateSplitDoc(doc, ctx) {
4515
+ const errors = [];
4516
+ const warnings = [];
4517
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
4518
+ return { errors: ["拆分稿必须是一个 JSON 对象"], warnings };
4519
+ }
4520
+ const d = doc;
4521
+ const vocab = ctx.vocab ?? {
4522
+ narrative: NARRATIVES,
4523
+ container_stage: CONTAINER_STAGES,
4524
+ base_track: BASE_TRACKS
4525
+ };
4526
+ const freeVocab = vocab.unknown_narrative === "allow";
4527
+ if (d.contract_version !== "v1") {
4528
+ errors.push(`contract_version 必须为 "v1"(实际:${JSON.stringify(d.contract_version)})`);
4529
+ }
4530
+ if (!isNonEmptyStr(d.transcript_hash)) {
4531
+ errors.push("缺 transcript_hash(应从投影视图透传)");
4532
+ } else if (d.transcript_hash !== ctx.transcriptHash) {
4533
+ errors.push(`transcript_hash 不匹配:拆分稿 ${d.transcript_hash} ≠ 当前 transcript ${ctx.transcriptHash}——转写已变更,请重新导出视图并重拆`);
4534
+ }
4535
+ if (!Array.isArray(d.beats) || d.beats.length === 0) {
4536
+ errors.push("beats 必须是非空数组");
4537
+ return { errors, warnings };
4538
+ }
4539
+ const idIndex = new Map;
4540
+ ctx.utteranceIds.forEach((id, i) => idIndex.set(id, i));
4541
+ const ranges = [];
4542
+ const seenBeatIds = new Set;
4543
+ d.beats.forEach((raw, i) => {
4544
+ const tag = (() => {
4545
+ const bid = raw?.id;
4546
+ return isNonEmptyStr(bid) ? bid : `beats[${i}]`;
4547
+ })();
4548
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
4549
+ errors.push(`${tag}:beat 必须是对象`);
4550
+ return;
4551
+ }
4552
+ const b = raw;
4553
+ if (!isNonEmptyStr(b.id))
4554
+ errors.push(`${tag}:缺 id`);
4555
+ else if (!/^B\d{2,}$/.test(b.id))
4556
+ errors.push(`${b.id}:id 须为 "B"+两位起序号(如 B01)`);
4557
+ else if (seenBeatIds.has(b.id))
4558
+ errors.push(`${b.id}:beat id 重复`);
4559
+ else
4560
+ seenBeatIds.add(b.id);
4561
+ if (freeVocab) {
4562
+ if (!isNonEmptyStr(b.base_track))
4563
+ errors.push(`${tag}:缺 base_track`);
4564
+ if (!isNonEmptyStr(b.narrative))
4565
+ errors.push(`${tag}:缺 narrative`);
4566
+ if (!isNonEmptyStr(b.container_stage))
4567
+ errors.push(`${tag}:缺 container_stage`);
4568
+ } else {
4569
+ if (!enumOk(b.base_track, vocab.base_track))
4570
+ errors.push(`${tag}:base_track 非法(栏目词表:${vocab.base_track.join(" | ")})`);
4571
+ if (!enumOk(b.narrative, vocab.narrative))
4572
+ errors.push(`${tag}:narrative 非法(不在栏目词表内)`);
4573
+ if (!enumOk(b.container_stage, vocab.container_stage))
4574
+ errors.push(`${tag}:container_stage 非法(不在栏目词表内)`);
4575
+ }
4576
+ if (!enumOk(b.lane, LANES))
4577
+ errors.push(`${tag}:lane 非法(四选一:${LANES.join(" | ")})`);
4578
+ if (!enumOk(b.irreplaceability, IRREPLACEABILITY))
4579
+ errors.push(`${tag}:irreplaceability 非法(四枚举之一)`);
4580
+ if (!isNonEmptyStr(b.rhythm))
4581
+ errors.push(`${tag}:缺 rhythm(人读节奏标签)`);
4582
+ if (!isNonEmptyStr(b.visual_task))
4583
+ errors.push(`${tag}:缺 visual_task(一句话视觉任务)`);
4584
+ const span = b.span;
4585
+ let fromIdx = -1;
4586
+ let toIdx = -1;
4587
+ if (!span || !isNonEmptyStr(span.from) || !isNonEmptyStr(span.to)) {
4588
+ errors.push(`${tag}:缺 span.from / span.to(utterance id 区间)`);
4589
+ } else {
4590
+ if (!idIndex.has(span.from))
4591
+ errors.push(`${tag}:span.from 引用了不存在的 utterance id ${span.from}`);
4592
+ else
4593
+ fromIdx = idIndex.get(span.from);
4594
+ if (!idIndex.has(span.to))
4595
+ errors.push(`${tag}:span.to 引用了不存在的 utterance id ${span.to}`);
4596
+ else
4597
+ toIdx = idIndex.get(span.to);
4598
+ if (fromIdx >= 0 && toIdx >= 0) {
4599
+ if (fromIdx > toIdx)
4600
+ errors.push(`${tag}:区间倒序(span.from ${span.from} 晚于 span.to ${span.to})`);
4601
+ else if (isNonEmptyStr(b.id))
4602
+ ranges.push({ id: b.id, from: fromIdx, to: toIdx });
4603
+ }
4604
+ }
4605
+ validateHandoff(tag, b, errors, warnings);
4606
+ if (b.aux_layers != null) {
4607
+ if (!Array.isArray(b.aux_layers))
4608
+ errors.push(`${tag}:aux_layers 必须是数组`);
4609
+ else
4610
+ b.aux_layers.forEach((a, ai) => validateAux(`${tag}.aux[${ai}]`, a, idIndex, errors));
4611
+ }
4612
+ });
4613
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
4614
+ for (let i = 1;i < sorted.length; i++) {
4615
+ const prev = sorted[i - 1];
4616
+ const cur = sorted[i];
4617
+ if (cur.from <= prev.to) {
4618
+ errors.push(`${prev.id} 与 ${cur.id}:utterance 区间重叠(beats 之间不允许交集)`);
4619
+ }
4620
+ }
4621
+ return { errors, warnings };
4622
+ }
4623
+ function validateHandoff(tag, b, errors, warnings) {
4624
+ const lane = b.lane;
4625
+ const handoff = b.handoff;
4626
+ if (lane === "A_ROLL") {
4627
+ if (handoff != null)
4628
+ warnings.push(`${tag}:A_ROLL 不应带 handoff,已忽略`);
4629
+ return;
4630
+ }
4631
+ if (lane === "RRV_MG") {
4632
+ if (!handoff || typeof handoff.duration_hint !== "number") {
4633
+ errors.push(`${tag}:RRV_MG 的 handoff.duration_hint 必填(秒,数值)`);
4634
+ }
4635
+ return;
4636
+ }
4637
+ if (lane === "FILM_BROLL") {
4638
+ const q = handoff?.queries;
4639
+ if (!Array.isArray(q) || q.length === 0 || !q.every((x) => isNonEmptyStr(x))) {
4640
+ errors.push(`${tag}:FILM_BROLL 缺检索 query(handoff.queries 必须为非空字符串数组)`);
4641
+ }
4642
+ return;
4643
+ }
4644
+ }
4645
+ function validateAux(tag, raw, idIndex, errors) {
4646
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
4647
+ errors.push(`${tag}:辅助层必须是对象`);
4648
+ return;
4649
+ }
4650
+ const a = raw;
4651
+ if (!enumOk(a.type, AUX_TYPES))
4652
+ errors.push(`${tag}:type 非法(七类之一)`);
4653
+ if (!isNonEmptyStr(a.role))
4654
+ errors.push(`${tag}:缺 role(职责)`);
4655
+ const m = a.mount;
4656
+ if (m === "same_beat")
4657
+ return;
4658
+ if (typeof m === "object" && m !== null) {
4659
+ const mo = m;
4660
+ if (isNonEmptyStr(mo.trigger)) {
4661
+ if (!idIndex.has(mo.trigger))
4662
+ errors.push(`${tag}:mount.trigger 引用了不存在的 utterance id ${mo.trigger}`);
4663
+ return;
4664
+ }
4665
+ if (isNonEmptyStr(mo.from) && isNonEmptyStr(mo.to)) {
4666
+ if (!idIndex.has(mo.from))
4667
+ errors.push(`${tag}:mount.from 引用了不存在的 utterance id ${mo.from}`);
4668
+ if (!idIndex.has(mo.to))
4669
+ errors.push(`${tag}:mount.to 引用了不存在的 utterance id ${mo.to}`);
4670
+ if (idIndex.has(mo.from) && idIndex.has(mo.to) && idIndex.get(mo.from) > idIndex.get(mo.to)) {
4671
+ errors.push(`${tag}:mount 区间倒序`);
4672
+ }
4673
+ return;
4674
+ }
4675
+ }
4676
+ errors.push(`${tag}:mount 非法(应为 "same_beat" | {from,to} | {trigger})`);
4677
+ }
4678
+ function r3(n) {
4679
+ return Math.round(n * 1000) / 1000;
4680
+ }
4681
+ function buildLanding(doc, view, opts) {
4682
+ const byId = new Map;
4683
+ for (const id of opts.utteranceIds)
4684
+ byId.set(id, []);
4685
+ for (const u of view.utterances) {
4686
+ if (u.dropped || u.track_st == null || u.track_ed == null)
4687
+ continue;
4688
+ if (!byId.has(u.id))
4689
+ byId.set(u.id, []);
4690
+ byId.get(u.id).push({ track_st: u.track_st, track_ed: u.track_ed });
4691
+ }
4692
+ const idIndex = new Map;
4693
+ opts.utteranceIds.forEach((id, i) => idIndex.set(id, i));
4694
+ const split = {
4695
+ contract_version: doc.contract_version,
4696
+ transcript_hash: doc.transcript_hash,
4697
+ projected_at: opts.projectedAt,
4698
+ ...opts.sourceIndex ? { material_id: opts.sourceIndex.materialId } : {},
4699
+ beats: []
4700
+ };
4701
+ const dispatch = { rrv_mg: [], film_broll: [], ai_drama: [] };
4702
+ const skipped = [];
4703
+ const shrunk = [];
4704
+ const unhandledLanes = new Set;
4705
+ for (const beat of doc.beats) {
4706
+ const fromIdx = idIndex.get(beat.span.from);
4707
+ const toIdx = idIndex.get(beat.span.to);
4708
+ const spanIds = opts.utteranceIds.slice(fromIdx, toIdx + 1);
4709
+ const instances = spanIds.flatMap((id) => byId.get(id) ?? []);
4710
+ const droppedCount = spanIds.filter((id) => (byId.get(id)?.length ?? 0) === 0).length;
4711
+ if (instances.length === 0) {
4712
+ skipped.push({ beat: beat.id, reason: "span 内全部 utterance 被剪,未落轨" });
4713
+ continue;
4714
+ }
4715
+ const track_st = Math.min(...instances.map((x) => x.track_st));
4716
+ const track_ed = Math.max(...instances.map((x) => x.track_ed));
4717
+ const isShrunk = droppedCount > 0;
4718
+ const metaBeat = { id: beat.id, lane: beat.lane, span: beat.span, track_st, track_ed };
4719
+ if (opts.sourceIndex) {
4720
+ const from = opts.sourceIndex.utterances.get(beat.span.from);
4721
+ const to = opts.sourceIndex.utterances.get(beat.span.to);
4722
+ if (from && to && to.ed > from.st) {
4723
+ metaBeat.source_ranges = [{ st: r3(from.st), ed: r3(to.ed) }];
4724
+ }
4725
+ }
4726
+ if (isShrunk)
4727
+ metaBeat.shrunk = true;
4728
+ if (beat.narrative)
4729
+ metaBeat.narrative = beat.narrative;
4730
+ if (beat.container_stage)
4731
+ metaBeat.container_stage = beat.container_stage;
4732
+ if (beat.visual_task)
4733
+ metaBeat.visual_task = beat.visual_task;
4734
+ if (beat.lane !== "A_ROLL" && beat.handoff)
4735
+ metaBeat.handoff = beat.handoff;
4736
+ split.beats.push(metaBeat);
4737
+ if (isShrunk) {
4738
+ shrunk.push({ beat: beat.id, kept: spanIds.length - droppedCount, dropped: droppedCount, track_st, track_ed });
4739
+ }
4740
+ const h = beat.handoff ?? {};
4741
+ const compositionId = `${opts.projectSlug}-${beat.id}`;
4742
+ if (beat.lane === "RRV_MG") {
4743
+ dispatch.rrv_mg.push({
4744
+ beat: beat.id,
4745
+ composition_id: compositionId,
4746
+ duration: typeof h.duration_hint === "number" ? h.duration_hint : null,
4747
+ theme: h.theme,
4748
+ bg: h.bg,
4749
+ slug_hint: h.slug_hint,
4750
+ track_st,
4751
+ track_ed
4752
+ });
4753
+ } else if (beat.lane === "FILM_BROLL") {
4754
+ dispatch.film_broll.push({
4755
+ beat: beat.id,
4756
+ queries: Array.isArray(h.queries) ? h.queries : [],
4757
+ shots: h.shots,
4758
+ per_shot_sec: h.per_shot_sec,
4759
+ exclude: h.exclude,
4760
+ track_st,
4761
+ track_ed
4762
+ });
4763
+ } else if (beat.lane === "AI_DRAMA") {
4764
+ dispatch.ai_drama.push({ beat: beat.id, ...h, track_st, track_ed });
4765
+ } else if (beat.lane !== "A_ROLL") {
4766
+ unhandledLanes.add(beat.lane);
4767
+ }
4768
+ }
4769
+ return { split, dispatch, skipped, shrunk, unhandledLanes: [...unhandledLanes] };
4770
+ }
4771
+ function renderSplitMarkdown(doc, landing, meta) {
4772
+ const L = [];
4773
+ const metaById = new Map(landing.split.beats.map((b) => [b.id, b]));
4774
+ const skippedIds = new Set(landing.skipped.map((s) => s.beat));
4775
+ L.push(`# 视觉拆分稿(${meta.projectSlug})`);
4776
+ L.push("");
4777
+ L.push(`- contract_version:\`${doc.contract_version}\``);
4778
+ L.push(`- transcript_hash:\`${doc.transcript_hash}\``);
4779
+ L.push(`- projected_at:\`${meta.projectedAt}\``);
4780
+ L.push(`- beats:${doc.beats.length}(落轨 ${landing.split.beats.length} · 跳过 ${landing.skipped.length} · 收缩 ${landing.shrunk.length})`);
4781
+ L.push("");
4782
+ L.push("# Beat Timeline");
4783
+ L.push("");
4784
+ for (const beat of doc.beats) {
4785
+ const mb = metaById.get(beat.id);
4786
+ L.push(`## ${beat.id}${skippedIds.has(beat.id) ? "(整段被剪 · 跳过)" : mb?.shrunk ? "(部分被剪 · 已收缩)" : ""}`);
4787
+ L.push(`- 文稿范围:\`${beat.span.from} … ${beat.span.to}\``);
4788
+ L.push(`- 底轨:\`${beat.base_track}\``);
4789
+ L.push(`- 主层:\`${beat.lane}\``);
4790
+ L.push(`- 叙事功能:\`${beat.narrative}\``);
4791
+ L.push(`- 容器阶段:\`${beat.container_stage}\``);
4792
+ if (beat.rhythm)
4793
+ L.push(`- 节奏标签:\`${beat.rhythm}\``);
4794
+ L.push(`- 视觉任务:${beat.visual_task}`);
4795
+ L.push(`- 不可替代性:\`${beat.irreplaceability}\``);
4796
+ if (mb)
4797
+ L.push(`- 轨道时码:\`${mb.track_st}s … ${mb.track_ed}s\``);
4798
+ if (beat.callback_of)
4799
+ L.push(`- 回扣对象:\`${beat.callback_of}\``);
4800
+ for (const a of beat.aux_layers ?? []) {
4801
+ const mount = a.mount === "same_beat" ? "同 beat" : ("trigger" in a.mount) ? `触发 ${a.mount.trigger}` : `${a.mount.from} … ${a.mount.to}`;
4802
+ L.push(` - 辅助层 \`${a.type}\`(${mount}):${a.role}`);
4803
+ }
4804
+ L.push("");
4805
+ }
4806
+ L.push("# Production Queues");
4807
+ L.push("");
4808
+ L.push("## A_ROLL Queue");
4809
+ for (const b of doc.beats.filter((x) => x.lane === "A_ROLL" && !skippedIds.has(x.id))) {
4810
+ L.push(`- \`${b.id}\` ${b.visual_task}`);
4811
+ }
4812
+ L.push("");
4813
+ L.push("## RRV_MG Queue");
4814
+ for (const r of landing.dispatch.rrv_mg) {
4815
+ L.push(`- \`${r.beat}\` composition_id=\`${r.composition_id}\`${r.duration != null ? ` · ${r.duration}s` : ""}`);
4816
+ }
4817
+ L.push("");
4818
+ L.push("## AI_DRAMA Queue");
4819
+ for (const a of landing.dispatch.ai_drama)
4820
+ L.push(`- \`${a.beat}\` ${a.track_st}s…${a.track_ed}s`);
4821
+ L.push("");
4822
+ L.push("## FILM_BROLL Queue");
4823
+ for (const f of landing.dispatch.film_broll)
4824
+ L.push(`- \`${f.beat}\` queries=[${f.queries.join(" / ")}]`);
4825
+ L.push("");
4826
+ return L.join(`
4827
+ `);
4828
+ }
4829
+
4830
+ // src/lib/column-config.ts
4831
+ var DEFAULT_COLUMN_CONFIG = {
4832
+ meta: { id: "real-roam-guide", name: "实在界漫游指南(内置默认)" },
4833
+ vocab: {
4834
+ narrative: [...NARRATIVES],
4835
+ container_stage: [...CONTAINER_STAGES],
4836
+ base_track: [...BASE_TRACKS]
4837
+ },
4838
+ lanes: { enabled: [...LANES] },
4839
+ fallback: { unknown_narrative: "reject" }
4840
+ };
4841
+ function columnsDir() {
4842
+ return join5(gitruckHome(), "columns");
4843
+ }
4844
+ var uniq = (xs) => [...new Set(xs)];
4845
+ function strArr(v) {
4846
+ if (!Array.isArray(v))
4847
+ return;
4848
+ return v.filter((x) => typeof x === "string");
4849
+ }
4850
+ function foldColumnConfigs(layers) {
4851
+ const out = {};
4852
+ for (const l of layers) {
4853
+ if (!l || typeof l !== "object" || Array.isArray(l))
4854
+ continue;
4855
+ if (l.meta && typeof l.meta === "object")
4856
+ out.meta = { ...out.meta, ...l.meta };
4857
+ if (l.vocab && typeof l.vocab === "object") {
4858
+ out.vocab ??= {};
4859
+ for (const k of ["narrative", "container_stage", "base_track"]) {
4860
+ const add = strArr(l.vocab[k]);
4861
+ if (add)
4862
+ out.vocab[k] = uniq([...out.vocab[k] ?? [], ...add]);
4863
+ }
4864
+ }
4865
+ if (l.lanes && typeof l.lanes === "object") {
4866
+ out.lanes ??= {};
4867
+ const en = strArr(l.lanes.enabled);
4868
+ if (en)
4869
+ out.lanes.enabled = uniq([...out.lanes.enabled ?? [], ...en]);
4870
+ if (l.lanes.appearance && typeof l.lanes.appearance === "object") {
4871
+ out.lanes.appearance = l.lanes.appearance;
4872
+ }
4873
+ }
4874
+ if (l.broll && typeof l.broll === "object") {
4875
+ out.broll ??= {};
4876
+ const tags = strArr(l.broll.column_tag_ids);
4877
+ if (tags)
4878
+ out.broll.column_tag_ids = uniq([...out.broll.column_tag_ids ?? [], ...tags]);
4879
+ const fa = strArr(l.broll.facet_allowed);
4880
+ if (fa) {
4881
+ out.broll.facet_allowed = out.broll.facet_allowed ? out.broll.facet_allowed.filter((x) => fa.includes(x)) : [...fa];
4882
+ }
4883
+ if (typeof l.broll.material_class_policy === "string")
4884
+ out.broll.material_class_policy = l.broll.material_class_policy;
4885
+ if (l.broll.facet_defaults && typeof l.broll.facet_defaults === "object")
4886
+ out.broll.facet_defaults = l.broll.facet_defaults;
4887
+ }
4888
+ if (l.style !== undefined)
4889
+ out.style = l.style;
4890
+ if (l.fallback && typeof l.fallback === "object") {
4891
+ const un = l.fallback.unknown_narrative;
4892
+ if (un === "allow" || un === "reject")
4893
+ out.fallback = { unknown_narrative: un };
4894
+ }
4895
+ }
4896
+ return out;
4897
+ }
4898
+ function readLocalColumn(columnId, dir, warnings) {
4899
+ const p = join5(dir, `${columnId}.json`);
4900
+ if (!existsSync5(p)) {
4901
+ warnings.push(`栏目配置不存在:${p},回落内置默认`);
4902
+ return;
4903
+ }
4904
+ try {
4905
+ const parsed = JSON.parse(readFileSync2(p, "utf8"));
4906
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
4907
+ warnings.push(`栏目配置格式异常(非 JSON 对象):${p},回落内置默认`);
4908
+ return;
4909
+ }
4910
+ return parsed;
4911
+ } catch {
4912
+ warnings.push(`栏目配置损坏(JSON 解析失败):${p},回落内置默认`);
4913
+ return;
4914
+ }
4915
+ }
4916
+ function resolveColumnConfig(opts = {}) {
4917
+ const warnings = [];
4918
+ const layers = [DEFAULT_COLUMN_CONFIG];
4919
+ if (opts.columnId) {
4920
+ const l2 = readLocalColumn(opts.columnId, opts.columnsDir ?? columnsDir(), warnings);
4921
+ if (l2)
4922
+ layers.push(l2);
4923
+ }
4924
+ const config = foldColumnConfigs(layers);
4925
+ config.style = normalizeColumnStyle(config.style, warnings);
4926
+ for (const n of producesNotices(config.style))
4927
+ warnings.push(n);
4928
+ return { config, warnings };
4929
+ }
4930
+ var HANDOFF_REGISTRY = LANES;
4931
+ function normalizeEntries(list, kind, warnings) {
4932
+ if (list === undefined)
4933
+ return;
4934
+ if (!Array.isArray(list)) {
4935
+ warnings.push(`style.${kind} 非数组,已忽略`);
4936
+ return;
4937
+ }
4938
+ const out = [];
4939
+ list.forEach((e, i) => {
4940
+ if (typeof e !== "object" || e === null || Array.isArray(e)) {
4941
+ warnings.push(`style.${kind}[${i}] 非对象,已跳过`);
4942
+ return;
4943
+ }
4944
+ const o = e;
4945
+ if (typeof o.id !== "string" || typeof o.ref !== "string" || !o.id || !o.ref) {
4946
+ warnings.push(`style.${kind}[${i}] 缺 id/ref,已跳过`);
4947
+ return;
4948
+ }
4949
+ out.push(e);
4950
+ });
4951
+ return out;
4952
+ }
4953
+ function normalizeColumnStyle(raw, warnings) {
4954
+ if (raw === undefined)
4955
+ return;
4956
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
4957
+ warnings.push("style 块非对象,已忽略");
4958
+ return;
4959
+ }
4960
+ const r = raw;
4961
+ const style = {};
4962
+ const skills = normalizeEntries(r.skills, "skills", warnings);
4963
+ if (skills)
4964
+ style.skills = skills;
4965
+ const shared = normalizeEntries(r.shared, "shared", warnings);
4966
+ if (shared)
4967
+ style.shared = shared;
4968
+ if (typeof r.bundle_ref === "string")
4969
+ style.bundle_ref = r.bundle_ref;
4970
+ return style;
4971
+ }
4972
+ function editDistance(a, b) {
4973
+ const m = a.length;
4974
+ const n = b.length;
4975
+ if (m === 0)
4976
+ return n;
4977
+ if (n === 0)
4978
+ return m;
4979
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
4980
+ for (let i = 1;i <= m; i++) {
4981
+ const cur = [i];
4982
+ for (let j = 1;j <= n; j++) {
4983
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
4984
+ }
4985
+ prev = cur;
4986
+ }
4987
+ return prev[n];
4988
+ }
4989
+ function producesValues(e) {
4990
+ if (typeof e.produces === "string")
4991
+ return [e.produces];
4992
+ if (Array.isArray(e.produces))
4993
+ return e.produces.filter((x) => typeof x === "string");
4994
+ return [];
4995
+ }
4996
+ function producesNotices(style) {
4997
+ const notices = [];
4998
+ if (!style?.skills)
4999
+ return notices;
5000
+ const seen = new Set;
5001
+ for (const e of style.skills) {
5002
+ if (e.routing === "none")
5003
+ continue;
5004
+ for (const v of producesValues(e)) {
5005
+ if (HANDOFF_REGISTRY.includes(v) || seen.has(v))
5006
+ continue;
5007
+ const near = HANDOFF_REGISTRY.find((r) => r.toLowerCase() === v.toLowerCase() || editDistance(r.toUpperCase(), v.toUpperCase()) <= 2);
5008
+ if (near) {
5009
+ seen.add(v);
5010
+ notices.push(`style.skills[${e.id}].produces="${v}" 疑似拼写(接近注册类型 "${near}");如确为管线外产物可设 routing:"none"`);
5011
+ }
5012
+ }
5013
+ }
5014
+ return notices;
5015
+ }
5016
+ function effectiveVocab(config) {
5017
+ return {
5018
+ narrative: config.vocab?.narrative ?? [...NARRATIVES],
5019
+ container_stage: config.vocab?.container_stage ?? [...CONTAINER_STAGES],
5020
+ base_track: config.vocab?.base_track ?? [...BASE_TRACKS],
5021
+ unknown_narrative: config.fallback?.unknown_narrative
5022
+ };
5023
+ }
4465
5024
 
4466
5025
  // src/lib/ffmpeg.ts
4467
5026
  import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
4468
- import { existsSync as existsSync5 } from "node:fs";
4469
- import { join as join5 } from "node:path";
5027
+ import { existsSync as existsSync6 } from "node:fs";
5028
+ import { join as join6 } from "node:path";
4470
5029
  var isWin = process.platform === "win32";
4471
5030
  var bin = (base) => isWin ? `${base}.exe` : base;
4472
5031
  var FFMPEG_INSTALL_HINT = `未找到 ffmpeg/ffprobe。请把二者放到 ${ffmpegDir()}(agent 可代办:先查本地确实缺失才拉,` + `面向国内用户优先国内加速站点——GitHub 代理 pass-through 拉 BtbN/gyan.dev 官方静态构建,或同合云自建镜像,` + `并做 sha256 校验),或用 --ffmpeg-path <目录> 指定已装位置。`;
@@ -4488,9 +5047,9 @@ function resolveFfmpeg(ffmpegPath) {
4488
5047
  dirs.push([ffmpegDir(), "~/.gitruck/ffmpeg"]);
4489
5048
  let found = null;
4490
5049
  for (const [dir, label] of dirs) {
4491
- const ff = join5(dir, bin("ffmpeg"));
4492
- const fp = join5(dir, bin("ffprobe"));
4493
- if (existsSync5(ff) && existsSync5(fp)) {
5050
+ const ff = join6(dir, bin("ffmpeg"));
5051
+ const fp = join6(dir, bin("ffprobe"));
5052
+ if (existsSync6(ff) && existsSync6(fp)) {
4494
5053
  found = { ffmpeg: ff, ffprobe: fp, source: label };
4495
5054
  break;
4496
5055
  }
@@ -4571,12 +5130,12 @@ function probeCapabilities(res) {
4571
5130
  }
4572
5131
 
4573
5132
  // src/lib/version.ts
4574
- import { readFileSync as readFileSync2 } from "node:fs";
4575
- import { join as join6 } from "node:path";
5133
+ import { readFileSync as readFileSync3 } from "node:fs";
5134
+ import { join as join7 } from "node:path";
4576
5135
  var REGISTRY = "https://registry.npmjs.org/@gitruck%2Fcli";
4577
5136
  function currentVersion() {
4578
5137
  try {
4579
- const { version } = JSON.parse(readFileSync2(join6(packageRoot(), "package.json"), "utf8"));
5138
+ const { version } = JSON.parse(readFileSync3(join7(packageRoot(), "package.json"), "utf8"));
4580
5139
  return version;
4581
5140
  } catch {
4582
5141
  return "0.0.0";
@@ -4658,7 +5217,7 @@ async function runDoctor() {
4658
5217
  }
4659
5218
  rows.push({ name: "云端连通 + 鉴权", status: apiStatus, detail: apiDetail });
4660
5219
  const draftDir = resolveJianyingDraftDir(undefined);
4661
- const draftOk = !!draftDir && existsSync6(draftDir);
5220
+ const draftOk = !!draftDir && existsSync7(draftDir);
4662
5221
  rows.push({
4663
5222
  name: "剪映草稿目录",
4664
5223
  status: draftOk ? "ok" : "warn",
@@ -4666,8 +5225,15 @@ async function runDoctor() {
4666
5225
  });
4667
5226
  rows.push({
4668
5227
  name: "配置文件",
4669
- status: existsSync6(configPath()) ? "ok" : "warn",
4670
- detail: existsSync6(configPath()) ? configPath() : `未生成 —— 跑 gtrk init(${configPath()})`
5228
+ status: existsSync7(configPath()) ? "ok" : "warn",
5229
+ detail: existsSync7(configPath()) ? configPath() : `未生成 —— 跑 gtrk init(${configPath()})`
5230
+ });
5231
+ const col = uc.defaultColumn;
5232
+ const colFile = col ? join8(columnsDir(), `${col}.json`) : undefined;
5233
+ rows.push({
5234
+ name: "当前栏目",
5235
+ status: "ok",
5236
+ detail: col ? `${col}${colFile && existsSync7(colFile) ? `(${colFile})` : `(⚠ 配置文件缺失:${colFile},将回落内置默认)`}` : "内置默认 —— 想建自己栏目的风格体系,跑 /gtrk-style-maker(不建也能直接用默认)"
4671
5237
  });
4672
5238
  const ff = resolveFfmpeg();
4673
5239
  if (ff) {
@@ -4714,7 +5280,7 @@ gtrk 体检:
4714
5280
  }
4715
5281
 
4716
5282
  // src/commands/init.ts
4717
- var GUIDE_IMAGE = join7(packageRoot(), "assets", "jianying-draft-path.png");
5283
+ var GUIDE_IMAGE = join9(packageRoot(), "assets", "jianying-draft-path.png");
4718
5284
  function registerInit(program2) {
4719
5285
  program2.command("init").description("一次性配置:API Key + 剪映草稿目录(之后所有命令免重复配置)").option("--api-key <key>", "非交互:直接指定 API Key").option("--api-base <url>", "非交互:指定 API 根地址(缺省用默认生产地址)").option("--jianying-draft-dir <dir>", "非交互:剪映草稿目录(传 auto 则自动探测)").option("--reconfigure", "重走配置向导(默认:已配过则跳过、保留现有配置)").option("-y, --yes", "非交互:用传入值 + 自动探测,不弹任何提示").action(runInit);
4720
5286
  }
@@ -4753,7 +5319,7 @@ async function runInit(opts) {
4753
5319
  defaultValue: existing.apiBase ?? DEFAULT_API_BASE
4754
5320
  })).trim();
4755
5321
  let jianyingDraftDir;
4756
- if (existing.jianyingDraftDir && existsSync7(existing.jianyingDraftDir)) {
5322
+ if (existing.jianyingDraftDir && existsSync8(existing.jianyingDraftDir)) {
4757
5323
  if (await promptConfirm(`剪映草稿目录现为 ${existing.jianyingDraftDir},保留吗?`, true)) {
4758
5324
  jianyingDraftDir = existing.jianyingDraftDir;
4759
5325
  }
@@ -4769,7 +5335,7 @@ async function runInit(opts) {
4769
5335
  openFile(GUIDE_IMAGE);
4770
5336
  const manual = (await promptText("剪映草稿根目录(…\\com.lveditor.draft),留空跳过:")).trim();
4771
5337
  if (manual) {
4772
- if (existsSync7(manual))
5338
+ if (existsSync8(manual))
4773
5339
  jianyingDraftDir = resolve2(manual);
4774
5340
  else
4775
5341
  log.warn(`目录不存在,已跳过:${manual}`);
@@ -4794,6 +5360,7 @@ async function afterConfigDoctor() {
4794
5360
  log.step("装好了!两种用法任选:");
4795
5361
  log.info('① 命令行直接剪:gtrk oralcut "<毛片.mp4>" --script "<文字稿.txt>"(无稿就别加 --script)');
4796
5362
  log.info("② 重启你常用的 AI agent(Claude / Codex / Trae / WorkBuddy 等),用 /gtrk-oralcut <你的口播剪辑需求>,一句话交给它,体验更智能的剪辑~");
5363
+ log.info("想有自己栏目的风格体系?在 agent 里跑 /gtrk-style-maker 建一次「你的厨房」(skill 家族 + 栏目配置);不建就直接用默认,照常开剪。");
4797
5364
  } else {
4798
5365
  log.warn("上面体检有项没通,按提示处理好再开剪(多半是 API Key 或剪映目录)。");
4799
5366
  }
@@ -4835,9 +5402,9 @@ function registerInstall(program2) {
4835
5402
  }
4836
5403
 
4837
5404
  // src/commands/oralcut.ts
4838
- import { resolve as resolve3, join as join12, dirname as dirname2, basename as basename5, extname as extname2 } from "node:path";
5405
+ import { resolve as resolve3, join as join14, dirname as dirname2, basename as basename5, extname as extname2 } from "node:path";
4839
5406
  import { mkdir as mkdir4, writeFile as writeFile5, readFile as readFile3 } from "node:fs/promises";
4840
- import { existsSync as existsSync11 } from "node:fs";
5407
+ import { existsSync as existsSync12 } from "node:fs";
4841
5408
 
4842
5409
  // src/lib/config.ts
4843
5410
  function loadConfig() {
@@ -4966,9 +5533,9 @@ async function download(url, dest) {
4966
5533
  }
4967
5534
 
4968
5535
  // src/lib/upload-cache.ts
4969
- import { join as join8 } from "node:path";
5536
+ import { join as join10 } from "node:path";
4970
5537
  import { stat as stat3, mkdir, readFile, writeFile as writeFile2 } from "node:fs/promises";
4971
- import { existsSync as existsSync8 } from "node:fs";
5538
+ import { existsSync as existsSync9 } from "node:fs";
4972
5539
 
4973
5540
  // src/lib/chunk-upload.ts
4974
5541
  var import_hash_wasm = __toESM(require_index_umd(), 1);
@@ -5200,14 +5767,14 @@ async function putPart(cfg, uploadId, idx, view) {
5200
5767
 
5201
5768
  // src/lib/upload-cache.ts
5202
5769
  var CACHE_DIR = gitruckHome();
5203
- var CACHE_FILE = join8(CACHE_DIR, "upload-cache.json");
5204
- var SESSION_FILE = join8(CACHE_DIR, "upload-sessions.json");
5770
+ var CACHE_FILE = join10(CACHE_DIR, "upload-cache.json");
5771
+ var SESSION_FILE = join10(CACHE_DIR, "upload-sessions.json");
5205
5772
  async function fingerprint(path) {
5206
5773
  const s = await stat3(path);
5207
5774
  return `${s.size}:${Math.round(s.mtimeMs)}`;
5208
5775
  }
5209
5776
  async function load() {
5210
- if (!existsSync8(CACHE_FILE))
5777
+ if (!existsSync9(CACHE_FILE))
5211
5778
  return {};
5212
5779
  try {
5213
5780
  return JSON.parse(await readFile(CACHE_FILE, "utf8"));
@@ -5228,7 +5795,7 @@ async function invalidateUpload(path) {
5228
5795
  }
5229
5796
  }
5230
5797
  async function loadSessions() {
5231
- if (!existsSync8(SESSION_FILE))
5798
+ if (!existsSync9(SESSION_FILE))
5232
5799
  return {};
5233
5800
  try {
5234
5801
  return JSON.parse(await readFile(SESSION_FILE, "utf8"));
@@ -5283,8 +5850,8 @@ async function uploadCached(cfg, path, opts) {
5283
5850
 
5284
5851
  // src/lib/media.ts
5285
5852
  import { mkdir as mkdir2, stat as stat4 } from "node:fs/promises";
5286
- import { existsSync as existsSync9 } from "node:fs";
5287
- import { basename as basename3, extname, join as join9 } from "node:path";
5853
+ import { existsSync as existsSync10 } from "node:fs";
5854
+ import { basename as basename3, extname, join as join11 } from "node:path";
5288
5855
  function parseFps(rate) {
5289
5856
  if (typeof rate !== "string")
5290
5857
  return 0;
@@ -5334,12 +5901,12 @@ async function artifactPath(inputAbs, ext) {
5334
5901
  const s = await stat4(inputAbs);
5335
5902
  const base = basename3(inputAbs, extname(inputAbs));
5336
5903
  await mkdir2(audioCacheDir(), { recursive: true });
5337
- return join9(audioCacheDir(), `${base}.${s.size}_${Math.round(s.mtimeMs)}.${ext}`);
5904
+ return join11(audioCacheDir(), `${base}.${s.size}_${Math.round(s.mtimeMs)}.${ext}`);
5338
5905
  }
5339
5906
  async function extractAudio(inputAbs, ffmpegPath) {
5340
5907
  const { ffmpeg } = requireFfmpeg(ffmpegPath);
5341
5908
  const out = await artifactPath(inputAbs, "mp3");
5342
- if (existsSync9(out))
5909
+ if (existsSync10(out))
5343
5910
  return out;
5344
5911
  await runFfmpeg(ffmpeg, [
5345
5912
  "-y",
@@ -5363,7 +5930,7 @@ async function extractAudio(inputAbs, ffmpegPath) {
5363
5930
  async function compress720p(inputAbs, ffmpegPath) {
5364
5931
  const { ffmpeg } = requireFfmpeg(ffmpegPath);
5365
5932
  const out = await artifactPath(inputAbs, "720p.mp4");
5366
- if (existsSync9(out))
5933
+ if (existsSync10(out))
5367
5934
  return out;
5368
5935
  await runFfmpeg(ffmpeg, [
5369
5936
  "-y",
@@ -5395,14 +5962,14 @@ function assertDurationConsistent(originalDuration, artifactAbs, ffmpegPath, tol
5395
5962
  }
5396
5963
 
5397
5964
  // src/lib/materialize.ts
5398
- import { join as join11, basename as basename4 } from "node:path";
5965
+ import { join as join13, basename as basename4 } from "node:path";
5399
5966
  import { mkdir as mkdir3, cp, writeFile as writeFile4 } from "node:fs/promises";
5400
5967
 
5401
5968
  // src/lib/render.ts
5402
5969
  import { writeFile as writeFile3, unlink, readFile as readFile2 } from "node:fs/promises";
5403
- import { existsSync as existsSync10 } from "node:fs";
5970
+ import { existsSync as existsSync11 } from "node:fs";
5404
5971
  import { tmpdir } from "node:os";
5405
- import { join as join10 } from "node:path";
5972
+ import { join as join12 } from "node:path";
5406
5973
  var AUDIO_SAMPLE_RATE = 48000;
5407
5974
  var AUDIO_LAYOUT = "stereo";
5408
5975
  var DEFAULT_CRF = 18;
@@ -5542,11 +6109,25 @@ function buildFilterGraph(gtrk, materialPaths, params = {}) {
5542
6109
  return { inputs, graph: chains.join(";"), total };
5543
6110
  }
5544
6111
  function materialPathsFromGtrk(gtrk) {
6112
+ const used = new Set;
6113
+ const sortedV = sortedTracks(gtrk.video_track || []);
6114
+ const consumers = [sortedV[0], ...gtrk.audio_track || []];
6115
+ for (const t of consumers) {
6116
+ if (!t)
6117
+ continue;
6118
+ for (const c3 of t.track_timeline || []) {
6119
+ const m = c3.material;
6120
+ if (m != null)
6121
+ used.add(String(m));
6122
+ }
6123
+ }
5545
6124
  const map = {};
5546
6125
  for (const m of gtrk.materials || []) {
6126
+ if (!used.has(String(m.id)))
6127
+ continue;
5547
6128
  if (!m.path)
5548
6129
  throw new Error(`gtrk 素材 ${m.id} 缺 path(source_path),无法本地渲染`);
5549
- if (!existsSync10(m.path))
6130
+ if (!existsSync11(m.path))
5550
6131
  throw new Error(`gtrk 素材文件不存在:${m.path}`);
5551
6132
  map[String(m.id)] = m.path;
5552
6133
  }
@@ -5560,7 +6141,7 @@ async function renderGtrk(gtrk, outputPath, opts = {}) {
5560
6141
  const { ffmpeg } = requireFfmpeg(opts.ffmpegPath);
5561
6142
  const materialPaths = materialPathsFromGtrk(gtrk);
5562
6143
  const { inputs, graph, total } = buildFilterGraph(gtrk, materialPaths, { crf });
5563
- const filterFile = join10(tmpdir(), `gtrk-filter-${process.pid}-${inputs.length}.txt`);
6144
+ const filterFile = join12(tmpdir(), `gtrk-filter-${process.pid}-${inputs.length}.txt`);
5564
6145
  await writeFile3(filterFile, graph, "utf8");
5565
6146
  try {
5566
6147
  const args = ["-y"];
@@ -5610,7 +6191,7 @@ async function materializeResult(opts) {
5610
6191
  throw new Error("任务无工程文件产物(检查 project_formats / 任务是否产出)");
5611
6192
  const errors = { ...output.errors ?? {} };
5612
6193
  await mkdir3(outDir, { recursive: true });
5613
- const resultPath = join11(outDir, "result.json");
6194
+ const resultPath = join13(outDir, "result.json");
5614
6195
  const writeResult = async (extra) => {
5615
6196
  const r = {
5616
6197
  ok: Object.keys(errors).length === 0,
@@ -5632,9 +6213,9 @@ async function materializeResult(opts) {
5632
6213
  const byFormat = {};
5633
6214
  for (const f of files) {
5634
6215
  const base = baseFormat(f.format);
5635
- const fmtDir = join11(outDir, base);
6216
+ const fmtDir = join13(outDir, base);
5636
6217
  await mkdir3(fmtDir, { recursive: true });
5637
- const dest = join11(fmtDir, f.filename);
6218
+ const dest = join13(fmtDir, f.filename);
5638
6219
  try {
5639
6220
  await dl(f.download_url, dest);
5640
6221
  (byFormat[base] ??= []).push(dest);
@@ -5651,9 +6232,9 @@ async function materializeResult(opts) {
5651
6232
  let jianyingDraftPath = null;
5652
6233
  if (byFormat.jianying && opts.draftDir) {
5653
6234
  try {
5654
- jianyingDraftPath = join11(opts.draftDir, basename4(outDir));
6235
+ jianyingDraftPath = join13(opts.draftDir, basename4(outDir));
5655
6236
  await mkdir3(jianyingDraftPath, { recursive: true });
5656
- await cp(join11(outDir, "jianying"), jianyingDraftPath, { recursive: true });
6237
+ await cp(join13(outDir, "jianying"), jianyingDraftPath, { recursive: true });
5657
6238
  log.info(`剪映草稿已落到:${jianyingDraftPath}`);
5658
6239
  } catch (e) {
5659
6240
  jianyingDraftPath = null;
@@ -5670,7 +6251,7 @@ async function materializeResult(opts) {
5670
6251
  log.step("本地渲染成片(ffmpeg)…");
5671
6252
  const project = await readGtrkFile(gtrkPath);
5672
6253
  const name = opts.projName ?? gtrkSourceName(project) ?? taskId;
5673
- const outMp4 = join11(outDir, `${name}.mp4`);
6254
+ const outMp4 = join13(outDir, `${name}.mp4`);
5674
6255
  const r = await renderGtrk(project, outMp4, {
5675
6256
  crf: opts.crf != null ? Number(opts.crf) : undefined,
5676
6257
  codec: opts.codec,
@@ -5692,7 +6273,7 @@ async function materializeResult(opts) {
5692
6273
  log.step("三方打开(产物已就位,按需自取):");
5693
6274
  for (const base of Object.keys(byFormat)) {
5694
6275
  const meta = FORMAT_META[base];
5695
- const target = base === "jianying" ? jianyingDraftPath ?? join11(outDir, "jianying") : byFormat[base][0];
6276
+ const target = base === "jianying" ? jianyingDraftPath ?? join13(outDir, "jianying") : byFormat[base][0];
5696
6277
  console.log(` • ${meta?.label ?? base}:${meta?.openHint(target) ?? target}`);
5697
6278
  }
5698
6279
  if (rendered)
@@ -5760,18 +6341,18 @@ async function runOralCut(input, opts) {
5760
6341
  routeLogsToStderr();
5761
6342
  const cfg = loadConfig();
5762
6343
  const inputAbs = resolve3(input);
5763
- if (!existsSync11(inputAbs))
6344
+ if (!existsSync12(inputAbs))
5764
6345
  throw new Error(`毛片不存在:${inputAbs}`);
5765
6346
  const projName = basename5(inputAbs, extname2(inputAbs));
5766
6347
  const formats = opts.formats.split(",").map((s) => s.trim()).filter(Boolean);
5767
6348
  if (opts.render && !formats.includes("gtrk"))
5768
6349
  formats.push("gtrk");
5769
6350
  const wantJianying = formats.some((f) => f === "jianying" || f === "capcut");
5770
- const outDir = resolve3(opts.out ?? join12(dirname2(inputAbs), `${projName}-video-project-${timestamp()}`));
6351
+ const outDir = resolve3(opts.out ?? join14(dirname2(inputAbs), `${projName}-video-project-${timestamp()}`));
5771
6352
  let scriptPath = opts.script ? resolve3(opts.script) : undefined;
5772
6353
  if (!scriptPath) {
5773
- const sibling = join12(dirname2(inputAbs), `${projName}.txt`);
5774
- if (existsSync11(sibling)) {
6354
+ const sibling = join14(dirname2(inputAbs), `${projName}.txt`);
6355
+ if (existsSync12(sibling)) {
5775
6356
  scriptPath = sibling;
5776
6357
  log.info(`自动识别到同名文稿:${sibling}(按有稿剪辑;不想用就改名或显式 --script)`);
5777
6358
  }
@@ -5837,7 +6418,7 @@ async function runOralCut(input, opts) {
5837
6418
  }
5838
6419
  log.info(`task_id = ${taskId}`);
5839
6420
  await mkdir4(outDir, { recursive: true });
5840
- await writeFile5(join12(outDir, "task.json"), JSON.stringify({ taskId, taskType: TASK_TYPE, fileId: up.fileId, source: inputAbs, formats, createdAt: new Date().toISOString() }, null, 2));
6421
+ await writeFile5(join14(outDir, "task.json"), JSON.stringify({ taskId, taskType: TASK_TYPE, fileId: up.fileId, source: inputAbs, formats, createdAt: new Date().toISOString() }, null, 2));
5841
6422
  log.step("④ 云端处理中(每 5s 轮询)…");
5842
6423
  const result = await pollTask(cfg, TASK_TYPE, taskId, (status, progress) => {
5843
6424
  log.tick(`${status}${progress != null ? ` ${Math.round(progress)}%` : ""}`);
@@ -5861,7 +6442,7 @@ async function runOralCut(input, opts) {
5861
6442
  }
5862
6443
 
5863
6444
  // src/commands/oralcut-result.ts
5864
- import { resolve as resolve4, join as join13 } from "node:path";
6445
+ import { resolve as resolve4, join as join15 } from "node:path";
5865
6446
  var TASK_TYPE2 = "cli/video_oral_cut_for_cli";
5866
6447
  function timestamp2() {
5867
6448
  const d = new Date;
@@ -5895,7 +6476,7 @@ async function runOralCutResult(taskId, opts) {
5895
6476
  const pct = got.progress != null ? ` ${Math.round(got.progress)}%` : "";
5896
6477
  throw new Error(`任务尚未完成(当前 ${got.status || "未知"}${pct}),暂无法取回结果;请稍后再试。`);
5897
6478
  }
5898
- const outDir = resolve4(opts.out ?? join13(process.cwd(), `${taskId}-video-project-${timestamp2()}`));
6479
+ const outDir = resolve4(opts.out ?? join15(process.cwd(), `${taskId}-video-project-${timestamp2()}`));
5899
6480
  const draftDir = resolveJianyingDraftDir(opts.jianyingDraftDir);
5900
6481
  await materializeResult({
5901
6482
  outDir,
@@ -5955,16 +6536,16 @@ function registerUpgrade(program2) {
5955
6536
  }
5956
6537
 
5957
6538
  // src/commands/render.ts
5958
- import { resolve as resolve5, dirname as dirname3, join as join14, basename as basename6, extname as extname3 } from "node:path";
5959
- import { existsSync as existsSync12 } from "node:fs";
6539
+ import { resolve as resolve5, dirname as dirname3, join as join16, basename as basename6, extname as extname3 } from "node:path";
6540
+ import { existsSync as existsSync13 } from "node:fs";
5960
6541
  function registerRender(program2) {
5961
6542
  program2.command("render <gtrk>").description("本地渲染:gtrk 工程按 EDL 用本地 ffmpeg 渲染成片 mp4(素材取原片本地路径)").option("-o, --out <file>", "输出 mp4 路径(缺省 = <gtrk 同目录>/<gtrk 名>.mp4)").option("--crf <n>", "视频质量 CRF 14-28(越小越清晰/文件越大,默认 18)").option("--codec <c>", "视频编码(默认 h264)").option("--ffmpeg-path <dir>", "指定 ffmpeg/ffprobe 所在目录(缺省 ~/.gitruck/ffmpeg → 系统)").option("--no-open", "完成后不自动打开产物目录").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON").action(async (gtrk, opts) => {
5962
6543
  if (opts.json)
5963
6544
  routeLogsToStderr();
5964
6545
  const gtrkAbs = resolve5(gtrk);
5965
- if (!existsSync12(gtrkAbs))
6546
+ if (!existsSync13(gtrkAbs))
5966
6547
  throw new Error(`gtrk 工程不存在:${gtrkAbs}`);
5967
- const outMp4 = resolve5(opts.out ?? join14(dirname3(gtrkAbs), `${basename6(gtrkAbs, extname3(gtrkAbs))}.mp4`));
6548
+ const outMp4 = resolve5(opts.out ?? join16(dirname3(gtrkAbs), `${basename6(gtrkAbs, extname3(gtrkAbs))}.mp4`));
5968
6549
  log.step(`▶ 本地渲染:${basename6(gtrkAbs)} → ${basename6(outMp4)}`);
5969
6550
  const project = await readGtrkFile(gtrkAbs);
5970
6551
  const result = await renderGtrk(project, outMp4, {
@@ -5987,12 +6568,1129 @@ function registerRender(program2) {
5987
6568
  });
5988
6569
  }
5989
6570
 
6571
+ // src/commands/split.ts
6572
+ import { resolve as resolve6, join as join18, dirname as dirname5, basename as basename8 } from "node:path";
6573
+ import { existsSync as existsSync14 } from "node:fs";
6574
+ import { readFile as readFile4, writeFile as writeFile6, mkdir as mkdir5 } from "node:fs/promises";
6575
+ import { createHash } from "node:crypto";
6576
+
6577
+ // src/lib/projection.ts
6578
+ function r32(n) {
6579
+ return Math.round(n * 1000) / 1000;
6580
+ }
6581
+ function normClip(c3) {
6582
+ const clip_st = c3.clip_st ?? 0;
6583
+ const track_st = c3.track_st ?? 0;
6584
+ const dur = c3.duration ?? (c3.clip_ed != null ? c3.clip_ed - clip_st : 0);
6585
+ const clip_ed = c3.clip_ed ?? clip_st + dur;
6586
+ return { clip_st, clip_ed, track_st };
6587
+ }
6588
+ function pickMainVideoTrack(gtrk) {
6589
+ const tracks = gtrk.video_track ?? [];
6590
+ if (!tracks.length)
6591
+ return;
6592
+ let best = tracks[0];
6593
+ let bestIdx = best.track_index ?? 0;
6594
+ for (const t of tracks) {
6595
+ const idx = t.track_index ?? 0;
6596
+ if (idx < bestIdx) {
6597
+ best = t;
6598
+ bestIdx = idx;
6599
+ }
6600
+ }
6601
+ return best;
6602
+ }
6603
+ function projectTranscript(transcript, gtrk, opts = {}) {
6604
+ const materialId = String(transcript.material_id);
6605
+ const mainTrack = pickMainVideoTrack(gtrk);
6606
+ const clips = (mainTrack?.track_timeline ?? []).filter((c3) => c3.material != null && String(c3.material) === materialId).map(normClip);
6607
+ const entries = [];
6608
+ transcript.utterances.forEach((utt, sourceIndex) => {
6609
+ const totalWords = utt.words?.length ?? 0;
6610
+ const instances = [];
6611
+ for (const clip of clips) {
6612
+ const surviving = [];
6613
+ for (const word of utt.words ?? []) {
6614
+ const s = Math.max(word.st, clip.clip_st);
6615
+ const e = Math.min(word.ed, clip.clip_ed);
6616
+ if (e > s) {
6617
+ surviving.push({
6618
+ w: word.w,
6619
+ track_st: r32(clip.track_st + (s - clip.clip_st)),
6620
+ track_ed: r32(clip.track_st + (e - clip.clip_st))
6621
+ });
6622
+ }
6623
+ }
6624
+ if (surviving.length) {
6625
+ instances.push({
6626
+ track_st: Math.min(...surviving.map((x) => x.track_st)),
6627
+ track_ed: Math.max(...surviving.map((x) => x.track_ed)),
6628
+ kept_words: surviving.length,
6629
+ words: surviving
6630
+ });
6631
+ }
6632
+ }
6633
+ if (!instances.length) {
6634
+ entries.push({
6635
+ id: utt.id,
6636
+ text: utt.text,
6637
+ dropped: true,
6638
+ sourceIndex,
6639
+ instIndex: 0,
6640
+ track_st: null,
6641
+ track_ed: null,
6642
+ kept_words: 0,
6643
+ total_words: totalWords,
6644
+ words: [],
6645
+ sortKey: 0
6646
+ });
6647
+ } else {
6648
+ instances.sort((a, b) => a.track_st - b.track_st);
6649
+ instances.forEach((inst, instIndex) => {
6650
+ entries.push({
6651
+ id: utt.id,
6652
+ text: utt.text,
6653
+ dropped: false,
6654
+ sourceIndex,
6655
+ instIndex,
6656
+ track_st: inst.track_st,
6657
+ track_ed: inst.track_ed,
6658
+ kept_words: inst.kept_words,
6659
+ total_words: totalWords,
6660
+ words: inst.words,
6661
+ sortKey: inst.track_st
6662
+ });
6663
+ });
6664
+ }
6665
+ });
6666
+ let maxEd = 0;
6667
+ for (const e of entries) {
6668
+ if (e.dropped)
6669
+ e.sortKey = maxEd;
6670
+ else
6671
+ maxEd = Math.max(maxEd, e.track_ed ?? maxEd);
6672
+ }
6673
+ entries.sort((a, b) => a.sortKey - b.sortKey || a.sourceIndex - b.sourceIndex || a.instIndex - b.instIndex);
6674
+ const utterances = entries.map((e) => {
6675
+ const u = {
6676
+ id: e.id,
6677
+ text: e.text,
6678
+ track_st: e.track_st,
6679
+ track_ed: e.track_ed,
6680
+ dropped: e.dropped,
6681
+ kept_words: e.kept_words,
6682
+ total_words: e.total_words
6683
+ };
6684
+ if (opts.words)
6685
+ u.words = e.words;
6686
+ return u;
6687
+ });
6688
+ return {
6689
+ transcript_hash: transcript.text_hash,
6690
+ projected_at: opts.projectedAt ?? new Date().toISOString(),
6691
+ utterances
6692
+ };
6693
+ }
6694
+
6695
+ // src/lib/gtrk-writeback.ts
6696
+ import { readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync2, renameSync, unlinkSync } from "node:fs";
6697
+ import { dirname as dirname4, join as join17, basename as basename7 } from "node:path";
6698
+ import { randomBytes as randomBytes2 } from "node:crypto";
6699
+ function readGtrk(path) {
6700
+ const raw = readFileSync4(path, "utf8");
6701
+ let gtrk;
6702
+ try {
6703
+ gtrk = JSON.parse(raw);
6704
+ } catch (e) {
6705
+ throw new Error(`工程文件不是合法 JSON:${path}(${e instanceof Error ? e.message : String(e)})`);
6706
+ }
6707
+ if (typeof gtrk !== "object" || gtrk === null || Array.isArray(gtrk)) {
6708
+ throw new Error(`工程文件结构异常(顶层非对象):${path}`);
6709
+ }
6710
+ return { gtrk, mtimeMs: statSync(path).mtimeMs };
6711
+ }
6712
+ function assertGtrkV1(gtrk) {
6713
+ if (gtrk.version !== "v1") {
6714
+ throw new Error(`工程文件不是 v1(version=${JSON.stringify(gtrk.version)}):请用新链路重产 v1 工程后再拆分`);
6715
+ }
6716
+ }
6717
+ function writeStructMetaSplit(path, gtrk, splitObj, expectedMtimeMs) {
6718
+ const cur = statSync(path).mtimeMs;
6719
+ if (cur !== expectedMtimeMs) {
6720
+ throw new Error("工程文件在 split 运行期间被外部修改(保存冲突),已拒绝写入;请重新导出视图后重试(客户端侧需先保存、发起后等重载)");
6721
+ }
6722
+ const nextStructMeta = { ...gtrk.struct_meta ?? {}, split: splitObj };
6723
+ const next = { ...gtrk, struct_meta: nextStructMeta };
6724
+ const tmp = join17(dirname4(path), `.${basename7(path)}.${randomBytes2(6).toString("hex")}.tmp`);
6725
+ try {
6726
+ writeFileSync2(tmp, JSON.stringify(next, null, 2));
6727
+ renameSync(tmp, path);
6728
+ } catch (e) {
6729
+ try {
6730
+ unlinkSync(tmp);
6731
+ } catch {}
6732
+ throw e;
6733
+ }
6734
+ }
6735
+ function writeGtrkAtomic(path, next, expectedMtimeMs) {
6736
+ const cur = statSync(path).mtimeMs;
6737
+ if (cur !== expectedMtimeMs) {
6738
+ throw new Error("工程文件在 matrix 运行期间被外部修改(保存冲突),已拒绝写入;请关闭客户端未保存的工程或重跑(plan 与已下载代理均保留)");
6739
+ }
6740
+ const tmp = join17(dirname4(path), `.${basename7(path)}.${randomBytes2(6).toString("hex")}.tmp`);
6741
+ try {
6742
+ writeFileSync2(tmp, JSON.stringify(next, null, 2));
6743
+ renameSync(tmp, path);
6744
+ } catch (e) {
6745
+ try {
6746
+ unlinkSync(tmp);
6747
+ } catch {}
6748
+ throw e;
6749
+ }
6750
+ }
6751
+
6752
+ // src/commands/split.ts
6753
+ var TRANSCRIPT_MISSING = "工程目录内找不到 transcript.json(可能是旧任务产物):请用新版本重跑 gtrk oralcut(恒出 transcript)," + "或(规划中)用 transcribe 生成后再拆分;本命令不做降级猜测。";
6754
+ function registerSplit(program2) {
6755
+ program2.command("split [splitdoc]").description("视觉拆分派单器:无 positional=导出投影视图;带拆分稿=校验落地(写回 struct_meta.split + dispatch)").option("--project <dir>", "oralcut 产物目录(自动定位 gtrk/project.gtrk 与 transcript/transcript.json)").option("--gtrk <path>", "显式指定 .gtrk 工程文件(非标准布局兜底)").option("--transcript <path>", "显式指定 transcript.json(非标准布局兜底)").option("--column <id>", "栏目配置 id(~/.gitruck/columns/<id>.json;缺省取 config defaultColumn,再缺省内置默认栏目)").option("--md", "落地时额外渲染人读稿 split/visual-split.md").option("--words", "视图模式附字级明细(缺省只出句级)").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON").action(async (splitdoc, opts) => {
6756
+ await runSplit(splitdoc, opts);
6757
+ });
6758
+ }
6759
+ function firstExisting(cands) {
6760
+ return cands.find((p) => existsSync14(p));
6761
+ }
6762
+ function resolvePaths(opts) {
6763
+ const project = opts.project ? resolve6(opts.project) : undefined;
6764
+ let gtrkPath;
6765
+ if (opts.gtrk) {
6766
+ gtrkPath = resolve6(opts.gtrk);
6767
+ } else if (project) {
6768
+ gtrkPath = firstExisting([join18(project, "gtrk", "project.gtrk"), join18(project, "project.gtrk")]) ?? join18(project, "gtrk", "project.gtrk");
6769
+ } else {
6770
+ throw new Error("需 --project <目录> 或显式 --gtrk <path>");
6771
+ }
6772
+ if (!existsSync14(gtrkPath))
6773
+ throw new Error(`找不到工程文件:${gtrkPath}`);
6774
+ let transcriptPath;
6775
+ if (opts.transcript)
6776
+ transcriptPath = resolve6(opts.transcript);
6777
+ else if (project)
6778
+ transcriptPath = firstExisting([
6779
+ join18(project, "transcript", "transcript.json"),
6780
+ join18(project, "json", "transcript.json"),
6781
+ join18(project, "transcript.json")
6782
+ ]);
6783
+ const baseDir = project ?? dirname5(gtrkPath);
6784
+ return { baseDir, gtrkPath, transcriptPath };
6785
+ }
6786
+ function slugify(name) {
6787
+ const s = name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
6788
+ return s || "project";
6789
+ }
6790
+ async function loadTranscript(path) {
6791
+ const t = JSON.parse(await readFile4(path, "utf8"));
6792
+ if (!t || !Array.isArray(t.utterances) || typeof t.material_id !== "string" || typeof t.text_hash !== "string") {
6793
+ throw new Error(`transcript.json 结构异常(缺 utterances/material_id/text_hash):${path}`);
6794
+ }
6795
+ t.text_hash = createHash("sha256").update(t.utterances.map((u) => u.text ?? "").join(`
6796
+ `), "utf8").digest("hex");
6797
+ return t;
6798
+ }
6799
+ async function runSplit(splitdoc, opts) {
6800
+ if (opts.json)
6801
+ routeLogsToStderr();
6802
+ const { baseDir, gtrkPath, transcriptPath } = resolvePaths(opts);
6803
+ return splitdoc ? runLand(resolve6(splitdoc), baseDir, gtrkPath, transcriptPath, opts) : runView(baseDir, gtrkPath, transcriptPath, opts);
6804
+ }
6805
+ async function runView(baseDir, gtrkPath, transcriptPath, opts) {
6806
+ if (!transcriptPath || !existsSync14(transcriptPath))
6807
+ throw new Error(TRANSCRIPT_MISSING);
6808
+ log.step("▶ 导出投影视图(transcript × 当刻 .gtrk)…");
6809
+ const transcript = await loadTranscript(transcriptPath);
6810
+ const { gtrk } = readGtrk(gtrkPath);
6811
+ const view = projectTranscript(transcript, gtrk, { words: opts.words });
6812
+ const splitDir = join18(baseDir, "split");
6813
+ await mkdir5(splitDir, { recursive: true });
6814
+ const viewPath = join18(splitDir, "view.json");
6815
+ await writeFile6(viewPath, JSON.stringify(view, null, 2));
6816
+ const dropped = view.utterances.filter((u) => u.dropped).length;
6817
+ log.ok(`投影视图已生成:${viewPath}(${view.utterances.length} 条,其中 ${dropped} 条被剪)`);
6818
+ const result = {
6819
+ ok: true,
6820
+ mode: "view",
6821
+ viewPath,
6822
+ transcript_hash: view.transcript_hash,
6823
+ projected_at: view.projected_at,
6824
+ counts: { entries: view.utterances.length, dropped },
6825
+ view
6826
+ };
6827
+ if (opts.json)
6828
+ console.log(JSON.stringify(result));
6829
+ return result;
6830
+ }
6831
+ async function runLand(splitdocPath, baseDir, gtrkPath, transcriptPath, opts) {
6832
+ if (!existsSync14(splitdocPath))
6833
+ throw new Error(`找不到拆分稿:${splitdocPath}`);
6834
+ if (!transcriptPath || !existsSync14(transcriptPath))
6835
+ throw new Error(TRANSCRIPT_MISSING);
6836
+ log.step("▶ 校验拆分稿并落地…");
6837
+ const doc = JSON.parse(await readFile4(splitdocPath, "utf8"));
6838
+ const transcript = await loadTranscript(transcriptPath);
6839
+ const { gtrk, mtimeMs } = readGtrk(gtrkPath);
6840
+ assertGtrkV1(gtrk);
6841
+ const columnId = opts.column ?? readUserConfig().defaultColumn;
6842
+ const resolved = resolveColumnConfig({ columnId });
6843
+ for (const w of resolved.warnings)
6844
+ log.warn(w);
6845
+ const ctx = {
6846
+ utteranceIds: transcript.utterances.map((u) => u.id),
6847
+ transcriptHash: transcript.text_hash,
6848
+ vocab: effectiveVocab(resolved.config)
6849
+ };
6850
+ const { errors, warnings } = validateSplitDoc(doc, ctx);
6851
+ for (const w of warnings)
6852
+ log.warn(w);
6853
+ if (errors.length) {
6854
+ throw new Error(`拆分稿校验失败(${errors.length} 条,未写入任何产物):
6855
+ ` + errors.map((e) => ` - ${e}`).join(`
6856
+ `));
6857
+ }
6858
+ const projectedAt = new Date().toISOString();
6859
+ const view = projectTranscript(transcript, gtrk, { projectedAt });
6860
+ const projectSlug = slugify(basename8(baseDir));
6861
+ const landing = buildLanding(doc, view, {
6862
+ utteranceIds: ctx.utteranceIds,
6863
+ projectSlug,
6864
+ projectedAt,
6865
+ sourceIndex: {
6866
+ materialId: String(transcript.material_id),
6867
+ utterances: new Map(transcript.utterances.map((u) => [u.id, { st: u.st, ed: u.ed }]))
6868
+ }
6869
+ });
6870
+ writeStructMetaSplit(gtrkPath, gtrk, landing.split, mtimeMs);
6871
+ const splitDir = join18(baseDir, "split");
6872
+ await mkdir5(splitDir, { recursive: true });
6873
+ const dispatchPath = join18(splitDir, "dispatch.json");
6874
+ await writeFile6(dispatchPath, JSON.stringify(landing.dispatch, null, 2));
6875
+ let mdPath = null;
6876
+ if (opts.md) {
6877
+ mdPath = join18(splitDir, "visual-split.md");
6878
+ await writeFile6(mdPath, renderSplitMarkdown(doc, landing, { projectSlug, projectedAt }));
6879
+ }
6880
+ log.ok(`落地完成:${landing.split.beats.length}/${doc.beats.length} beat 落轨` + `(RRV_MG ${landing.dispatch.rrv_mg.length} · FILM_BROLL ${landing.dispatch.film_broll.length} · AI_DRAMA ${landing.dispatch.ai_drama.length})`);
6881
+ for (const s of landing.skipped)
6882
+ log.warn(`跳过 ${s.beat}:${s.reason}`);
6883
+ for (const s of landing.shrunk)
6884
+ log.warn(`收缩 ${s.beat}:${s.dropped} 句被剪,按存活 ${s.kept} 句包络 → ${s.track_st}s…${s.track_ed}s(建议人工复核)`);
6885
+ if (landing.unhandledLanes.length > 0) {
6886
+ log.warn(`未派单 lane:${landing.unhandledLanes.join("、")}——通过校验却无 dispatch 分支,落地静默丢队列(新增 lane 时请同步 buildLanding 分派)`);
6887
+ }
6888
+ const result = {
6889
+ ok: true,
6890
+ mode: "land",
6891
+ gtrk: gtrkPath,
6892
+ dispatchPath,
6893
+ mdPath,
6894
+ transcript_hash: doc.transcript_hash,
6895
+ projected_at: projectedAt,
6896
+ beats: { total: doc.beats.length, landed: landing.split.beats.length, skipped: landing.skipped, shrunk: landing.shrunk },
6897
+ queues: {
6898
+ rrv_mg: landing.dispatch.rrv_mg.length,
6899
+ film_broll: landing.dispatch.film_broll.length,
6900
+ ai_drama: landing.dispatch.ai_drama.length
6901
+ }
6902
+ };
6903
+ if (opts.json)
6904
+ console.log(JSON.stringify(result));
6905
+ return result;
6906
+ }
6907
+
6908
+ // src/commands/matrix.ts
6909
+ import { resolve as resolve7, join as join19, dirname as dirname6, basename as basename9 } from "node:path";
6910
+ import { existsSync as existsSync15 } from "node:fs";
6911
+ import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir6 } from "node:fs/promises";
6912
+
6913
+ // src/lib/matrix-lay.ts
6914
+ var BROLL_PREVIEW_DIR = "assets/broll-preview";
6915
+ var BROLL_MATERIAL_PREFIX = "broll-";
6916
+ var BROLL_META_CANDIDATE_CAP = 12;
6917
+ var SHOT_TARGET_DEFAULT = 3;
6918
+ var MIN_SHOT_SEC = 1.2;
6919
+ var SCORE_FLOOR_DEFAULT = 0.2;
6920
+ var MAX_SLOTS_PER_BEAT = 32;
6921
+ function mergedCandidates(beat) {
6922
+ const all = [];
6923
+ for (const q of beat.queries)
6924
+ for (const r of q.results ?? [])
6925
+ all.push(r);
6926
+ return all.sort((a, b) => b.score - a.score);
6927
+ }
6928
+ function previewUrlFor(result) {
6929
+ const direct = result.preview_url;
6930
+ if (typeof direct === "string" && direct)
6931
+ return direct;
6932
+ const cover = result.cover_url;
6933
+ if (typeof cover === "string") {
6934
+ const derived = cover.replace(/\/keyframe\/([^/]+)\/cover\.jpg.*$/, "/preview/$1.mp4");
6935
+ if (derived !== cover)
6936
+ return derived;
6937
+ }
6938
+ return null;
6939
+ }
6940
+ function previewDims(width, height) {
6941
+ if (!width || !height || width <= 0 || height <= 0)
6942
+ return;
6943
+ if (width <= 640)
6944
+ return [width, height];
6945
+ const h = Math.max(2, Math.round(height * 640 / width / 2) * 2);
6946
+ return [640, h];
6947
+ }
6948
+ var r33 = (n) => Math.round(n * 1000) / 1000;
6949
+ function buildQueryPools(beat, scoreFloor) {
6950
+ const out = [];
6951
+ for (const q of beat.queries) {
6952
+ const pool = [];
6953
+ for (const cand of q.results ?? []) {
6954
+ if (cand.excluded_hint)
6955
+ continue;
6956
+ const segs = cand.segments?.length ? cand.segments : [{ start: 0, end: cand.duration ?? SHOT_TARGET_DEFAULT, best: (cand.duration ?? SHOT_TARGET_DEFAULT) / 2, score: cand.score }];
6957
+ for (const seg of segs) {
6958
+ if (seg.score < scoreFloor)
6959
+ continue;
6960
+ pool.push({ cand, seg, query: q.query, key: `${cand.clip_id}@${seg.start}` });
6961
+ }
6962
+ }
6963
+ pool.sort((a, b) => b.seg.score - a.seg.score);
6964
+ if (pool.length)
6965
+ out.push({ query: q.query, pool });
6966
+ }
6967
+ return out;
6968
+ }
6969
+ function pairAvail(p) {
6970
+ const dur = typeof p.cand.duration === "number" && p.cand.duration > 0 ? p.cand.duration : undefined;
6971
+ return dur ?? Math.max(0, p.seg.end - p.seg.start);
6972
+ }
6973
+ function hashStr(s) {
6974
+ let h = 2166136261;
6975
+ for (let i = 0;i < s.length; i++) {
6976
+ h ^= s.charCodeAt(i);
6977
+ h = Math.imul(h, 16777619);
6978
+ }
6979
+ return h >>> 0;
6980
+ }
6981
+ function mulberry32(seed) {
6982
+ let a = seed >>> 0;
6983
+ return () => {
6984
+ a = a + 1831565813 >>> 0;
6985
+ let t = a;
6986
+ t = Math.imul(t ^ t >>> 15, t | 1);
6987
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
6988
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
6989
+ };
6990
+ }
6991
+ function shotRange(beat, span) {
6992
+ const shots = typeof beat.requested_shots === "number" && beat.requested_shots > 0 ? beat.requested_shots : undefined;
6993
+ const anchor = typeof beat.per_shot_sec === "number" && beat.per_shot_sec > 0 ? beat.per_shot_sec : shots ? Math.min(Math.max(span / shots, 1.5), 6) : undefined;
6994
+ if (anchor === undefined)
6995
+ return [2, 4];
6996
+ const lo = Math.max(1.5, anchor * 0.8);
6997
+ const hi = Math.max(lo + 0.5, Math.min(8, anchor * 1.6));
6998
+ return [lo, hi];
6999
+ }
7000
+ function fillBeatTrack(opts) {
7001
+ const { beat, trackOrder, consumed, scoreFloor } = opts;
7002
+ const span = beat.track_ed - beat.track_st;
7003
+ if (!(span > 0))
7004
+ return [];
7005
+ const [shotMin, shotMax] = shotRange(beat, span);
7006
+ const rand = mulberry32(hashStr(`${beat.beat}#${trackOrder}`));
7007
+ const pools = buildQueryPools(beat, scoreFloor);
7008
+ if (!pools.length)
7009
+ return [];
7010
+ const slots = [];
7011
+ let cursor = beat.track_st;
7012
+ let prevClip = null;
7013
+ let lastPick = null;
7014
+ let gapRun = 0;
7015
+ for (let slotIdx = 0;slotIdx < MAX_SLOTS_PER_BEAT; slotIdx++) {
7016
+ const remaining = beat.track_ed - cursor;
7017
+ if (remaining < MIN_SHOT_SEC)
7018
+ break;
7019
+ let dTarget;
7020
+ if (remaining <= shotMax)
7021
+ dTarget = remaining;
7022
+ else if (remaining < shotMax + shotMin)
7023
+ dTarget = remaining / 2;
7024
+ else
7025
+ dTarget = shotMin + rand() * (shotMax - shotMin);
7026
+ const minLen = Math.min(MIN_SHOT_SEC, remaining);
7027
+ let pick = null;
7028
+ for (let t = 0;t < pools.length && !pick; t++) {
7029
+ const { pool } = pools[(slotIdx + t) % pools.length];
7030
+ pick = pool.find((p) => !consumed.has(p.key) && p.cand.clip_id !== prevClip && pairAvail(p) >= minLen) ?? null;
7031
+ }
7032
+ if (!pick) {
7033
+ gapRun++;
7034
+ if (gapRun >= 2)
7035
+ break;
7036
+ cursor += Math.min(dTarget, remaining);
7037
+ prevClip = null;
7038
+ continue;
7039
+ }
7040
+ gapRun = 0;
7041
+ const d = Math.min(dTarget, pairAvail(pick), remaining);
7042
+ const dur = typeof pick.cand.duration === "number" && pick.cand.duration > 0 ? pick.cand.duration : undefined;
7043
+ const lo = dur !== undefined ? 0 : pick.seg.start;
7044
+ const hi = dur ?? pick.seg.end;
7045
+ const maxSt = Math.max(lo, hi - d);
7046
+ const clipSt = Math.min(Math.max(pick.seg.best - d / 2, lo), maxSt);
7047
+ slots.push({
7048
+ clip_id: pick.cand.clip_id,
7049
+ query: pick.query,
7050
+ score: pick.seg.score,
7051
+ clip_st: r33(clipSt),
7052
+ clip_ed: r33(clipSt + d),
7053
+ track_st: r33(cursor),
7054
+ track_ed: r33(cursor + d)
7055
+ });
7056
+ consumed.add(pick.key);
7057
+ prevClip = pick.cand.clip_id;
7058
+ lastPick = pick;
7059
+ cursor += d;
7060
+ }
7061
+ const last = slots[slots.length - 1];
7062
+ if (last && lastPick) {
7063
+ const tail = beat.track_ed - last.track_ed;
7064
+ if (tail > 0.000001 && tail < MIN_SHOT_SEC) {
7065
+ const dur = typeof lastPick.cand.duration === "number" && lastPick.cand.duration > 0 ? lastPick.cand.duration : undefined;
7066
+ const hi = dur ?? lastPick.seg.end;
7067
+ const ext = Math.min(tail, Math.max(0, hi - last.clip_ed));
7068
+ if (ext > 0.000001) {
7069
+ last.clip_ed = r33(last.clip_ed + ext);
7070
+ last.track_ed = r33(last.track_ed + ext);
7071
+ }
7072
+ }
7073
+ }
7074
+ return slots;
7075
+ }
7076
+ function planBeatFills(plan, lay, scoreFloor) {
7077
+ const fills = new Map;
7078
+ const clipIds = new Set;
7079
+ const consumed = new Set;
7080
+ for (const beat of plan.beats) {
7081
+ const perTrack = [];
7082
+ for (let k = 0;k < Math.max(0, lay); k++) {
7083
+ const slots = fillBeatTrack({ beat, trackOrder: k, consumed, scoreFloor });
7084
+ perTrack.push(slots);
7085
+ for (const s of slots)
7086
+ clipIds.add(s.clip_id);
7087
+ }
7088
+ fills.set(beat.beat, perTrack);
7089
+ }
7090
+ return { fills, clipIds };
7091
+ }
7092
+ function layBrollTracks(opts) {
7093
+ const { gtrk, plan, lay, fills, downloads } = opts;
7094
+ const videoTracks = [...gtrk.video_track ?? []];
7095
+ const materials = [...gtrk.materials ?? []];
7096
+ const structMeta = { ...gtrk.struct_meta ?? {} };
7097
+ const prevBroll = structMeta.broll;
7098
+ const prevIndices = new Set(Array.isArray(prevBroll?.lay_tracks) ? prevBroll.lay_tracks.filter((x) => typeof x === "number") : []);
7099
+ const removedTracks = videoTracks.filter((t) => typeof t.track_index === "number" && prevIndices.has(t.track_index));
7100
+ const keptTracks = videoTracks.filter((t) => !(typeof t.track_index === "number" && prevIndices.has(t.track_index)));
7101
+ const removedMaterialIds = new Set;
7102
+ for (const t of removedTracks) {
7103
+ for (const c3 of t.track_timeline ?? []) {
7104
+ const m = c3.material;
7105
+ if (typeof m === "string" && m.startsWith(BROLL_MATERIAL_PREFIX))
7106
+ removedMaterialIds.add(m);
7107
+ }
7108
+ }
7109
+ const keptMaterials = materials.filter((m) => !(typeof m.id === "string" && removedMaterialIds.has(m.id)));
7110
+ const canvas = Array.isArray(gtrk.video_size) ? gtrk.video_size : [1920, 1080];
7111
+ const baseIndex = keptTracks.reduce((mx, t) => Math.max(mx, typeof t.track_index === "number" ? t.track_index : 0), -1) + 1;
7112
+ const candById = new Map;
7113
+ for (const beat of plan.beats)
7114
+ for (const c3 of mergedCandidates(beat))
7115
+ if (!candById.has(c3.clip_id))
7116
+ candById.set(c3.clip_id, c3);
7117
+ const metaBeats = [];
7118
+ const newMaterialsById = new Map;
7119
+ const trackClips = new Map;
7120
+ let laidClips = 0;
7121
+ let beatsWithCandidates = 0;
7122
+ for (const beat of plan.beats) {
7123
+ const merged = mergedCandidates(beat);
7124
+ if (merged.length > 0)
7125
+ beatsWithCandidates++;
7126
+ const perTrack = fills.get(beat.beat) ?? [];
7127
+ const laid = [];
7128
+ for (let k = 0;k < perTrack.length; k++) {
7129
+ const slots = perTrack[k].filter((s) => downloads.has(s.clip_id));
7130
+ if (!slots.length)
7131
+ continue;
7132
+ const trackIndex = baseIndex + k;
7133
+ const bucket = trackClips.get(trackIndex) ?? [];
7134
+ slots.forEach((s, i) => {
7135
+ const materialId = `${BROLL_MATERIAL_PREFIX}${s.clip_id}`;
7136
+ if (!newMaterialsById.has(materialId)) {
7137
+ const cand = candById.get(s.clip_id);
7138
+ const dims = previewDims(cand?.width, cand?.height);
7139
+ const mat = { id: materialId, path: downloads.get(s.clip_id).rel };
7140
+ if (typeof cand?.duration === "number")
7141
+ mat.duration = cand.duration;
7142
+ if (dims)
7143
+ mat.video_size = dims;
7144
+ if (typeof cand?.fps === "number")
7145
+ mat.video_rate = cand.fps;
7146
+ newMaterialsById.set(materialId, mat);
7147
+ }
7148
+ bucket.push({
7149
+ clip_id: `${beat.beat}-broll-${k}-${i}`,
7150
+ material: materialId,
7151
+ clip_st: s.clip_st,
7152
+ clip_ed: s.clip_ed,
7153
+ track_st: s.track_st,
7154
+ track_ed: s.track_ed,
7155
+ duration: r33(s.track_ed - s.track_st)
7156
+ });
7157
+ laidClips++;
7158
+ });
7159
+ trackClips.set(trackIndex, bucket);
7160
+ laid.push({ order: k, clip_id: slots[0].clip_id, track_index: trackIndex, slots });
7161
+ }
7162
+ const metaBeat = {
7163
+ beat: beat.beat,
7164
+ track_st: beat.track_st,
7165
+ track_ed: beat.track_ed,
7166
+ candidates: merged.slice(0, BROLL_META_CANDIDATE_CAP).map((c3) => {
7167
+ const dl = downloads.get(c3.clip_id);
7168
+ const seg = c3.segments?.[0];
7169
+ return {
7170
+ clip_id: c3.clip_id,
7171
+ score: c3.score,
7172
+ cover_url: c3.cover_url,
7173
+ preview_path: dl?.rel ?? null,
7174
+ source: dl?.source ?? null,
7175
+ raw_url: c3.url,
7176
+ seg: seg ? { start: seg.start, end: seg.end, best: seg.best } : null
7177
+ };
7178
+ }),
7179
+ laid,
7180
+ pinned: null
7181
+ };
7182
+ if (typeof beat.per_shot_sec === "number")
7183
+ metaBeat.per_shot_sec = beat.per_shot_sec;
7184
+ metaBeats.push(metaBeat);
7185
+ }
7186
+ const createdTracks = [...trackClips.entries()].filter(([, clips]) => clips.length > 0).sort((a, b) => a[0] - b[0]).map(([track_index, clips]) => ({
7187
+ track_index,
7188
+ track_size: [canvas[0], canvas[1]],
7189
+ muted: false,
7190
+ track_timeline: clips.sort((a, b) => a.track_st - b.track_st)
7191
+ }));
7192
+ const broll = {
7193
+ contract_version: "v1",
7194
+ generated_at: opts.generatedAt,
7195
+ plan_path: opts.planPath,
7196
+ lay_tracks: createdTracks.map((t) => t.track_index),
7197
+ confirmed: false,
7198
+ beats: metaBeats
7199
+ };
7200
+ const next = {
7201
+ ...gtrk,
7202
+ materials: [...keptMaterials, ...newMaterialsById.values()],
7203
+ video_track: [...keptTracks, ...createdTracks],
7204
+ struct_meta: { ...structMeta, broll }
7205
+ };
7206
+ return {
7207
+ next,
7208
+ summary: { laidTracks: broll.lay_tracks, laidClips, beatsWithCandidates },
7209
+ broll
7210
+ };
7211
+ }
7212
+
7213
+ // src/lib/matrix.ts
7214
+ var URL_TTL_NOTE = "结果 url 带签名默认 24h 过期;过期后重跑 gtrk matrix 即重签(plan 幂等重生成)。";
7215
+ var ENDPOINTS = {
7216
+ internal: "/task/custom/search",
7217
+ external: "/task/video_clip_search"
7218
+ };
7219
+ function decideRoute(memberType) {
7220
+ const tier = memberType === "internal" ? "internal" : "external";
7221
+ return { tier, endpoint: ENDPOINTS[tier] };
7222
+ }
7223
+ var WIDE_RECALL_FACTOR = 3;
7224
+ var TOP_K_MIN = 10;
7225
+ var TOP_K_MAX = 50;
7226
+ var TOP_K_DEFAULT = 10;
7227
+ function asPositiveInt(v) {
7228
+ return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
7229
+ }
7230
+ function asPositiveNum(v) {
7231
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
7232
+ }
7233
+ function shotsToTopK(shots, override) {
7234
+ if (override && override > 0)
7235
+ return Math.min(Math.max(Math.floor(override), 1), TOP_K_MAX);
7236
+ const n = asPositiveInt(shots);
7237
+ if (!n)
7238
+ return TOP_K_DEFAULT;
7239
+ return Math.min(Math.max(n * WIDE_RECALL_FACTOR, TOP_K_MIN), TOP_K_MAX);
7240
+ }
7241
+ function trimFacets(defaults, allowed) {
7242
+ if (!defaults || typeof defaults !== "object")
7243
+ return;
7244
+ const entries = Object.entries(defaults).filter(([k]) => !allowed || allowed.includes(k));
7245
+ return entries.length ? Object.fromEntries(entries) : undefined;
7246
+ }
7247
+ var MATERIAL_CLASSES = ["real_shot", "concept"];
7248
+ function buildSearchBody(tier, query, dispatch, broll, overrides = {}) {
7249
+ const body = { query, top_k: shotsToTopK(dispatch?.shots, overrides.topK) };
7250
+ const perShot = asPositiveNum(dispatch?.per_shot_sec);
7251
+ if (perShot)
7252
+ body.filters = { min_duration: perShot };
7253
+ if (tier === "internal") {
7254
+ if (broll?.column_tag_ids?.length)
7255
+ body.column_tag_ids = [...broll.column_tag_ids];
7256
+ const mc = overrides.materialClass ?? broll?.material_class_policy;
7257
+ if (mc && MATERIAL_CLASSES.includes(mc))
7258
+ body.material_class = mc;
7259
+ const facets = trimFacets(broll?.facet_defaults, broll?.facet_allowed);
7260
+ if (facets)
7261
+ body.facets = facets;
7262
+ }
7263
+ return body;
7264
+ }
7265
+ function strArr2(v) {
7266
+ if (!Array.isArray(v))
7267
+ return;
7268
+ const out = v.filter((x) => typeof x === "string");
7269
+ return out.length ? out : undefined;
7270
+ }
7271
+ function markExcluded(results, exclude) {
7272
+ if (!exclude?.length)
7273
+ return;
7274
+ for (const r of results) {
7275
+ if (typeof r.note === "string" && r.note && exclude.some((w) => r.note.includes(w))) {
7276
+ r.excluded_hint = true;
7277
+ }
7278
+ }
7279
+ }
7280
+ function dedupeBeatQueries(queries) {
7281
+ const bestByClip = new Map;
7282
+ for (let qi = 0;qi < queries.length; qi++) {
7283
+ for (const r of queries[qi].results ?? []) {
7284
+ const prev = bestByClip.get(r.clip_id);
7285
+ if (!prev || r.score > prev.r.score)
7286
+ bestByClip.set(r.clip_id, { qi, r });
7287
+ }
7288
+ }
7289
+ for (let qi = 0;qi < queries.length; qi++) {
7290
+ const q = queries[qi];
7291
+ if (!q.results)
7292
+ continue;
7293
+ q.results = q.results.filter((r) => {
7294
+ const best = bestByClip.get(r.clip_id);
7295
+ if (best.qi === qi && best.r === r)
7296
+ return true;
7297
+ const list = best.r.also_matched_queries ??= [];
7298
+ if (!list.includes(q.query))
7299
+ list.push(q.query);
7300
+ return false;
7301
+ });
7302
+ }
7303
+ }
7304
+ function buildPlanBeat(entry, outcomes) {
7305
+ const beat = {
7306
+ beat: entry.beat,
7307
+ track_st: entry.track_st,
7308
+ track_ed: entry.track_ed,
7309
+ queries: []
7310
+ };
7311
+ const shots = asPositiveInt(entry.shots);
7312
+ if (shots)
7313
+ beat.requested_shots = shots;
7314
+ const perShot = asPositiveNum(entry.per_shot_sec);
7315
+ if (perShot)
7316
+ beat.per_shot_sec = perShot;
7317
+ const exclude = strArr2(entry.exclude);
7318
+ if (exclude)
7319
+ beat.exclude = exclude;
7320
+ for (const o of outcomes) {
7321
+ if (o.error) {
7322
+ beat.queries.push({ query: o.query, error: o.error });
7323
+ continue;
7324
+ }
7325
+ const results = (o.data?.results ?? []).map((r) => ({ ...r }));
7326
+ markExcluded(results, exclude);
7327
+ const pq = { query: o.query, results };
7328
+ if (typeof o.data?.recalled === "number")
7329
+ pq.recalled = o.data.recalled;
7330
+ beat.queries.push(pq);
7331
+ }
7332
+ dedupeBeatQueries(beat.queries);
7333
+ return beat;
7334
+ }
7335
+ function buildPlan(opts) {
7336
+ const plan = {
7337
+ plan_version: "v1",
7338
+ generated_at: opts.generatedAt,
7339
+ member_type: opts.memberType,
7340
+ url_ttl_note: URL_TTL_NOTE,
7341
+ beats: opts.beats
7342
+ };
7343
+ if (opts.projectSlug)
7344
+ plan.project_slug = opts.projectSlug;
7345
+ if (opts.columnId)
7346
+ plan.column_id = opts.columnId;
7347
+ return plan;
7348
+ }
7349
+ function classifyApiError(code, msg) {
7350
+ switch (code) {
7351
+ case 400:
7352
+ return `请求体非法(网关校验):${msg || "请求参数或请求体格式错误"}`;
7353
+ case 6502:
7354
+ return "鉴权失败——检查 API Key(gtrk init 重配)";
7355
+ case 403:
7356
+ return "非矩阵成员或身份可能已变更——矩阵成员口(custom/search)仅对 internal 档位开放";
7357
+ case 6401:
7358
+ return "检索上游故障,或检索参数/栏目配置非法(如 facets 值拼写)——稍后重试仍失败请检查栏目配置的 broll 块";
7359
+ case 6402:
7360
+ return "检索上游超时——稍后重试";
7361
+ default:
7362
+ return `云端错误 (code=${code ?? "?"}):${msg || "未知错误"}`;
7363
+ }
7364
+ }
7365
+ var SEARCH_TIMEOUT_MS = 25000;
7366
+ async function probeMemberType(cfg) {
7367
+ const res = await fetch(`${cfg.base}/user/get_user_info`, {
7368
+ method: "POST",
7369
+ headers: { accept: "application/json", Authorization: cfg.apiKey },
7370
+ body: "",
7371
+ signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS)
7372
+ });
7373
+ const r = await parseJson(res);
7374
+ if (r.code !== 200)
7375
+ throw new CloudError(r.code, classifyApiError(r.code, r.msg));
7376
+ return decideRoute(r.data?.matrix_member_type).tier;
7377
+ }
7378
+ function parseClipIdSafe(text, status) {
7379
+ try {
7380
+ return JSON.parse(text.replace(/"clip_id"\s*:\s*(\d+)/g, '"clip_id":"$1"'));
7381
+ } catch {
7382
+ throw new Error(`服务响应解析失败 (HTTP ${status})`);
7383
+ }
7384
+ }
7385
+ async function searchOnce(cfg, tier, body) {
7386
+ const res = await fetch(`${cfg.base}${ENDPOINTS[tier]}`, {
7387
+ method: "POST",
7388
+ headers: { accept: "application/json", "Content-Type": "application/json", Authorization: cfg.apiKey },
7389
+ body: JSON.stringify(body),
7390
+ signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS)
7391
+ });
7392
+ const r = parseClipIdSafe(await res.text(), res.status);
7393
+ if (r.code !== 200)
7394
+ throw new CloudError(r.code, classifyApiError(r.code, r.msg));
7395
+ return r.data ?? {};
7396
+ }
7397
+
7398
+ // src/commands/matrix.ts
7399
+ function registerMatrix(program2) {
7400
+ 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) => {
7401
+ await runMatrix(parseAdhocQuery(words), opts);
7402
+ });
7403
+ }
7404
+ function parseAdhocQuery(words) {
7405
+ if (!words || words.length === 0)
7406
+ return;
7407
+ if (words[0] !== "search") {
7408
+ throw new Error(`未知子命令「${words[0]}」——ad-hoc 检索用法:gtrk matrix search "<query>";派单消费用法:gtrk matrix --project <dir>`);
7409
+ }
7410
+ const query = words.slice(1).join(" ").trim();
7411
+ if (!query)
7412
+ throw new Error('检索词不能为空:gtrk matrix search "<query>"');
7413
+ return query;
7414
+ }
7415
+ async function runMatrix(searchQuery, opts) {
7416
+ if (opts.json)
7417
+ routeLogsToStderr();
7418
+ const cfg = loadConfig();
7419
+ log.step("▶ 身份探针(matrix_member_type)…");
7420
+ const tier = await probeMemberType(cfg);
7421
+ log.info(`档位:${tier}${tier === "internal" ? "(矩阵成员口 /task/custom/search)" : "(通用口 /task/video_clip_search)"}`);
7422
+ const columnId = opts.column ?? readUserConfig().defaultColumn;
7423
+ const resolved = resolveColumnConfig({ columnId });
7424
+ for (const w of resolved.warnings)
7425
+ log.warn(w);
7426
+ const broll = resolved.config.broll;
7427
+ const effectiveColumnId = columnId ?? resolved.config.meta?.id;
7428
+ if (tier === "external") {
7429
+ if (opts.materialClass === "concept") {
7430
+ throw new Error("external 档位服务端固定 real_shot+有版权素材,concept 不可用(--material-class concept 无法满足)");
7431
+ }
7432
+ if (opts.materialClass) {
7433
+ log.warn("external 档位服务端固定 real_shot+有版权素材,--material-class 参数不适用(已忽略)");
7434
+ }
7435
+ if (broll && (broll.column_tag_ids?.length || broll.material_class_policy || broll.facet_defaults)) {
7436
+ log.warn("当前身份为 external,栏目检索偏好(column_tag_ids/material_class/facets)不适用");
7437
+ }
7438
+ }
7439
+ const topK = opts.topK ? Number(opts.topK) : undefined;
7440
+ const overrides = { topK, materialClass: opts.materialClass };
7441
+ const brollForTier = tier === "internal" ? broll : undefined;
7442
+ return searchQuery !== undefined ? runAdhoc(searchQuery, cfg, tier, brollForTier, overrides, effectiveColumnId, opts) : runPlanMode(cfg, tier, brollForTier, overrides, effectiveColumnId, opts);
7443
+ }
7444
+ async function runPlanMode(cfg, tier, broll, overrides, columnId, opts) {
7445
+ let dispatchPath;
7446
+ let baseDir;
7447
+ if (opts.dispatch) {
7448
+ dispatchPath = resolve7(opts.dispatch);
7449
+ baseDir = dirname6(dirname6(dispatchPath));
7450
+ } else if (opts.project) {
7451
+ baseDir = resolve7(opts.project);
7452
+ dispatchPath = join19(baseDir, "split", "dispatch.json");
7453
+ } else {
7454
+ throw new Error('需 --project <目录> 或显式 --dispatch <path>(ad-hoc 检索用:gtrk matrix search "<query>")');
7455
+ }
7456
+ if (!existsSync15(dispatchPath))
7457
+ throw new Error(`找不到派单清单:${dispatchPath}(先跑 gtrk split <拆分稿> 落地派单)`);
7458
+ const dispatch = JSON.parse(await readFile5(dispatchPath, "utf8"));
7459
+ const queue = Array.isArray(dispatch.film_broll) ? dispatch.film_broll : [];
7460
+ log.step(`▶ B-roll 检索:${queue.length} 个 beat(${tier} 口)…`);
7461
+ const beats = [];
7462
+ let okCount = 0;
7463
+ let errCount = 0;
7464
+ let resultCount = 0;
7465
+ for (const entry of queue) {
7466
+ const outcomes = [];
7467
+ for (const q of entry.queries) {
7468
+ try {
7469
+ const body = buildSearchBody(tier, q, entry, broll, overrides);
7470
+ const data = await searchOnce(cfg, tier, body);
7471
+ outcomes.push({ query: q, data });
7472
+ okCount++;
7473
+ resultCount += data.results?.length ?? 0;
7474
+ log.info(`${entry.beat}「${q}」→ ${data.results?.length ?? 0} 条候选(召回 ${data.recalled ?? "?"})`);
7475
+ } catch (e) {
7476
+ const code = e.code;
7477
+ const msg = e instanceof Error ? e.message : String(e);
7478
+ outcomes.push({ query: q, error: { ...code != null ? { code } : {}, msg } });
7479
+ errCount++;
7480
+ log.warn(`${entry.beat}「${q}」失败:${msg}`);
7481
+ }
7482
+ }
7483
+ beats.push(buildPlanBeat(entry, outcomes));
7484
+ }
7485
+ const totalQueries = okCount + errCount;
7486
+ if (queue.length === 0)
7487
+ log.warn("无 B-roll 派单(film_broll 队列为空)——照常写出空 plan");
7488
+ if (totalQueries > 0 && okCount === 0) {
7489
+ throw new Error(`全部 ${totalQueries} 个 query 检索失败,未写入 plan(逐条原因见上方日志)`);
7490
+ }
7491
+ const projectSlug = slugify2(basename9(baseDir));
7492
+ const plan = buildPlan({
7493
+ generatedAt: new Date().toISOString(),
7494
+ memberType: tier,
7495
+ projectSlug,
7496
+ columnId,
7497
+ beats
7498
+ });
7499
+ const splitDir = join19(baseDir, "split");
7500
+ await mkdir6(splitDir, { recursive: true });
7501
+ const planPath = join19(splitDir, "broll-plan.json");
7502
+ await writeFile7(planPath, JSON.stringify(plan, null, 2));
7503
+ log.ok(`候选清单已生成:${planPath}(${beats.length} beat · ${okCount}/${totalQueries} query 成功 · ${resultCount} 条候选)`);
7504
+ log.info("清单只含引用不含素材:cover_url 可直接预览;url 带签名默认 24h 过期,过期重跑本命令即重签。");
7505
+ const layN = parseLay(opts.lay);
7506
+ let laySummary;
7507
+ if (layN > 0) {
7508
+ laySummary = await layIntoProject(baseDir, plan, layN, parseScoreFloor(opts.scoreFloor));
7509
+ }
7510
+ const result = {
7511
+ ok: true,
7512
+ mode: "plan",
7513
+ memberType: tier,
7514
+ ...columnId ? { columnId } : {},
7515
+ planPath,
7516
+ ...laySummary ? { lay: laySummary } : {},
7517
+ counts: { beats: beats.length, queries: totalQueries, results: resultCount, errors: errCount }
7518
+ };
7519
+ if (opts.json)
7520
+ console.log(JSON.stringify(result));
7521
+ return result;
7522
+ }
7523
+ function parseLay(raw) {
7524
+ if (raw === undefined)
7525
+ return 1;
7526
+ const n = Number(raw);
7527
+ if (Number.isInteger(n) && n >= 0)
7528
+ return n;
7529
+ log.warn(`--lay 取值非法(${raw}),按默认 1 处理`);
7530
+ return 1;
7531
+ }
7532
+ function parseScoreFloor(raw) {
7533
+ if (raw === undefined)
7534
+ return SCORE_FLOOR_DEFAULT;
7535
+ const n = Number(raw);
7536
+ if (Number.isFinite(n) && n >= 0 && n <= 1)
7537
+ return n;
7538
+ log.warn(`--score-floor 取值非法(${raw}),按默认 ${SCORE_FLOOR_DEFAULT} 处理`);
7539
+ return SCORE_FLOOR_DEFAULT;
7540
+ }
7541
+ function locateGtrk(baseDir) {
7542
+ const cands = [join19(baseDir, "gtrk", "project.gtrk"), join19(baseDir, "project.gtrk")];
7543
+ return cands.find((p) => existsSync15(p));
7544
+ }
7545
+ async function layIntoProject(baseDir, plan, layN, scoreFloor) {
7546
+ const gtrkPath = locateGtrk(baseDir);
7547
+ if (!gtrkPath) {
7548
+ log.warn(`未找到工程文件(${join19(baseDir, "gtrk", "project.gtrk")}),跳过铺轨——plan 已产出,可后续在有工程的目录重跑`);
7549
+ return;
7550
+ }
7551
+ const { gtrk, mtimeMs } = readGtrk(gtrkPath);
7552
+ assertGtrkV1(gtrk);
7553
+ const { fills, clipIds } = planBeatFills(plan, layN, scoreFloor);
7554
+ const slotCount = [...fills.values()].flat().reduce((n, s) => n + s.length, 0);
7555
+ log.step(`▶ 候选铺轨(${layN} 轨 · 平铺 ${slotCount} 槽位 · ${clipIds.size} 个 clip,preview 代理)…`);
7556
+ const gtrkDir = dirname6(gtrkPath);
7557
+ const previewDir = join19(gtrkDir, ...BROLL_PREVIEW_DIR.split("/"));
7558
+ await mkdir6(previewDir, { recursive: true });
7559
+ const prevSource = new Map;
7560
+ const prevBroll = gtrk.struct_meta?.broll;
7561
+ for (const b of prevBroll?.beats ?? []) {
7562
+ for (const c3 of b.candidates ?? []) {
7563
+ if (typeof c3.clip_id === "string" && c3.preview_path && (c3.source === "preview" || c3.source === "raw")) {
7564
+ prevSource.set(c3.clip_id, c3.source);
7565
+ }
7566
+ }
7567
+ }
7568
+ const candById = new Map;
7569
+ for (const beat of plan.beats)
7570
+ for (const c3 of mergedCandidates(beat))
7571
+ if (!candById.has(c3.clip_id))
7572
+ candById.set(c3.clip_id, c3);
7573
+ const downloads = new Map;
7574
+ const dlStats = { preview: 0, raw: 0, reused: 0, failed: 0 };
7575
+ for (const clipId of clipIds) {
7576
+ const cand = candById.get(clipId);
7577
+ if (!cand)
7578
+ continue;
7579
+ const rel = `${BROLL_PREVIEW_DIR}/${clipId}.mp4`;
7580
+ const abs = join19(gtrkDir, ...rel.split("/"));
7581
+ if (existsSync15(abs)) {
7582
+ const prev = prevSource.get(clipId);
7583
+ if (prev !== "raw") {
7584
+ downloads.set(clipId, { rel, source: prev ?? "preview" });
7585
+ dlStats.reused++;
7586
+ continue;
7587
+ }
7588
+ const retried = await downloadProxy(cand, abs, { previewOnly: true });
7589
+ if (retried === "preview") {
7590
+ downloads.set(clipId, { rel, source: "preview" });
7591
+ dlStats.preview++;
7592
+ log.info(`clip ${clipId} 代理已补产,已从原片回落态换回 preview`);
7593
+ } else {
7594
+ downloads.set(clipId, { rel, source: "raw" });
7595
+ dlStats.reused++;
7596
+ }
7597
+ continue;
7598
+ }
7599
+ const got = await downloadProxy(cand, abs);
7600
+ if (got) {
7601
+ downloads.set(clipId, { rel, source: got });
7602
+ dlStats[got]++;
7603
+ } else {
7604
+ dlStats.failed++;
7605
+ }
7606
+ }
7607
+ const { next, summary } = layBrollTracks({
7608
+ gtrk,
7609
+ plan,
7610
+ lay: layN,
7611
+ fills,
7612
+ downloads,
7613
+ generatedAt: new Date().toISOString(),
7614
+ planPath: "split/broll-plan.json"
7615
+ });
7616
+ writeGtrkAtomic(gtrkPath, next, mtimeMs);
7617
+ 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}` : ""})`);
7618
+ log.info("opencut 打开工程即见候选轨:轨道头小眼睛可开关对比;确认下载原片属挑选 UI(E-P1)。");
7619
+ if (dlStats.raw > 0) {
7620
+ log.warn("部分候选无 preview 代理已回落原片(体积较大)——服务端 backfill 后重跑本命令可换回代理。");
7621
+ }
7622
+ return { laidTracks: summary.laidTracks, laidClips: summary.laidClips, downloads: dlStats };
7623
+ }
7624
+ async function downloadProxy(cand, absPath, opts = {}) {
7625
+ const tryFetch = async (url) => {
7626
+ try {
7627
+ const res = await fetch(url, { signal: AbortSignal.timeout(180000) });
7628
+ if (!res.ok)
7629
+ return null;
7630
+ return Buffer.from(await res.arrayBuffer());
7631
+ } catch {
7632
+ return null;
7633
+ }
7634
+ };
7635
+ const previewUrl = previewUrlFor(cand);
7636
+ if (previewUrl) {
7637
+ const bytes = await tryFetch(previewUrl);
7638
+ if (bytes) {
7639
+ await writeFile7(absPath, bytes);
7640
+ return "preview";
7641
+ }
7642
+ }
7643
+ if (opts.previewOnly)
7644
+ return null;
7645
+ const raw = await tryFetch(cand.url);
7646
+ if (raw) {
7647
+ await writeFile7(absPath, raw);
7648
+ log.warn(`clip ${cand.clip_id} 无 preview 代理,已回落原片(${(raw.length / 1048576).toFixed(1)}MB)`);
7649
+ return "raw";
7650
+ }
7651
+ log.warn(`clip ${cand.clip_id} 代理与原片均下载失败,该候选槽位跳过`);
7652
+ return null;
7653
+ }
7654
+ async function runAdhoc(query, cfg, tier, broll, overrides, columnId, opts) {
7655
+ log.step(`▶ ad-hoc 检索「${query}」(${tier} 口)…`);
7656
+ const body = buildSearchBody(tier, query, undefined, broll, overrides);
7657
+ const data = await searchOnce(cfg, tier, body);
7658
+ const results = data.results ?? [];
7659
+ log.ok(`${results.length} 条候选(召回 ${data.recalled ?? "?"})`);
7660
+ const result = {
7661
+ ok: true,
7662
+ mode: "search",
7663
+ memberType: tier,
7664
+ ...columnId ? { columnId } : {},
7665
+ results,
7666
+ counts: { beats: 0, queries: 1, results: results.length, errors: 0 }
7667
+ };
7668
+ if (opts.out) {
7669
+ const outPath = resolve7(opts.out);
7670
+ await writeFile7(outPath, JSON.stringify({ query, recalled: data.recalled, results }, null, 2));
7671
+ log.ok(`结果已落盘:${outPath}`);
7672
+ result.outPath = outPath;
7673
+ } else if (!opts.json) {
7674
+ for (const r of results.slice(0, 10)) {
7675
+ const seg = r.segments?.[0];
7676
+ log.info(`clip ${r.clip_id} · score ${r.score}${seg ? ` · 最佳段 ${seg.start}s–${seg.end}s(锚点 ${seg.best}s)` : ""}${r.note ? ` · ${String(r.note).slice(0, 40)}` : ""}`);
7677
+ }
7678
+ }
7679
+ if (opts.json)
7680
+ console.log(JSON.stringify(result));
7681
+ return result;
7682
+ }
7683
+ function slugify2(name) {
7684
+ const s = name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
7685
+ return s || "project";
7686
+ }
7687
+
5990
7688
  // src/index.ts
5991
7689
  try {
5992
7690
  process.loadEnvFile?.();
5993
7691
  } catch {}
5994
7692
  migrateLegacyHome();
5995
- var { version } = JSON.parse(readFileSync3(join15(packageRoot(), "package.json"), "utf8"));
7693
+ var { version } = JSON.parse(readFileSync5(join20(packageRoot(), "package.json"), "utf8"));
5996
7694
  var program2 = new Command;
5997
7695
  program2.name("gtrk").description("同合云成片流水线 CLI —— agent 驱动云端任务、产物拉回本地、三方工程文件(客户端/剪映/PR)互通").version(version);
5998
7696
  registerInstall(program2);
@@ -6003,6 +7701,8 @@ registerDoctor(program2);
6003
7701
  registerSkills(program2);
6004
7702
  registerUpgrade(program2);
6005
7703
  registerRender(program2);
7704
+ registerSplit(program2);
7705
+ registerMatrix(program2);
6006
7706
  program2.parseAsync(process.argv).catch((e) => {
6007
7707
  console.error(`
6008
7708
  ❌ ${e instanceof Error ? e.message : String(e)}`);