@gitruck/cli 0.2.4 → 0.2.7

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 readFileSync4 } from "node:fs";
4200
- import { join as join17 } from "node:path";
4199
+ import { readFileSync as readFileSync5 } from "node:fs";
4200
+ import { join as join21 } from "node:path";
4201
4201
 
4202
4202
  // src/lib/paths.ts
4203
4203
  import { dirname, join } from "node:path";
@@ -4274,7 +4274,14 @@ var log = {
4274
4274
  };
4275
4275
 
4276
4276
  // src/commands/skills.ts
4277
- var SKILL_NAMES = ["gtrk-oralcut", "gtrk-splitter"];
4277
+ var SKILL_NAMES = [
4278
+ "gtrk-oralcut",
4279
+ "gtrk-splitter",
4280
+ "gtrk-matrix",
4281
+ "gtrk-mg",
4282
+ "gtrk-ai-drama",
4283
+ "gtrk-style-maker"
4284
+ ];
4278
4285
  function installSkill(opts = {}) {
4279
4286
  const destRoot = opts.dir ?? join2(homedir2(), ".claude", "skills");
4280
4287
  let allOk = true;
@@ -4295,19 +4302,19 @@ function installSkill(opts = {}) {
4295
4302
  log.warn(`skill 安装失败(${name},不影响命令行使用):${e instanceof Error ? e.message : String(e)}`);
4296
4303
  }
4297
4304
  }
4298
- log.info("在 Claude Code 里打 /gtrk-oralcut 或 /gtrk-splitter,也可直接说「帮我剪个口播 / 拆个分镜」触发(可能需重载会话)。");
4305
+ log.info("在 Claude Code 里打 /gtrk-oralcut、/gtrk-splitter 或 /gtrk-style-maker,也可直接说「帮我剪个口播 / 拆个分镜 / 造我栏目的风格 skill」触发(可能需重载会话)。");
4299
4306
  return allOk;
4300
4307
  }
4301
4308
  function registerSkills(program2) {
4302
4309
  const skills = program2.command("skills").description("管理 agent skill(安装到 Claude Code)");
4303
- skills.command("install").description("把 /gtrk-oralcut /gtrk-splitter 安装到 ~/.claude/skills(对标飞书 skills add)").option("--dir <dir>", "自定义 skills 目录(缺省 ~/.claude/skills)").action((opts) => {
4310
+ skills.command("install").description("把 gtrk 全家框架 skill 安装到 ~/.claude/skills(对标飞书 skills add)").option("--dir <dir>", "自定义 skills 目录(缺省 ~/.claude/skills)").action((opts) => {
4304
4311
  installSkill({ dir: opts.dir });
4305
4312
  });
4306
4313
  }
4307
4314
 
4308
4315
  // src/commands/init.ts
4309
- import { join as join7, resolve as resolve2 } from "node:path";
4310
- import { existsSync as existsSync7 } from "node:fs";
4316
+ import { join as join9, resolve as resolve2 } from "node:path";
4317
+ import { existsSync as existsSync8 } from "node:fs";
4311
4318
 
4312
4319
  // src/lib/user-config.ts
4313
4320
  import { join as join3 } from "node:path";
@@ -4466,808 +4473,1475 @@ async function promptConfirm(message, defaultYes = true) {
4466
4473
  }
4467
4474
 
4468
4475
  // src/commands/doctor.ts
4469
- import { existsSync as existsSync6 } from "node:fs";
4476
+ import { existsSync as existsSync7 } from "node:fs";
4477
+ import { join as join8 } from "node:path";
4470
4478
 
4471
- // src/lib/ffmpeg.ts
4472
- import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
4473
- import { existsSync as existsSync5 } from "node:fs";
4479
+ // src/lib/column-config.ts
4474
4480
  import { join as join5 } from "node:path";
4475
- var isWin = process.platform === "win32";
4476
- var bin = (base) => isWin ? `${base}.exe` : base;
4477
- var FFMPEG_INSTALL_HINT = `未找到 ffmpeg/ffprobe。请把二者放到 ${ffmpegDir()}(agent 可代办:先查本地确实缺失才拉,` + `面向国内用户优先国内加速站点——GitHub 代理 pass-through 拉 BtbN/gyan.dev 官方静态构建,或同合云自建镜像,` + `并做 sha256 校验),或用 --ffmpeg-path <目录> 指定已装位置。`;
4478
- var _cache = new Map;
4479
- function onSystemPath(cmd) {
4480
- try {
4481
- return spawnSync2(cmd, ["-version"], { stdio: "ignore" }).status === 0;
4482
- } catch {
4483
- return false;
4484
- }
4481
+ import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
4482
+
4483
+ // src/lib/splitdoc.ts
4484
+ var BASE_TRACKS = ["真人出镜", "口播继续", "旁白主导"];
4485
+ var LANES = ["A_ROLL", "MG", "AI_DRAMA", "FILM_BROLL"];
4486
+ var NARRATIVES = [
4487
+ "mirror-hook",
4488
+ "demolition",
4489
+ "container-translation",
4490
+ "abyssal-fall",
4491
+ "holding",
4492
+ "reversal-elevation",
4493
+ "callback-closure",
4494
+ "typography-emphasis"
4495
+ ];
4496
+ var CONTAINER_STAGES = [
4497
+ "none",
4498
+ "seed",
4499
+ "expand",
4500
+ "translate",
4501
+ "rupture",
4502
+ "flip",
4503
+ "callback"
4504
+ ];
4505
+ var IRREPLACEABILITY = ["必须真人出镜", "优先 MG", "可被 B-roll 替代", "可降级处理"];
4506
+ var AUX_TYPES = [
4507
+ "quote-card",
4508
+ "term-callout",
4509
+ "network-diagram",
4510
+ "archive-caption",
4511
+ "pause-card",
4512
+ "data-annotation",
4513
+ "timeline-tag",
4514
+ "overlay"
4515
+ ];
4516
+ var LEGACY_LANE_ALIASES = { RRV_MG: "MG" };
4517
+ function normalizeLane(v) {
4518
+ if (typeof v !== "string")
4519
+ return;
4520
+ if (LANES.includes(v))
4521
+ return v;
4522
+ return LEGACY_LANE_ALIASES[v];
4485
4523
  }
4486
- function resolveFfmpeg(ffmpegPath) {
4487
- const key = ffmpegPath ?? "";
4488
- if (_cache.has(key))
4489
- return _cache.get(key) ?? null;
4490
- const dirs = [];
4491
- if (ffmpegPath)
4492
- dirs.push([ffmpegPath, ffmpegPath]);
4493
- dirs.push([ffmpegDir(), "~/.gitruck/ffmpeg"]);
4494
- let found = null;
4495
- for (const [dir, label] of dirs) {
4496
- const ff = join5(dir, bin("ffmpeg"));
4497
- const fp = join5(dir, bin("ffprobe"));
4498
- if (existsSync5(ff) && existsSync5(fp)) {
4499
- found = { ffmpeg: ff, ffprobe: fp, source: label };
4500
- break;
4501
- }
4502
- }
4503
- if (!found && onSystemPath("ffmpeg") && onSystemPath("ffprobe")) {
4504
- found = { ffmpeg: "ffmpeg", ffprobe: "ffprobe", source: "system" };
4505
- }
4506
- _cache.set(key, found);
4507
- return found;
4524
+ function isNonEmptyStr(v) {
4525
+ return typeof v === "string" && v.trim().length > 0;
4508
4526
  }
4509
- function requireFfmpeg(ffmpegPath) {
4510
- const r = resolveFfmpeg(ffmpegPath);
4511
- if (!r)
4512
- throw new Error(FFMPEG_INSTALL_HINT);
4513
- return r;
4527
+ function enumOk(v, list) {
4528
+ return typeof v === "string" && list.includes(v);
4514
4529
  }
4515
- function ffprobeJson(ffprobePath, args) {
4516
- const r = spawnSync2(ffprobePath, args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
4517
- if (r.status !== 0) {
4518
- throw new Error(`ffprobe 失败(code=${r.status}):${(r.stderr || "").slice(-300)}`);
4530
+ function validateSplitDoc(doc, ctx) {
4531
+ const errors = [];
4532
+ const warnings = [];
4533
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
4534
+ return { errors: ["拆分稿必须是一个 JSON 对象"], warnings };
4519
4535
  }
4520
- return JSON.parse(r.stdout || "{}");
4521
- }
4522
- function runFfmpeg(ffmpegPath, args, onLine) {
4523
- return new Promise((resolve2, reject) => {
4524
- const p = spawn2(ffmpegPath, args, { env: process.env });
4525
- let tail = "";
4526
- p.stderr.on("data", (buf) => {
4527
- const s = buf.toString("utf8");
4528
- tail = (tail + s).slice(-4000);
4529
- if (onLine) {
4530
- for (const ln of s.split(/\r?\n/))
4531
- if (ln)
4532
- onLine(ln);
4536
+ const d = doc;
4537
+ const vocab = ctx.vocab ?? {
4538
+ narrative: NARRATIVES,
4539
+ container_stage: CONTAINER_STAGES,
4540
+ base_track: BASE_TRACKS
4541
+ };
4542
+ const freeVocab = vocab.unknown_narrative === "allow";
4543
+ if (d.contract_version !== "v1") {
4544
+ errors.push(`contract_version 必须为 "v1"(实际:${JSON.stringify(d.contract_version)})`);
4545
+ }
4546
+ if (!isNonEmptyStr(d.transcript_hash)) {
4547
+ errors.push("缺 transcript_hash(应从投影视图透传)");
4548
+ } else if (d.transcript_hash !== ctx.transcriptHash) {
4549
+ errors.push(`transcript_hash 不匹配:拆分稿 ${d.transcript_hash} ≠ 当前 transcript ${ctx.transcriptHash}——转写已变更,请重新导出视图并重拆`);
4550
+ }
4551
+ if (!Array.isArray(d.beats) || d.beats.length === 0) {
4552
+ errors.push("beats 必须是非空数组");
4553
+ return { errors, warnings };
4554
+ }
4555
+ const idIndex = new Map;
4556
+ ctx.utteranceIds.forEach((id, i) => idIndex.set(id, i));
4557
+ const ranges = [];
4558
+ const seenBeatIds = new Set;
4559
+ d.beats.forEach((raw, i) => {
4560
+ const tag = (() => {
4561
+ const bid = raw?.id;
4562
+ return isNonEmptyStr(bid) ? bid : `beats[${i}]`;
4563
+ })();
4564
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
4565
+ errors.push(`${tag}:beat 必须是对象`);
4566
+ return;
4567
+ }
4568
+ const b = raw;
4569
+ if (!isNonEmptyStr(b.id))
4570
+ errors.push(`${tag}:缺 id`);
4571
+ else if (!/^B\d{2,}$/.test(b.id))
4572
+ errors.push(`${b.id}:id 须为 "B"+两位起序号(如 B01)`);
4573
+ else if (seenBeatIds.has(b.id))
4574
+ errors.push(`${b.id}:beat id 重复`);
4575
+ else
4576
+ seenBeatIds.add(b.id);
4577
+ if (freeVocab) {
4578
+ if (!isNonEmptyStr(b.base_track))
4579
+ errors.push(`${tag}:缺 base_track`);
4580
+ if (!isNonEmptyStr(b.narrative))
4581
+ errors.push(`${tag}:缺 narrative`);
4582
+ if (!isNonEmptyStr(b.container_stage))
4583
+ errors.push(`${tag}:缺 container_stage`);
4584
+ } else {
4585
+ if (!enumOk(b.base_track, vocab.base_track))
4586
+ errors.push(`${tag}:base_track 非法(栏目词表:${vocab.base_track.join(" | ")})`);
4587
+ if (!enumOk(b.narrative, vocab.narrative))
4588
+ errors.push(`${tag}:narrative 非法(不在栏目词表内)`);
4589
+ if (!enumOk(b.container_stage, vocab.container_stage))
4590
+ errors.push(`${tag}:container_stage 非法(不在栏目词表内)`);
4591
+ }
4592
+ if (!normalizeLane(b.lane))
4593
+ errors.push(`${tag}:lane 非法(四选一:${LANES.join(" | ")})`);
4594
+ if (!enumOk(b.irreplaceability, IRREPLACEABILITY))
4595
+ errors.push(`${tag}:irreplaceability 非法(四枚举之一)`);
4596
+ if (!isNonEmptyStr(b.rhythm))
4597
+ errors.push(`${tag}:缺 rhythm(人读节奏标签)`);
4598
+ if (!isNonEmptyStr(b.visual_task))
4599
+ errors.push(`${tag}:缺 visual_task(一句话视觉任务)`);
4600
+ const span = b.span;
4601
+ let fromIdx = -1;
4602
+ let toIdx = -1;
4603
+ if (!span || !isNonEmptyStr(span.from) || !isNonEmptyStr(span.to)) {
4604
+ errors.push(`${tag}:缺 span.from / span.to(utterance id 区间)`);
4605
+ } else {
4606
+ if (!idIndex.has(span.from))
4607
+ errors.push(`${tag}:span.from 引用了不存在的 utterance id ${span.from}`);
4608
+ else
4609
+ fromIdx = idIndex.get(span.from);
4610
+ if (!idIndex.has(span.to))
4611
+ errors.push(`${tag}:span.to 引用了不存在的 utterance id ${span.to}`);
4612
+ else
4613
+ toIdx = idIndex.get(span.to);
4614
+ if (fromIdx >= 0 && toIdx >= 0) {
4615
+ if (fromIdx > toIdx)
4616
+ errors.push(`${tag}:区间倒序(span.from ${span.from} 晚于 span.to ${span.to})`);
4617
+ else if (isNonEmptyStr(b.id))
4618
+ ranges.push({ id: b.id, from: fromIdx, to: toIdx });
4533
4619
  }
4534
- });
4535
- p.on("error", (e) => reject(e));
4536
- p.on("close", (code) => {
4537
- if (code === 0)
4538
- resolve2();
4620
+ }
4621
+ validateHandoff(tag, b, errors, warnings);
4622
+ if (b.aux_layers != null) {
4623
+ if (!Array.isArray(b.aux_layers))
4624
+ errors.push(`${tag}:aux_layers 必须是数组`);
4539
4625
  else
4540
- reject(new Error(`ffmpeg 退出码 ${code}:${tail.slice(-600)}`));
4541
- });
4542
- });
4543
- }
4544
- function probeCapabilities(res) {
4545
- const ver = spawnSync2(res.ffmpeg, ["-version"], { encoding: "utf8" });
4546
- const version = (ver.stdout || "").split(/\r?\n/)[0]?.trim() || "unknown";
4547
- const enc = spawnSync2(res.ffmpeg, ["-hide_banner", "-encoders"], {
4548
- encoding: "utf8",
4549
- maxBuffer: 16 * 1024 * 1024
4550
- });
4551
- const fil = spawnSync2(res.ffmpeg, ["-hide_banner", "-filters"], {
4552
- encoding: "utf8",
4553
- maxBuffer: 16 * 1024 * 1024
4626
+ b.aux_layers.forEach((a, ai) => validateAux(`${tag}.aux[${ai}]`, a, idIndex, errors, warnings));
4627
+ }
4554
4628
  });
4555
- const encoders = new Set;
4556
- for (const ln of (enc.stdout || "").split(/\r?\n/)) {
4557
- const m = ln.trim().match(/^\S+\s+(\S+)/);
4558
- if (m)
4559
- encoders.add(m[1]);
4560
- }
4561
- const filters = new Set;
4562
- for (const ln of (fil.stdout || "").split(/\r?\n/)) {
4563
- const m = ln.trim().match(/^\S+\s+(\S+)/);
4564
- if (m)
4565
- filters.add(m[1]);
4629
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
4630
+ for (let i = 1;i < sorted.length; i++) {
4631
+ const prev = sorted[i - 1];
4632
+ const cur = sorted[i];
4633
+ if (cur.from <= prev.to) {
4634
+ errors.push(`${prev.id} 与 ${cur.id}:utterance 区间重叠(beats 之间不允许交集)`);
4635
+ }
4566
4636
  }
4567
- return {
4568
- version,
4569
- encoders,
4570
- filters,
4571
- hasLibx264: encoders.has("libx264"),
4572
- hasAac: encoders.has("aac"),
4573
- hasAfade: filters.has("afade"),
4574
- hasAresample: filters.has("aresample")
4575
- };
4637
+ return { errors, warnings };
4576
4638
  }
4577
-
4578
- // src/lib/version.ts
4579
- import { readFileSync as readFileSync2 } from "node:fs";
4580
- import { join as join6 } from "node:path";
4581
- var REGISTRY = "https://registry.npmjs.org/@gitruck%2Fcli";
4582
- function currentVersion() {
4583
- try {
4584
- const { version } = JSON.parse(readFileSync2(join6(packageRoot(), "package.json"), "utf8"));
4585
- return version;
4586
- } catch {
4587
- return "0.0.0";
4639
+ function validateHandoff(tag, b, errors, warnings) {
4640
+ const lane = b.lane;
4641
+ const handoff = b.handoff;
4642
+ if (lane === "A_ROLL") {
4643
+ if (handoff != null)
4644
+ warnings.push(`${tag}:A_ROLL 不应带 handoff,已忽略`);
4645
+ return;
4588
4646
  }
4589
- }
4590
- async function latestVersion(timeoutMs = 5000) {
4591
- try {
4592
- const res = await fetch(REGISTRY, {
4593
- headers: { accept: "application/vnd.npm.install-v1+json" },
4594
- signal: AbortSignal.timeout(timeoutMs)
4595
- });
4596
- if (!res.ok)
4597
- return null;
4598
- const data = await res.json();
4599
- return data["dist-tags"]?.latest ?? null;
4600
- } catch {
4601
- return null;
4647
+ if (lane === "MG" || lane === "RRV_MG") {
4648
+ if (!handoff || typeof handoff.duration_hint !== "number") {
4649
+ errors.push(`${tag}:MG 的 handoff.duration_hint 必填(秒,数值)`);
4650
+ }
4651
+ if (handoff && handoff.category !== undefined && !isKnownCategory(handoff.category)) {
4652
+ warnings.push(`${tag}:handoff.category「${String(handoff.category)}」非已知品类(${MG_CATEGORIES.join("/")}),已透传但下游按 opaque 反推`);
4653
+ }
4654
+ return;
4655
+ }
4656
+ if (lane === "FILM_BROLL") {
4657
+ const q = handoff?.queries;
4658
+ if (!Array.isArray(q) || q.length === 0 || !q.every((x) => isNonEmptyStr(x))) {
4659
+ errors.push(`${tag}:FILM_BROLL 缺检索 query(handoff.queries 必须为非空字符串数组)`);
4660
+ }
4661
+ return;
4602
4662
  }
4603
4663
  }
4604
- function cmpSemver(a, b) {
4605
- const norm = (s) => s.replace(/^v/, "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
4606
- const pa = norm(a);
4607
- const pb = norm(b);
4608
- for (let i = 0;i < 3; i++) {
4609
- const d = (pa[i] ?? 0) - (pb[i] ?? 0);
4610
- if (d !== 0)
4611
- return d > 0 ? 1 : -1;
4612
- }
4613
- return 0;
4614
- }
4615
-
4616
- // src/commands/doctor.ts
4617
- function registerDoctor(program2) {
4618
- program2.command("doctor").description("体检:配置 / 云端连通 / 剪映目录 / 运行时是否就绪").action(async () => {
4619
- await runDoctor();
4620
- });
4621
- }
4622
- var MARK = { ok: "✅", warn: "⚠️ ", fail: "❌" };
4623
- async function runDoctor() {
4624
- const rows = [];
4625
- const latestP = latestVersion(5000).catch(() => null);
4626
- const bunVer = process.versions.bun;
4627
- rows.push({
4628
- name: "运行时",
4629
- status: "ok",
4630
- detail: bunVer ? `bun ${bunVer}` : `node ${process.version}`
4631
- });
4632
- const uc = readUserConfig();
4633
- const apiKey = (process.env.GITRUCK_API_KEY ?? uc.apiKey ?? "").trim();
4634
- const apiBase = (process.env.GITRUCK_API_BASE ?? uc.apiBase ?? DEFAULT_API_BASE).trim().replace(/\/+$/, "");
4635
- rows.push({
4636
- name: "API Key",
4637
- status: apiKey ? "ok" : "fail",
4638
- detail: apiKey ? `已配(${apiKey.slice(0, 6)}…,来源 ${process.env.GITRUCK_API_KEY ? "环境变量" : "gtrk init"})` : "未配 —— 跑 gtrk init"
4639
- });
4640
- rows.push({ name: "API 根地址", status: "ok", detail: apiBase });
4641
- let apiStatus = "warn";
4642
- let apiDetail = "跳过(未配 Key)";
4643
- if (apiKey) {
4644
- try {
4645
- const res = await fetch(`${apiBase}/user/get_user_info`, {
4646
- method: "POST",
4647
- headers: { accept: "application/json", Authorization: apiKey },
4648
- body: "",
4649
- signal: AbortSignal.timeout(8000)
4650
- });
4651
- const data = await res.json().catch(() => ({}));
4652
- if (data.code === 200) {
4653
- apiStatus = "ok";
4654
- apiDetail = "可达,鉴权通过";
4655
- } else {
4656
- apiStatus = "fail";
4657
- apiDetail = `可达,但鉴权失败(code=${data.code ?? res.status}${data.msg ? `,${data.msg}` : ""})—— 检查 API Key`;
4658
- }
4659
- } catch (e) {
4660
- apiStatus = "fail";
4661
- apiDetail = `连不上:${e instanceof Error ? e.message : String(e)}`;
4662
- }
4663
- }
4664
- rows.push({ name: "云端连通 + 鉴权", status: apiStatus, detail: apiDetail });
4665
- const draftDir = resolveJianyingDraftDir(undefined);
4666
- const draftOk = !!draftDir && existsSync6(draftDir);
4667
- rows.push({
4668
- name: "剪映草稿目录",
4669
- status: draftOk ? "ok" : "warn",
4670
- detail: draftOk ? draftDir : "未配/未探到 —— 要剪映直开就跑 gtrk init 或加 --jianying-draft-dir"
4671
- });
4672
- rows.push({
4673
- name: "配置文件",
4674
- status: existsSync6(configPath()) ? "ok" : "warn",
4675
- detail: existsSync6(configPath()) ? configPath() : `未生成 —— 跑 gtrk init(${configPath()})`
4676
- });
4677
- const ff = resolveFfmpeg();
4678
- if (ff) {
4679
- let hasX264 = false;
4680
- let ver = "";
4681
- try {
4682
- const cap = probeCapabilities(ff);
4683
- hasX264 = cap.hasLibx264;
4684
- ver = cap.version.replace(/^ffmpeg version\s*/i, "v");
4685
- } catch {}
4686
- rows.push({
4687
- name: "本地渲染 (ffmpeg)",
4688
- status: hasX264 ? "ok" : "warn",
4689
- detail: hasX264 ? `就绪(来源 ${ff.source}${ver ? `,${ver.split(/\s/)[0]}` : ""})` : `找到 ffmpeg(${ff.source})但缺 libx264 —— 本地渲染需换含 libx264 的构建`
4690
- });
4691
- } else {
4692
- rows.push({
4693
- name: "本地渲染 (ffmpeg)",
4694
- status: "warn",
4695
- detail: "未找到 —— 只出工程文件可忽略;要本地渲染成片,让 agent 装 ffmpeg/ffprobe 到 ~/.gitruck/ffmpeg 或 --ffmpeg-path"
4696
- });
4697
- }
4698
- const cur = currentVersion();
4699
- const latest = await latestP;
4700
- rows.splice(1, 0, {
4701
- name: "CLI 版本",
4702
- status: latest && cmpSemver(latest, cur) > 0 ? "warn" : "ok",
4703
- detail: latest && cmpSemver(latest, cur) > 0 ? `v${cur} —— 有新版 v${latest},跑 gtrk upgrade 升级` : latest ? `v${cur}(已是最新)` : `v${cur}`
4704
- });
4705
- console.log(`
4706
- gtrk 体检:
4707
- `);
4708
- for (const r of rows)
4709
- console.log(` ${MARK[r.status]} ${r.name}:${r.detail}`);
4710
- const failed = rows.some((r) => r.status === "fail");
4711
- console.log(failed ? `
4712
- 有项不通,按提示处理后再开剪。
4713
- ` : `
4714
- 一切就绪,可以开剪。
4715
- `);
4716
- if (failed)
4717
- process.exitCode = 1;
4718
- return !failed;
4719
- }
4720
-
4721
- // src/commands/init.ts
4722
- var GUIDE_IMAGE = join7(packageRoot(), "assets", "jianying-draft-path.png");
4723
- function registerInit(program2) {
4724
- 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);
4725
- }
4726
- async function runInit(opts) {
4727
- if (opts.yes || opts.apiKey)
4728
- return runInitNonInteractive(opts);
4729
- if (!process.stdin.isTTY) {
4730
- log.err("交互式 init 需要真实终端;脚本/agent 请用:gtrk init --api-key <KEY> -y");
4731
- process.exitCode = 1;
4664
+ function validateAux(tag, raw, idIndex, errors, warnings) {
4665
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
4666
+ errors.push(`${tag}:辅助层必须是对象`);
4732
4667
  return;
4733
4668
  }
4734
- const existing = readUserConfig();
4735
- const configured = !!existing.apiKey;
4736
- log.step("▶ gtrk 安装配置");
4737
- if (configured && !opts.reconfigure) {
4738
- log.info(`检测到已有配置:API Key ${mask(existing.apiKey)}|根地址 ${existing.apiBase ?? DEFAULT_API_BASE}|剪映目录 ${existing.jianyingDraftDir ?? "未配"}`);
4739
- if (await promptConfirm("保留现有配置、跳过重填?(要改配置选 N)", true)) {
4740
- log.info("已保留现有配置(要改配置随时跑 gtrk init --reconfigure)。");
4741
- return afterConfigDoctor();
4742
- }
4743
- log.info("好,重新配置——各项可直接回车沿用现有值。");
4744
- }
4745
- let apiKey = existing.apiKey ?? "";
4746
- const keyMsg = apiKey ? `粘贴新的 API Key,或直接回车沿用现有(${mask(apiKey)}):` : "粘贴同合云 API Key(Ctrl+V 粘贴):";
4747
- for (;; ) {
4748
- const entered = (await promptSecret(keyMsg)).trim();
4749
- if (entered) {
4750
- apiKey = entered;
4751
- break;
4669
+ const a = raw;
4670
+ if (!enumOk(a.type, AUX_TYPES))
4671
+ errors.push(`${tag}:type 非法(八类之一)`);
4672
+ if (!isNonEmptyStr(a.role))
4673
+ errors.push(`${tag}:缺 role(职责)`);
4674
+ if (a.type === "overlay") {
4675
+ const handoff = a.handoff;
4676
+ if (!handoff || typeof handoff.duration_hint !== "number" || !(handoff.duration_hint > 0)) {
4677
+ errors.push(`${tag}:overlay aux 缺颗粒时长(handoff.duration_hint 必填且须为正数)`);
4752
4678
  }
4753
- if (apiKey)
4754
- break;
4755
- log.warn("API Key 不能为空,再来一次");
4756
- }
4757
- const apiBase = (await promptText("云端 API 根地址(回车用默认):", {
4758
- defaultValue: existing.apiBase ?? DEFAULT_API_BASE
4759
- })).trim();
4760
- let jianyingDraftDir;
4761
- if (existing.jianyingDraftDir && existsSync7(existing.jianyingDraftDir)) {
4762
- if (await promptConfirm(`剪映草稿目录现为 ${existing.jianyingDraftDir},保留吗?`, true)) {
4763
- jianyingDraftDir = existing.jianyingDraftDir;
4679
+ if (handoff && handoff.category !== undefined && !isKnownCategory(handoff.category)) {
4680
+ warnings.push(`${tag}:handoff.category「${String(handoff.category)}」非已知品类(${MG_CATEGORIES.join("/")}),已透传但下游按 opaque 反推`);
4764
4681
  }
4765
4682
  }
4766
- if (!jianyingDraftDir) {
4767
- const probed = probeJianyingDraftDir();
4768
- if (probed && await promptConfirm(`自动找到剪映草稿目录:${probed},用它吗?`, true)) {
4769
- jianyingDraftDir = probed;
4683
+ const m = a.mount;
4684
+ if (m === "same_beat")
4685
+ return;
4686
+ if (typeof m === "object" && m !== null) {
4687
+ const mo = m;
4688
+ if (isNonEmptyStr(mo.trigger)) {
4689
+ if (!idIndex.has(mo.trigger))
4690
+ errors.push(`${tag}:mount.trigger 引用了不存在的 utterance id ${mo.trigger}`);
4691
+ return;
4770
4692
  }
4771
- }
4772
- if (!jianyingDraftDir) {
4773
- log.info("没自动找到(或你选了手动)。已打开一张指引图:剪映 全局设置 草稿 →「草稿位置」,把那一行路径复制过来;留空则跳过(剪映只产 draft_content.json、缺 meta、需手动导入)。");
4774
- openFile(GUIDE_IMAGE);
4775
- const manual = (await promptText("剪映草稿根目录(…\\com.lveditor.draft),留空跳过:")).trim();
4776
- if (manual) {
4777
- if (existsSync7(manual))
4778
- jianyingDraftDir = resolve2(manual);
4779
- else
4780
- log.warn(`目录不存在,已跳过:${manual}`);
4693
+ if (isNonEmptyStr(mo.from) && isNonEmptyStr(mo.to)) {
4694
+ if (!idIndex.has(mo.from))
4695
+ errors.push(`${tag}:mount.from 引用了不存在的 utterance id ${mo.from}`);
4696
+ if (!idIndex.has(mo.to))
4697
+ errors.push(`${tag}:mount.to 引用了不存在的 utterance id ${mo.to}`);
4698
+ if (idIndex.has(mo.from) && idIndex.has(mo.to) && idIndex.get(mo.from) > idIndex.get(mo.to)) {
4699
+ errors.push(`${tag}:mount 区间倒序`);
4700
+ }
4701
+ return;
4781
4702
  }
4782
4703
  }
4783
- const patch = { apiKey, apiBase };
4784
- if (jianyingDraftDir)
4785
- patch.jianyingDraftDir = jianyingDraftDir;
4786
- writeUserConfig(patch);
4787
- log.ok(`配置已写入 ${configPath()}`);
4788
- if (!jianyingDraftDir) {
4789
- log.warn("未配剪映草稿目录:要剪映直接打开,之后可重跑 gtrk init,或单次加 --jianying-draft-dir");
4790
- }
4791
- return afterConfigDoctor();
4704
+ errors.push(`${tag}:mount 非法(应为 "same_beat" | {from,to} | {trigger})`);
4792
4705
  }
4793
- function mask(key) {
4794
- return `${key.slice(0, 6)}…`;
4706
+ var MG_CATEGORIES = ["overlay", "fullscreen", "subtitle", "title"];
4707
+ var CATEGORY_EXPECTED_OPAQUE = {
4708
+ overlay: false,
4709
+ fullscreen: true,
4710
+ subtitle: false,
4711
+ title: true,
4712
+ "rrv-overlay": false,
4713
+ "mg-fullscreen": true,
4714
+ "explain-subtitle": false,
4715
+ "op-ed-title": true
4716
+ };
4717
+ function isKnownCategory(v) {
4718
+ return typeof v === "string" && Object.prototype.hasOwnProperty.call(CATEGORY_EXPECTED_OPAQUE, v);
4795
4719
  }
4796
- async function afterConfigDoctor() {
4797
- const healthy = await runDoctor();
4798
- if (healthy) {
4799
- log.step("装好了!两种用法任选:");
4800
- log.info('① 命令行直接剪:gtrk oralcut "<毛片.mp4>" --script "<文字稿.txt>"(无稿就别加 --script)');
4801
- log.info("② 重启你常用的 AI agent(Claude / Codex / Trae / WorkBuddy 等),用 /gtrk-oralcut <你的口播剪辑需求>,一句话交给它,体验更智能的剪辑~");
4802
- } else {
4803
- log.warn("上面体检有项没通,按提示处理好再开剪(多半是 API Key 或剪映目录)。");
4804
- }
4720
+ function r3(n) {
4721
+ return Math.round(n * 1000) / 1000;
4805
4722
  }
4806
- async function runInitNonInteractive(opts) {
4807
- const existing = readUserConfig();
4808
- const apiKey = (opts.apiKey ?? existing.apiKey ?? "").trim();
4809
- if (!apiKey) {
4810
- log.err("非交互模式需要 --api-key(或改用交互式 gtrk init)");
4811
- process.exitCode = 1;
4812
- return;
4723
+ function buildLanding(doc, view, opts) {
4724
+ const byId = new Map;
4725
+ for (const id of opts.utteranceIds)
4726
+ byId.set(id, []);
4727
+ for (const u of view.utterances) {
4728
+ if (u.dropped || u.track_st == null || u.track_ed == null)
4729
+ continue;
4730
+ if (!byId.has(u.id))
4731
+ byId.set(u.id, []);
4732
+ byId.get(u.id).push({ track_st: u.track_st, track_ed: u.track_ed });
4813
4733
  }
4814
- const apiBase = (opts.apiBase ?? existing.apiBase ?? DEFAULT_API_BASE).trim();
4815
- let jianyingDraftDir;
4816
- const dirOpt = opts.jianyingDraftDir;
4817
- if (dirOpt && dirOpt !== "auto")
4818
- jianyingDraftDir = resolve2(dirOpt);
4819
- else if (dirOpt === "auto" || !existing.jianyingDraftDir)
4820
- jianyingDraftDir = probeJianyingDraftDir();
4821
- else
4822
- jianyingDraftDir = existing.jianyingDraftDir;
4823
- const patch = { apiKey, apiBase };
4824
- if (jianyingDraftDir)
4825
- patch.jianyingDraftDir = jianyingDraftDir;
4826
- writeUserConfig(patch);
4827
- log.ok(`配置已写入 ${configPath()}`);
4828
- log.info(`剪映草稿目录:${jianyingDraftDir ?? "未配(剪映需手动导入,可加 --jianying-draft-dir)"}`);
4829
- await runDoctor();
4830
- }
4831
-
4832
- // src/commands/install.ts
4833
- function registerInstall(program2) {
4834
- program2.command("install").description("一条命令装全:安装 /gtrk-oralcut skill + 配置(对标飞书 lark-cli install)").option("--api-key <key>", "非交互:直接指定 API Key").option("--api-base <url>", "非交互:指定 API 根地址").option("--jianying-draft-dir <dir>", "非交互:剪映草稿目录(传 auto 则自动探测)").option("--skills-dir <dir>", "自定义 skills 目录(缺省 ~/.claude/skills)").option("--reconfigure", "重走配置向导(默认:已配过则保留现有配置、只刷新 skill)").option("-y, --yes", "非交互:用传入值 + 自动探测,不弹任何提示").action(async (opts) => {
4835
- log.step("① 安装 / 刷新 agent skill…");
4836
- installSkill({ dir: opts.skillsDir });
4837
- log.step("② 配置 + 体检…");
4838
- await runInit(opts);
4839
- });
4734
+ const idIndex = new Map;
4735
+ opts.utteranceIds.forEach((id, i) => idIndex.set(id, i));
4736
+ const split = {
4737
+ contract_version: doc.contract_version,
4738
+ transcript_hash: doc.transcript_hash,
4739
+ projected_at: opts.projectedAt,
4740
+ ...opts.sourceIndex ? { material_id: opts.sourceIndex.materialId } : {},
4741
+ beats: []
4742
+ };
4743
+ const dispatch = { mg: [], film_broll: [], ai_drama: [] };
4744
+ const skipped = [];
4745
+ const shrunk = [];
4746
+ const unhandledLanes = new Set;
4747
+ for (const beat of doc.beats) {
4748
+ const fromIdx = idIndex.get(beat.span.from);
4749
+ const toIdx = idIndex.get(beat.span.to);
4750
+ const spanIds = opts.utteranceIds.slice(fromIdx, toIdx + 1);
4751
+ const instances = spanIds.flatMap((id) => byId.get(id) ?? []);
4752
+ const droppedCount = spanIds.filter((id) => (byId.get(id)?.length ?? 0) === 0).length;
4753
+ if (instances.length === 0) {
4754
+ skipped.push({ beat: beat.id, reason: "span 内全部 utterance 被剪,未落轨" });
4755
+ continue;
4756
+ }
4757
+ const track_st = Math.min(...instances.map((x) => x.track_st));
4758
+ const track_ed = Math.max(...instances.map((x) => x.track_ed));
4759
+ const isShrunk = droppedCount > 0;
4760
+ const lane = normalizeLane(beat.lane) ?? beat.lane;
4761
+ const metaBeat = { id: beat.id, lane, span: beat.span, track_st, track_ed };
4762
+ if (opts.sourceIndex) {
4763
+ const from = opts.sourceIndex.utterances.get(beat.span.from);
4764
+ const to = opts.sourceIndex.utterances.get(beat.span.to);
4765
+ if (from && to && to.ed > from.st) {
4766
+ metaBeat.source_ranges = [{ st: r3(from.st), ed: r3(to.ed) }];
4767
+ }
4768
+ }
4769
+ if (isShrunk)
4770
+ metaBeat.shrunk = true;
4771
+ if (beat.narrative)
4772
+ metaBeat.narrative = beat.narrative;
4773
+ if (beat.container_stage)
4774
+ metaBeat.container_stage = beat.container_stage;
4775
+ if (beat.visual_task)
4776
+ metaBeat.visual_task = beat.visual_task;
4777
+ if (lane === "MG" && typeof beat.handoff?.category === "string")
4778
+ metaBeat.category = beat.handoff.category;
4779
+ if (lane !== "A_ROLL" && beat.handoff)
4780
+ metaBeat.handoff = beat.handoff;
4781
+ split.beats.push(metaBeat);
4782
+ if (isShrunk) {
4783
+ shrunk.push({ beat: beat.id, kept: spanIds.length - droppedCount, dropped: droppedCount, track_st, track_ed });
4784
+ }
4785
+ const h = beat.handoff ?? {};
4786
+ const compositionId = `${opts.projectSlug}-${beat.id}`;
4787
+ if (lane === "MG") {
4788
+ dispatch.mg.push({
4789
+ beat: beat.id,
4790
+ composition_id: compositionId,
4791
+ duration: typeof h.duration_hint === "number" ? h.duration_hint : null,
4792
+ ...h.category !== undefined ? { category: h.category } : {},
4793
+ theme: h.theme,
4794
+ bg: h.bg,
4795
+ slug_hint: h.slug_hint,
4796
+ track_st,
4797
+ track_ed
4798
+ });
4799
+ } else if (lane === "FILM_BROLL") {
4800
+ dispatch.film_broll.push({
4801
+ beat: beat.id,
4802
+ queries: Array.isArray(h.queries) ? h.queries : [],
4803
+ shots: h.shots,
4804
+ per_shot_sec: h.per_shot_sec,
4805
+ exclude: h.exclude,
4806
+ track_st,
4807
+ track_ed
4808
+ });
4809
+ } else if (lane === "AI_DRAMA") {
4810
+ dispatch.ai_drama.push({ beat: beat.id, ...h, track_st, track_ed });
4811
+ } else if (lane !== "A_ROLL") {
4812
+ unhandledLanes.add(lane);
4813
+ }
4814
+ let auxN = 0;
4815
+ for (const aux of beat.aux_layers ?? []) {
4816
+ if (aux.type !== "overlay")
4817
+ continue;
4818
+ auxN += 1;
4819
+ const auxTag = `${beat.id}-aux${auxN}`;
4820
+ const mount = aux.mount;
4821
+ let auxFromId;
4822
+ let auxToId;
4823
+ if (mount === "same_beat") {
4824
+ auxFromId = beat.span.from;
4825
+ auxToId = beat.span.to;
4826
+ } else if ("from" in mount && "to" in mount) {
4827
+ auxFromId = mount.from;
4828
+ auxToId = mount.to;
4829
+ } else {
4830
+ skipped.push({ beat: auxTag, reason: "overlay aux 使用 {trigger} 点挂载,一期不支持(二期补合成窗口)" });
4831
+ continue;
4832
+ }
4833
+ const auxFromIdx = idIndex.get(auxFromId);
4834
+ const auxToIdx = idIndex.get(auxToId);
4835
+ const auxSpanIds = opts.utteranceIds.slice(auxFromIdx, auxToIdx + 1);
4836
+ const auxInstances = auxSpanIds.flatMap((id) => byId.get(id) ?? []);
4837
+ if (auxInstances.length === 0) {
4838
+ skipped.push({ beat: auxTag, reason: "overlay aux 源区间 utterance 全被剪,未落轨" });
4839
+ continue;
4840
+ }
4841
+ const auxTrackSt = Math.min(...auxInstances.map((x) => x.track_st));
4842
+ const auxTrackEd = Math.max(...auxInstances.map((x) => x.track_ed));
4843
+ const auxCompositionId = `${opts.projectSlug}-${beat.id}-aux${auxN}`;
4844
+ const ah = aux.handoff;
4845
+ dispatch.mg.push({
4846
+ beat: beat.id,
4847
+ composition_id: auxCompositionId,
4848
+ duration: ah && typeof ah.duration_hint === "number" ? ah.duration_hint : null,
4849
+ category: "overlay",
4850
+ theme: ah?.theme,
4851
+ bg: ah?.bg,
4852
+ slug_hint: ah?.slug_hint,
4853
+ track_st: auxTrackSt,
4854
+ track_ed: auxTrackEd
4855
+ });
4856
+ const auxMetaBeat = {
4857
+ id: auxTag,
4858
+ lane: "MG",
4859
+ span: { from: auxFromId, to: auxToId },
4860
+ track_st: auxTrackSt,
4861
+ track_ed: auxTrackEd,
4862
+ category: "overlay"
4863
+ };
4864
+ if (opts.sourceIndex) {
4865
+ const sfrom = opts.sourceIndex.utterances.get(auxFromId);
4866
+ const sto = opts.sourceIndex.utterances.get(auxToId);
4867
+ if (sfrom && sto && sto.ed > sfrom.st) {
4868
+ auxMetaBeat.source_ranges = [{ st: r3(sfrom.st), ed: r3(sto.ed) }];
4869
+ }
4870
+ }
4871
+ split.beats.push(auxMetaBeat);
4872
+ }
4873
+ }
4874
+ return { split, dispatch, skipped, shrunk, unhandledLanes: [...unhandledLanes] };
4840
4875
  }
4841
-
4842
- // src/commands/oralcut.ts
4843
- import { resolve as resolve3, join as join12, dirname as dirname2, basename as basename5, extname as extname2 } from "node:path";
4844
- import { mkdir as mkdir4, writeFile as writeFile5, readFile as readFile3 } from "node:fs/promises";
4845
- import { existsSync as existsSync11 } from "node:fs";
4846
-
4847
- // src/lib/config.ts
4848
- function loadConfig() {
4849
- const uc = readUserConfig();
4850
- const apiKey = (process.env.GITRUCK_API_KEY ?? uc.apiKey ?? "").trim();
4851
- const base = (process.env.GITRUCK_API_BASE ?? uc.apiBase ?? DEFAULT_API_BASE).trim().replace(/\/+$/, "");
4852
- if (!apiKey) {
4853
- throw new Error("缺 API Key —— 先跑 `gtrk init` 配置(或设环境变量 GITRUCK_API_KEY)");
4876
+ function renderSplitMarkdown(doc, landing, meta) {
4877
+ const L = [];
4878
+ const metaById = new Map(landing.split.beats.map((b) => [b.id, b]));
4879
+ const skippedIds = new Set(landing.skipped.map((s) => s.beat));
4880
+ L.push(`# 视觉拆分稿(${meta.projectSlug})`);
4881
+ L.push("");
4882
+ L.push(`- contract_version:\`${doc.contract_version}\``);
4883
+ L.push(`- transcript_hash:\`${doc.transcript_hash}\``);
4884
+ L.push(`- projected_at:\`${meta.projectedAt}\``);
4885
+ L.push(`- beats:${doc.beats.length}(落轨 ${landing.split.beats.length} · 跳过 ${landing.skipped.length} · 收缩 ${landing.shrunk.length})`);
4886
+ L.push("");
4887
+ L.push("# Beat Timeline");
4888
+ L.push("");
4889
+ for (const beat of doc.beats) {
4890
+ const mb = metaById.get(beat.id);
4891
+ L.push(`## ${beat.id}${skippedIds.has(beat.id) ? "(整段被剪 · 跳过)" : mb?.shrunk ? "(部分被剪 · 已收缩)" : ""}`);
4892
+ L.push(`- 文稿范围:\`${beat.span.from} … ${beat.span.to}\``);
4893
+ L.push(`- 底轨:\`${beat.base_track}\``);
4894
+ L.push(`- 主层:\`${beat.lane}\``);
4895
+ L.push(`- 叙事功能:\`${beat.narrative}\``);
4896
+ L.push(`- 容器阶段:\`${beat.container_stage}\``);
4897
+ if (beat.rhythm)
4898
+ L.push(`- 节奏标签:\`${beat.rhythm}\``);
4899
+ L.push(`- 视觉任务:${beat.visual_task}`);
4900
+ L.push(`- 不可替代性:\`${beat.irreplaceability}\``);
4901
+ if (mb)
4902
+ L.push(`- 轨道时码:\`${mb.track_st}s … ${mb.track_ed}s\``);
4903
+ if (beat.callback_of)
4904
+ L.push(`- 回扣对象:\`${beat.callback_of}\``);
4905
+ for (const a of beat.aux_layers ?? []) {
4906
+ const mount = a.mount === "same_beat" ? "同 beat" : ("trigger" in a.mount) ? `触发 ${a.mount.trigger}` : `${a.mount.from} … ${a.mount.to}`;
4907
+ L.push(` - 辅助层 \`${a.type}\`(${mount}):${a.role}`);
4908
+ }
4909
+ L.push("");
4854
4910
  }
4855
- return { base, apiKey };
4911
+ L.push("# Production Queues");
4912
+ L.push("");
4913
+ L.push("## A_ROLL Queue");
4914
+ for (const b of doc.beats.filter((x) => x.lane === "A_ROLL" && !skippedIds.has(x.id))) {
4915
+ L.push(`- \`${b.id}\` ${b.visual_task}`);
4916
+ }
4917
+ L.push("");
4918
+ L.push("## MG Queue");
4919
+ for (const r of landing.dispatch.mg) {
4920
+ L.push(`- \`${r.beat}\` composition_id=\`${r.composition_id}\`${r.duration != null ? ` · ${r.duration}s` : ""}`);
4921
+ }
4922
+ L.push("");
4923
+ L.push("## AI_DRAMA Queue");
4924
+ for (const a of landing.dispatch.ai_drama)
4925
+ L.push(`- \`${a.beat}\` ${a.track_st}s…${a.track_ed}s`);
4926
+ L.push("");
4927
+ L.push("## FILM_BROLL Queue");
4928
+ for (const f of landing.dispatch.film_broll)
4929
+ L.push(`- \`${f.beat}\` queries=[${f.queries.join(" / ")}]`);
4930
+ L.push("");
4931
+ return L.join(`
4932
+ `);
4856
4933
  }
4857
4934
 
4858
- // src/lib/cloud.ts
4859
- import { basename } from "node:path";
4860
- import { writeFile, stat } from "node:fs/promises";
4861
- import { createReadStream } from "node:fs";
4862
- import { Readable } from "node:stream";
4863
- import { randomBytes } from "node:crypto";
4864
-
4865
- class CloudError extends Error {
4866
- code;
4867
- constructor(code, message) {
4868
- super(message);
4869
- this.code = code;
4870
- this.name = "CloudError";
4935
+ // src/lib/column-config.ts
4936
+ var DEFAULT_COLUMN_CONFIG = {
4937
+ meta: { id: "real-roam-guide", name: "实在界漫游指南(内置默认)" },
4938
+ vocab: {
4939
+ narrative: [...NARRATIVES],
4940
+ container_stage: [...CONTAINER_STAGES],
4941
+ base_track: [...BASE_TRACKS]
4942
+ },
4943
+ lanes: { enabled: [...LANES] },
4944
+ fallback: { unknown_narrative: "reject" }
4945
+ };
4946
+ function columnsDir() {
4947
+ return join5(gitruckHome(), "columns");
4948
+ }
4949
+ var uniq = (xs) => [...new Set(xs)];
4950
+ function strArr(v) {
4951
+ if (!Array.isArray(v))
4952
+ return;
4953
+ return v.filter((x) => typeof x === "string");
4954
+ }
4955
+ function foldColumnConfigs(layers) {
4956
+ const out = {};
4957
+ for (const l of layers) {
4958
+ if (!l || typeof l !== "object" || Array.isArray(l))
4959
+ continue;
4960
+ if (l.meta && typeof l.meta === "object")
4961
+ out.meta = { ...out.meta, ...l.meta };
4962
+ if (l.vocab && typeof l.vocab === "object") {
4963
+ out.vocab ??= {};
4964
+ for (const k of ["narrative", "container_stage", "base_track"]) {
4965
+ const add = strArr(l.vocab[k]);
4966
+ if (add)
4967
+ out.vocab[k] = uniq([...out.vocab[k] ?? [], ...add]);
4968
+ }
4969
+ }
4970
+ if (l.lanes && typeof l.lanes === "object") {
4971
+ out.lanes ??= {};
4972
+ const en = strArr(l.lanes.enabled);
4973
+ if (en)
4974
+ out.lanes.enabled = uniq([...out.lanes.enabled ?? [], ...en]);
4975
+ if (l.lanes.appearance && typeof l.lanes.appearance === "object") {
4976
+ out.lanes.appearance = l.lanes.appearance;
4977
+ }
4978
+ }
4979
+ if (l.broll && typeof l.broll === "object") {
4980
+ out.broll ??= {};
4981
+ const tags = strArr(l.broll.column_tag_ids);
4982
+ if (tags)
4983
+ out.broll.column_tag_ids = uniq([...out.broll.column_tag_ids ?? [], ...tags]);
4984
+ const fa = strArr(l.broll.facet_allowed);
4985
+ if (fa) {
4986
+ out.broll.facet_allowed = out.broll.facet_allowed ? out.broll.facet_allowed.filter((x) => fa.includes(x)) : [...fa];
4987
+ }
4988
+ if (typeof l.broll.material_class_policy === "string")
4989
+ out.broll.material_class_policy = l.broll.material_class_policy;
4990
+ if (l.broll.facet_defaults && typeof l.broll.facet_defaults === "object")
4991
+ out.broll.facet_defaults = l.broll.facet_defaults;
4992
+ }
4993
+ if (l.style !== undefined)
4994
+ out.style = l.style;
4995
+ if (l.fallback && typeof l.fallback === "object") {
4996
+ const un = l.fallback.unknown_narrative;
4997
+ if (un === "allow" || un === "reject")
4998
+ out.fallback = { unknown_narrative: un };
4999
+ }
4871
5000
  }
5001
+ return out;
4872
5002
  }
4873
- async function parseJson(res) {
5003
+ function readLocalColumn(columnId, dir, warnings) {
5004
+ const p = join5(dir, `${columnId}.json`);
5005
+ if (!existsSync5(p)) {
5006
+ warnings.push(`栏目配置不存在:${p},回落内置默认`);
5007
+ return;
5008
+ }
4874
5009
  try {
4875
- return await res.json();
5010
+ const parsed = JSON.parse(readFileSync2(p, "utf8"));
5011
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
5012
+ warnings.push(`栏目配置格式异常(非 JSON 对象):${p},回落内置默认`);
5013
+ return;
5014
+ }
5015
+ return parsed;
4876
5016
  } catch {
4877
- throw new Error(`服务响应解析失败 (HTTP ${res.status})`);
5017
+ warnings.push(`栏目配置损坏(JSON 解析失败):${p},回落内置默认`);
5018
+ return;
4878
5019
  }
4879
5020
  }
4880
- async function uploadFile(cfg, path) {
4881
- const size = (await stat(path)).size;
4882
- const boundary = `----gtrkFormBoundary${randomBytes(16).toString("hex")}`;
4883
- const head = Buffer.from(`--${boundary}\r
4884
- ` + `Content-Disposition: form-data; name="file"; filename="${basename(path)}"\r
4885
- ` + `Content-Type: application/octet-stream\r
4886
- \r
4887
- `, "utf8");
4888
- const tail = Buffer.from(`\r
4889
- --${boundary}--\r
4890
- `, "utf8");
4891
- async function* multipart() {
4892
- yield head;
4893
- for await (const chunk of createReadStream(path))
4894
- yield chunk;
4895
- yield tail;
5021
+ function resolveColumnConfig(opts = {}) {
5022
+ const warnings = [];
5023
+ const layers = [DEFAULT_COLUMN_CONFIG];
5024
+ if (opts.columnId) {
5025
+ const l2 = readLocalColumn(opts.columnId, opts.columnsDir ?? columnsDir(), warnings);
5026
+ if (l2)
5027
+ layers.push(l2);
4896
5028
  }
4897
- const res = await fetch(`${cfg.base}/base/file/upload`, {
4898
- method: "POST",
4899
- headers: {
4900
- Authorization: cfg.apiKey,
4901
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
4902
- "Content-Length": String(head.length + size + tail.length)
4903
- },
4904
- body: Readable.toWeb(Readable.from(multipart())),
4905
- duplex: "half"
4906
- });
4907
- const r = await parseJson(res);
4908
- const fid = r.data?.file_id ?? r.data?.id;
4909
- if (r.code === 200 && fid)
4910
- return String(fid);
4911
- throw new Error(`上传失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5029
+ const config = foldColumnConfigs(layers);
5030
+ config.style = normalizeColumnStyle(config.style, warnings);
5031
+ for (const n of producesNotices(config.style))
5032
+ warnings.push(n);
5033
+ return { config, warnings };
4912
5034
  }
4913
- async function submitTask(cfg, taskType, payload) {
4914
- const res = await fetch(`${cfg.base}/task/${taskType}`, {
4915
- method: "POST",
4916
- headers: { Authorization: cfg.apiKey, "Content-Type": "application/json" },
4917
- body: JSON.stringify(payload)
4918
- });
4919
- const r = await parseJson(res);
4920
- if (r.code === 200 && r.data?.task_id)
4921
- return String(r.data.task_id);
4922
- throw new Error(`提交失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5035
+ var HANDOFF_REGISTRY = LANES;
5036
+ var LEGACY_HANDOFF_ALIASES = { RRV_MG: "MG" };
5037
+ function normalizeHandoffType(v) {
5038
+ return LEGACY_HANDOFF_ALIASES[v] ?? v;
4923
5039
  }
4924
- async function getTaskResult(cfg, taskType, taskId) {
4925
- const res = await fetch(`${cfg.base}/task/${taskType}/${taskId}`, {
4926
- headers: { Authorization: cfg.apiKey }
4927
- });
4928
- const r = await parseJson(res);
4929
- if (r.code != null && r.code !== 200) {
4930
- throw new CloudError(r.code, `任务查询失败 (code=${r.code}):${r.msg ?? ""}`);
4931
- }
4932
- const data = r.data ?? {};
4933
- return {
4934
- status: String(data.status ?? ""),
4935
- progress: typeof data.progress === "number" ? data.progress : undefined,
4936
- output: data.output_result ?? {}
4937
- };
5040
+ function isRegisteredHandoff(v) {
5041
+ return HANDOFF_REGISTRY.includes(normalizeHandoffType(v));
4938
5042
  }
4939
- async function pollTask(cfg, taskType, taskId, onTick) {
4940
- const start = Date.now();
4941
- const TIMEOUT_MS = 30 * 60 * 1000;
4942
- const INTERVAL_MS = 5000;
4943
- for (;; ) {
4944
- if (Date.now() - start > TIMEOUT_MS) {
4945
- throw new Error("任务超时(超过 30 分钟)。可稍后在云端查任务或重试。");
4946
- }
4947
- await new Promise((r) => setTimeout(r, INTERVAL_MS));
4948
- let got;
4949
- try {
4950
- got = await getTaskResult(cfg, taskType, taskId);
4951
- } catch (e) {
4952
- if (e instanceof CloudError)
4953
- throw e;
4954
- continue;
5043
+ function normalizeEntries(list, kind, warnings) {
5044
+ if (list === undefined)
5045
+ return;
5046
+ if (!Array.isArray(list)) {
5047
+ warnings.push(`style.${kind} 非数组,已忽略`);
5048
+ return;
5049
+ }
5050
+ const out = [];
5051
+ list.forEach((e, i) => {
5052
+ if (typeof e !== "object" || e === null || Array.isArray(e)) {
5053
+ warnings.push(`style.${kind}[${i}] 非对象,已跳过`);
5054
+ return;
4955
5055
  }
4956
- if (got.status === "completed")
4957
- return got.output;
4958
- if (got.status === "failed" || got.status === "cancelled") {
4959
- const out = got.output;
4960
- throw new Error(out?.error ?? (got.status === "failed" ? "任务失败" : "任务已取消"));
5056
+ const o = e;
5057
+ if (typeof o.id !== "string" || typeof o.ref !== "string" || !o.id || !o.ref) {
5058
+ warnings.push(`style.${kind}[${i}] id/ref,已跳过`);
5059
+ return;
4961
5060
  }
4962
- onTick?.(got.status || "处理中", got.progress);
5061
+ out.push(e);
5062
+ });
5063
+ return out;
5064
+ }
5065
+ function normalizeColumnStyle(raw, warnings) {
5066
+ if (raw === undefined)
5067
+ return;
5068
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
5069
+ warnings.push("style 块非对象,已忽略");
5070
+ return;
4963
5071
  }
5072
+ const r = raw;
5073
+ const style = {};
5074
+ const skills = normalizeEntries(r.skills, "skills", warnings);
5075
+ if (skills)
5076
+ style.skills = skills;
5077
+ const shared = normalizeEntries(r.shared, "shared", warnings);
5078
+ if (shared)
5079
+ style.shared = shared;
5080
+ if (typeof r.bundle_ref === "string")
5081
+ style.bundle_ref = r.bundle_ref;
5082
+ return style;
4964
5083
  }
4965
- async function download(url, dest) {
4966
- const res = await fetch(url);
4967
- if (!res.ok)
4968
- throw new Error(`下载失败 HTTP ${res.status}:${url}`);
4969
- const buf = Buffer.from(await res.arrayBuffer());
4970
- await writeFile(dest, buf);
5084
+ function editDistance(a, b) {
5085
+ const m = a.length;
5086
+ const n = b.length;
5087
+ if (m === 0)
5088
+ return n;
5089
+ if (n === 0)
5090
+ return m;
5091
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
5092
+ for (let i = 1;i <= m; i++) {
5093
+ const cur = [i];
5094
+ for (let j = 1;j <= n; j++) {
5095
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
5096
+ }
5097
+ prev = cur;
5098
+ }
5099
+ return prev[n];
4971
5100
  }
4972
-
4973
- // src/lib/upload-cache.ts
4974
- import { join as join8 } from "node:path";
4975
- import { stat as stat3, mkdir, readFile, writeFile as writeFile2 } from "node:fs/promises";
4976
- import { existsSync as existsSync8 } from "node:fs";
4977
-
4978
- // src/lib/chunk-upload.ts
4979
- var import_hash_wasm = __toESM(require_index_umd(), 1);
4980
- import { open, stat as stat2 } from "node:fs/promises";
4981
- import { basename as basename2 } from "node:path";
4982
- var CHUNK_THRESHOLD = 256 * 1024 * 1024;
4983
- var CONCURRENCY = 3;
4984
- var PART_RETRIES = 3;
4985
- var BACKOFF_MS = [2000, 4000, 8000];
4986
- var INCOMPLETE_ROUNDS = 2;
4987
- var MAX_REBUILDS = 1;
4988
- var CODE_SESSION_NOT_FOUND = 6024;
4989
- var CODE_PART_INVALID = 6025;
4990
- var CODE_CHECKSUM_MISMATCH = 6026;
4991
- var CODE_INCOMPLETE = 6027;
4992
- var FP_BUFFER = 8192;
4993
- var FP_SKIP = 64 * 1024 * 1024;
4994
- async function fastBlake3(path) {
4995
- try {
4996
- const hasher = await import_hash_wasm.createBLAKE3();
4997
- hasher.init();
4998
- const fh = await open(path, "r");
4999
- try {
5000
- const buf = Buffer.alloc(FP_BUFFER);
5001
- let count = 0;
5002
- let pos = 0;
5003
- for (;; ) {
5004
- const { bytesRead } = await fh.read(buf, 0, FP_BUFFER, pos);
5005
- if (bytesRead === 0)
5006
- break;
5007
- count += FP_BUFFER;
5008
- if (count >= FP_SKIP)
5009
- break;
5010
- hasher.update(buf.subarray(0, bytesRead));
5011
- pos += bytesRead;
5101
+ function producesValues(e) {
5102
+ if (typeof e.produces === "string")
5103
+ return [e.produces];
5104
+ if (Array.isArray(e.produces))
5105
+ return e.produces.filter((x) => typeof x === "string");
5106
+ return [];
5107
+ }
5108
+ function producesNotices(style) {
5109
+ const notices = [];
5110
+ if (!style?.skills)
5111
+ return notices;
5112
+ const seen = new Set;
5113
+ for (const e of style.skills) {
5114
+ if (e.routing === "none")
5115
+ continue;
5116
+ for (const v of producesValues(e)) {
5117
+ if (isRegisteredHandoff(v) || seen.has(v))
5118
+ continue;
5119
+ const near = HANDOFF_REGISTRY.find((r) => r.toLowerCase() === v.toLowerCase() || editDistance(r.toUpperCase(), v.toUpperCase()) <= 2);
5120
+ if (near) {
5121
+ seen.add(v);
5122
+ notices.push(`style.skills[${e.id}].produces="${v}" 疑似拼写(接近注册类型 "${near}");如确为管线外产物可设 routing:"none"`);
5012
5123
  }
5013
- return hasher.digest("hex");
5014
- } finally {
5015
- await fh.close();
5016
5124
  }
5017
- } catch {
5018
- return;
5019
5125
  }
5126
+ return notices;
5020
5127
  }
5021
- async function partBlake3(view) {
5022
- const hasher = await import_hash_wasm.createBLAKE3();
5023
- hasher.init();
5024
- hasher.update(view);
5025
- return hasher.digest("hex");
5128
+ function effectiveVocab(config) {
5129
+ return {
5130
+ narrative: config.vocab?.narrative ?? [...NARRATIVES],
5131
+ container_stage: config.vocab?.container_stage ?? [...CONTAINER_STAGES],
5132
+ base_track: config.vocab?.base_track ?? [...BASE_TRACKS],
5133
+ unknown_narrative: config.fallback?.unknown_narrative
5134
+ };
5026
5135
  }
5027
5136
 
5028
- class SessionGoneError extends Error {
5029
- constructor() {
5030
- super("分片会话已失效");
5031
- this.name = "SessionGoneError";
5137
+ // src/lib/ffmpeg.ts
5138
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
5139
+ import { existsSync as existsSync6 } from "node:fs";
5140
+ import { join as join6 } from "node:path";
5141
+ var isWin = process.platform === "win32";
5142
+ var bin = (base) => isWin ? `${base}.exe` : base;
5143
+ var FFMPEG_INSTALL_HINT = `未找到 ffmpeg/ffprobe。请把二者放到 ${ffmpegDir()}(agent 可代办:先查本地确实缺失才拉,` + `面向国内用户优先国内加速站点——GitHub 代理 pass-through 拉 BtbN/gyan.dev 官方静态构建,或同合云自建镜像,` + `并做 sha256 校验),或用 --ffmpeg-path <目录> 指定已装位置。`;
5144
+ var _cache = new Map;
5145
+ function onSystemPath(cmd) {
5146
+ try {
5147
+ return spawnSync2(cmd, ["-version"], { stdio: "ignore" }).status === 0;
5148
+ } catch {
5149
+ return false;
5032
5150
  }
5033
5151
  }
5034
- function isTransient(e) {
5035
- const msg = String(e?.message ?? e);
5036
- return /fetch failed|ECONNRESET|ETIMEDOUT|EPIPE|UND_ERR|服务响应解析失败 \(HTTP 5/i.test(msg);
5152
+ function resolveFfmpeg(ffmpegPath) {
5153
+ const key = ffmpegPath ?? "";
5154
+ if (_cache.has(key))
5155
+ return _cache.get(key) ?? null;
5156
+ const dirs = [];
5157
+ if (ffmpegPath)
5158
+ dirs.push([ffmpegPath, ffmpegPath]);
5159
+ dirs.push([ffmpegDir(), "~/.gitruck/ffmpeg"]);
5160
+ let found = null;
5161
+ for (const [dir, label] of dirs) {
5162
+ const ff = join6(dir, bin("ffmpeg"));
5163
+ const fp = join6(dir, bin("ffprobe"));
5164
+ if (existsSync6(ff) && existsSync6(fp)) {
5165
+ found = { ffmpeg: ff, ffprobe: fp, source: label };
5166
+ break;
5167
+ }
5168
+ }
5169
+ if (!found && onSystemPath("ffmpeg") && onSystemPath("ffprobe")) {
5170
+ found = { ffmpeg: "ffmpeg", ffprobe: "ffprobe", source: "system" };
5171
+ }
5172
+ _cache.set(key, found);
5173
+ return found;
5037
5174
  }
5038
- async function sleep(ms) {
5039
- await new Promise((r) => setTimeout(r, ms));
5175
+ function requireFfmpeg(ffmpegPath) {
5176
+ const r = resolveFfmpeg(ffmpegPath);
5177
+ if (!r)
5178
+ throw new Error(FFMPEG_INSTALL_HINT);
5179
+ return r;
5040
5180
  }
5041
- async function uploadChunked(cfg, path, opts) {
5042
- const size = (await stat2(path)).size;
5043
- const name = basename2(path);
5044
- for (let rebuilds = 0;; rebuilds++) {
5045
- try {
5046
- return await attemptOnce(cfg, path, name, size, opts);
5047
- } catch (e) {
5048
- if (e instanceof SessionGoneError && rebuilds < MAX_REBUILDS) {
5049
- await opts.store.clear(opts.fingerprint);
5050
- log.warn("云端分片会话已失效,自动重建整传一次");
5051
- continue;
5052
- }
5053
- throw e;
5054
- }
5181
+ function ffprobeJson(ffprobePath, args) {
5182
+ const r = spawnSync2(ffprobePath, args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
5183
+ if (r.status !== 0) {
5184
+ throw new Error(`ffprobe 失败(code=${r.status}):${(r.stderr || "").slice(-300)}`);
5055
5185
  }
5186
+ return JSON.parse(r.stdout || "{}");
5056
5187
  }
5057
- async function attemptOnce(cfg, path, name, size, opts) {
5058
- const { fingerprint, store, force } = opts;
5059
- let session = force ? undefined : await store.load(fingerprint);
5060
- let missing;
5061
- if (session) {
5062
- const st = await chunkStatus(cfg, session.uploadId);
5063
- if (st === undefined) {
5064
- await store.clear(fingerprint);
5065
- session = undefined;
5066
- } else {
5067
- session = { ...session, partSize: st.part_size, totalParts: st.total_parts };
5068
- missing = st.missing;
5069
- if (missing.length < session.totalParts) {
5070
- log.info(`断点续传:云端已有 ${session.totalParts - missing.length}/${session.totalParts} 片,补 ${missing.length} 片`);
5188
+ function runFfmpeg(ffmpegPath, args, onLine) {
5189
+ return new Promise((resolve2, reject) => {
5190
+ const p = spawn2(ffmpegPath, args, { env: process.env });
5191
+ let tail = "";
5192
+ p.stderr.on("data", (buf) => {
5193
+ const s = buf.toString("utf8");
5194
+ tail = (tail + s).slice(-4000);
5195
+ if (onLine) {
5196
+ for (const ln of s.split(/\r?\n/))
5197
+ if (ln)
5198
+ onLine(ln);
5071
5199
  }
5072
- }
5073
- }
5074
- if (!session) {
5075
- const blake3Id = force ? undefined : await fastBlake3(path);
5076
- const body = { filename: name, size };
5077
- if (blake3Id)
5078
- body.blake3_id = blake3Id;
5079
- const res = await fetch(`${cfg.base}/base/file/upload/chunk/init`, {
5080
- method: "POST",
5081
- headers: { Authorization: cfg.apiKey, "Content-Type": "application/json" },
5082
- body: JSON.stringify(body)
5083
5200
  });
5084
- const r = await parseJson(res);
5085
- if (r.code !== 200) {
5086
- throw new Error(`分片上传 init 失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5087
- }
5088
- if (r.data?.file_id != null) {
5089
- log.info("秒传命中:云端已有同内容文件,零字节上传");
5090
- return String(r.data.file_id);
5091
- }
5092
- if (r.data?.upload_id == null || !r.data.part_size || !r.data.total_parts) {
5093
- throw new Error("分片上传 init 响应缺字段(upload_id/part_size/total_parts)");
5094
- }
5095
- session = {
5096
- uploadId: String(r.data.upload_id),
5097
- partSize: r.data.part_size,
5098
- totalParts: r.data.total_parts,
5099
- size,
5100
- path,
5101
- createdAt: Date.now()
5102
- };
5103
- await store.save(fingerprint, session);
5104
- missing = undefined;
5105
- }
5106
- if (missing === undefined) {
5107
- missing = Array.from({ length: session.totalParts }, (_, i) => i);
5108
- }
5109
- await uploadParts(cfg, path, session, missing);
5110
- for (let round = 0;; round++) {
5111
- const res = await fetch(`${cfg.base}/base/file/upload/chunk/${session.uploadId}/complete`, { method: "POST", headers: { Authorization: cfg.apiKey } });
5112
- const r = await parseJson(res);
5113
- if (r.code === 200 && r.data?.file_id != null) {
5114
- await store.clear(fingerprint);
5115
- return String(r.data.file_id);
5116
- }
5117
- if (r.code === CODE_SESSION_NOT_FOUND)
5118
- throw new SessionGoneError;
5119
- if (r.code === CODE_INCOMPLETE && round < INCOMPLETE_ROUNDS) {
5120
- const st = await chunkStatus(cfg, session.uploadId);
5121
- if (st === undefined)
5122
- throw new SessionGoneError;
5123
- await uploadParts(cfg, path, session, st.missing);
5124
- continue;
5125
- }
5126
- throw new Error(`分片上传 complete 失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5127
- }
5201
+ p.on("error", (e) => reject(e));
5202
+ p.on("close", (code) => {
5203
+ if (code === 0)
5204
+ resolve2();
5205
+ else
5206
+ reject(new Error(`ffmpeg 退出码 ${code}:${tail.slice(-600)}`));
5207
+ });
5208
+ });
5128
5209
  }
5129
- async function chunkStatus(cfg, uploadId) {
5130
- const res = await fetch(`${cfg.base}/base/file/upload/chunk/${uploadId}`, {
5131
- headers: { Authorization: cfg.apiKey }
5210
+ function probeCapabilities(res) {
5211
+ const ver = spawnSync2(res.ffmpeg, ["-version"], { encoding: "utf8" });
5212
+ const version = (ver.stdout || "").split(/\r?\n/)[0]?.trim() || "unknown";
5213
+ const enc = spawnSync2(res.ffmpeg, ["-hide_banner", "-encoders"], {
5214
+ encoding: "utf8",
5215
+ maxBuffer: 16 * 1024 * 1024
5132
5216
  });
5133
- const r = await parseJson(res);
5134
- if (r.code === CODE_SESSION_NOT_FOUND)
5135
- return;
5136
- if (r.code !== 200 || !r.data) {
5137
- throw new Error(`分片会话查询失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5217
+ const fil = spawnSync2(res.ffmpeg, ["-hide_banner", "-filters"], {
5218
+ encoding: "utf8",
5219
+ maxBuffer: 16 * 1024 * 1024
5220
+ });
5221
+ const encoders = new Set;
5222
+ for (const ln of (enc.stdout || "").split(/\r?\n/)) {
5223
+ const m = ln.trim().match(/^\S+\s+(\S+)/);
5224
+ if (m)
5225
+ encoders.add(m[1]);
5138
5226
  }
5139
- return r.data;
5227
+ const filters = new Set;
5228
+ for (const ln of (fil.stdout || "").split(/\r?\n/)) {
5229
+ const m = ln.trim().match(/^\S+\s+(\S+)/);
5230
+ if (m)
5231
+ filters.add(m[1]);
5232
+ }
5233
+ return {
5234
+ version,
5235
+ encoders,
5236
+ filters,
5237
+ hasLibx264: encoders.has("libx264"),
5238
+ hasAac: encoders.has("aac"),
5239
+ hasAfade: filters.has("afade"),
5240
+ hasAresample: filters.has("aresample")
5241
+ };
5140
5242
  }
5141
- async function uploadParts(cfg, path, session, indexes) {
5142
- if (indexes.length === 0)
5143
- return;
5144
- const { uploadId, partSize, totalParts, size } = session;
5145
- let done = totalParts - indexes.length;
5146
- const fh = await open(path, "r");
5243
+
5244
+ // src/lib/version.ts
5245
+ import { readFileSync as readFileSync3 } from "node:fs";
5246
+ import { join as join7 } from "node:path";
5247
+ var REGISTRY = "https://registry.npmjs.org/@gitruck%2Fcli";
5248
+ function currentVersion() {
5147
5249
  try {
5148
- const queue = [...indexes];
5149
- const worker = async () => {
5150
- const buf = Buffer.alloc(partSize);
5151
- for (;; ) {
5152
- const idx = queue.shift();
5153
- if (idx === undefined)
5154
- return;
5155
- const offset = idx * partSize;
5156
- const len = idx === totalParts - 1 ? size - offset : partSize;
5157
- const { bytesRead } = await fh.read(buf, 0, len, offset);
5158
- if (bytesRead !== len) {
5159
- throw new Error(`本地文件读取不足(第${idx}片期望${len}字节实读${bytesRead})——文件被改动?`);
5160
- }
5161
- const view = buf.subarray(0, len);
5162
- await putPart(cfg, uploadId, idx, view);
5163
- done++;
5164
- log.tick(`分片上传 ${done}/${totalParts}(${Math.round(done / totalParts * 100)}%)`);
5165
- }
5166
- };
5167
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, indexes.length) }, worker));
5168
- } finally {
5169
- log.tickEnd();
5170
- await fh.close();
5250
+ const { version } = JSON.parse(readFileSync3(join7(packageRoot(), "package.json"), "utf8"));
5251
+ return version;
5252
+ } catch {
5253
+ return "0.0.0";
5171
5254
  }
5172
5255
  }
5173
- async function putPart(cfg, uploadId, idx, view) {
5174
- const b3 = await partBlake3(view);
5175
- let lastErr;
5176
- for (let attempt = 0;attempt <= PART_RETRIES; attempt++) {
5177
- if (attempt > 0)
5178
- await sleep(BACKOFF_MS[Math.min(attempt - 1, BACKOFF_MS.length - 1)]);
5256
+ async function latestVersion(timeoutMs = 5000) {
5257
+ try {
5258
+ const res = await fetch(REGISTRY, {
5259
+ headers: { accept: "application/vnd.npm.install-v1+json" },
5260
+ signal: AbortSignal.timeout(timeoutMs)
5261
+ });
5262
+ if (!res.ok)
5263
+ return null;
5264
+ const data = await res.json();
5265
+ return data["dist-tags"]?.latest ?? null;
5266
+ } catch {
5267
+ return null;
5268
+ }
5269
+ }
5270
+ function cmpSemver(a, b) {
5271
+ const norm = (s) => s.replace(/^v/, "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
5272
+ const pa = norm(a);
5273
+ const pb = norm(b);
5274
+ for (let i = 0;i < 3; i++) {
5275
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
5276
+ if (d !== 0)
5277
+ return d > 0 ? 1 : -1;
5278
+ }
5279
+ return 0;
5280
+ }
5281
+
5282
+ // src/commands/doctor.ts
5283
+ function registerDoctor(program2) {
5284
+ program2.command("doctor").description("体检:配置 / 云端连通 / 剪映目录 / 运行时是否就绪").action(async () => {
5285
+ await runDoctor();
5286
+ });
5287
+ }
5288
+ var MARK = { ok: "✅", warn: "⚠️ ", fail: "❌" };
5289
+ async function runDoctor() {
5290
+ const rows = [];
5291
+ const latestP = latestVersion(5000).catch(() => null);
5292
+ const bunVer = process.versions.bun;
5293
+ rows.push({
5294
+ name: "运行时",
5295
+ status: "ok",
5296
+ detail: bunVer ? `bun ${bunVer}` : `node ${process.version}`
5297
+ });
5298
+ const uc = readUserConfig();
5299
+ const apiKey = (process.env.GITRUCK_API_KEY ?? uc.apiKey ?? "").trim();
5300
+ const apiBase = (process.env.GITRUCK_API_BASE ?? uc.apiBase ?? DEFAULT_API_BASE).trim().replace(/\/+$/, "");
5301
+ rows.push({
5302
+ name: "API Key",
5303
+ status: apiKey ? "ok" : "fail",
5304
+ detail: apiKey ? `已配(${apiKey.slice(0, 6)}…,来源 ${process.env.GITRUCK_API_KEY ? "环境变量" : "gtrk init"})` : "未配 —— 跑 gtrk init"
5305
+ });
5306
+ rows.push({ name: "API 根地址", status: "ok", detail: apiBase });
5307
+ let apiStatus = "warn";
5308
+ let apiDetail = "跳过(未配 Key)";
5309
+ if (apiKey) {
5179
5310
  try {
5180
- const res = await fetch(`${cfg.base}/base/file/upload/chunk/${uploadId}/${idx}?blake3=${b3}`, {
5181
- method: "PUT",
5182
- headers: { Authorization: cfg.apiKey, "Content-Type": "application/octet-stream" },
5183
- body: view
5311
+ const res = await fetch(`${apiBase}/user/get_user_info`, {
5312
+ method: "POST",
5313
+ headers: { accept: "application/json", Authorization: apiKey },
5314
+ body: "",
5315
+ signal: AbortSignal.timeout(8000)
5184
5316
  });
5185
- const r = await parseJson(res);
5186
- if (r.code === 200)
5187
- return;
5188
- if (r.code === CODE_SESSION_NOT_FOUND)
5189
- throw new SessionGoneError;
5190
- if (r.code === CODE_CHECKSUM_MISMATCH || r.code === CODE_PART_INVALID) {
5191
- lastErr = new Error(`第${idx}片被拒 (code=${r.code}):${r.msg ?? ""}`);
5192
- continue;
5317
+ const data = await res.json().catch(() => ({}));
5318
+ if (data.code === 200) {
5319
+ apiStatus = "ok";
5320
+ apiDetail = "可达,鉴权通过";
5321
+ } else {
5322
+ apiStatus = "fail";
5323
+ apiDetail = `可达,但鉴权失败(code=${data.code ?? res.status}${data.msg ? `,${data.msg}` : ""})—— 检查 API Key`;
5193
5324
  }
5194
- throw new Error(`第${idx}片上传失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5195
5325
  } catch (e) {
5196
- if (e instanceof SessionGoneError)
5197
- throw e;
5198
- if (!isTransient(e))
5199
- throw e;
5200
- lastErr = e;
5326
+ apiStatus = "fail";
5327
+ apiDetail = `连不上:${e instanceof Error ? e.message : String(e)}`;
5201
5328
  }
5202
5329
  }
5203
- throw lastErr instanceof Error ? lastErr : new Error(`第${idx}片重试${PART_RETRIES}次仍失败`);
5204
- }
5205
-
5206
- // src/lib/upload-cache.ts
5207
- var CACHE_DIR = gitruckHome();
5208
- var CACHE_FILE = join8(CACHE_DIR, "upload-cache.json");
5209
- var SESSION_FILE = join8(CACHE_DIR, "upload-sessions.json");
5210
- async function fingerprint(path) {
5211
- const s = await stat3(path);
5212
- return `${s.size}:${Math.round(s.mtimeMs)}`;
5213
- }
5214
- async function load() {
5215
- if (!existsSync8(CACHE_FILE))
5216
- return {};
5217
- try {
5218
- return JSON.parse(await readFile(CACHE_FILE, "utf8"));
5219
- } catch {
5220
- return {};
5330
+ rows.push({ name: "云端连通 + 鉴权", status: apiStatus, detail: apiDetail });
5331
+ const draftDir = resolveJianyingDraftDir(undefined);
5332
+ const draftOk = !!draftDir && existsSync7(draftDir);
5333
+ rows.push({
5334
+ name: "剪映草稿目录",
5335
+ status: draftOk ? "ok" : "warn",
5336
+ detail: draftOk ? draftDir : "未配/未探到 —— 要剪映直开就跑 gtrk init 或加 --jianying-draft-dir"
5337
+ });
5338
+ rows.push({
5339
+ name: "配置文件",
5340
+ status: existsSync7(configPath()) ? "ok" : "warn",
5341
+ detail: existsSync7(configPath()) ? configPath() : `未生成 —— 跑 gtrk init(${configPath()})`
5342
+ });
5343
+ const col = uc.defaultColumn;
5344
+ const colFile = col ? join8(columnsDir(), `${col}.json`) : undefined;
5345
+ rows.push({
5346
+ name: "当前栏目",
5347
+ status: "ok",
5348
+ detail: col ? `${col}${colFile && existsSync7(colFile) ? `(${colFile})` : `(⚠ 配置文件缺失:${colFile},将回落内置默认)`}` : "内置默认 —— 想建自己栏目的风格体系,跑 /gtrk-style-maker(不建也能直接用默认)"
5349
+ });
5350
+ const ff = resolveFfmpeg();
5351
+ if (ff) {
5352
+ let hasX264 = false;
5353
+ let ver = "";
5354
+ try {
5355
+ const cap = probeCapabilities(ff);
5356
+ hasX264 = cap.hasLibx264;
5357
+ ver = cap.version.replace(/^ffmpeg version\s*/i, "v");
5358
+ } catch {}
5359
+ rows.push({
5360
+ name: "本地渲染 (ffmpeg)",
5361
+ status: hasX264 ? "ok" : "warn",
5362
+ detail: hasX264 ? `就绪(来源 ${ff.source}${ver ? `,${ver.split(/\s/)[0]}` : ""})` : `找到 ffmpeg(${ff.source})但缺 libx264 —— 本地渲染需换含 libx264 的构建`
5363
+ });
5364
+ } else {
5365
+ rows.push({
5366
+ name: "本地渲染 (ffmpeg)",
5367
+ status: "warn",
5368
+ detail: "未找到 —— 只出工程文件可忽略;要本地渲染成片,让 agent 装 ffmpeg/ffprobe 到 ~/.gitruck/ffmpeg 或 --ffmpeg-path"
5369
+ });
5221
5370
  }
5371
+ const cur = currentVersion();
5372
+ const latest = await latestP;
5373
+ rows.splice(1, 0, {
5374
+ name: "CLI 版本",
5375
+ status: latest && cmpSemver(latest, cur) > 0 ? "warn" : "ok",
5376
+ detail: latest && cmpSemver(latest, cur) > 0 ? `v${cur} —— 有新版 v${latest},跑 gtrk upgrade 升级` : latest ? `v${cur}(已是最新)` : `v${cur}`
5377
+ });
5378
+ console.log(`
5379
+ gtrk 体检:
5380
+ `);
5381
+ for (const r of rows)
5382
+ console.log(` ${MARK[r.status]} ${r.name}:${r.detail}`);
5383
+ const failed = rows.some((r) => r.status === "fail");
5384
+ console.log(failed ? `
5385
+ 有项不通,按提示处理后再开剪。
5386
+ ` : `
5387
+ 一切就绪,可以开剪。
5388
+ `);
5389
+ if (failed)
5390
+ process.exitCode = 1;
5391
+ return !failed;
5222
5392
  }
5223
- async function save(cache) {
5224
- await mkdir(CACHE_DIR, { recursive: true });
5225
- await writeFile2(CACHE_FILE, JSON.stringify(cache, null, 2));
5226
- }
5227
- async function invalidateUpload(path) {
5228
- const fp = await fingerprint(path);
5229
- const cache = await load();
5230
- if (cache[fp]) {
5231
- delete cache[fp];
5232
- await save(cache);
5233
- }
5393
+
5394
+ // src/commands/init.ts
5395
+ var GUIDE_IMAGE = join9(packageRoot(), "assets", "jianying-draft-path.png");
5396
+ function registerInit(program2) {
5397
+ 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);
5234
5398
  }
5235
- async function loadSessions() {
5236
- if (!existsSync8(SESSION_FILE))
5237
- return {};
5238
- try {
5239
- return JSON.parse(await readFile(SESSION_FILE, "utf8"));
5240
- } catch {
5241
- return {};
5399
+ async function runInit(opts) {
5400
+ if (opts.yes || opts.apiKey)
5401
+ return runInitNonInteractive(opts);
5402
+ if (!process.stdin.isTTY) {
5403
+ log.err("交互式 init 需要真实终端;脚本/agent 请用:gtrk init --api-key <KEY> -y");
5404
+ process.exitCode = 1;
5405
+ return;
5242
5406
  }
5243
- }
5244
- async function saveSessions(sessions) {
5245
- await mkdir(CACHE_DIR, { recursive: true });
5246
- await writeFile2(SESSION_FILE, JSON.stringify(sessions, null, 2));
5247
- }
5248
- var fileSessionStore = {
5249
- async load(fp) {
5250
- return (await loadSessions())[fp];
5251
- },
5252
- async save(fp, rec) {
5253
- const sessions = await loadSessions();
5254
- sessions[fp] = rec;
5255
- await saveSessions(sessions);
5256
- },
5257
- async clear(fp) {
5258
- const sessions = await loadSessions();
5259
- if (sessions[fp]) {
5260
- delete sessions[fp];
5261
- await saveSessions(sessions);
5407
+ const existing = readUserConfig();
5408
+ const configured = !!existing.apiKey;
5409
+ log.step("▶ gtrk 安装配置");
5410
+ if (configured && !opts.reconfigure) {
5411
+ log.info(`检测到已有配置:API Key ${mask(existing.apiKey)}|根地址 ${existing.apiBase ?? DEFAULT_API_BASE}|剪映目录 ${existing.jianyingDraftDir ?? "未配"}`);
5412
+ if (await promptConfirm("保留现有配置、跳过重填?(要改配置选 N)", true)) {
5413
+ log.info("已保留现有配置(要改配置随时跑 gtrk init --reconfigure)。");
5414
+ return afterConfigDoctor();
5262
5415
  }
5416
+ log.info("好,重新配置——各项可直接回车沿用现有值。");
5263
5417
  }
5264
- };
5265
- async function uploadCached(cfg, path, opts) {
5266
- const fp = await fingerprint(path);
5267
- const cache = await load();
5268
- const hit = cache[fp]?.fileId;
5269
- if (!opts?.force && hit)
5270
- return { fileId: hit, cached: true };
5418
+ let apiKey = existing.apiKey ?? "";
5419
+ const keyMsg = apiKey ? `粘贴新的 API Key,或直接回车沿用现有(${mask(apiKey)}):` : "粘贴同合云 API Key(Ctrl+V 粘贴):";
5420
+ for (;; ) {
5421
+ const entered = (await promptSecret(keyMsg)).trim();
5422
+ if (entered) {
5423
+ apiKey = entered;
5424
+ break;
5425
+ }
5426
+ if (apiKey)
5427
+ break;
5428
+ log.warn("API Key 不能为空,再来一次");
5429
+ }
5430
+ const apiBase = (await promptText("云端 API 根地址(回车用默认):", {
5431
+ defaultValue: existing.apiBase ?? DEFAULT_API_BASE
5432
+ })).trim();
5433
+ let jianyingDraftDir;
5434
+ if (existing.jianyingDraftDir && existsSync8(existing.jianyingDraftDir)) {
5435
+ if (await promptConfirm(`剪映草稿目录现为 ${existing.jianyingDraftDir},保留吗?`, true)) {
5436
+ jianyingDraftDir = existing.jianyingDraftDir;
5437
+ }
5438
+ }
5439
+ if (!jianyingDraftDir) {
5440
+ const probed = probeJianyingDraftDir();
5441
+ if (probed && await promptConfirm(`自动找到剪映草稿目录:${probed},用它吗?`, true)) {
5442
+ jianyingDraftDir = probed;
5443
+ }
5444
+ }
5445
+ if (!jianyingDraftDir) {
5446
+ log.info("没自动找到(或你选了手动)。已打开一张指引图:剪映 → 全局设置 → 草稿 →「草稿位置」,把那一行路径复制过来;留空则跳过(剪映只产 draft_content.json、缺 meta、需手动导入)。");
5447
+ openFile(GUIDE_IMAGE);
5448
+ const manual = (await promptText("剪映草稿根目录(…\\com.lveditor.draft),留空跳过:")).trim();
5449
+ if (manual) {
5450
+ if (existsSync8(manual))
5451
+ jianyingDraftDir = resolve2(manual);
5452
+ else
5453
+ log.warn(`目录不存在,已跳过:${manual}`);
5454
+ }
5455
+ }
5456
+ const patch = { apiKey, apiBase };
5457
+ if (jianyingDraftDir)
5458
+ patch.jianyingDraftDir = jianyingDraftDir;
5459
+ writeUserConfig(patch);
5460
+ log.ok(`配置已写入 ${configPath()}`);
5461
+ if (!jianyingDraftDir) {
5462
+ log.warn("未配剪映草稿目录:要剪映直接打开,之后可重跑 gtrk init,或单次加 --jianying-draft-dir");
5463
+ }
5464
+ return afterConfigDoctor();
5465
+ }
5466
+ function mask(key) {
5467
+ return `${key.slice(0, 6)}…`;
5468
+ }
5469
+ async function afterConfigDoctor() {
5470
+ const healthy = await runDoctor();
5471
+ if (healthy) {
5472
+ log.step("装好了!两种用法任选:");
5473
+ log.info('① 命令行直接剪:gtrk oralcut "<毛片.mp4>" --script "<文字稿.txt>"(无稿就别加 --script)');
5474
+ log.info("② 重启你常用的 AI agent(Claude / Codex / Trae / WorkBuddy 等),用 /gtrk-oralcut <你的口播剪辑需求>,一句话交给它,体验更智能的剪辑~");
5475
+ log.info("想有自己栏目的风格体系?在 agent 里跑 /gtrk-style-maker 建一次「你的厨房」(skill 家族 + 栏目配置);不建就直接用默认,照常开剪。");
5476
+ } else {
5477
+ log.warn("上面体检有项没通,按提示处理好再开剪(多半是 API Key 或剪映目录)。");
5478
+ }
5479
+ }
5480
+ async function runInitNonInteractive(opts) {
5481
+ const existing = readUserConfig();
5482
+ const apiKey = (opts.apiKey ?? existing.apiKey ?? "").trim();
5483
+ if (!apiKey) {
5484
+ log.err("非交互模式需要 --api-key(或改用交互式 gtrk init)");
5485
+ process.exitCode = 1;
5486
+ return;
5487
+ }
5488
+ const apiBase = (opts.apiBase ?? existing.apiBase ?? DEFAULT_API_BASE).trim();
5489
+ let jianyingDraftDir;
5490
+ const dirOpt = opts.jianyingDraftDir;
5491
+ if (dirOpt && dirOpt !== "auto")
5492
+ jianyingDraftDir = resolve2(dirOpt);
5493
+ else if (dirOpt === "auto" || !existing.jianyingDraftDir)
5494
+ jianyingDraftDir = probeJianyingDraftDir();
5495
+ else
5496
+ jianyingDraftDir = existing.jianyingDraftDir;
5497
+ const patch = { apiKey, apiBase };
5498
+ if (jianyingDraftDir)
5499
+ patch.jianyingDraftDir = jianyingDraftDir;
5500
+ writeUserConfig(patch);
5501
+ log.ok(`配置已写入 ${configPath()}`);
5502
+ log.info(`剪映草稿目录:${jianyingDraftDir ?? "未配(剪映需手动导入,可加 --jianying-draft-dir)"}`);
5503
+ await runDoctor();
5504
+ }
5505
+
5506
+ // src/commands/install.ts
5507
+ function registerInstall(program2) {
5508
+ program2.command("install").description("一条命令装全:安装 /gtrk-oralcut skill + 配置(对标飞书 lark-cli install)").option("--api-key <key>", "非交互:直接指定 API Key").option("--api-base <url>", "非交互:指定 API 根地址").option("--jianying-draft-dir <dir>", "非交互:剪映草稿目录(传 auto 则自动探测)").option("--skills-dir <dir>", "自定义 skills 目录(缺省 ~/.claude/skills)").option("--reconfigure", "重走配置向导(默认:已配过则保留现有配置、只刷新 skill)").option("-y, --yes", "非交互:用传入值 + 自动探测,不弹任何提示").action(async (opts) => {
5509
+ log.step("① 安装 / 刷新 agent skill…");
5510
+ installSkill({ dir: opts.skillsDir });
5511
+ log.step("② 配置 + 体检…");
5512
+ await runInit(opts);
5513
+ });
5514
+ }
5515
+
5516
+ // src/commands/oralcut.ts
5517
+ import { resolve as resolve3, join as join14, dirname as dirname2, basename as basename5, extname as extname2 } from "node:path";
5518
+ import { mkdir as mkdir4, writeFile as writeFile5, readFile as readFile3 } from "node:fs/promises";
5519
+ import { existsSync as existsSync12 } from "node:fs";
5520
+
5521
+ // src/lib/config.ts
5522
+ function loadConfig() {
5523
+ const uc = readUserConfig();
5524
+ const apiKey = (process.env.GITRUCK_API_KEY ?? uc.apiKey ?? "").trim();
5525
+ const base = (process.env.GITRUCK_API_BASE ?? uc.apiBase ?? DEFAULT_API_BASE).trim().replace(/\/+$/, "");
5526
+ if (!apiKey) {
5527
+ throw new Error("缺 API Key —— 先跑 `gtrk init` 配置(或设环境变量 GITRUCK_API_KEY)");
5528
+ }
5529
+ return { base, apiKey };
5530
+ }
5531
+
5532
+ // src/lib/cloud.ts
5533
+ import { basename } from "node:path";
5534
+ import { writeFile, stat } from "node:fs/promises";
5535
+ import { createReadStream } from "node:fs";
5536
+ import { Readable } from "node:stream";
5537
+ import { randomBytes } from "node:crypto";
5538
+
5539
+ class CloudError extends Error {
5540
+ code;
5541
+ constructor(code, message) {
5542
+ super(message);
5543
+ this.code = code;
5544
+ this.name = "CloudError";
5545
+ }
5546
+ }
5547
+ async function parseJson(res) {
5548
+ try {
5549
+ return await res.json();
5550
+ } catch {
5551
+ throw new Error(`服务响应解析失败 (HTTP ${res.status})`);
5552
+ }
5553
+ }
5554
+ async function uploadFile(cfg, path) {
5555
+ const size = (await stat(path)).size;
5556
+ const boundary = `----gtrkFormBoundary${randomBytes(16).toString("hex")}`;
5557
+ const head = Buffer.from(`--${boundary}\r
5558
+ ` + `Content-Disposition: form-data; name="file"; filename="${basename(path)}"\r
5559
+ ` + `Content-Type: application/octet-stream\r
5560
+ \r
5561
+ `, "utf8");
5562
+ const tail = Buffer.from(`\r
5563
+ --${boundary}--\r
5564
+ `, "utf8");
5565
+ async function* multipart() {
5566
+ yield head;
5567
+ for await (const chunk of createReadStream(path))
5568
+ yield chunk;
5569
+ yield tail;
5570
+ }
5571
+ const res = await fetch(`${cfg.base}/base/file/upload`, {
5572
+ method: "POST",
5573
+ headers: {
5574
+ Authorization: cfg.apiKey,
5575
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
5576
+ "Content-Length": String(head.length + size + tail.length)
5577
+ },
5578
+ body: Readable.toWeb(Readable.from(multipart())),
5579
+ duplex: "half"
5580
+ });
5581
+ const r = await parseJson(res);
5582
+ const fid = r.data?.file_id ?? r.data?.id;
5583
+ if (r.code === 200 && fid)
5584
+ return String(fid);
5585
+ throw new Error(`上传失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5586
+ }
5587
+ async function submitTask(cfg, taskType, payload) {
5588
+ const res = await fetch(`${cfg.base}/task/${taskType}`, {
5589
+ method: "POST",
5590
+ headers: { Authorization: cfg.apiKey, "Content-Type": "application/json" },
5591
+ body: JSON.stringify(payload)
5592
+ });
5593
+ const r = await parseJson(res);
5594
+ if (r.code === 200 && r.data?.task_id)
5595
+ return String(r.data.task_id);
5596
+ throw new Error(`提交失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5597
+ }
5598
+ async function getTaskResult(cfg, taskType, taskId) {
5599
+ const res = await fetch(`${cfg.base}/task/${taskType}/${taskId}`, {
5600
+ headers: { Authorization: cfg.apiKey }
5601
+ });
5602
+ const r = await parseJson(res);
5603
+ if (r.code != null && r.code !== 200) {
5604
+ throw new CloudError(r.code, `任务查询失败 (code=${r.code}):${r.msg ?? ""}`);
5605
+ }
5606
+ const data = r.data ?? {};
5607
+ return {
5608
+ status: String(data.status ?? ""),
5609
+ progress: typeof data.progress === "number" ? data.progress : undefined,
5610
+ output: data.output_result ?? {}
5611
+ };
5612
+ }
5613
+ async function pollTask(cfg, taskType, taskId, onTick) {
5614
+ const start = Date.now();
5615
+ const TIMEOUT_MS = 30 * 60 * 1000;
5616
+ const INTERVAL_MS = 5000;
5617
+ for (;; ) {
5618
+ if (Date.now() - start > TIMEOUT_MS) {
5619
+ throw new Error("任务超时(超过 30 分钟)。可稍后在云端查任务或重试。");
5620
+ }
5621
+ await new Promise((r) => setTimeout(r, INTERVAL_MS));
5622
+ let got;
5623
+ try {
5624
+ got = await getTaskResult(cfg, taskType, taskId);
5625
+ } catch (e) {
5626
+ if (e instanceof CloudError)
5627
+ throw e;
5628
+ continue;
5629
+ }
5630
+ if (got.status === "completed")
5631
+ return got.output;
5632
+ if (got.status === "failed" || got.status === "cancelled") {
5633
+ const out = got.output;
5634
+ throw new Error(out?.error ?? (got.status === "failed" ? "任务失败" : "任务已取消"));
5635
+ }
5636
+ onTick?.(got.status || "处理中", got.progress);
5637
+ }
5638
+ }
5639
+ async function download(url, dest) {
5640
+ const res = await fetch(url);
5641
+ if (!res.ok)
5642
+ throw new Error(`下载失败 HTTP ${res.status}:${url}`);
5643
+ const buf = Buffer.from(await res.arrayBuffer());
5644
+ await writeFile(dest, buf);
5645
+ }
5646
+
5647
+ // src/lib/upload-cache.ts
5648
+ import { join as join10 } from "node:path";
5649
+ import { stat as stat3, mkdir, readFile, writeFile as writeFile2 } from "node:fs/promises";
5650
+ import { existsSync as existsSync9 } from "node:fs";
5651
+
5652
+ // src/lib/chunk-upload.ts
5653
+ var import_hash_wasm = __toESM(require_index_umd(), 1);
5654
+ import { open, stat as stat2 } from "node:fs/promises";
5655
+ import { basename as basename2 } from "node:path";
5656
+ var CHUNK_THRESHOLD = 256 * 1024 * 1024;
5657
+ var CONCURRENCY = 3;
5658
+ var PART_RETRIES = 3;
5659
+ var BACKOFF_MS = [2000, 4000, 8000];
5660
+ var INCOMPLETE_ROUNDS = 2;
5661
+ var MAX_REBUILDS = 1;
5662
+ var CODE_SESSION_NOT_FOUND = 6024;
5663
+ var CODE_PART_INVALID = 6025;
5664
+ var CODE_CHECKSUM_MISMATCH = 6026;
5665
+ var CODE_INCOMPLETE = 6027;
5666
+ var FP_BUFFER = 8192;
5667
+ var FP_SKIP = 64 * 1024 * 1024;
5668
+ async function fastBlake3(path) {
5669
+ try {
5670
+ const hasher = await import_hash_wasm.createBLAKE3();
5671
+ hasher.init();
5672
+ const fh = await open(path, "r");
5673
+ try {
5674
+ const buf = Buffer.alloc(FP_BUFFER);
5675
+ let count = 0;
5676
+ let pos = 0;
5677
+ for (;; ) {
5678
+ const { bytesRead } = await fh.read(buf, 0, FP_BUFFER, pos);
5679
+ if (bytesRead === 0)
5680
+ break;
5681
+ count += FP_BUFFER;
5682
+ if (count >= FP_SKIP)
5683
+ break;
5684
+ hasher.update(buf.subarray(0, bytesRead));
5685
+ pos += bytesRead;
5686
+ }
5687
+ return hasher.digest("hex");
5688
+ } finally {
5689
+ await fh.close();
5690
+ }
5691
+ } catch {
5692
+ return;
5693
+ }
5694
+ }
5695
+ async function partBlake3(view) {
5696
+ const hasher = await import_hash_wasm.createBLAKE3();
5697
+ hasher.init();
5698
+ hasher.update(view);
5699
+ return hasher.digest("hex");
5700
+ }
5701
+
5702
+ class SessionGoneError extends Error {
5703
+ constructor() {
5704
+ super("分片会话已失效");
5705
+ this.name = "SessionGoneError";
5706
+ }
5707
+ }
5708
+ function isTransient(e) {
5709
+ const msg = String(e?.message ?? e);
5710
+ return /fetch failed|ECONNRESET|ETIMEDOUT|EPIPE|UND_ERR|服务响应解析失败 \(HTTP 5/i.test(msg);
5711
+ }
5712
+ async function sleep(ms) {
5713
+ await new Promise((r) => setTimeout(r, ms));
5714
+ }
5715
+ async function uploadChunked(cfg, path, opts) {
5716
+ const size = (await stat2(path)).size;
5717
+ const name = basename2(path);
5718
+ for (let rebuilds = 0;; rebuilds++) {
5719
+ try {
5720
+ return await attemptOnce(cfg, path, name, size, opts);
5721
+ } catch (e) {
5722
+ if (e instanceof SessionGoneError && rebuilds < MAX_REBUILDS) {
5723
+ await opts.store.clear(opts.fingerprint);
5724
+ log.warn("云端分片会话已失效,自动重建整传一次");
5725
+ continue;
5726
+ }
5727
+ throw e;
5728
+ }
5729
+ }
5730
+ }
5731
+ async function attemptOnce(cfg, path, name, size, opts) {
5732
+ const { fingerprint, store, force } = opts;
5733
+ let session = force ? undefined : await store.load(fingerprint);
5734
+ let missing;
5735
+ if (session) {
5736
+ const st = await chunkStatus(cfg, session.uploadId);
5737
+ if (st === undefined) {
5738
+ await store.clear(fingerprint);
5739
+ session = undefined;
5740
+ } else {
5741
+ session = { ...session, partSize: st.part_size, totalParts: st.total_parts };
5742
+ missing = st.missing;
5743
+ if (missing.length < session.totalParts) {
5744
+ log.info(`断点续传:云端已有 ${session.totalParts - missing.length}/${session.totalParts} 片,补 ${missing.length} 片`);
5745
+ }
5746
+ }
5747
+ }
5748
+ if (!session) {
5749
+ const blake3Id = force ? undefined : await fastBlake3(path);
5750
+ const body = { filename: name, size };
5751
+ if (blake3Id)
5752
+ body.blake3_id = blake3Id;
5753
+ const res = await fetch(`${cfg.base}/base/file/upload/chunk/init`, {
5754
+ method: "POST",
5755
+ headers: { Authorization: cfg.apiKey, "Content-Type": "application/json" },
5756
+ body: JSON.stringify(body)
5757
+ });
5758
+ const r = await parseJson(res);
5759
+ if (r.code !== 200) {
5760
+ throw new Error(`分片上传 init 失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5761
+ }
5762
+ if (r.data?.file_id != null) {
5763
+ log.info("秒传命中:云端已有同内容文件,零字节上传");
5764
+ return String(r.data.file_id);
5765
+ }
5766
+ if (r.data?.upload_id == null || !r.data.part_size || !r.data.total_parts) {
5767
+ throw new Error("分片上传 init 响应缺字段(upload_id/part_size/total_parts)");
5768
+ }
5769
+ session = {
5770
+ uploadId: String(r.data.upload_id),
5771
+ partSize: r.data.part_size,
5772
+ totalParts: r.data.total_parts,
5773
+ size,
5774
+ path,
5775
+ createdAt: Date.now()
5776
+ };
5777
+ await store.save(fingerprint, session);
5778
+ missing = undefined;
5779
+ }
5780
+ if (missing === undefined) {
5781
+ missing = Array.from({ length: session.totalParts }, (_, i) => i);
5782
+ }
5783
+ await uploadParts(cfg, path, session, missing);
5784
+ for (let round = 0;; round++) {
5785
+ const res = await fetch(`${cfg.base}/base/file/upload/chunk/${session.uploadId}/complete`, { method: "POST", headers: { Authorization: cfg.apiKey } });
5786
+ const r = await parseJson(res);
5787
+ if (r.code === 200 && r.data?.file_id != null) {
5788
+ await store.clear(fingerprint);
5789
+ return String(r.data.file_id);
5790
+ }
5791
+ if (r.code === CODE_SESSION_NOT_FOUND)
5792
+ throw new SessionGoneError;
5793
+ if (r.code === CODE_INCOMPLETE && round < INCOMPLETE_ROUNDS) {
5794
+ const st = await chunkStatus(cfg, session.uploadId);
5795
+ if (st === undefined)
5796
+ throw new SessionGoneError;
5797
+ await uploadParts(cfg, path, session, st.missing);
5798
+ continue;
5799
+ }
5800
+ throw new Error(`分片上传 complete 失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5801
+ }
5802
+ }
5803
+ async function chunkStatus(cfg, uploadId) {
5804
+ const res = await fetch(`${cfg.base}/base/file/upload/chunk/${uploadId}`, {
5805
+ headers: { Authorization: cfg.apiKey }
5806
+ });
5807
+ const r = await parseJson(res);
5808
+ if (r.code === CODE_SESSION_NOT_FOUND)
5809
+ return;
5810
+ if (r.code !== 200 || !r.data) {
5811
+ throw new Error(`分片会话查询失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5812
+ }
5813
+ return r.data;
5814
+ }
5815
+ async function uploadParts(cfg, path, session, indexes) {
5816
+ if (indexes.length === 0)
5817
+ return;
5818
+ const { uploadId, partSize, totalParts, size } = session;
5819
+ let done = totalParts - indexes.length;
5820
+ const fh = await open(path, "r");
5821
+ try {
5822
+ const queue = [...indexes];
5823
+ const worker = async () => {
5824
+ const buf = Buffer.alloc(partSize);
5825
+ for (;; ) {
5826
+ const idx = queue.shift();
5827
+ if (idx === undefined)
5828
+ return;
5829
+ const offset = idx * partSize;
5830
+ const len = idx === totalParts - 1 ? size - offset : partSize;
5831
+ const { bytesRead } = await fh.read(buf, 0, len, offset);
5832
+ if (bytesRead !== len) {
5833
+ throw new Error(`本地文件读取不足(第${idx}片期望${len}字节实读${bytesRead})——文件被改动?`);
5834
+ }
5835
+ const view = buf.subarray(0, len);
5836
+ await putPart(cfg, uploadId, idx, view);
5837
+ done++;
5838
+ log.tick(`分片上传 ${done}/${totalParts}(${Math.round(done / totalParts * 100)}%)`);
5839
+ }
5840
+ };
5841
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, indexes.length) }, worker));
5842
+ } finally {
5843
+ log.tickEnd();
5844
+ await fh.close();
5845
+ }
5846
+ }
5847
+ async function putPart(cfg, uploadId, idx, view) {
5848
+ const b3 = await partBlake3(view);
5849
+ let lastErr;
5850
+ for (let attempt = 0;attempt <= PART_RETRIES; attempt++) {
5851
+ if (attempt > 0)
5852
+ await sleep(BACKOFF_MS[Math.min(attempt - 1, BACKOFF_MS.length - 1)]);
5853
+ try {
5854
+ const res = await fetch(`${cfg.base}/base/file/upload/chunk/${uploadId}/${idx}?blake3=${b3}`, {
5855
+ method: "PUT",
5856
+ headers: { Authorization: cfg.apiKey, "Content-Type": "application/octet-stream" },
5857
+ body: view
5858
+ });
5859
+ const r = await parseJson(res);
5860
+ if (r.code === 200)
5861
+ return;
5862
+ if (r.code === CODE_SESSION_NOT_FOUND)
5863
+ throw new SessionGoneError;
5864
+ if (r.code === CODE_CHECKSUM_MISMATCH || r.code === CODE_PART_INVALID) {
5865
+ lastErr = new Error(`第${idx}片被拒 (code=${r.code}):${r.msg ?? ""}`);
5866
+ continue;
5867
+ }
5868
+ throw new Error(`第${idx}片上传失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5869
+ } catch (e) {
5870
+ if (e instanceof SessionGoneError)
5871
+ throw e;
5872
+ if (!isTransient(e))
5873
+ throw e;
5874
+ lastErr = e;
5875
+ }
5876
+ }
5877
+ throw lastErr instanceof Error ? lastErr : new Error(`第${idx}片重试${PART_RETRIES}次仍失败`);
5878
+ }
5879
+
5880
+ // src/lib/upload-cache.ts
5881
+ var CACHE_DIR = gitruckHome();
5882
+ var CACHE_FILE = join10(CACHE_DIR, "upload-cache.json");
5883
+ var SESSION_FILE = join10(CACHE_DIR, "upload-sessions.json");
5884
+ async function fingerprint(path) {
5885
+ const s = await stat3(path);
5886
+ return `${s.size}:${Math.round(s.mtimeMs)}`;
5887
+ }
5888
+ async function load() {
5889
+ if (!existsSync9(CACHE_FILE))
5890
+ return {};
5891
+ try {
5892
+ return JSON.parse(await readFile(CACHE_FILE, "utf8"));
5893
+ } catch {
5894
+ return {};
5895
+ }
5896
+ }
5897
+ async function save(cache) {
5898
+ await mkdir(CACHE_DIR, { recursive: true });
5899
+ await writeFile2(CACHE_FILE, JSON.stringify(cache, null, 2));
5900
+ }
5901
+ async function invalidateUpload(path) {
5902
+ const fp = await fingerprint(path);
5903
+ const cache = await load();
5904
+ if (cache[fp]) {
5905
+ delete cache[fp];
5906
+ await save(cache);
5907
+ }
5908
+ }
5909
+ async function loadSessions() {
5910
+ if (!existsSync9(SESSION_FILE))
5911
+ return {};
5912
+ try {
5913
+ return JSON.parse(await readFile(SESSION_FILE, "utf8"));
5914
+ } catch {
5915
+ return {};
5916
+ }
5917
+ }
5918
+ async function saveSessions(sessions) {
5919
+ await mkdir(CACHE_DIR, { recursive: true });
5920
+ await writeFile2(SESSION_FILE, JSON.stringify(sessions, null, 2));
5921
+ }
5922
+ var fileSessionStore = {
5923
+ async load(fp) {
5924
+ return (await loadSessions())[fp];
5925
+ },
5926
+ async save(fp, rec) {
5927
+ const sessions = await loadSessions();
5928
+ sessions[fp] = rec;
5929
+ await saveSessions(sessions);
5930
+ },
5931
+ async clear(fp) {
5932
+ const sessions = await loadSessions();
5933
+ if (sessions[fp]) {
5934
+ delete sessions[fp];
5935
+ await saveSessions(sessions);
5936
+ }
5937
+ }
5938
+ };
5939
+ async function uploadCached(cfg, path, opts) {
5940
+ const fp = await fingerprint(path);
5941
+ const cache = await load();
5942
+ const hit = cache[fp]?.fileId;
5943
+ if (!opts?.force && hit)
5944
+ return { fileId: hit, cached: true };
5271
5945
  const s0 = await stat3(path);
5272
5946
  const fileId = s0.size >= CHUNK_THRESHOLD ? await uploadChunked(cfg, path, {
5273
5947
  fingerprint: fp,
@@ -5288,8 +5962,8 @@ async function uploadCached(cfg, path, opts) {
5288
5962
 
5289
5963
  // src/lib/media.ts
5290
5964
  import { mkdir as mkdir2, stat as stat4 } from "node:fs/promises";
5291
- import { existsSync as existsSync9 } from "node:fs";
5292
- import { basename as basename3, extname, join as join9 } from "node:path";
5965
+ import { existsSync as existsSync10 } from "node:fs";
5966
+ import { basename as basename3, extname, join as join11 } from "node:path";
5293
5967
  function parseFps(rate) {
5294
5968
  if (typeof rate !== "string")
5295
5969
  return 0;
@@ -5339,12 +6013,12 @@ async function artifactPath(inputAbs, ext) {
5339
6013
  const s = await stat4(inputAbs);
5340
6014
  const base = basename3(inputAbs, extname(inputAbs));
5341
6015
  await mkdir2(audioCacheDir(), { recursive: true });
5342
- return join9(audioCacheDir(), `${base}.${s.size}_${Math.round(s.mtimeMs)}.${ext}`);
6016
+ return join11(audioCacheDir(), `${base}.${s.size}_${Math.round(s.mtimeMs)}.${ext}`);
5343
6017
  }
5344
6018
  async function extractAudio(inputAbs, ffmpegPath) {
5345
6019
  const { ffmpeg } = requireFfmpeg(ffmpegPath);
5346
6020
  const out = await artifactPath(inputAbs, "mp3");
5347
- if (existsSync9(out))
6021
+ if (existsSync10(out))
5348
6022
  return out;
5349
6023
  await runFfmpeg(ffmpeg, [
5350
6024
  "-y",
@@ -5368,7 +6042,7 @@ async function extractAudio(inputAbs, ffmpegPath) {
5368
6042
  async function compress720p(inputAbs, ffmpegPath) {
5369
6043
  const { ffmpeg } = requireFfmpeg(ffmpegPath);
5370
6044
  const out = await artifactPath(inputAbs, "720p.mp4");
5371
- if (existsSync9(out))
6045
+ if (existsSync10(out))
5372
6046
  return out;
5373
6047
  await runFfmpeg(ffmpeg, [
5374
6048
  "-y",
@@ -5400,14 +6074,14 @@ function assertDurationConsistent(originalDuration, artifactAbs, ffmpegPath, tol
5400
6074
  }
5401
6075
 
5402
6076
  // src/lib/materialize.ts
5403
- import { join as join11, basename as basename4 } from "node:path";
6077
+ import { join as join13, basename as basename4 } from "node:path";
5404
6078
  import { mkdir as mkdir3, cp, writeFile as writeFile4 } from "node:fs/promises";
5405
6079
 
5406
6080
  // src/lib/render.ts
5407
6081
  import { writeFile as writeFile3, unlink, readFile as readFile2 } from "node:fs/promises";
5408
- import { existsSync as existsSync10 } from "node:fs";
6082
+ import { existsSync as existsSync11 } from "node:fs";
5409
6083
  import { tmpdir } from "node:os";
5410
- import { join as join10 } from "node:path";
6084
+ import { join as join12 } from "node:path";
5411
6085
  var AUDIO_SAMPLE_RATE = 48000;
5412
6086
  var AUDIO_LAYOUT = "stereo";
5413
6087
  var DEFAULT_CRF = 18;
@@ -5547,11 +6221,25 @@ function buildFilterGraph(gtrk, materialPaths, params = {}) {
5547
6221
  return { inputs, graph: chains.join(";"), total };
5548
6222
  }
5549
6223
  function materialPathsFromGtrk(gtrk) {
6224
+ const used = new Set;
6225
+ const sortedV = sortedTracks(gtrk.video_track || []);
6226
+ const consumers = [sortedV[0], ...gtrk.audio_track || []];
6227
+ for (const t of consumers) {
6228
+ if (!t)
6229
+ continue;
6230
+ for (const c3 of t.track_timeline || []) {
6231
+ const m = c3.material;
6232
+ if (m != null)
6233
+ used.add(String(m));
6234
+ }
6235
+ }
5550
6236
  const map = {};
5551
6237
  for (const m of gtrk.materials || []) {
6238
+ if (!used.has(String(m.id)))
6239
+ continue;
5552
6240
  if (!m.path)
5553
6241
  throw new Error(`gtrk 素材 ${m.id} 缺 path(source_path),无法本地渲染`);
5554
- if (!existsSync10(m.path))
6242
+ if (!existsSync11(m.path))
5555
6243
  throw new Error(`gtrk 素材文件不存在:${m.path}`);
5556
6244
  map[String(m.id)] = m.path;
5557
6245
  }
@@ -5565,7 +6253,7 @@ async function renderGtrk(gtrk, outputPath, opts = {}) {
5565
6253
  const { ffmpeg } = requireFfmpeg(opts.ffmpegPath);
5566
6254
  const materialPaths = materialPathsFromGtrk(gtrk);
5567
6255
  const { inputs, graph, total } = buildFilterGraph(gtrk, materialPaths, { crf });
5568
- const filterFile = join10(tmpdir(), `gtrk-filter-${process.pid}-${inputs.length}.txt`);
6256
+ const filterFile = join12(tmpdir(), `gtrk-filter-${process.pid}-${inputs.length}.txt`);
5569
6257
  await writeFile3(filterFile, graph, "utf8");
5570
6258
  try {
5571
6259
  const args = ["-y"];
@@ -5615,7 +6303,7 @@ async function materializeResult(opts) {
5615
6303
  throw new Error("任务无工程文件产物(检查 project_formats / 任务是否产出)");
5616
6304
  const errors = { ...output.errors ?? {} };
5617
6305
  await mkdir3(outDir, { recursive: true });
5618
- const resultPath = join11(outDir, "result.json");
6306
+ const resultPath = join13(outDir, "result.json");
5619
6307
  const writeResult = async (extra) => {
5620
6308
  const r = {
5621
6309
  ok: Object.keys(errors).length === 0,
@@ -5637,9 +6325,9 @@ async function materializeResult(opts) {
5637
6325
  const byFormat = {};
5638
6326
  for (const f of files) {
5639
6327
  const base = baseFormat(f.format);
5640
- const fmtDir = join11(outDir, base);
6328
+ const fmtDir = join13(outDir, base);
5641
6329
  await mkdir3(fmtDir, { recursive: true });
5642
- const dest = join11(fmtDir, f.filename);
6330
+ const dest = join13(fmtDir, f.filename);
5643
6331
  try {
5644
6332
  await dl(f.download_url, dest);
5645
6333
  (byFormat[base] ??= []).push(dest);
@@ -5656,9 +6344,9 @@ async function materializeResult(opts) {
5656
6344
  let jianyingDraftPath = null;
5657
6345
  if (byFormat.jianying && opts.draftDir) {
5658
6346
  try {
5659
- jianyingDraftPath = join11(opts.draftDir, basename4(outDir));
6347
+ jianyingDraftPath = join13(opts.draftDir, basename4(outDir));
5660
6348
  await mkdir3(jianyingDraftPath, { recursive: true });
5661
- await cp(join11(outDir, "jianying"), jianyingDraftPath, { recursive: true });
6349
+ await cp(join13(outDir, "jianying"), jianyingDraftPath, { recursive: true });
5662
6350
  log.info(`剪映草稿已落到:${jianyingDraftPath}`);
5663
6351
  } catch (e) {
5664
6352
  jianyingDraftPath = null;
@@ -5675,7 +6363,7 @@ async function materializeResult(opts) {
5675
6363
  log.step("本地渲染成片(ffmpeg)…");
5676
6364
  const project = await readGtrkFile(gtrkPath);
5677
6365
  const name = opts.projName ?? gtrkSourceName(project) ?? taskId;
5678
- const outMp4 = join11(outDir, `${name}.mp4`);
6366
+ const outMp4 = join13(outDir, `${name}.mp4`);
5679
6367
  const r = await renderGtrk(project, outMp4, {
5680
6368
  crf: opts.crf != null ? Number(opts.crf) : undefined,
5681
6369
  codec: opts.codec,
@@ -5697,7 +6385,7 @@ async function materializeResult(opts) {
5697
6385
  log.step("三方打开(产物已就位,按需自取):");
5698
6386
  for (const base of Object.keys(byFormat)) {
5699
6387
  const meta = FORMAT_META[base];
5700
- const target = base === "jianying" ? jianyingDraftPath ?? join11(outDir, "jianying") : byFormat[base][0];
6388
+ const target = base === "jianying" ? jianyingDraftPath ?? join13(outDir, "jianying") : byFormat[base][0];
5701
6389
  console.log(` • ${meta?.label ?? base}:${meta?.openHint(target) ?? target}`);
5702
6390
  }
5703
6391
  if (rendered)
@@ -5765,18 +6453,18 @@ async function runOralCut(input, opts) {
5765
6453
  routeLogsToStderr();
5766
6454
  const cfg = loadConfig();
5767
6455
  const inputAbs = resolve3(input);
5768
- if (!existsSync11(inputAbs))
6456
+ if (!existsSync12(inputAbs))
5769
6457
  throw new Error(`毛片不存在:${inputAbs}`);
5770
6458
  const projName = basename5(inputAbs, extname2(inputAbs));
5771
6459
  const formats = opts.formats.split(",").map((s) => s.trim()).filter(Boolean);
5772
6460
  if (opts.render && !formats.includes("gtrk"))
5773
6461
  formats.push("gtrk");
5774
6462
  const wantJianying = formats.some((f) => f === "jianying" || f === "capcut");
5775
- const outDir = resolve3(opts.out ?? join12(dirname2(inputAbs), `${projName}-video-project-${timestamp()}`));
6463
+ const outDir = resolve3(opts.out ?? join14(dirname2(inputAbs), `${projName}-video-project-${timestamp()}`));
5776
6464
  let scriptPath = opts.script ? resolve3(opts.script) : undefined;
5777
6465
  if (!scriptPath) {
5778
- const sibling = join12(dirname2(inputAbs), `${projName}.txt`);
5779
- if (existsSync11(sibling)) {
6466
+ const sibling = join14(dirname2(inputAbs), `${projName}.txt`);
6467
+ if (existsSync12(sibling)) {
5780
6468
  scriptPath = sibling;
5781
6469
  log.info(`自动识别到同名文稿:${sibling}(按有稿剪辑;不想用就改名或显式 --script)`);
5782
6470
  }
@@ -5842,605 +6530,286 @@ async function runOralCut(input, opts) {
5842
6530
  }
5843
6531
  log.info(`task_id = ${taskId}`);
5844
6532
  await mkdir4(outDir, { recursive: true });
5845
- await writeFile5(join12(outDir, "task.json"), JSON.stringify({ taskId, taskType: TASK_TYPE, fileId: up.fileId, source: inputAbs, formats, createdAt: new Date().toISOString() }, null, 2));
6533
+ await writeFile5(join14(outDir, "task.json"), JSON.stringify({ taskId, taskType: TASK_TYPE, fileId: up.fileId, source: inputAbs, formats, createdAt: new Date().toISOString() }, null, 2));
5846
6534
  log.step("④ 云端处理中(每 5s 轮询)…");
5847
- const result = await pollTask(cfg, TASK_TYPE, taskId, (status, progress) => {
5848
- log.tick(`${status}${progress != null ? ` ${Math.round(progress)}%` : ""}`);
5849
- });
5850
- log.tickEnd();
5851
- await materializeResult({
5852
- outDir,
5853
- output: result,
5854
- taskId,
5855
- fileId: up.fileId,
5856
- draftDir,
5857
- render: opts.render,
5858
- crf: opts.crf,
5859
- codec: opts.codec,
5860
- ffmpegPath: opts.ffmpegPath,
5861
- projName,
5862
- json: opts.json,
5863
- open: opts.open
5864
- });
5865
- log.ok(`闭环完成。产物目录:${outDir}`);
5866
- }
5867
-
5868
- // src/commands/oralcut-result.ts
5869
- import { resolve as resolve4, join as join13 } from "node:path";
5870
- var TASK_TYPE2 = "cli/video_oral_cut_for_cli";
5871
- function timestamp2() {
5872
- const d = new Date;
5873
- const p = (n) => String(n).padStart(2, "0");
5874
- return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
5875
- }
5876
- function registerOralCutResult(program2) {
5877
- program2.command("oralcut-result <taskId>").description("按 task_id 取回已完成任务的报告 + 三方工程产物(可选 --render),不重跑云端").option("-o, --out <dir>", "产物目录(缺省 = <当前目录>/<taskId>-video-project-<时间戳>)").option("--render", "额外本地渲染成片(需原毛片仍在 gtrk 内嵌路径 + ffmpeg)").option("--crf <n>", "本地渲染 CRF 14-28(默认 18;需配 --render)").option("--codec <c>", "本地渲染编码(默认 h264;需配 --render)").option("--ffmpeg-path <dir>", "指定 ffmpeg/ffprobe 目录(缺省 ~/.gitruck/ffmpeg → 系统)").option("--jianying-draft-dir <dir>", "剪映草稿根目录;传路径或 auto(默认读配置 / 自动探测)").option("--no-open", "完成后不自动打开产物目录(默认会自动打开)").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON(给 agent/脚本解析)").action(async (taskId, opts) => {
5878
- await runOralCutResult(taskId, opts);
5879
- });
5880
- }
5881
- async function runOralCutResult(taskId, opts) {
5882
- if (opts.json)
5883
- routeLogsToStderr();
5884
- const cfg = loadConfig();
5885
- log.step(`▶ 按 task_id 取回口播剪辑结果:${taskId}`);
5886
- let got;
5887
- try {
5888
- got = await getTaskResult(cfg, TASK_TYPE2, taskId);
5889
- } catch (e) {
5890
- if (e instanceof CloudError) {
5891
- throw new Error(`取任务结果失败(code=${e.code}):${e.message}。` + `注意:取结果需用「提交该任务的同一账号」的 API Key;异账号或已删任务会报 TASK_NOT_FOUND。`);
5892
- }
5893
- throw e;
5894
- }
5895
- if (got.status !== "completed") {
5896
- if (got.status === "failed" || got.status === "cancelled") {
5897
- const out = got.output;
5898
- throw new Error(`任务未成功(${got.status}):${out?.error ?? "无产物可取回"}`);
5899
- }
5900
- const pct = got.progress != null ? ` ${Math.round(got.progress)}%` : "";
5901
- throw new Error(`任务尚未完成(当前 ${got.status || "未知"}${pct}),暂无法取回结果;请稍后再试。`);
5902
- }
5903
- const outDir = resolve4(opts.out ?? join13(process.cwd(), `${taskId}-video-project-${timestamp2()}`));
5904
- const draftDir = resolveJianyingDraftDir(opts.jianyingDraftDir);
5905
- await materializeResult({
5906
- outDir,
5907
- output: got.output,
5908
- taskId,
5909
- draftDir,
5910
- render: opts.render,
5911
- crf: opts.crf,
5912
- codec: opts.codec,
5913
- ffmpegPath: opts.ffmpegPath,
5914
- json: opts.json,
5915
- open: opts.open
5916
- });
5917
- log.ok(`已取回。产物目录:${outDir}`);
5918
- }
5919
-
5920
- // src/commands/upgrade.ts
5921
- import { spawnSync as spawnSync3 } from "node:child_process";
5922
- var CLIENT_UPGRADE = "irm https://api.ai-mcn.tv:9000/broadcast/exe/install.ps1 | iex";
5923
- function run(cmd) {
5924
- const r = spawnSync3(cmd, { stdio: "inherit", shell: true });
5925
- return r.status ?? 1;
5926
- }
5927
- function registerUpgrade(program2) {
5928
- program2.command("upgrade").description("升级 gtrk CLI 到最新版 + 刷新 skill(配置保留);--check 只查不装").option("--check", "只检查有没有新版本,不执行升级").action(async (opts) => {
5929
- const cur = currentVersion();
5930
- log.step(`当前 v${cur},查询最新版…`);
5931
- const latest = await latestVersion();
5932
- if (!latest) {
5933
- log.warn("查不到最新版本(网络问题?)。手动升级:npm i -g @gitruck/cli@latest");
5934
- log.info(`客户端(桌面端)升级:${CLIENT_UPGRADE}`);
5935
- return;
5936
- }
5937
- if (cmpSemver(latest, cur) <= 0) {
5938
- log.ok(`已是最新版 v${cur}。`);
5939
- log.info(`客户端(桌面端)如需升级:${CLIENT_UPGRADE}`);
5940
- return;
5941
- }
5942
- log.info(`发现新版本 v${latest}(当前 v${cur})。`);
5943
- if (opts.check) {
5944
- log.info("跑 `gtrk upgrade` 升级(会保留你现有的配置)。");
5945
- return;
5946
- }
5947
- log.step(`① 升级 CLI → v${latest}…`);
5948
- if (run("npm i -g @gitruck/cli@latest") !== 0) {
5949
- log.err("升级失败。手动重试:npm i -g @gitruck/cli@latest(若报权限,按你的 npm 全局目录权限处理)");
5950
- process.exitCode = 1;
5951
- return;
5952
- }
5953
- log.step("② 刷新 /gtrk-oralcut skill…");
5954
- if (run("gtrk skills install") !== 0) {
5955
- log.warn("skill 没刷成,手动跑一次:gtrk skills install");
5956
- }
5957
- log.ok(`已升级到 v${latest}。配置原样保留,直接接着用即可(gtrk doctor 可自检)。`);
5958
- log.info(`客户端(桌面端)如需一起升级:${CLIENT_UPGRADE}`);
5959
- });
5960
- }
5961
-
5962
- // src/commands/render.ts
5963
- import { resolve as resolve5, dirname as dirname3, join as join14, basename as basename6, extname as extname3 } from "node:path";
5964
- import { existsSync as existsSync12 } from "node:fs";
5965
- function registerRender(program2) {
5966
- 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) => {
5967
- if (opts.json)
5968
- routeLogsToStderr();
5969
- const gtrkAbs = resolve5(gtrk);
5970
- if (!existsSync12(gtrkAbs))
5971
- throw new Error(`gtrk 工程不存在:${gtrkAbs}`);
5972
- const outMp4 = resolve5(opts.out ?? join14(dirname3(gtrkAbs), `${basename6(gtrkAbs, extname3(gtrkAbs))}.mp4`));
5973
- log.step(`▶ 本地渲染:${basename6(gtrkAbs)} → ${basename6(outMp4)}`);
5974
- const project = await readGtrkFile(gtrkAbs);
5975
- const result = await renderGtrk(project, outMp4, {
5976
- crf: opts.crf != null ? Number(opts.crf) : undefined,
5977
- codec: opts.codec,
5978
- ffmpegPath: opts.ffmpegPath,
5979
- onLine: (l) => {
5980
- const m = l.match(/time=(\S+)/);
5981
- if (m)
5982
- log.tick(`渲染中 ${m[1]}`);
5983
- }
5984
- });
5985
- log.tickEnd();
5986
- log.ok(`渲染完成:${outMp4}(${result.duration.toFixed(1)}s)`);
5987
- if (opts.open)
5988
- openFolder(dirname3(outMp4));
5989
- if (opts.json) {
5990
- console.log(JSON.stringify({ ok: true, output: outMp4, duration: result.duration }));
5991
- }
5992
- });
5993
- }
5994
-
5995
- // src/commands/split.ts
5996
- import { resolve as resolve6, join as join16, dirname as dirname5, basename as basename8 } from "node:path";
5997
- import { existsSync as existsSync13 } from "node:fs";
5998
- import { readFile as readFile4, writeFile as writeFile6, mkdir as mkdir5 } from "node:fs/promises";
5999
- import { createHash } from "node:crypto";
6000
-
6001
- // src/lib/projection.ts
6002
- function r3(n) {
6003
- return Math.round(n * 1000) / 1000;
6004
- }
6005
- function normClip(c3) {
6006
- const clip_st = c3.clip_st ?? 0;
6007
- const track_st = c3.track_st ?? 0;
6008
- const dur = c3.duration ?? (c3.clip_ed != null ? c3.clip_ed - clip_st : 0);
6009
- const clip_ed = c3.clip_ed ?? clip_st + dur;
6010
- return { clip_st, clip_ed, track_st };
6011
- }
6012
- function pickMainVideoTrack(gtrk) {
6013
- const tracks = gtrk.video_track ?? [];
6014
- if (!tracks.length)
6015
- return;
6016
- let best = tracks[0];
6017
- let bestIdx = best.track_index ?? 0;
6018
- for (const t of tracks) {
6019
- const idx = t.track_index ?? 0;
6020
- if (idx < bestIdx) {
6021
- best = t;
6022
- bestIdx = idx;
6023
- }
6024
- }
6025
- return best;
6026
- }
6027
- function projectTranscript(transcript, gtrk, opts = {}) {
6028
- const materialId = String(transcript.material_id);
6029
- const mainTrack = pickMainVideoTrack(gtrk);
6030
- const clips = (mainTrack?.track_timeline ?? []).filter((c3) => c3.material != null && String(c3.material) === materialId).map(normClip);
6031
- const entries = [];
6032
- transcript.utterances.forEach((utt, sourceIndex) => {
6033
- const totalWords = utt.words?.length ?? 0;
6034
- const instances = [];
6035
- for (const clip of clips) {
6036
- const surviving = [];
6037
- for (const word of utt.words ?? []) {
6038
- const s = Math.max(word.st, clip.clip_st);
6039
- const e = Math.min(word.ed, clip.clip_ed);
6040
- if (e > s) {
6041
- surviving.push({
6042
- w: word.w,
6043
- track_st: r3(clip.track_st + (s - clip.clip_st)),
6044
- track_ed: r3(clip.track_st + (e - clip.clip_st))
6045
- });
6046
- }
6047
- }
6048
- if (surviving.length) {
6049
- instances.push({
6050
- track_st: Math.min(...surviving.map((x) => x.track_st)),
6051
- track_ed: Math.max(...surviving.map((x) => x.track_ed)),
6052
- kept_words: surviving.length,
6053
- words: surviving
6054
- });
6055
- }
6056
- }
6057
- if (!instances.length) {
6058
- entries.push({
6059
- id: utt.id,
6060
- text: utt.text,
6061
- dropped: true,
6062
- sourceIndex,
6063
- instIndex: 0,
6064
- track_st: null,
6065
- track_ed: null,
6066
- kept_words: 0,
6067
- total_words: totalWords,
6068
- words: [],
6069
- sortKey: 0
6070
- });
6071
- } else {
6072
- instances.sort((a, b) => a.track_st - b.track_st);
6073
- instances.forEach((inst, instIndex) => {
6074
- entries.push({
6075
- id: utt.id,
6076
- text: utt.text,
6077
- dropped: false,
6078
- sourceIndex,
6079
- instIndex,
6080
- track_st: inst.track_st,
6081
- track_ed: inst.track_ed,
6082
- kept_words: inst.kept_words,
6083
- total_words: totalWords,
6084
- words: inst.words,
6085
- sortKey: inst.track_st
6086
- });
6087
- });
6088
- }
6089
- });
6090
- let maxEd = 0;
6091
- for (const e of entries) {
6092
- if (e.dropped)
6093
- e.sortKey = maxEd;
6094
- else
6095
- maxEd = Math.max(maxEd, e.track_ed ?? maxEd);
6096
- }
6097
- entries.sort((a, b) => a.sortKey - b.sortKey || a.sourceIndex - b.sourceIndex || a.instIndex - b.instIndex);
6098
- const utterances = entries.map((e) => {
6099
- const u = {
6100
- id: e.id,
6101
- text: e.text,
6102
- track_st: e.track_st,
6103
- track_ed: e.track_ed,
6104
- dropped: e.dropped,
6105
- kept_words: e.kept_words,
6106
- total_words: e.total_words
6107
- };
6108
- if (opts.words)
6109
- u.words = e.words;
6110
- return u;
6535
+ const result = await pollTask(cfg, TASK_TYPE, taskId, (status, progress) => {
6536
+ log.tick(`${status}${progress != null ? ` ${Math.round(progress)}%` : ""}`);
6111
6537
  });
6112
- return {
6113
- transcript_hash: transcript.text_hash,
6114
- projected_at: opts.projectedAt ?? new Date().toISOString(),
6115
- utterances
6116
- };
6538
+ log.tickEnd();
6539
+ await materializeResult({
6540
+ outDir,
6541
+ output: result,
6542
+ taskId,
6543
+ fileId: up.fileId,
6544
+ draftDir,
6545
+ render: opts.render,
6546
+ crf: opts.crf,
6547
+ codec: opts.codec,
6548
+ ffmpegPath: opts.ffmpegPath,
6549
+ projName,
6550
+ json: opts.json,
6551
+ open: opts.open
6552
+ });
6553
+ log.ok(`闭环完成。产物目录:${outDir}`);
6117
6554
  }
6118
6555
 
6119
- // src/lib/splitdoc.ts
6120
- var BASE_TRACKS = ["真人出镜", "口播继续", "旁白主导"];
6121
- var LANES = ["A_ROLL", "RRV_MG", "AI_DRAMA", "FILM_BROLL"];
6122
- var NARRATIVES = [
6123
- "mirror-hook",
6124
- "demolition",
6125
- "container-translation",
6126
- "abyssal-fall",
6127
- "holding",
6128
- "reversal-elevation",
6129
- "callback-closure",
6130
- "typography-emphasis"
6131
- ];
6132
- var CONTAINER_STAGES = [
6133
- "none",
6134
- "seed",
6135
- "expand",
6136
- "translate",
6137
- "rupture",
6138
- "flip",
6139
- "callback"
6140
- ];
6141
- var IRREPLACEABILITY = ["必须真人出镜", "优先 MG", "可被 B-roll 替代", "可降级处理"];
6142
- var AUX_TYPES = [
6143
- "quote-card",
6144
- "term-callout",
6145
- "network-diagram",
6146
- "archive-caption",
6147
- "pause-card",
6148
- "data-annotation",
6149
- "timeline-tag"
6150
- ];
6151
- function isNonEmptyStr(v) {
6152
- return typeof v === "string" && v.trim().length > 0;
6556
+ // src/commands/oralcut-result.ts
6557
+ import { resolve as resolve4, join as join15 } from "node:path";
6558
+ var TASK_TYPE2 = "cli/video_oral_cut_for_cli";
6559
+ function timestamp2() {
6560
+ const d = new Date;
6561
+ const p = (n) => String(n).padStart(2, "0");
6562
+ return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
6153
6563
  }
6154
- function enumOk(v, list) {
6155
- return typeof v === "string" && list.includes(v);
6564
+ function registerOralCutResult(program2) {
6565
+ program2.command("oralcut-result <taskId>").description("按 task_id 取回已完成任务的报告 + 三方工程产物(可选 --render),不重跑云端").option("-o, --out <dir>", "产物目录(缺省 = <当前目录>/<taskId>-video-project-<时间戳>)").option("--render", "额外本地渲染成片(需原毛片仍在 gtrk 内嵌路径 + ffmpeg)").option("--crf <n>", "本地渲染 CRF 14-28(默认 18;需配 --render)").option("--codec <c>", "本地渲染编码(默认 h264;需配 --render)").option("--ffmpeg-path <dir>", "指定 ffmpeg/ffprobe 目录(缺省 ~/.gitruck/ffmpeg → 系统)").option("--jianying-draft-dir <dir>", "剪映草稿根目录;传路径或 auto(默认读配置 / 自动探测)").option("--no-open", "完成后不自动打开产物目录(默认会自动打开)").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON(给 agent/脚本解析)").action(async (taskId, opts) => {
6566
+ await runOralCutResult(taskId, opts);
6567
+ });
6156
6568
  }
6157
- function validateSplitDoc(doc, ctx) {
6158
- const errors = [];
6159
- const warnings = [];
6160
- if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
6161
- return { errors: ["拆分稿必须是一个 JSON 对象"], warnings };
6162
- }
6163
- const d = doc;
6164
- if (d.contract_version !== "v1") {
6165
- errors.push(`contract_version 必须为 "v1"(实际:${JSON.stringify(d.contract_version)})`);
6166
- }
6167
- if (!isNonEmptyStr(d.transcript_hash)) {
6168
- errors.push("缺 transcript_hash(应从投影视图透传)");
6169
- } else if (d.transcript_hash !== ctx.transcriptHash) {
6170
- errors.push(`transcript_hash 不匹配:拆分稿 ${d.transcript_hash} ≠ 当前 transcript ${ctx.transcriptHash}——转写已变更,请重新导出视图并重拆`);
6569
+ async function runOralCutResult(taskId, opts) {
6570
+ if (opts.json)
6571
+ routeLogsToStderr();
6572
+ const cfg = loadConfig();
6573
+ log.step(`▶ task_id 取回口播剪辑结果:${taskId}`);
6574
+ let got;
6575
+ try {
6576
+ got = await getTaskResult(cfg, TASK_TYPE2, taskId);
6577
+ } catch (e) {
6578
+ if (e instanceof CloudError) {
6579
+ throw new Error(`取任务结果失败(code=${e.code}):${e.message}。` + `注意:取结果需用「提交该任务的同一账号」的 API Key;异账号或已删任务会报 TASK_NOT_FOUND。`);
6580
+ }
6581
+ throw e;
6171
6582
  }
6172
- if (!Array.isArray(d.beats) || d.beats.length === 0) {
6173
- errors.push("beats 必须是非空数组");
6174
- return { errors, warnings };
6583
+ if (got.status !== "completed") {
6584
+ if (got.status === "failed" || got.status === "cancelled") {
6585
+ const out = got.output;
6586
+ throw new Error(`任务未成功(${got.status}):${out?.error ?? "无产物可取回"}`);
6587
+ }
6588
+ const pct = got.progress != null ? ` ${Math.round(got.progress)}%` : "";
6589
+ throw new Error(`任务尚未完成(当前 ${got.status || "未知"}${pct}),暂无法取回结果;请稍后再试。`);
6175
6590
  }
6176
- const idIndex = new Map;
6177
- ctx.utteranceIds.forEach((id, i) => idIndex.set(id, i));
6178
- const ranges = [];
6179
- const seenBeatIds = new Set;
6180
- d.beats.forEach((raw, i) => {
6181
- const tag = (() => {
6182
- const bid = raw?.id;
6183
- return isNonEmptyStr(bid) ? bid : `beats[${i}]`;
6184
- })();
6185
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
6186
- errors.push(`${tag}:beat 必须是对象`);
6591
+ const outDir = resolve4(opts.out ?? join15(process.cwd(), `${taskId}-video-project-${timestamp2()}`));
6592
+ const draftDir = resolveJianyingDraftDir(opts.jianyingDraftDir);
6593
+ await materializeResult({
6594
+ outDir,
6595
+ output: got.output,
6596
+ taskId,
6597
+ draftDir,
6598
+ render: opts.render,
6599
+ crf: opts.crf,
6600
+ codec: opts.codec,
6601
+ ffmpegPath: opts.ffmpegPath,
6602
+ json: opts.json,
6603
+ open: opts.open
6604
+ });
6605
+ log.ok(`已取回。产物目录:${outDir}`);
6606
+ }
6607
+
6608
+ // src/commands/upgrade.ts
6609
+ import { spawnSync as spawnSync3 } from "node:child_process";
6610
+ var CLIENT_UPGRADE = "irm https://api.ai-mcn.tv:9000/broadcast/exe/install.ps1 | iex";
6611
+ function run(cmd) {
6612
+ const r = spawnSync3(cmd, { stdio: "inherit", shell: true });
6613
+ return r.status ?? 1;
6614
+ }
6615
+ function registerUpgrade(program2) {
6616
+ program2.command("upgrade").description("升级 gtrk CLI 到最新版 + 刷新 skill(配置保留);--check 只查不装").option("--check", "只检查有没有新版本,不执行升级").action(async (opts) => {
6617
+ const cur = currentVersion();
6618
+ log.step(`当前 v${cur},查询最新版…`);
6619
+ const latest = await latestVersion();
6620
+ if (!latest) {
6621
+ log.warn("查不到最新版本(网络问题?)。手动升级:npm i -g @gitruck/cli@latest");
6622
+ log.info(`客户端(桌面端)升级:${CLIENT_UPGRADE}`);
6187
6623
  return;
6188
6624
  }
6189
- const b = raw;
6190
- if (!isNonEmptyStr(b.id))
6191
- errors.push(`${tag}:缺 id`);
6192
- else if (!/^B\d{2,}$/.test(b.id))
6193
- errors.push(`${b.id}:id 须为 "B"+两位起序号(如 B01)`);
6194
- else if (seenBeatIds.has(b.id))
6195
- errors.push(`${b.id}:beat id 重复`);
6196
- else
6197
- seenBeatIds.add(b.id);
6198
- if (!enumOk(b.base_track, BASE_TRACKS))
6199
- errors.push(`${tag}:base_track 非法(三选一:${BASE_TRACKS.join(" | ")})`);
6200
- if (!enumOk(b.lane, LANES))
6201
- errors.push(`${tag}:lane 非法(四选一:${LANES.join(" | ")})`);
6202
- if (!enumOk(b.narrative, NARRATIVES))
6203
- errors.push(`${tag}:narrative 非法(八枚举之一)`);
6204
- if (!enumOk(b.container_stage, CONTAINER_STAGES))
6205
- errors.push(`${tag}:container_stage 非法(七枚举之一)`);
6206
- if (!enumOk(b.irreplaceability, IRREPLACEABILITY))
6207
- errors.push(`${tag}:irreplaceability 非法(四枚举之一)`);
6208
- if (!isNonEmptyStr(b.rhythm))
6209
- errors.push(`${tag}:缺 rhythm(人读节奏标签)`);
6210
- if (!isNonEmptyStr(b.visual_task))
6211
- errors.push(`${tag}:缺 visual_task(一句话视觉任务)`);
6212
- const span = b.span;
6213
- let fromIdx = -1;
6214
- let toIdx = -1;
6215
- if (!span || !isNonEmptyStr(span.from) || !isNonEmptyStr(span.to)) {
6216
- errors.push(`${tag}:缺 span.from / span.to(utterance id 区间)`);
6217
- } else {
6218
- if (!idIndex.has(span.from))
6219
- errors.push(`${tag}:span.from 引用了不存在的 utterance id ${span.from}`);
6220
- else
6221
- fromIdx = idIndex.get(span.from);
6222
- if (!idIndex.has(span.to))
6223
- errors.push(`${tag}:span.to 引用了不存在的 utterance id ${span.to}`);
6224
- else
6225
- toIdx = idIndex.get(span.to);
6226
- if (fromIdx >= 0 && toIdx >= 0) {
6227
- if (fromIdx > toIdx)
6228
- errors.push(`${tag}:区间倒序(span.from ${span.from} 晚于 span.to ${span.to})`);
6229
- else if (isNonEmptyStr(b.id))
6230
- ranges.push({ id: b.id, from: fromIdx, to: toIdx });
6231
- }
6625
+ if (cmpSemver(latest, cur) <= 0) {
6626
+ log.ok(`已是最新版 v${cur}。`);
6627
+ log.info(`客户端(桌面端)如需升级:${CLIENT_UPGRADE}`);
6628
+ return;
6232
6629
  }
6233
- validateHandoff(tag, b, errors, warnings);
6234
- if (b.aux_layers != null) {
6235
- if (!Array.isArray(b.aux_layers))
6236
- errors.push(`${tag}:aux_layers 必须是数组`);
6237
- else
6238
- b.aux_layers.forEach((a, ai) => validateAux(`${tag}.aux[${ai}]`, a, idIndex, errors));
6630
+ log.info(`发现新版本 v${latest}(当前 v${cur})。`);
6631
+ if (opts.check) {
6632
+ log.info("跑 `gtrk upgrade` 升级(会保留你现有的配置)。");
6633
+ return;
6239
6634
  }
6240
- });
6241
- const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
6242
- for (let i = 1;i < sorted.length; i++) {
6243
- const prev = sorted[i - 1];
6244
- const cur = sorted[i];
6245
- if (cur.from <= prev.to) {
6246
- errors.push(`${prev.id} 与 ${cur.id}:utterance 区间重叠(beats 之间不允许交集)`);
6635
+ log.step(`① 升级 CLI → v${latest}…`);
6636
+ if (run("npm i -g @gitruck/cli@latest") !== 0) {
6637
+ log.err("升级失败。手动重试:npm i -g @gitruck/cli@latest(若报权限,按你的 npm 全局目录权限处理)");
6638
+ process.exitCode = 1;
6639
+ return;
6247
6640
  }
6248
- }
6249
- return { errors, warnings };
6250
- }
6251
- function validateHandoff(tag, b, errors, warnings) {
6252
- const lane = b.lane;
6253
- const handoff = b.handoff;
6254
- if (lane === "A_ROLL") {
6255
- if (handoff != null)
6256
- warnings.push(`${tag}:A_ROLL 不应带 handoff,已忽略`);
6257
- return;
6258
- }
6259
- if (lane === "RRV_MG") {
6260
- if (!handoff || typeof handoff.duration_hint !== "number") {
6261
- errors.push(`${tag}:RRV_MG 的 handoff.duration_hint 必填(秒,数值)`);
6641
+ log.step("② 刷新 /gtrk-oralcut skill…");
6642
+ if (run("gtrk skills install") !== 0) {
6643
+ log.warn("skill 没刷成,手动跑一次:gtrk skills install");
6262
6644
  }
6263
- return;
6264
- }
6265
- if (lane === "FILM_BROLL") {
6266
- const q = handoff?.queries;
6267
- if (!Array.isArray(q) || q.length === 0 || !q.every((x) => isNonEmptyStr(x))) {
6268
- errors.push(`${tag}:FILM_BROLL 缺检索 query(handoff.queries 必须为非空字符串数组)`);
6645
+ log.ok(`已升级到 v${latest}。配置原样保留,直接接着用即可(gtrk doctor 可自检)。`);
6646
+ log.info(`客户端(桌面端)如需一起升级:${CLIENT_UPGRADE}`);
6647
+ });
6648
+ }
6649
+
6650
+ // src/commands/render.ts
6651
+ import { resolve as resolve5, dirname as dirname3, join as join16, basename as basename6, extname as extname3 } from "node:path";
6652
+ import { existsSync as existsSync13 } from "node:fs";
6653
+ function registerRender(program2) {
6654
+ 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) => {
6655
+ if (opts.json)
6656
+ routeLogsToStderr();
6657
+ const gtrkAbs = resolve5(gtrk);
6658
+ if (!existsSync13(gtrkAbs))
6659
+ throw new Error(`gtrk 工程不存在:${gtrkAbs}`);
6660
+ const outMp4 = resolve5(opts.out ?? join16(dirname3(gtrkAbs), `${basename6(gtrkAbs, extname3(gtrkAbs))}.mp4`));
6661
+ log.step(`▶ 本地渲染:${basename6(gtrkAbs)} → ${basename6(outMp4)}`);
6662
+ const project = await readGtrkFile(gtrkAbs);
6663
+ const result = await renderGtrk(project, outMp4, {
6664
+ crf: opts.crf != null ? Number(opts.crf) : undefined,
6665
+ codec: opts.codec,
6666
+ ffmpegPath: opts.ffmpegPath,
6667
+ onLine: (l) => {
6668
+ const m = l.match(/time=(\S+)/);
6669
+ if (m)
6670
+ log.tick(`渲染中 ${m[1]}`);
6671
+ }
6672
+ });
6673
+ log.tickEnd();
6674
+ log.ok(`渲染完成:${outMp4}(${result.duration.toFixed(1)}s)`);
6675
+ if (opts.open)
6676
+ openFolder(dirname3(outMp4));
6677
+ if (opts.json) {
6678
+ console.log(JSON.stringify({ ok: true, output: outMp4, duration: result.duration }));
6269
6679
  }
6270
- return;
6271
- }
6680
+ });
6272
6681
  }
6273
- function validateAux(tag, raw, idIndex, errors) {
6274
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
6275
- errors.push(`${tag}:辅助层必须是对象`);
6276
- return;
6277
- }
6278
- const a = raw;
6279
- if (!enumOk(a.type, AUX_TYPES))
6280
- errors.push(`${tag}:type 非法(七类之一)`);
6281
- if (!isNonEmptyStr(a.role))
6282
- errors.push(`${tag}:缺 role(职责)`);
6283
- const m = a.mount;
6284
- if (m === "same_beat")
6682
+
6683
+ // src/commands/split.ts
6684
+ import { resolve as resolve6, join as join18, dirname as dirname5, basename as basename8 } from "node:path";
6685
+ import { existsSync as existsSync14 } from "node:fs";
6686
+ import { readFile as readFile4, writeFile as writeFile6, mkdir as mkdir5 } from "node:fs/promises";
6687
+ import { createHash } from "node:crypto";
6688
+
6689
+ // src/lib/projection.ts
6690
+ function r32(n) {
6691
+ return Math.round(n * 1000) / 1000;
6692
+ }
6693
+ function normClip(c3) {
6694
+ const clip_st = c3.clip_st ?? 0;
6695
+ const track_st = c3.track_st ?? 0;
6696
+ const dur = c3.duration ?? (c3.clip_ed != null ? c3.clip_ed - clip_st : 0);
6697
+ const clip_ed = c3.clip_ed ?? clip_st + dur;
6698
+ return { clip_st, clip_ed, track_st };
6699
+ }
6700
+ function pickMainVideoTrack(gtrk) {
6701
+ const tracks = gtrk.video_track ?? [];
6702
+ if (!tracks.length)
6285
6703
  return;
6286
- if (typeof m === "object" && m !== null) {
6287
- const mo = m;
6288
- if (isNonEmptyStr(mo.trigger)) {
6289
- if (!idIndex.has(mo.trigger))
6290
- errors.push(`${tag}:mount.trigger 引用了不存在的 utterance id ${mo.trigger}`);
6291
- return;
6292
- }
6293
- if (isNonEmptyStr(mo.from) && isNonEmptyStr(mo.to)) {
6294
- if (!idIndex.has(mo.from))
6295
- errors.push(`${tag}:mount.from 引用了不存在的 utterance id ${mo.from}`);
6296
- if (!idIndex.has(mo.to))
6297
- errors.push(`${tag}:mount.to 引用了不存在的 utterance id ${mo.to}`);
6298
- if (idIndex.has(mo.from) && idIndex.has(mo.to) && idIndex.get(mo.from) > idIndex.get(mo.to)) {
6299
- errors.push(`${tag}:mount 区间倒序`);
6300
- }
6301
- return;
6704
+ let best = tracks[0];
6705
+ let bestIdx = best.track_index ?? 0;
6706
+ for (const t of tracks) {
6707
+ const idx = t.track_index ?? 0;
6708
+ if (idx < bestIdx) {
6709
+ best = t;
6710
+ bestIdx = idx;
6302
6711
  }
6303
6712
  }
6304
- errors.push(`${tag}:mount 非法(应为 "same_beat" | {from,to} | {trigger})`);
6713
+ return best;
6305
6714
  }
6306
- function buildLanding(doc, view, opts) {
6307
- const byId = new Map;
6308
- for (const id of opts.utteranceIds)
6309
- byId.set(id, []);
6310
- for (const u of view.utterances) {
6311
- if (u.dropped || u.track_st == null || u.track_ed == null)
6312
- continue;
6313
- if (!byId.has(u.id))
6314
- byId.set(u.id, []);
6315
- byId.get(u.id).push({ track_st: u.track_st, track_ed: u.track_ed });
6316
- }
6317
- const idIndex = new Map;
6318
- opts.utteranceIds.forEach((id, i) => idIndex.set(id, i));
6319
- const split = {
6320
- contract_version: doc.contract_version,
6321
- transcript_hash: doc.transcript_hash,
6322
- projected_at: opts.projectedAt,
6323
- beats: []
6324
- };
6325
- const dispatch = { rrv_mg: [], film_broll: [], ai_drama: [] };
6326
- const skipped = [];
6327
- const shrunk = [];
6328
- for (const beat of doc.beats) {
6329
- const fromIdx = idIndex.get(beat.span.from);
6330
- const toIdx = idIndex.get(beat.span.to);
6331
- const spanIds = opts.utteranceIds.slice(fromIdx, toIdx + 1);
6332
- const instances = spanIds.flatMap((id) => byId.get(id) ?? []);
6333
- const droppedCount = spanIds.filter((id) => (byId.get(id)?.length ?? 0) === 0).length;
6334
- if (instances.length === 0) {
6335
- skipped.push({ beat: beat.id, reason: "span 内全部 utterance 被剪,未落轨" });
6336
- continue;
6337
- }
6338
- const track_st = Math.min(...instances.map((x) => x.track_st));
6339
- const track_ed = Math.max(...instances.map((x) => x.track_ed));
6340
- const isShrunk = droppedCount > 0;
6341
- const metaBeat = { id: beat.id, lane: beat.lane, span: beat.span, track_st, track_ed };
6342
- if (isShrunk)
6343
- metaBeat.shrunk = true;
6344
- if (beat.lane !== "A_ROLL" && beat.handoff)
6345
- metaBeat.handoff = beat.handoff;
6346
- split.beats.push(metaBeat);
6347
- if (isShrunk) {
6348
- shrunk.push({ beat: beat.id, kept: spanIds.length - droppedCount, dropped: droppedCount, track_st, track_ed });
6715
+ function projectTranscript(transcript, gtrk, opts = {}) {
6716
+ const materialId = String(transcript.material_id);
6717
+ const mainTrack = pickMainVideoTrack(gtrk);
6718
+ const clips = (mainTrack?.track_timeline ?? []).filter((c3) => c3.material != null && String(c3.material) === materialId).map(normClip);
6719
+ const entries = [];
6720
+ transcript.utterances.forEach((utt, sourceIndex) => {
6721
+ const totalWords = utt.words?.length ?? 0;
6722
+ const instances = [];
6723
+ for (const clip of clips) {
6724
+ const surviving = [];
6725
+ for (const word of utt.words ?? []) {
6726
+ const s = Math.max(word.st, clip.clip_st);
6727
+ const e = Math.min(word.ed, clip.clip_ed);
6728
+ if (e > s) {
6729
+ surviving.push({
6730
+ w: word.w,
6731
+ track_st: r32(clip.track_st + (s - clip.clip_st)),
6732
+ track_ed: r32(clip.track_st + (e - clip.clip_st))
6733
+ });
6734
+ }
6735
+ }
6736
+ if (surviving.length) {
6737
+ instances.push({
6738
+ track_st: Math.min(...surviving.map((x) => x.track_st)),
6739
+ track_ed: Math.max(...surviving.map((x) => x.track_ed)),
6740
+ kept_words: surviving.length,
6741
+ words: surviving
6742
+ });
6743
+ }
6349
6744
  }
6350
- const h = beat.handoff ?? {};
6351
- const compositionId = `${opts.projectSlug}-${beat.id}`;
6352
- if (beat.lane === "RRV_MG") {
6353
- dispatch.rrv_mg.push({
6354
- beat: beat.id,
6355
- composition_id: compositionId,
6356
- duration: typeof h.duration_hint === "number" ? h.duration_hint : null,
6357
- theme: h.theme,
6358
- bg: h.bg,
6359
- slug_hint: h.slug_hint,
6360
- track_st,
6361
- track_ed
6745
+ if (!instances.length) {
6746
+ entries.push({
6747
+ id: utt.id,
6748
+ text: utt.text,
6749
+ dropped: true,
6750
+ sourceIndex,
6751
+ instIndex: 0,
6752
+ track_st: null,
6753
+ track_ed: null,
6754
+ kept_words: 0,
6755
+ total_words: totalWords,
6756
+ words: [],
6757
+ sortKey: 0
6362
6758
  });
6363
- } else if (beat.lane === "FILM_BROLL") {
6364
- dispatch.film_broll.push({
6365
- beat: beat.id,
6366
- queries: Array.isArray(h.queries) ? h.queries : [],
6367
- shots: h.shots,
6368
- per_shot_sec: h.per_shot_sec,
6369
- exclude: h.exclude,
6370
- track_st,
6371
- track_ed
6759
+ } else {
6760
+ instances.sort((a, b) => a.track_st - b.track_st);
6761
+ instances.forEach((inst, instIndex) => {
6762
+ entries.push({
6763
+ id: utt.id,
6764
+ text: utt.text,
6765
+ dropped: false,
6766
+ sourceIndex,
6767
+ instIndex,
6768
+ track_st: inst.track_st,
6769
+ track_ed: inst.track_ed,
6770
+ kept_words: inst.kept_words,
6771
+ total_words: totalWords,
6772
+ words: inst.words,
6773
+ sortKey: inst.track_st
6774
+ });
6372
6775
  });
6373
- } else if (beat.lane === "AI_DRAMA") {
6374
- dispatch.ai_drama.push({ beat: beat.id, ...h, track_st, track_ed });
6375
- }
6376
- }
6377
- return { split, dispatch, skipped, shrunk };
6378
- }
6379
- function renderSplitMarkdown(doc, landing, meta) {
6380
- const L = [];
6381
- const metaById = new Map(landing.split.beats.map((b) => [b.id, b]));
6382
- const skippedIds = new Set(landing.skipped.map((s) => s.beat));
6383
- L.push(`# 视觉拆分稿(${meta.projectSlug})`);
6384
- L.push("");
6385
- L.push(`- contract_version:\`${doc.contract_version}\``);
6386
- L.push(`- transcript_hash:\`${doc.transcript_hash}\``);
6387
- L.push(`- projected_at:\`${meta.projectedAt}\``);
6388
- L.push(`- beats:${doc.beats.length}(落轨 ${landing.split.beats.length} · 跳过 ${landing.skipped.length} · 收缩 ${landing.shrunk.length})`);
6389
- L.push("");
6390
- L.push("# Beat Timeline");
6391
- L.push("");
6392
- for (const beat of doc.beats) {
6393
- const mb = metaById.get(beat.id);
6394
- L.push(`## ${beat.id}${skippedIds.has(beat.id) ? "(整段被剪 · 跳过)" : mb?.shrunk ? "(部分被剪 · 已收缩)" : ""}`);
6395
- L.push(`- 文稿范围:\`${beat.span.from} … ${beat.span.to}\``);
6396
- L.push(`- 底轨:\`${beat.base_track}\``);
6397
- L.push(`- 主层:\`${beat.lane}\``);
6398
- L.push(`- 叙事功能:\`${beat.narrative}\``);
6399
- L.push(`- 容器阶段:\`${beat.container_stage}\``);
6400
- if (beat.rhythm)
6401
- L.push(`- 节奏标签:\`${beat.rhythm}\``);
6402
- L.push(`- 视觉任务:${beat.visual_task}`);
6403
- L.push(`- 不可替代性:\`${beat.irreplaceability}\``);
6404
- if (mb)
6405
- L.push(`- 轨道时码:\`${mb.track_st}s … ${mb.track_ed}s\``);
6406
- if (beat.callback_of)
6407
- L.push(`- 回扣对象:\`${beat.callback_of}\``);
6408
- for (const a of beat.aux_layers ?? []) {
6409
- const mount = a.mount === "same_beat" ? "同 beat" : ("trigger" in a.mount) ? `触发 ${a.mount.trigger}` : `${a.mount.from} … ${a.mount.to}`;
6410
- L.push(` - 辅助层 \`${a.type}\`(${mount}):${a.role}`);
6411
6776
  }
6412
- L.push("");
6413
- }
6414
- L.push("# Production Queues");
6415
- L.push("");
6416
- L.push("## A_ROLL Queue");
6417
- for (const b of doc.beats.filter((x) => x.lane === "A_ROLL" && !skippedIds.has(x.id))) {
6418
- L.push(`- \`${b.id}\` ${b.visual_task}`);
6419
- }
6420
- L.push("");
6421
- L.push("## RRV_MG Queue");
6422
- for (const r of landing.dispatch.rrv_mg) {
6423
- L.push(`- \`${r.beat}\` composition_id=\`${r.composition_id}\`${r.duration != null ? ` · ${r.duration}s` : ""}`);
6777
+ });
6778
+ let maxEd = 0;
6779
+ for (const e of entries) {
6780
+ if (e.dropped)
6781
+ e.sortKey = maxEd;
6782
+ else
6783
+ maxEd = Math.max(maxEd, e.track_ed ?? maxEd);
6424
6784
  }
6425
- L.push("");
6426
- L.push("## AI_DRAMA Queue");
6427
- for (const a of landing.dispatch.ai_drama)
6428
- L.push(`- \`${a.beat}\` ${a.track_st}s…${a.track_ed}s`);
6429
- L.push("");
6430
- L.push("## FILM_BROLL Queue");
6431
- for (const f of landing.dispatch.film_broll)
6432
- L.push(`- \`${f.beat}\` queries=[${f.queries.join(" / ")}]`);
6433
- L.push("");
6434
- return L.join(`
6435
- `);
6785
+ entries.sort((a, b) => a.sortKey - b.sortKey || a.sourceIndex - b.sourceIndex || a.instIndex - b.instIndex);
6786
+ const utterances = entries.map((e) => {
6787
+ const u = {
6788
+ id: e.id,
6789
+ text: e.text,
6790
+ track_st: e.track_st,
6791
+ track_ed: e.track_ed,
6792
+ dropped: e.dropped,
6793
+ kept_words: e.kept_words,
6794
+ total_words: e.total_words
6795
+ };
6796
+ if (opts.words)
6797
+ u.words = e.words;
6798
+ return u;
6799
+ });
6800
+ return {
6801
+ transcript_hash: transcript.text_hash,
6802
+ projected_at: opts.projectedAt ?? new Date().toISOString(),
6803
+ utterances
6804
+ };
6436
6805
  }
6437
6806
 
6438
6807
  // src/lib/gtrk-writeback.ts
6439
- import { readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync2, renameSync, unlinkSync } from "node:fs";
6440
- import { dirname as dirname4, join as join15, basename as basename7 } from "node:path";
6808
+ import { readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync2, renameSync, unlinkSync } from "node:fs";
6809
+ import { dirname as dirname4, join as join17, basename as basename7 } from "node:path";
6441
6810
  import { randomBytes as randomBytes2 } from "node:crypto";
6442
6811
  function readGtrk(path) {
6443
- const raw = readFileSync3(path, "utf8");
6812
+ const raw = readFileSync4(path, "utf8");
6444
6813
  let gtrk;
6445
6814
  try {
6446
6815
  gtrk = JSON.parse(raw);
@@ -6464,7 +6833,23 @@ function writeStructMetaSplit(path, gtrk, splitObj, expectedMtimeMs) {
6464
6833
  }
6465
6834
  const nextStructMeta = { ...gtrk.struct_meta ?? {}, split: splitObj };
6466
6835
  const next = { ...gtrk, struct_meta: nextStructMeta };
6467
- const tmp = join15(dirname4(path), `.${basename7(path)}.${randomBytes2(6).toString("hex")}.tmp`);
6836
+ const tmp = join17(dirname4(path), `.${basename7(path)}.${randomBytes2(6).toString("hex")}.tmp`);
6837
+ try {
6838
+ writeFileSync2(tmp, JSON.stringify(next, null, 2));
6839
+ renameSync(tmp, path);
6840
+ } catch (e) {
6841
+ try {
6842
+ unlinkSync(tmp);
6843
+ } catch {}
6844
+ throw e;
6845
+ }
6846
+ }
6847
+ function writeGtrkAtomic(path, next, expectedMtimeMs) {
6848
+ const cur = statSync(path).mtimeMs;
6849
+ if (cur !== expectedMtimeMs) {
6850
+ throw new Error("工程文件在 matrix 运行期间被外部修改(保存冲突),已拒绝写入;请关闭客户端未保存的工程或重跑(plan 与已下载代理均保留)");
6851
+ }
6852
+ const tmp = join17(dirname4(path), `.${basename7(path)}.${randomBytes2(6).toString("hex")}.tmp`);
6468
6853
  try {
6469
6854
  writeFileSync2(tmp, JSON.stringify(next, null, 2));
6470
6855
  renameSync(tmp, path);
@@ -6479,12 +6864,12 @@ function writeStructMetaSplit(path, gtrk, splitObj, expectedMtimeMs) {
6479
6864
  // src/commands/split.ts
6480
6865
  var TRANSCRIPT_MISSING = "工程目录内找不到 transcript.json(可能是旧任务产物):请用新版本重跑 gtrk oralcut(恒出 transcript)," + "或(规划中)用 transcribe 生成后再拆分;本命令不做降级猜测。";
6481
6866
  function registerSplit(program2) {
6482
- 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("--md", "落地时额外渲染人读稿 split/visual-split.md").option("--words", "视图模式附字级明细(缺省只出句级)").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON").action(async (splitdoc, opts) => {
6867
+ 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) => {
6483
6868
  await runSplit(splitdoc, opts);
6484
6869
  });
6485
6870
  }
6486
6871
  function firstExisting(cands) {
6487
- return cands.find((p) => existsSync13(p));
6872
+ return cands.find((p) => existsSync14(p));
6488
6873
  }
6489
6874
  function resolvePaths(opts) {
6490
6875
  const project = opts.project ? resolve6(opts.project) : undefined;
@@ -6492,20 +6877,20 @@ function resolvePaths(opts) {
6492
6877
  if (opts.gtrk) {
6493
6878
  gtrkPath = resolve6(opts.gtrk);
6494
6879
  } else if (project) {
6495
- gtrkPath = firstExisting([join16(project, "gtrk", "project.gtrk"), join16(project, "project.gtrk")]) ?? join16(project, "gtrk", "project.gtrk");
6880
+ gtrkPath = firstExisting([join18(project, "gtrk", "project.gtrk"), join18(project, "project.gtrk")]) ?? join18(project, "gtrk", "project.gtrk");
6496
6881
  } else {
6497
6882
  throw new Error("需 --project <目录> 或显式 --gtrk <path>");
6498
6883
  }
6499
- if (!existsSync13(gtrkPath))
6884
+ if (!existsSync14(gtrkPath))
6500
6885
  throw new Error(`找不到工程文件:${gtrkPath}`);
6501
6886
  let transcriptPath;
6502
6887
  if (opts.transcript)
6503
6888
  transcriptPath = resolve6(opts.transcript);
6504
6889
  else if (project)
6505
6890
  transcriptPath = firstExisting([
6506
- join16(project, "transcript", "transcript.json"),
6507
- join16(project, "json", "transcript.json"),
6508
- join16(project, "transcript.json")
6891
+ join18(project, "transcript", "transcript.json"),
6892
+ join18(project, "json", "transcript.json"),
6893
+ join18(project, "transcript.json")
6509
6894
  ]);
6510
6895
  const baseDir = project ?? dirname5(gtrkPath);
6511
6896
  return { baseDir, gtrkPath, transcriptPath };
@@ -6530,15 +6915,15 @@ async function runSplit(splitdoc, opts) {
6530
6915
  return splitdoc ? runLand(resolve6(splitdoc), baseDir, gtrkPath, transcriptPath, opts) : runView(baseDir, gtrkPath, transcriptPath, opts);
6531
6916
  }
6532
6917
  async function runView(baseDir, gtrkPath, transcriptPath, opts) {
6533
- if (!transcriptPath || !existsSync13(transcriptPath))
6918
+ if (!transcriptPath || !existsSync14(transcriptPath))
6534
6919
  throw new Error(TRANSCRIPT_MISSING);
6535
6920
  log.step("▶ 导出投影视图(transcript × 当刻 .gtrk)…");
6536
6921
  const transcript = await loadTranscript(transcriptPath);
6537
6922
  const { gtrk } = readGtrk(gtrkPath);
6538
6923
  const view = projectTranscript(transcript, gtrk, { words: opts.words });
6539
- const splitDir = join16(baseDir, "split");
6924
+ const splitDir = join18(baseDir, "split");
6540
6925
  await mkdir5(splitDir, { recursive: true });
6541
- const viewPath = join16(splitDir, "view.json");
6926
+ const viewPath = join18(splitDir, "view.json");
6542
6927
  await writeFile6(viewPath, JSON.stringify(view, null, 2));
6543
6928
  const dropped = view.utterances.filter((u) => u.dropped).length;
6544
6929
  log.ok(`投影视图已生成:${viewPath}(${view.utterances.length} 条,其中 ${dropped} 条被剪)`);
@@ -6556,16 +6941,24 @@ async function runView(baseDir, gtrkPath, transcriptPath, opts) {
6556
6941
  return result;
6557
6942
  }
6558
6943
  async function runLand(splitdocPath, baseDir, gtrkPath, transcriptPath, opts) {
6559
- if (!existsSync13(splitdocPath))
6944
+ if (!existsSync14(splitdocPath))
6560
6945
  throw new Error(`找不到拆分稿:${splitdocPath}`);
6561
- if (!transcriptPath || !existsSync13(transcriptPath))
6946
+ if (!transcriptPath || !existsSync14(transcriptPath))
6562
6947
  throw new Error(TRANSCRIPT_MISSING);
6563
6948
  log.step("▶ 校验拆分稿并落地…");
6564
6949
  const doc = JSON.parse(await readFile4(splitdocPath, "utf8"));
6565
6950
  const transcript = await loadTranscript(transcriptPath);
6566
6951
  const { gtrk, mtimeMs } = readGtrk(gtrkPath);
6567
6952
  assertGtrkV1(gtrk);
6568
- const ctx = { utteranceIds: transcript.utterances.map((u) => u.id), transcriptHash: transcript.text_hash };
6953
+ const columnId = opts.column ?? readUserConfig().defaultColumn;
6954
+ const resolved = resolveColumnConfig({ columnId });
6955
+ for (const w of resolved.warnings)
6956
+ log.warn(w);
6957
+ const ctx = {
6958
+ utteranceIds: transcript.utterances.map((u) => u.id),
6959
+ transcriptHash: transcript.text_hash,
6960
+ vocab: effectiveVocab(resolved.config)
6961
+ };
6569
6962
  const { errors, warnings } = validateSplitDoc(doc, ctx);
6570
6963
  for (const w of warnings)
6571
6964
  log.warn(w);
@@ -6577,22 +6970,33 @@ async function runLand(splitdocPath, baseDir, gtrkPath, transcriptPath, opts) {
6577
6970
  const projectedAt = new Date().toISOString();
6578
6971
  const view = projectTranscript(transcript, gtrk, { projectedAt });
6579
6972
  const projectSlug = slugify(basename8(baseDir));
6580
- const landing = buildLanding(doc, view, { utteranceIds: ctx.utteranceIds, projectSlug, projectedAt });
6973
+ const landing = buildLanding(doc, view, {
6974
+ utteranceIds: ctx.utteranceIds,
6975
+ projectSlug,
6976
+ projectedAt,
6977
+ sourceIndex: {
6978
+ materialId: String(transcript.material_id),
6979
+ utterances: new Map(transcript.utterances.map((u) => [u.id, { st: u.st, ed: u.ed }]))
6980
+ }
6981
+ });
6581
6982
  writeStructMetaSplit(gtrkPath, gtrk, landing.split, mtimeMs);
6582
- const splitDir = join16(baseDir, "split");
6983
+ const splitDir = join18(baseDir, "split");
6583
6984
  await mkdir5(splitDir, { recursive: true });
6584
- const dispatchPath = join16(splitDir, "dispatch.json");
6985
+ const dispatchPath = join18(splitDir, "dispatch.json");
6585
6986
  await writeFile6(dispatchPath, JSON.stringify(landing.dispatch, null, 2));
6586
6987
  let mdPath = null;
6587
6988
  if (opts.md) {
6588
- mdPath = join16(splitDir, "visual-split.md");
6989
+ mdPath = join18(splitDir, "visual-split.md");
6589
6990
  await writeFile6(mdPath, renderSplitMarkdown(doc, landing, { projectSlug, projectedAt }));
6590
6991
  }
6591
- 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})`);
6992
+ log.ok(`落地完成:${landing.split.beats.length}/${doc.beats.length} beat 落轨` + `(MG ${landing.dispatch.mg.length} · FILM_BROLL ${landing.dispatch.film_broll.length} · AI_DRAMA ${landing.dispatch.ai_drama.length})`);
6592
6993
  for (const s of landing.skipped)
6593
6994
  log.warn(`跳过 ${s.beat}:${s.reason}`);
6594
6995
  for (const s of landing.shrunk)
6595
6996
  log.warn(`收缩 ${s.beat}:${s.dropped} 句被剪,按存活 ${s.kept} 句包络 → ${s.track_st}s…${s.track_ed}s(建议人工复核)`);
6997
+ if (landing.unhandledLanes.length > 0) {
6998
+ log.warn(`未派单 lane:${landing.unhandledLanes.join("、")}——通过校验却无 dispatch 分支,落地静默丢队列(新增 lane 时请同步 buildLanding 分派)`);
6999
+ }
6596
7000
  const result = {
6597
7001
  ok: true,
6598
7002
  mode: "land",
@@ -6603,7 +7007,7 @@ async function runLand(splitdocPath, baseDir, gtrkPath, transcriptPath, opts) {
6603
7007
  projected_at: projectedAt,
6604
7008
  beats: { total: doc.beats.length, landed: landing.split.beats.length, skipped: landing.skipped, shrunk: landing.shrunk },
6605
7009
  queues: {
6606
- rrv_mg: landing.dispatch.rrv_mg.length,
7010
+ mg: landing.dispatch.mg.length,
6607
7011
  film_broll: landing.dispatch.film_broll.length,
6608
7012
  ai_drama: landing.dispatch.ai_drama.length
6609
7013
  }
@@ -6613,12 +7017,1166 @@ async function runLand(splitdocPath, baseDir, gtrkPath, transcriptPath, opts) {
6613
7017
  return result;
6614
7018
  }
6615
7019
 
7020
+ // src/commands/matrix.ts
7021
+ import { resolve as resolve7, join as join19, dirname as dirname6, basename as basename9 } from "node:path";
7022
+ import { existsSync as existsSync15 } from "node:fs";
7023
+ import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir6 } from "node:fs/promises";
7024
+
7025
+ // src/lib/matrix-lay.ts
7026
+ var BROLL_PREVIEW_DIR = "assets/broll-preview";
7027
+ var BROLL_MATERIAL_PREFIX = "broll-";
7028
+ var BROLL_META_CANDIDATE_CAP = 12;
7029
+ var SHOT_TARGET_DEFAULT = 3;
7030
+ var MIN_SHOT_SEC = 1.2;
7031
+ var SCORE_FLOOR_DEFAULT = 0.2;
7032
+ var MAX_SLOTS_PER_BEAT = 32;
7033
+ function mergedCandidates(beat) {
7034
+ const all = [];
7035
+ for (const q of beat.queries)
7036
+ for (const r of q.results ?? [])
7037
+ all.push(r);
7038
+ return all.sort((a, b) => b.score - a.score);
7039
+ }
7040
+ function previewUrlFor(result) {
7041
+ const direct = result.preview_url;
7042
+ if (typeof direct === "string" && direct)
7043
+ return direct;
7044
+ const cover = result.cover_url;
7045
+ if (typeof cover === "string") {
7046
+ const derived = cover.replace(/\/keyframe\/([^/]+)\/cover\.jpg.*$/, "/preview/$1.mp4");
7047
+ if (derived !== cover)
7048
+ return derived;
7049
+ }
7050
+ return null;
7051
+ }
7052
+ function previewDims(width, height) {
7053
+ if (!width || !height || width <= 0 || height <= 0)
7054
+ return;
7055
+ if (width <= 640)
7056
+ return [width, height];
7057
+ const h = Math.max(2, Math.round(height * 640 / width / 2) * 2);
7058
+ return [640, h];
7059
+ }
7060
+ var r33 = (n) => Math.round(n * 1000) / 1000;
7061
+ function buildQueryPools(beat, scoreFloor) {
7062
+ const out = [];
7063
+ for (const q of beat.queries) {
7064
+ const pool = [];
7065
+ for (const cand of q.results ?? []) {
7066
+ if (cand.excluded_hint)
7067
+ continue;
7068
+ 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 }];
7069
+ for (const seg of segs) {
7070
+ if (seg.score < scoreFloor)
7071
+ continue;
7072
+ pool.push({ cand, seg, query: q.query, key: `${cand.clip_id}@${seg.start}` });
7073
+ }
7074
+ }
7075
+ pool.sort((a, b) => b.seg.score - a.seg.score);
7076
+ if (pool.length)
7077
+ out.push({ query: q.query, pool });
7078
+ }
7079
+ return out;
7080
+ }
7081
+ function pairAvail(p) {
7082
+ const dur = typeof p.cand.duration === "number" && p.cand.duration > 0 ? p.cand.duration : undefined;
7083
+ return dur ?? Math.max(0, p.seg.end - p.seg.start);
7084
+ }
7085
+ function hashStr(s) {
7086
+ let h = 2166136261;
7087
+ for (let i = 0;i < s.length; i++) {
7088
+ h ^= s.charCodeAt(i);
7089
+ h = Math.imul(h, 16777619);
7090
+ }
7091
+ return h >>> 0;
7092
+ }
7093
+ function mulberry32(seed) {
7094
+ let a = seed >>> 0;
7095
+ return () => {
7096
+ a = a + 1831565813 >>> 0;
7097
+ let t = a;
7098
+ t = Math.imul(t ^ t >>> 15, t | 1);
7099
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
7100
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
7101
+ };
7102
+ }
7103
+ function shotRange(beat, span) {
7104
+ const shots = typeof beat.requested_shots === "number" && beat.requested_shots > 0 ? beat.requested_shots : undefined;
7105
+ 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;
7106
+ if (anchor === undefined)
7107
+ return [2, 4];
7108
+ const lo = Math.max(1.5, anchor * 0.8);
7109
+ const hi = Math.max(lo + 0.5, Math.min(8, anchor * 1.6));
7110
+ return [lo, hi];
7111
+ }
7112
+ function fillBeatTrack(opts) {
7113
+ const { beat, trackOrder, consumed, scoreFloor } = opts;
7114
+ const span = beat.track_ed - beat.track_st;
7115
+ if (!(span > 0))
7116
+ return [];
7117
+ const [shotMin, shotMax] = shotRange(beat, span);
7118
+ const rand = mulberry32(hashStr(`${beat.beat}#${trackOrder}`));
7119
+ const pools = buildQueryPools(beat, scoreFloor);
7120
+ if (!pools.length)
7121
+ return [];
7122
+ const slots = [];
7123
+ let cursor = beat.track_st;
7124
+ let prevClip = null;
7125
+ let lastPick = null;
7126
+ let gapRun = 0;
7127
+ for (let slotIdx = 0;slotIdx < MAX_SLOTS_PER_BEAT; slotIdx++) {
7128
+ const remaining = beat.track_ed - cursor;
7129
+ if (remaining < MIN_SHOT_SEC)
7130
+ break;
7131
+ let dTarget;
7132
+ if (remaining <= shotMax)
7133
+ dTarget = remaining;
7134
+ else if (remaining < shotMax + shotMin)
7135
+ dTarget = remaining / 2;
7136
+ else
7137
+ dTarget = shotMin + rand() * (shotMax - shotMin);
7138
+ const minLen = Math.min(MIN_SHOT_SEC, remaining);
7139
+ let pick = null;
7140
+ for (let t = 0;t < pools.length && !pick; t++) {
7141
+ const { pool } = pools[(slotIdx + t) % pools.length];
7142
+ pick = pool.find((p) => !consumed.has(p.key) && p.cand.clip_id !== prevClip && pairAvail(p) >= minLen) ?? null;
7143
+ }
7144
+ if (!pick) {
7145
+ gapRun++;
7146
+ if (gapRun >= 2)
7147
+ break;
7148
+ cursor += Math.min(dTarget, remaining);
7149
+ prevClip = null;
7150
+ continue;
7151
+ }
7152
+ gapRun = 0;
7153
+ const d = Math.min(dTarget, pairAvail(pick), remaining);
7154
+ const dur = typeof pick.cand.duration === "number" && pick.cand.duration > 0 ? pick.cand.duration : undefined;
7155
+ const lo = dur !== undefined ? 0 : pick.seg.start;
7156
+ const hi = dur ?? pick.seg.end;
7157
+ const maxSt = Math.max(lo, hi - d);
7158
+ const clipSt = Math.min(Math.max(pick.seg.best - d / 2, lo), maxSt);
7159
+ slots.push({
7160
+ clip_id: pick.cand.clip_id,
7161
+ query: pick.query,
7162
+ score: pick.seg.score,
7163
+ clip_st: r33(clipSt),
7164
+ clip_ed: r33(clipSt + d),
7165
+ track_st: r33(cursor),
7166
+ track_ed: r33(cursor + d)
7167
+ });
7168
+ consumed.add(pick.key);
7169
+ prevClip = pick.cand.clip_id;
7170
+ lastPick = pick;
7171
+ cursor += d;
7172
+ }
7173
+ const last = slots[slots.length - 1];
7174
+ if (last && lastPick) {
7175
+ const tail = beat.track_ed - last.track_ed;
7176
+ if (tail > 0.000001 && tail < MIN_SHOT_SEC) {
7177
+ const dur = typeof lastPick.cand.duration === "number" && lastPick.cand.duration > 0 ? lastPick.cand.duration : undefined;
7178
+ const hi = dur ?? lastPick.seg.end;
7179
+ const ext = Math.min(tail, Math.max(0, hi - last.clip_ed));
7180
+ if (ext > 0.000001) {
7181
+ last.clip_ed = r33(last.clip_ed + ext);
7182
+ last.track_ed = r33(last.track_ed + ext);
7183
+ }
7184
+ }
7185
+ }
7186
+ return slots;
7187
+ }
7188
+ function planBeatFills(plan, lay, scoreFloor) {
7189
+ const fills = new Map;
7190
+ const clipIds = new Set;
7191
+ const consumed = new Set;
7192
+ for (const beat of plan.beats) {
7193
+ const perTrack = [];
7194
+ for (let k = 0;k < Math.max(0, lay); k++) {
7195
+ const slots = fillBeatTrack({ beat, trackOrder: k, consumed, scoreFloor });
7196
+ perTrack.push(slots);
7197
+ for (const s of slots)
7198
+ clipIds.add(s.clip_id);
7199
+ }
7200
+ fills.set(beat.beat, perTrack);
7201
+ }
7202
+ return { fills, clipIds };
7203
+ }
7204
+ function layBrollTracks(opts) {
7205
+ const { gtrk, plan, lay, fills, downloads } = opts;
7206
+ const videoTracks = [...gtrk.video_track ?? []];
7207
+ const materials = [...gtrk.materials ?? []];
7208
+ const structMeta = { ...gtrk.struct_meta ?? {} };
7209
+ const prevBroll = structMeta.broll;
7210
+ const prevIndices = new Set(Array.isArray(prevBroll?.lay_tracks) ? prevBroll.lay_tracks.filter((x) => typeof x === "number") : []);
7211
+ const removedTracks = videoTracks.filter((t) => typeof t.track_index === "number" && prevIndices.has(t.track_index));
7212
+ const keptTracks = videoTracks.filter((t) => !(typeof t.track_index === "number" && prevIndices.has(t.track_index)));
7213
+ const removedMaterialIds = new Set;
7214
+ for (const t of removedTracks) {
7215
+ for (const c3 of t.track_timeline ?? []) {
7216
+ const m = c3.material;
7217
+ if (typeof m === "string" && m.startsWith(BROLL_MATERIAL_PREFIX))
7218
+ removedMaterialIds.add(m);
7219
+ }
7220
+ }
7221
+ const keptMaterials = materials.filter((m) => !(typeof m.id === "string" && removedMaterialIds.has(m.id)));
7222
+ const canvas = Array.isArray(gtrk.video_size) ? gtrk.video_size : [1920, 1080];
7223
+ const baseIndex = keptTracks.reduce((mx, t) => Math.max(mx, typeof t.track_index === "number" ? t.track_index : 0), -1) + 1;
7224
+ const candById = new Map;
7225
+ for (const beat of plan.beats)
7226
+ for (const c3 of mergedCandidates(beat))
7227
+ if (!candById.has(c3.clip_id))
7228
+ candById.set(c3.clip_id, c3);
7229
+ const metaBeats = [];
7230
+ const newMaterialsById = new Map;
7231
+ const trackClips = new Map;
7232
+ let laidClips = 0;
7233
+ let beatsWithCandidates = 0;
7234
+ for (const beat of plan.beats) {
7235
+ const merged = mergedCandidates(beat);
7236
+ if (merged.length > 0)
7237
+ beatsWithCandidates++;
7238
+ const perTrack = fills.get(beat.beat) ?? [];
7239
+ const laid = [];
7240
+ for (let k = 0;k < perTrack.length; k++) {
7241
+ const slots = perTrack[k].filter((s) => downloads.has(s.clip_id));
7242
+ if (!slots.length)
7243
+ continue;
7244
+ const trackIndex = baseIndex + k;
7245
+ const bucket = trackClips.get(trackIndex) ?? [];
7246
+ slots.forEach((s, i) => {
7247
+ const materialId = `${BROLL_MATERIAL_PREFIX}${s.clip_id}`;
7248
+ if (!newMaterialsById.has(materialId)) {
7249
+ const cand = candById.get(s.clip_id);
7250
+ const dims = previewDims(cand?.width, cand?.height);
7251
+ const mat = { id: materialId, path: downloads.get(s.clip_id).rel };
7252
+ if (typeof cand?.duration === "number")
7253
+ mat.duration = cand.duration;
7254
+ if (dims)
7255
+ mat.video_size = dims;
7256
+ if (typeof cand?.fps === "number")
7257
+ mat.video_rate = cand.fps;
7258
+ newMaterialsById.set(materialId, mat);
7259
+ }
7260
+ bucket.push({
7261
+ clip_id: `${beat.beat}-broll-${k}-${i}`,
7262
+ material: materialId,
7263
+ clip_st: s.clip_st,
7264
+ clip_ed: s.clip_ed,
7265
+ track_st: s.track_st,
7266
+ track_ed: s.track_ed,
7267
+ duration: r33(s.track_ed - s.track_st)
7268
+ });
7269
+ laidClips++;
7270
+ });
7271
+ trackClips.set(trackIndex, bucket);
7272
+ laid.push({ order: k, clip_id: slots[0].clip_id, track_index: trackIndex, slots });
7273
+ }
7274
+ const metaBeat = {
7275
+ beat: beat.beat,
7276
+ track_st: beat.track_st,
7277
+ track_ed: beat.track_ed,
7278
+ candidates: merged.slice(0, BROLL_META_CANDIDATE_CAP).map((c3) => {
7279
+ const dl = downloads.get(c3.clip_id);
7280
+ const seg = c3.segments?.[0];
7281
+ return {
7282
+ clip_id: c3.clip_id,
7283
+ score: c3.score,
7284
+ cover_url: c3.cover_url,
7285
+ preview_path: dl?.rel ?? null,
7286
+ source: dl?.source ?? null,
7287
+ raw_url: c3.url,
7288
+ seg: seg ? { start: seg.start, end: seg.end, best: seg.best } : null
7289
+ };
7290
+ }),
7291
+ laid,
7292
+ pinned: null
7293
+ };
7294
+ if (typeof beat.per_shot_sec === "number")
7295
+ metaBeat.per_shot_sec = beat.per_shot_sec;
7296
+ metaBeats.push(metaBeat);
7297
+ }
7298
+ const createdTracks = [...trackClips.entries()].filter(([, clips]) => clips.length > 0).sort((a, b) => a[0] - b[0]).map(([track_index, clips]) => ({
7299
+ track_index,
7300
+ track_size: [canvas[0], canvas[1]],
7301
+ muted: false,
7302
+ track_timeline: clips.sort((a, b) => a.track_st - b.track_st)
7303
+ }));
7304
+ const broll = {
7305
+ contract_version: "v1",
7306
+ generated_at: opts.generatedAt,
7307
+ plan_path: opts.planPath,
7308
+ lay_tracks: createdTracks.map((t) => t.track_index),
7309
+ confirmed: false,
7310
+ beats: metaBeats
7311
+ };
7312
+ const next = {
7313
+ ...gtrk,
7314
+ materials: [...keptMaterials, ...newMaterialsById.values()],
7315
+ video_track: [...keptTracks, ...createdTracks],
7316
+ struct_meta: { ...structMeta, broll }
7317
+ };
7318
+ return {
7319
+ next,
7320
+ summary: { laidTracks: broll.lay_tracks, laidClips, beatsWithCandidates },
7321
+ broll
7322
+ };
7323
+ }
7324
+
7325
+ // src/lib/matrix.ts
7326
+ var URL_TTL_NOTE = "结果 url 带签名默认 24h 过期;过期后重跑 gtrk matrix 即重签(plan 幂等重生成)。";
7327
+ var ENDPOINTS = {
7328
+ internal: "/task/custom/search",
7329
+ external: "/task/video_clip_search"
7330
+ };
7331
+ function decideRoute(memberType) {
7332
+ const tier = memberType === "internal" ? "internal" : "external";
7333
+ return { tier, endpoint: ENDPOINTS[tier] };
7334
+ }
7335
+ var WIDE_RECALL_FACTOR = 3;
7336
+ var TOP_K_MIN = 10;
7337
+ var TOP_K_MAX = 50;
7338
+ var TOP_K_DEFAULT = 10;
7339
+ function asPositiveInt(v) {
7340
+ return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
7341
+ }
7342
+ function asPositiveNum(v) {
7343
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
7344
+ }
7345
+ function shotsToTopK(shots, override) {
7346
+ if (override && override > 0)
7347
+ return Math.min(Math.max(Math.floor(override), 1), TOP_K_MAX);
7348
+ const n = asPositiveInt(shots);
7349
+ if (!n)
7350
+ return TOP_K_DEFAULT;
7351
+ return Math.min(Math.max(n * WIDE_RECALL_FACTOR, TOP_K_MIN), TOP_K_MAX);
7352
+ }
7353
+ function trimFacets(defaults, allowed) {
7354
+ if (!defaults || typeof defaults !== "object")
7355
+ return;
7356
+ const entries = Object.entries(defaults).filter(([k]) => !allowed || allowed.includes(k));
7357
+ return entries.length ? Object.fromEntries(entries) : undefined;
7358
+ }
7359
+ var MATERIAL_CLASSES = ["real_shot", "concept"];
7360
+ function buildSearchBody(tier, query, dispatch, broll, overrides = {}) {
7361
+ const body = { query, top_k: shotsToTopK(dispatch?.shots, overrides.topK) };
7362
+ const perShot = asPositiveNum(dispatch?.per_shot_sec);
7363
+ if (perShot)
7364
+ body.filters = { min_duration: perShot };
7365
+ if (tier === "internal") {
7366
+ if (broll?.column_tag_ids?.length)
7367
+ body.column_tag_ids = [...broll.column_tag_ids];
7368
+ const mc = overrides.materialClass ?? broll?.material_class_policy;
7369
+ if (mc && MATERIAL_CLASSES.includes(mc))
7370
+ body.material_class = mc;
7371
+ const facets = trimFacets(broll?.facet_defaults, broll?.facet_allowed);
7372
+ if (facets)
7373
+ body.facets = facets;
7374
+ }
7375
+ return body;
7376
+ }
7377
+ function strArr2(v) {
7378
+ if (!Array.isArray(v))
7379
+ return;
7380
+ const out = v.filter((x) => typeof x === "string");
7381
+ return out.length ? out : undefined;
7382
+ }
7383
+ function markExcluded(results, exclude) {
7384
+ if (!exclude?.length)
7385
+ return;
7386
+ for (const r of results) {
7387
+ if (typeof r.note === "string" && r.note && exclude.some((w) => r.note.includes(w))) {
7388
+ r.excluded_hint = true;
7389
+ }
7390
+ }
7391
+ }
7392
+ function dedupeBeatQueries(queries) {
7393
+ const bestByClip = new Map;
7394
+ for (let qi = 0;qi < queries.length; qi++) {
7395
+ for (const r of queries[qi].results ?? []) {
7396
+ const prev = bestByClip.get(r.clip_id);
7397
+ if (!prev || r.score > prev.r.score)
7398
+ bestByClip.set(r.clip_id, { qi, r });
7399
+ }
7400
+ }
7401
+ for (let qi = 0;qi < queries.length; qi++) {
7402
+ const q = queries[qi];
7403
+ if (!q.results)
7404
+ continue;
7405
+ q.results = q.results.filter((r) => {
7406
+ const best = bestByClip.get(r.clip_id);
7407
+ if (best.qi === qi && best.r === r)
7408
+ return true;
7409
+ const list = best.r.also_matched_queries ??= [];
7410
+ if (!list.includes(q.query))
7411
+ list.push(q.query);
7412
+ return false;
7413
+ });
7414
+ }
7415
+ }
7416
+ function buildPlanBeat(entry, outcomes) {
7417
+ const beat = {
7418
+ beat: entry.beat,
7419
+ track_st: entry.track_st,
7420
+ track_ed: entry.track_ed,
7421
+ queries: []
7422
+ };
7423
+ const shots = asPositiveInt(entry.shots);
7424
+ if (shots)
7425
+ beat.requested_shots = shots;
7426
+ const perShot = asPositiveNum(entry.per_shot_sec);
7427
+ if (perShot)
7428
+ beat.per_shot_sec = perShot;
7429
+ const exclude = strArr2(entry.exclude);
7430
+ if (exclude)
7431
+ beat.exclude = exclude;
7432
+ for (const o of outcomes) {
7433
+ if (o.error) {
7434
+ beat.queries.push({ query: o.query, error: o.error });
7435
+ continue;
7436
+ }
7437
+ const results = (o.data?.results ?? []).map((r) => ({ ...r }));
7438
+ markExcluded(results, exclude);
7439
+ const pq = { query: o.query, results };
7440
+ if (typeof o.data?.recalled === "number")
7441
+ pq.recalled = o.data.recalled;
7442
+ beat.queries.push(pq);
7443
+ }
7444
+ dedupeBeatQueries(beat.queries);
7445
+ return beat;
7446
+ }
7447
+ function buildPlan(opts) {
7448
+ const plan = {
7449
+ plan_version: "v1",
7450
+ generated_at: opts.generatedAt,
7451
+ member_type: opts.memberType,
7452
+ url_ttl_note: URL_TTL_NOTE,
7453
+ beats: opts.beats
7454
+ };
7455
+ if (opts.projectSlug)
7456
+ plan.project_slug = opts.projectSlug;
7457
+ if (opts.columnId)
7458
+ plan.column_id = opts.columnId;
7459
+ return plan;
7460
+ }
7461
+ function classifyApiError(code, msg) {
7462
+ switch (code) {
7463
+ case 400:
7464
+ return `请求体非法(网关校验):${msg || "请求参数或请求体格式错误"}`;
7465
+ case 6502:
7466
+ return "鉴权失败——检查 API Key(gtrk init 重配)";
7467
+ case 403:
7468
+ return "非矩阵成员或身份可能已变更——矩阵成员口(custom/search)仅对 internal 档位开放";
7469
+ case 6401:
7470
+ return "检索上游故障,或检索参数/栏目配置非法(如 facets 值拼写)——稍后重试仍失败请检查栏目配置的 broll 块";
7471
+ case 6402:
7472
+ return "检索上游超时——稍后重试";
7473
+ default:
7474
+ return `云端错误 (code=${code ?? "?"}):${msg || "未知错误"}`;
7475
+ }
7476
+ }
7477
+ var SEARCH_TIMEOUT_MS = 25000;
7478
+ async function probeMemberType(cfg) {
7479
+ const res = await fetch(`${cfg.base}/user/get_user_info`, {
7480
+ method: "POST",
7481
+ headers: { accept: "application/json", Authorization: cfg.apiKey },
7482
+ body: "",
7483
+ signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS)
7484
+ });
7485
+ const r = await parseJson(res);
7486
+ if (r.code !== 200)
7487
+ throw new CloudError(r.code, classifyApiError(r.code, r.msg));
7488
+ return decideRoute(r.data?.matrix_member_type).tier;
7489
+ }
7490
+ function parseClipIdSafe(text, status) {
7491
+ try {
7492
+ return JSON.parse(text.replace(/"clip_id"\s*:\s*(\d+)/g, '"clip_id":"$1"'));
7493
+ } catch {
7494
+ throw new Error(`服务响应解析失败 (HTTP ${status})`);
7495
+ }
7496
+ }
7497
+ async function searchOnce(cfg, tier, body) {
7498
+ const res = await fetch(`${cfg.base}${ENDPOINTS[tier]}`, {
7499
+ method: "POST",
7500
+ headers: { accept: "application/json", "Content-Type": "application/json", Authorization: cfg.apiKey },
7501
+ body: JSON.stringify(body),
7502
+ signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS)
7503
+ });
7504
+ const r = parseClipIdSafe(await res.text(), res.status);
7505
+ if (r.code !== 200)
7506
+ throw new CloudError(r.code, classifyApiError(r.code, r.msg));
7507
+ return r.data ?? {};
7508
+ }
7509
+
7510
+ // src/commands/matrix.ts
7511
+ function registerMatrix(program2) {
7512
+ 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) => {
7513
+ await runMatrix(parseAdhocQuery(words), opts);
7514
+ });
7515
+ }
7516
+ function parseAdhocQuery(words) {
7517
+ if (!words || words.length === 0)
7518
+ return;
7519
+ if (words[0] !== "search") {
7520
+ throw new Error(`未知子命令「${words[0]}」——ad-hoc 检索用法:gtrk matrix search "<query>";派单消费用法:gtrk matrix --project <dir>`);
7521
+ }
7522
+ const query = words.slice(1).join(" ").trim();
7523
+ if (!query)
7524
+ throw new Error('检索词不能为空:gtrk matrix search "<query>"');
7525
+ return query;
7526
+ }
7527
+ async function runMatrix(searchQuery, opts) {
7528
+ if (opts.json)
7529
+ routeLogsToStderr();
7530
+ const cfg = loadConfig();
7531
+ log.step("▶ 身份探针(matrix_member_type)…");
7532
+ const tier = await probeMemberType(cfg);
7533
+ log.info(`档位:${tier}${tier === "internal" ? "(矩阵成员口 /task/custom/search)" : "(通用口 /task/video_clip_search)"}`);
7534
+ const columnId = opts.column ?? readUserConfig().defaultColumn;
7535
+ const resolved = resolveColumnConfig({ columnId });
7536
+ for (const w of resolved.warnings)
7537
+ log.warn(w);
7538
+ const broll = resolved.config.broll;
7539
+ const effectiveColumnId = columnId ?? resolved.config.meta?.id;
7540
+ if (tier === "external") {
7541
+ if (opts.materialClass === "concept") {
7542
+ throw new Error("external 档位服务端固定 real_shot+有版权素材,concept 不可用(--material-class concept 无法满足)");
7543
+ }
7544
+ if (opts.materialClass) {
7545
+ log.warn("external 档位服务端固定 real_shot+有版权素材,--material-class 参数不适用(已忽略)");
7546
+ }
7547
+ if (broll && (broll.column_tag_ids?.length || broll.material_class_policy || broll.facet_defaults)) {
7548
+ log.warn("当前身份为 external,栏目检索偏好(column_tag_ids/material_class/facets)不适用");
7549
+ }
7550
+ }
7551
+ const topK = opts.topK ? Number(opts.topK) : undefined;
7552
+ const overrides = { topK, materialClass: opts.materialClass };
7553
+ const brollForTier = tier === "internal" ? broll : undefined;
7554
+ return searchQuery !== undefined ? runAdhoc(searchQuery, cfg, tier, brollForTier, overrides, effectiveColumnId, opts) : runPlanMode(cfg, tier, brollForTier, overrides, effectiveColumnId, opts);
7555
+ }
7556
+ async function runPlanMode(cfg, tier, broll, overrides, columnId, opts) {
7557
+ let dispatchPath;
7558
+ let baseDir;
7559
+ if (opts.dispatch) {
7560
+ dispatchPath = resolve7(opts.dispatch);
7561
+ baseDir = dirname6(dirname6(dispatchPath));
7562
+ } else if (opts.project) {
7563
+ baseDir = resolve7(opts.project);
7564
+ dispatchPath = join19(baseDir, "split", "dispatch.json");
7565
+ } else {
7566
+ throw new Error('需 --project <目录> 或显式 --dispatch <path>(ad-hoc 检索用:gtrk matrix search "<query>")');
7567
+ }
7568
+ if (!existsSync15(dispatchPath))
7569
+ throw new Error(`找不到派单清单:${dispatchPath}(先跑 gtrk split <拆分稿> 落地派单)`);
7570
+ const dispatch = JSON.parse(await readFile5(dispatchPath, "utf8"));
7571
+ const queue = Array.isArray(dispatch.film_broll) ? dispatch.film_broll : [];
7572
+ log.step(`▶ B-roll 检索:${queue.length} 个 beat(${tier} 口)…`);
7573
+ const beats = [];
7574
+ let okCount = 0;
7575
+ let errCount = 0;
7576
+ let resultCount = 0;
7577
+ for (const entry of queue) {
7578
+ const outcomes = [];
7579
+ for (const q of entry.queries) {
7580
+ try {
7581
+ const body = buildSearchBody(tier, q, entry, broll, overrides);
7582
+ const data = await searchOnce(cfg, tier, body);
7583
+ outcomes.push({ query: q, data });
7584
+ okCount++;
7585
+ resultCount += data.results?.length ?? 0;
7586
+ log.info(`${entry.beat}「${q}」→ ${data.results?.length ?? 0} 条候选(召回 ${data.recalled ?? "?"})`);
7587
+ } catch (e) {
7588
+ const code = e.code;
7589
+ const msg = e instanceof Error ? e.message : String(e);
7590
+ outcomes.push({ query: q, error: { ...code != null ? { code } : {}, msg } });
7591
+ errCount++;
7592
+ log.warn(`${entry.beat}「${q}」失败:${msg}`);
7593
+ }
7594
+ }
7595
+ beats.push(buildPlanBeat(entry, outcomes));
7596
+ }
7597
+ const totalQueries = okCount + errCount;
7598
+ if (queue.length === 0)
7599
+ log.warn("无 B-roll 派单(film_broll 队列为空)——照常写出空 plan");
7600
+ if (totalQueries > 0 && okCount === 0) {
7601
+ throw new Error(`全部 ${totalQueries} 个 query 检索失败,未写入 plan(逐条原因见上方日志)`);
7602
+ }
7603
+ const projectSlug = slugify2(basename9(baseDir));
7604
+ const plan = buildPlan({
7605
+ generatedAt: new Date().toISOString(),
7606
+ memberType: tier,
7607
+ projectSlug,
7608
+ columnId,
7609
+ beats
7610
+ });
7611
+ const splitDir = join19(baseDir, "split");
7612
+ await mkdir6(splitDir, { recursive: true });
7613
+ const planPath = join19(splitDir, "broll-plan.json");
7614
+ await writeFile7(planPath, JSON.stringify(plan, null, 2));
7615
+ log.ok(`候选清单已生成:${planPath}(${beats.length} beat · ${okCount}/${totalQueries} query 成功 · ${resultCount} 条候选)`);
7616
+ log.info("清单只含引用不含素材:cover_url 可直接预览;url 带签名默认 24h 过期,过期重跑本命令即重签。");
7617
+ const layN = parseLay(opts.lay);
7618
+ let laySummary;
7619
+ if (layN > 0) {
7620
+ laySummary = await layIntoProject(baseDir, plan, layN, parseScoreFloor(opts.scoreFloor));
7621
+ }
7622
+ const result = {
7623
+ ok: true,
7624
+ mode: "plan",
7625
+ memberType: tier,
7626
+ ...columnId ? { columnId } : {},
7627
+ planPath,
7628
+ ...laySummary ? { lay: laySummary } : {},
7629
+ counts: { beats: beats.length, queries: totalQueries, results: resultCount, errors: errCount }
7630
+ };
7631
+ if (opts.json)
7632
+ console.log(JSON.stringify(result));
7633
+ return result;
7634
+ }
7635
+ function parseLay(raw) {
7636
+ if (raw === undefined)
7637
+ return 1;
7638
+ const n = Number(raw);
7639
+ if (Number.isInteger(n) && n >= 0)
7640
+ return n;
7641
+ log.warn(`--lay 取值非法(${raw}),按默认 1 处理`);
7642
+ return 1;
7643
+ }
7644
+ function parseScoreFloor(raw) {
7645
+ if (raw === undefined)
7646
+ return SCORE_FLOOR_DEFAULT;
7647
+ const n = Number(raw);
7648
+ if (Number.isFinite(n) && n >= 0 && n <= 1)
7649
+ return n;
7650
+ log.warn(`--score-floor 取值非法(${raw}),按默认 ${SCORE_FLOOR_DEFAULT} 处理`);
7651
+ return SCORE_FLOOR_DEFAULT;
7652
+ }
7653
+ function locateGtrk(baseDir) {
7654
+ const cands = [join19(baseDir, "gtrk", "project.gtrk"), join19(baseDir, "project.gtrk")];
7655
+ return cands.find((p) => existsSync15(p));
7656
+ }
7657
+ async function layIntoProject(baseDir, plan, layN, scoreFloor) {
7658
+ const gtrkPath = locateGtrk(baseDir);
7659
+ if (!gtrkPath) {
7660
+ log.warn(`未找到工程文件(${join19(baseDir, "gtrk", "project.gtrk")}),跳过铺轨——plan 已产出,可后续在有工程的目录重跑`);
7661
+ return;
7662
+ }
7663
+ const { gtrk, mtimeMs } = readGtrk(gtrkPath);
7664
+ assertGtrkV1(gtrk);
7665
+ const { fills, clipIds } = planBeatFills(plan, layN, scoreFloor);
7666
+ const slotCount = [...fills.values()].flat().reduce((n, s) => n + s.length, 0);
7667
+ log.step(`▶ 候选铺轨(${layN} 轨 · 平铺 ${slotCount} 槽位 · ${clipIds.size} 个 clip,preview 代理)…`);
7668
+ const gtrkDir = dirname6(gtrkPath);
7669
+ const previewDir = join19(gtrkDir, ...BROLL_PREVIEW_DIR.split("/"));
7670
+ await mkdir6(previewDir, { recursive: true });
7671
+ const prevSource = new Map;
7672
+ const prevBroll = gtrk.struct_meta?.broll;
7673
+ for (const b of prevBroll?.beats ?? []) {
7674
+ for (const c3 of b.candidates ?? []) {
7675
+ if (typeof c3.clip_id === "string" && c3.preview_path && (c3.source === "preview" || c3.source === "raw")) {
7676
+ prevSource.set(c3.clip_id, c3.source);
7677
+ }
7678
+ }
7679
+ }
7680
+ const candById = new Map;
7681
+ for (const beat of plan.beats)
7682
+ for (const c3 of mergedCandidates(beat))
7683
+ if (!candById.has(c3.clip_id))
7684
+ candById.set(c3.clip_id, c3);
7685
+ const downloads = new Map;
7686
+ const dlStats = { preview: 0, raw: 0, reused: 0, failed: 0 };
7687
+ for (const clipId of clipIds) {
7688
+ const cand = candById.get(clipId);
7689
+ if (!cand)
7690
+ continue;
7691
+ const rel = `${BROLL_PREVIEW_DIR}/${clipId}.mp4`;
7692
+ const abs = join19(gtrkDir, ...rel.split("/"));
7693
+ if (existsSync15(abs)) {
7694
+ const prev = prevSource.get(clipId);
7695
+ if (prev !== "raw") {
7696
+ downloads.set(clipId, { rel, source: prev ?? "preview" });
7697
+ dlStats.reused++;
7698
+ continue;
7699
+ }
7700
+ const retried = await downloadProxy(cand, abs, { previewOnly: true });
7701
+ if (retried === "preview") {
7702
+ downloads.set(clipId, { rel, source: "preview" });
7703
+ dlStats.preview++;
7704
+ log.info(`clip ${clipId} 代理已补产,已从原片回落态换回 preview`);
7705
+ } else {
7706
+ downloads.set(clipId, { rel, source: "raw" });
7707
+ dlStats.reused++;
7708
+ }
7709
+ continue;
7710
+ }
7711
+ const got = await downloadProxy(cand, abs);
7712
+ if (got) {
7713
+ downloads.set(clipId, { rel, source: got });
7714
+ dlStats[got]++;
7715
+ } else {
7716
+ dlStats.failed++;
7717
+ }
7718
+ }
7719
+ const { next, summary } = layBrollTracks({
7720
+ gtrk,
7721
+ plan,
7722
+ lay: layN,
7723
+ fills,
7724
+ downloads,
7725
+ generatedAt: new Date().toISOString(),
7726
+ planPath: "split/broll-plan.json"
7727
+ });
7728
+ writeGtrkAtomic(gtrkPath, next, mtimeMs);
7729
+ 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}` : ""})`);
7730
+ log.info("opencut 打开工程即见候选轨:轨道头小眼睛可开关对比;确认下载原片属挑选 UI(E-P1)。");
7731
+ if (dlStats.raw > 0) {
7732
+ log.warn("部分候选无 preview 代理已回落原片(体积较大)——服务端 backfill 后重跑本命令可换回代理。");
7733
+ }
7734
+ return { laidTracks: summary.laidTracks, laidClips: summary.laidClips, downloads: dlStats };
7735
+ }
7736
+ async function downloadProxy(cand, absPath, opts = {}) {
7737
+ const tryFetch = async (url) => {
7738
+ try {
7739
+ const res = await fetch(url, { signal: AbortSignal.timeout(180000) });
7740
+ if (!res.ok)
7741
+ return null;
7742
+ return Buffer.from(await res.arrayBuffer());
7743
+ } catch {
7744
+ return null;
7745
+ }
7746
+ };
7747
+ const previewUrl = previewUrlFor(cand);
7748
+ if (previewUrl) {
7749
+ const bytes = await tryFetch(previewUrl);
7750
+ if (bytes) {
7751
+ await writeFile7(absPath, bytes);
7752
+ return "preview";
7753
+ }
7754
+ }
7755
+ if (opts.previewOnly)
7756
+ return null;
7757
+ const raw = await tryFetch(cand.url);
7758
+ if (raw) {
7759
+ await writeFile7(absPath, raw);
7760
+ log.warn(`clip ${cand.clip_id} 无 preview 代理,已回落原片(${(raw.length / 1048576).toFixed(1)}MB)`);
7761
+ return "raw";
7762
+ }
7763
+ log.warn(`clip ${cand.clip_id} 代理与原片均下载失败,该候选槽位跳过`);
7764
+ return null;
7765
+ }
7766
+ async function runAdhoc(query, cfg, tier, broll, overrides, columnId, opts) {
7767
+ log.step(`▶ ad-hoc 检索「${query}」(${tier} 口)…`);
7768
+ const body = buildSearchBody(tier, query, undefined, broll, overrides);
7769
+ const data = await searchOnce(cfg, tier, body);
7770
+ const results = data.results ?? [];
7771
+ log.ok(`${results.length} 条候选(召回 ${data.recalled ?? "?"})`);
7772
+ const result = {
7773
+ ok: true,
7774
+ mode: "search",
7775
+ memberType: tier,
7776
+ ...columnId ? { columnId } : {},
7777
+ results,
7778
+ counts: { beats: 0, queries: 1, results: results.length, errors: 0 }
7779
+ };
7780
+ if (opts.out) {
7781
+ const outPath = resolve7(opts.out);
7782
+ await writeFile7(outPath, JSON.stringify({ query, recalled: data.recalled, results }, null, 2));
7783
+ log.ok(`结果已落盘:${outPath}`);
7784
+ result.outPath = outPath;
7785
+ } else if (!opts.json) {
7786
+ for (const r of results.slice(0, 10)) {
7787
+ const seg = r.segments?.[0];
7788
+ 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)}` : ""}`);
7789
+ }
7790
+ }
7791
+ if (opts.json)
7792
+ console.log(JSON.stringify(result));
7793
+ return result;
7794
+ }
7795
+ function slugify2(name) {
7796
+ const s = name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
7797
+ return s || "project";
7798
+ }
7799
+
7800
+ // src/commands/mg.ts
7801
+ import { resolve as resolve8, join as join20, dirname as dirname7, basename as basename10 } from "node:path";
7802
+ import { existsSync as existsSync16 } from "node:fs";
7803
+ import { readFile as readFile6, mkdir as mkdir7, copyFile } from "node:fs/promises";
7804
+
7805
+ // src/lib/mg-lint.ts
7806
+ var CDN_OK = [/lib\.baomitu\.com/i, /cdnjs\.cloudflare\.com/i, /unpkg\.com/i];
7807
+ var CDN_WARN = [/jsdelivr\.net/i];
7808
+ function rootTag(html) {
7809
+ const m = html.match(/<[a-zA-Z][^>]*\bdata-composition-id\s*=[^>]*>/);
7810
+ return m ? m[0] : null;
7811
+ }
7812
+ function attr(tag, name) {
7813
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i"));
7814
+ return m ? m[1] : undefined;
7815
+ }
7816
+ function deriveOpaque(rootTagStr) {
7817
+ if (!rootTagStr)
7818
+ return { opaque: false, declared: false };
7819
+ const style = attr(rootTagStr, "style") ?? "";
7820
+ const bg = style.match(/background(?:-color)?\s*:\s*([^;"']+)/i);
7821
+ if (!bg)
7822
+ return { opaque: false, declared: false };
7823
+ const val = bg[1].trim().toLowerCase();
7824
+ const transparent = val === "transparent" || val === "none" || /rgba\([^)]*,\s*0\s*\)/.test(val);
7825
+ return { opaque: !transparent, declared: true };
7826
+ }
7827
+ var CATEGORY_EXPECTED_OPAQUE2 = {
7828
+ overlay: false,
7829
+ fullscreen: true,
7830
+ subtitle: false,
7831
+ title: true,
7832
+ "rrv-overlay": false,
7833
+ "mg-fullscreen": true,
7834
+ "explain-subtitle": false,
7835
+ "op-ed-title": true
7836
+ };
7837
+ function lintParticle(html, opts = {}) {
7838
+ const v = [];
7839
+ const push = (law, fatal, msg) => v.push({ law, fatal, msg });
7840
+ if (!/<template[\s>]/i.test(html))
7841
+ push("1-template", true, "缺 <template> 包裹根元素(裸 div 整片渲染失败)");
7842
+ const root = rootTag(html);
7843
+ const cid = root ? attr(root, "data-composition-id") : undefined;
7844
+ if (!root || !cid)
7845
+ push("1-composition-id", true, "根元素缺 data-composition-id");
7846
+ if (root) {
7847
+ if (attr(root, "data-width") !== "1920")
7848
+ push("1-width", true, `根 data-width 应为 "1920"(实为 ${attr(root, "data-width") ?? "缺"})`);
7849
+ if (attr(root, "data-height") !== "1080")
7850
+ push("1-height", true, `根 data-height 应为 "1080"(实为 ${attr(root, "data-height") ?? "缺"})`);
7851
+ }
7852
+ if (!/gsap\.timeline\s*\(\s*\{[^}]*\bpaused\s*:\s*true\b[^}]*\}\s*\)/.test(html))
7853
+ push("2-paused", true, "缺 gsap.timeline({ paused: true })");
7854
+ const regMatch = html.match(/window\.__timelines\s*\[\s*(["']([^"']+)["']|[A-Za-z_$][\w$]*)\s*\]\s*=/);
7855
+ if (!regMatch)
7856
+ push("2-register", true, "缺 window.__timelines[<id>] = 注册");
7857
+ else {
7858
+ let regId = regMatch[2];
7859
+ if (regId === undefined) {
7860
+ const ident = regMatch[1];
7861
+ const vm = html.match(new RegExp(`(?:var|const|let)\\s+${ident}\\s*=\\s*["']([^"']+)["']`));
7862
+ regId = vm?.[1];
7863
+ if (!regId)
7864
+ push("2-register", true, `__timelines[${ident}] 的 ${ident} 未见字面串赋值,无法静态判定注册 id`);
7865
+ }
7866
+ if (cid && regId !== undefined && regId !== cid)
7867
+ push("2-id-match", true, `__timelines 注册 id "${regId}" 与 data-composition-id "${cid}" 不一致`);
7868
+ }
7869
+ if (/Math\.random\s*\(/.test(html))
7870
+ push("3-random", true, "含 Math.random()(破 StaticGuard,逐帧不确定)");
7871
+ if (/Date\.now\s*\(/.test(html))
7872
+ push("3-date-now", true, "含 Date.now()");
7873
+ if (/new\s+Date\s*\(\s*\)/.test(html))
7874
+ push("3-new-date", true, "含无参 new Date()");
7875
+ for (const m of html.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi)) {
7876
+ const src = m[1];
7877
+ if (!/^https?:\/\//i.test(src))
7878
+ push("4-script-rel", true, `<script src> 非 http(s) 绝对 url:${src}`);
7879
+ else if (CDN_WARN.some((r) => r.test(src)))
7880
+ push("5-cdn-jsdelivr", false, `CDN 用 jsdelivr(国内渲染机不稳,建议 lib.baomitu.com):${src}`);
7881
+ else if (!CDN_OK.some((r) => r.test(src)))
7882
+ push("5-cdn-unknown", false, `CDN 不在已知可达白名单(渲染机可能拉不到):${src}`);
7883
+ }
7884
+ for (const [re, tag] of [
7885
+ [/<img\b[^>]*\bsrc\s*=\s*["'](?!https?:|data:)[^"']+["']/gi, "<img src>"],
7886
+ [/<link\b[^>]*\bhref\s*=\s*["'](?!https?:|data:)[^"']+["']/gi, "<link href>"],
7887
+ [/<use\b[^>]*\b(?:xlink:)?href\s*=\s*["'](?!#|https?:|data:)[^"']+["']/gi, "<use href>"],
7888
+ [/\burl\(\s*["']?(?!https?:|data:|#)[^)"']+["']?\s*\)/gi, "css url()"]
7889
+ ]) {
7890
+ if (re.test(html))
7891
+ push("4-rel-asset", true, `含相对外链 ${tag}(违反自包含,渲染机读不到)`);
7892
+ }
7893
+ const { opaque, declared } = deriveOpaque(root);
7894
+ if (root && !declared)
7895
+ push("4-bg-explicit", false, "根未显式声明 background(透明与否应明确;缺省按透明叠加 opaque=false 处理)");
7896
+ if (/var\(\s*--/.test(html))
7897
+ push("6-css-var", true, "含 CSS var(--...)(Hyperframes 不解析→整片全黑,须字面值)");
7898
+ const effectiveCid = opts.compositionId ?? cid;
7899
+ if (opts.dispatchIds && effectiveCid && !opts.dispatchIds.includes(effectiveCid))
7900
+ push("x-dispatch", false, `composition_id "${effectiveCid}" 不在 dispatch.mg 派单中`);
7901
+ if (opts.category && opts.category in CATEGORY_EXPECTED_OPAQUE2) {
7902
+ const expect = CATEGORY_EXPECTED_OPAQUE2[opts.category];
7903
+ if (expect !== opaque)
7904
+ push("x-category-opaque", false, `category「${opts.category}」期望${expect ? "不透明满屏" : "透明叠加"},但颗粒 HTML 反推为${opaque ? "不透明满屏" : "透明叠加"}(以 HTML 为准落 clip.opaque=${opaque})`);
7905
+ }
7906
+ return { ok: !v.some((x) => x.fatal), violations: v, opaque, compositionId: cid };
7907
+ }
7908
+
7909
+ // src/lib/mg-lay.ts
7910
+ var MG_MATERIAL_PREFIX = "mg-";
7911
+ var LEGACY_MATERIAL_PREFIX = "rrv-";
7912
+ var isOwnMaterialId = (id) => id.startsWith(MG_MATERIAL_PREFIX) || id.startsWith(LEGACY_MATERIAL_PREFIX);
7913
+ var r34 = (n) => Math.round(n * 1000) / 1000;
7914
+ function layTracksOf(prev) {
7915
+ return Array.isArray(prev?.lay_tracks) ? prev.lay_tracks.filter((x) => typeof x === "number") : [];
7916
+ }
7917
+ function layMgTracks(opts) {
7918
+ const { gtrk, items, generatedAt } = opts;
7919
+ const beatTracks = [...gtrk.beat_track ?? []];
7920
+ const materials = [...gtrk.materials ?? []];
7921
+ const structMeta = { ...gtrk.struct_meta ?? {} };
7922
+ const prevMg = structMeta.mg;
7923
+ const prevRrv = structMeta.rrv;
7924
+ const prevIndices = new Set([...layTracksOf(prevMg), ...layTracksOf(prevRrv)]);
7925
+ const removedTracks = beatTracks.filter((t) => typeof t.track_index === "number" && prevIndices.has(t.track_index));
7926
+ const keptTracks = beatTracks.filter((t) => !(typeof t.track_index === "number" && prevIndices.has(t.track_index)));
7927
+ const removedMaterialIds = new Set;
7928
+ for (const t of removedTracks) {
7929
+ for (const c3 of t.track_timeline ?? []) {
7930
+ const hm = c3.html_material;
7931
+ if (typeof hm === "string" && isOwnMaterialId(hm))
7932
+ removedMaterialIds.add(hm);
7933
+ }
7934
+ }
7935
+ const keptMaterials = materials.filter((m) => !(typeof m.id === "string" && removedMaterialIds.has(m.id)));
7936
+ const newMaterials = [];
7937
+ const clips = [];
7938
+ const metaBeats = [];
7939
+ const allIndices = [
7940
+ ...keptTracks,
7941
+ ...gtrk.video_track ?? [],
7942
+ ...gtrk.audio_track ?? []
7943
+ ].map((t) => typeof t.track_index === "number" ? t.track_index : 0);
7944
+ const newIndex = Math.max(9, ...allIndices) + 1;
7945
+ for (const it of items) {
7946
+ if (!(it.duration > 0)) {
7947
+ metaBeats.push({ ...toMetaBeat(it), laid: null });
7948
+ continue;
7949
+ }
7950
+ const materialId = `${MG_MATERIAL_PREFIX}${it.composition_id}`;
7951
+ newMaterials.push({ id: materialId, path: it.html_rel });
7952
+ clips.push({
7953
+ clip_id: it.composition_id,
7954
+ material: it.composition_id,
7955
+ html_material: materialId,
7956
+ opaque: it.opaque,
7957
+ track_st: it.track_st,
7958
+ duration: it.duration
7959
+ });
7960
+ metaBeats.push({ ...toMetaBeat(it), laid: { track_index: newIndex } });
7961
+ }
7962
+ const createdTracks = clips.length > 0 ? [
7963
+ {
7964
+ track_index: newIndex,
7965
+ track_timeline: clips.sort((a, b) => a.track_st - b.track_st)
7966
+ }
7967
+ ] : [];
7968
+ const mg = {
7969
+ contract_version: "v1",
7970
+ generated_at: generatedAt,
7971
+ lay_tracks: createdTracks.map((t) => t.track_index),
7972
+ beats: metaBeats
7973
+ };
7974
+ const nextStructMeta = { ...structMeta, mg };
7975
+ delete nextStructMeta.rrv;
7976
+ const next = {
7977
+ ...gtrk,
7978
+ materials: [...keptMaterials, ...newMaterials],
7979
+ beat_track: [...keptTracks, ...createdTracks],
7980
+ struct_meta: nextStructMeta
7981
+ };
7982
+ return {
7983
+ next,
7984
+ summary: { laidTrack: createdTracks[0]?.track_index ?? null, laidParticles: clips.length },
7985
+ mg
7986
+ };
7987
+ }
7988
+ function toMetaBeat(it) {
7989
+ return {
7990
+ beat: it.beat,
7991
+ composition_id: it.composition_id,
7992
+ track_st: it.track_st,
7993
+ track_ed: r34(it.track_ed),
7994
+ duration: it.duration,
7995
+ html_path: it.html_rel,
7996
+ ...it.category ? { category: it.category } : {}
7997
+ };
7998
+ }
7999
+
8000
+ // src/commands/mg.ts
8001
+ var MG_ASSET_DIR = "assets/mg";
8002
+ var MG_SRC_DIRS = ["mg", "rrv"];
8003
+ function registerMg(program2) {
8004
+ program2.command("mg [words...]").alias("rrv").description("MG 颗粒铺轨:无 positional=消费 dispatch.mg 铺 html-particle;`mg lint <file>`=单文件 lint;`mg status`=看板").option("--project <dir>", "oralcut 产物目录(定位 split/dispatch.json 与工程)").option("--dispatch <path>", "显式指定 dispatch.json(非标准布局兜底)").option("--only <beat>", "只跑单 beat").option("--lint-only", "只 lint 校验,不铺轨不写回").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON").action(async (words, opts) => {
8005
+ if (process.argv[2] === "rrv")
8006
+ log.warn("`gtrk rrv` 已更名为 `gtrk mg`(去品牌化),别名仍可用但建议改用 `gtrk mg`。");
8007
+ await runMg(words ?? [], opts);
8008
+ });
8009
+ }
8010
+ async function runMg(words, opts) {
8011
+ if (opts.json)
8012
+ routeLogsToStderr();
8013
+ const sub = words[0];
8014
+ if (sub === "lint")
8015
+ return runLint(words.slice(1), opts);
8016
+ if (sub === "status")
8017
+ return runStatus(opts);
8018
+ if (sub)
8019
+ throw new Error(`未知子命令「${sub}」——铺轨:gtrk mg --project <dir>;lint:gtrk mg lint <file>;看板:gtrk mg status`);
8020
+ return runLay(opts);
8021
+ }
8022
+ function resolveDispatch(opts) {
8023
+ if (opts.dispatch) {
8024
+ const dispatchPath = resolve8(opts.dispatch);
8025
+ return { dispatchPath, baseDir: dirname7(dirname7(dispatchPath)) };
8026
+ }
8027
+ if (opts.project) {
8028
+ const baseDir = resolve8(opts.project);
8029
+ return { dispatchPath: join20(baseDir, "split", "dispatch.json"), baseDir };
8030
+ }
8031
+ throw new Error("需 --project <目录> 或显式 --dispatch <path>");
8032
+ }
8033
+ function locateGtrk2(baseDir) {
8034
+ return [join20(baseDir, "gtrk", "project.gtrk"), join20(baseDir, "project.gtrk")].find((p) => existsSync16(p));
8035
+ }
8036
+ function locateSrcHtml(baseDir, compositionId) {
8037
+ for (const d of MG_SRC_DIRS) {
8038
+ const p = join20(baseDir, d, `${compositionId}.html`);
8039
+ if (existsSync16(p))
8040
+ return p;
8041
+ }
8042
+ return;
8043
+ }
8044
+ async function readMgQueue(dispatchPath) {
8045
+ if (!existsSync16(dispatchPath))
8046
+ throw new Error(`找不到派单清单:${dispatchPath}(先跑 gtrk split 落地派单)`);
8047
+ const dispatch = JSON.parse(await readFile6(dispatchPath, "utf8"));
8048
+ const queue = dispatch.mg ?? dispatch.rrv_mg;
8049
+ return Array.isArray(queue) ? queue : [];
8050
+ }
8051
+ async function runLay(opts) {
8052
+ const { dispatchPath, baseDir } = resolveDispatch(opts);
8053
+ let queue = await readMgQueue(dispatchPath);
8054
+ if (opts.only)
8055
+ queue = queue.filter((q) => q.beat === opts.only);
8056
+ log.step(`▶ MG 颗粒铺轨:${queue.length} 个 beat…`);
8057
+ const dispatchIds = queue.map((q) => q.composition_id);
8058
+ const items = [];
8059
+ const srcByComp = new Map;
8060
+ const skipped = [];
8061
+ for (const q of queue) {
8062
+ const srcPath = locateSrcHtml(baseDir, q.composition_id);
8063
+ if (!srcPath) {
8064
+ skipped.push({ beat: q.beat, reason: "缺颗粒 HTML(未产出)" });
8065
+ log.warn(`${q.beat}:缺 ${join20(baseDir, MG_SRC_DIRS[0], `${q.composition_id}.html`)},跳过`);
8066
+ continue;
8067
+ }
8068
+ const html = await readFile6(srcPath, "utf8");
8069
+ const category = typeof q.category === "string" ? q.category : undefined;
8070
+ const lint = lintParticle(html, { compositionId: q.composition_id, dispatchIds, category });
8071
+ for (const vv of lint.violations)
8072
+ (vv.fatal ? log.warn : log.info)(`${q.beat} lint ${vv.fatal ? "✗" : "·"} ${vv.law}: ${vv.msg}`);
8073
+ if (!lint.ok) {
8074
+ skipped.push({ beat: q.beat, reason: "lint 未过" });
8075
+ log.warn(`${q.beat}:lint 未过,跳过`);
8076
+ continue;
8077
+ }
8078
+ if (typeof q.duration !== "number" || !(q.duration > 0)) {
8079
+ skipped.push({ beat: q.beat, reason: "duration 非正数" });
8080
+ continue;
8081
+ }
8082
+ srcByComp.set(q.composition_id, srcPath);
8083
+ items.push({
8084
+ beat: q.beat,
8085
+ composition_id: q.composition_id,
8086
+ track_st: q.track_st,
8087
+ track_ed: q.track_ed,
8088
+ duration: q.duration,
8089
+ opaque: lint.opaque,
8090
+ html_rel: `${MG_ASSET_DIR}/${q.composition_id}.html`,
8091
+ ...category ? { category } : {}
8092
+ });
8093
+ }
8094
+ if (opts.lintOnly) {
8095
+ log.ok(`lint-only:${items.length}/${queue.length} 通过,${skipped.length} 跳过(不铺轨)`);
8096
+ return done(opts, { ok: skipped.length === 0, mode: "lay", lintOnly: true, passed: items.length, skipped });
8097
+ }
8098
+ const gtrkPath = locateGtrk2(baseDir);
8099
+ if (!gtrkPath) {
8100
+ log.warn(`未找到工程文件(${join20(baseDir, "gtrk", "project.gtrk")}),跳过铺轨——lint 已完成`);
8101
+ return done(opts, { ok: true, mode: "lay", laid: 0, skipped, note: "工程缺失,未铺轨" });
8102
+ }
8103
+ const { gtrk, mtimeMs } = readGtrk(gtrkPath);
8104
+ assertGtrkV1(gtrk);
8105
+ const gtrkDir = dirname7(gtrkPath);
8106
+ await mkdir7(join20(gtrkDir, ...MG_ASSET_DIR.split("/")), { recursive: true });
8107
+ for (const it of items) {
8108
+ await copyFile(srcByComp.get(it.composition_id), join20(gtrkDir, ...it.html_rel.split("/")));
8109
+ }
8110
+ const { next, summary } = layMgTracks({ gtrk, items, generatedAt: new Date().toISOString() });
8111
+ writeGtrkAtomic(gtrkPath, next, mtimeMs);
8112
+ log.ok(`铺轨完成:${summary.laidParticles} 颗粒 → beat_track ${summary.laidTrack ?? "-"}` + `${skipped.length ? `(${skipped.length} beat 跳过)` : ""}`);
8113
+ log.info("opencut 打开工程即见 MG overlay 轨(预览需 add-particle-project-folder-preview 上线);出片时客户端云渲。");
8114
+ return done(opts, {
8115
+ ok: true,
8116
+ mode: "lay",
8117
+ laid: summary.laidParticles,
8118
+ laidTrack: summary.laidTrack,
8119
+ skipped
8120
+ });
8121
+ }
8122
+ async function runLint(args, opts) {
8123
+ const file = args[0];
8124
+ if (!file)
8125
+ throw new Error("用法:gtrk mg lint <particle.html> [--dispatch <path>]");
8126
+ const html = await readFile6(resolve8(file), "utf8");
8127
+ let dispatchIds;
8128
+ if (opts.dispatch && existsSync16(resolve8(opts.dispatch))) {
8129
+ dispatchIds = (await readMgQueue(resolve8(opts.dispatch))).map((q) => q.composition_id);
8130
+ }
8131
+ const lint = lintParticle(html, { dispatchIds });
8132
+ for (const vv of lint.violations)
8133
+ (vv.fatal ? log.err : log.warn)(`${vv.fatal ? "✗" : "·"} ${vv.law}: ${vv.msg}`);
8134
+ if (lint.ok)
8135
+ log.ok(`lint 通过(${basename10(file)};opaque=${lint.opaque})`);
8136
+ else
8137
+ log.err(`lint 未过(${lint.violations.filter((v) => v.fatal).length} 项致命)`);
8138
+ const result = { mode: "lint", ...lint, ok: lint.ok };
8139
+ if (opts.json)
8140
+ console.log(JSON.stringify(result));
8141
+ if (!lint.ok)
8142
+ process.exitCode = 1;
8143
+ return result;
8144
+ }
8145
+ async function runStatus(opts) {
8146
+ const { dispatchPath, baseDir } = resolveDispatch(opts);
8147
+ const queue = await readMgQueue(dispatchPath);
8148
+ const gtrkPath = locateGtrk2(baseDir);
8149
+ let laidIds = new Set;
8150
+ if (gtrkPath) {
8151
+ const { gtrk } = readGtrk(gtrkPath);
8152
+ const structMeta = gtrk.struct_meta;
8153
+ const meta = structMeta?.mg ?? structMeta?.rrv;
8154
+ laidIds = new Set((meta?.beats ?? []).filter((b) => b.laid).map((b) => b.composition_id));
8155
+ }
8156
+ const rows = queue.map((q) => {
8157
+ const authored2 = locateSrcHtml(baseDir, q.composition_id) !== undefined;
8158
+ const laid2 = laidIds.has(q.composition_id);
8159
+ return { beat: q.beat, composition_id: q.composition_id, authored: authored2, laid: laid2, state: laid2 ? "已铺" : authored2 ? "已产未铺" : "缺 HTML" };
8160
+ });
8161
+ const authored = rows.filter((r) => r.authored).length;
8162
+ const laid = rows.filter((r) => r.laid).length;
8163
+ log.step(`▶ MG 看板:${queue.length} beat · ${authored} 已产 · ${laid} 已铺`);
8164
+ for (const r of rows)
8165
+ log.info(`${r.beat}(${r.composition_id})→ ${r.state}`);
8166
+ return done(opts, { ok: true, mode: "status", total: queue.length, authored, laid, rows });
8167
+ }
8168
+ function done(opts, result) {
8169
+ if (opts.json)
8170
+ console.log(JSON.stringify(result));
8171
+ return result;
8172
+ }
8173
+
6616
8174
  // src/index.ts
6617
8175
  try {
6618
8176
  process.loadEnvFile?.();
6619
8177
  } catch {}
6620
8178
  migrateLegacyHome();
6621
- var { version } = JSON.parse(readFileSync4(join17(packageRoot(), "package.json"), "utf8"));
8179
+ var { version } = JSON.parse(readFileSync5(join21(packageRoot(), "package.json"), "utf8"));
6622
8180
  var program2 = new Command;
6623
8181
  program2.name("gtrk").description("同合云成片流水线 CLI —— agent 驱动云端任务、产物拉回本地、三方工程文件(客户端/剪映/PR)互通").version(version);
6624
8182
  registerInstall(program2);
@@ -6630,6 +8188,8 @@ registerSkills(program2);
6630
8188
  registerUpgrade(program2);
6631
8189
  registerRender(program2);
6632
8190
  registerSplit(program2);
8191
+ registerMatrix(program2);
8192
+ registerMg(program2);
6633
8193
  program2.parseAsync(process.argv).catch((e) => {
6634
8194
  console.error(`
6635
8195
  ❌ ${e instanceof Error ? e.message : String(e)}`);