@gitruck/cli 0.2.2 → 0.2.4

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,13 +4196,14 @@ var {
4196
4196
  } = import__.default;
4197
4197
 
4198
4198
  // src/index.ts
4199
- import { readFileSync as readFileSync3 } from "node:fs";
4200
- import { join as join9 } from "node:path";
4199
+ import { readFileSync as readFileSync4 } from "node:fs";
4200
+ import { join as join17 } from "node:path";
4201
4201
 
4202
4202
  // src/lib/paths.ts
4203
4203
  import { dirname, join } from "node:path";
4204
4204
  import { fileURLToPath } from "node:url";
4205
- import { existsSync } from "node:fs";
4205
+ import { homedir } from "node:os";
4206
+ import { cpSync, existsSync, mkdirSync } from "node:fs";
4206
4207
  function packageRoot() {
4207
4208
  let dir = dirname(fileURLToPath(import.meta.url));
4208
4209
  for (let i = 0;i < 8; i++) {
@@ -4215,11 +4216,36 @@ function packageRoot() {
4215
4216
  }
4216
4217
  return dir;
4217
4218
  }
4219
+ var LEGACY_HOME = join(homedir(), ".gtrk-cli");
4220
+ var GITRUCK_HOME = join(homedir(), ".gitruck");
4221
+ function gitruckHome() {
4222
+ return GITRUCK_HOME;
4223
+ }
4224
+ function ffmpegDir() {
4225
+ return join(GITRUCK_HOME, "ffmpeg");
4226
+ }
4227
+ function audioCacheDir() {
4228
+ return join(GITRUCK_HOME, "audio-cache");
4229
+ }
4230
+ var _migrated = false;
4231
+ function migrateLegacyHome() {
4232
+ if (_migrated)
4233
+ return;
4234
+ _migrated = true;
4235
+ try {
4236
+ if (!existsSync(LEGACY_HOME))
4237
+ return;
4238
+ if (existsSync(join(GITRUCK_HOME, "config.json")))
4239
+ return;
4240
+ mkdirSync(GITRUCK_HOME, { recursive: true });
4241
+ cpSync(LEGACY_HOME, GITRUCK_HOME, { recursive: true, force: false, errorOnExist: false });
4242
+ } catch {}
4243
+ }
4218
4244
 
4219
4245
  // src/commands/skills.ts
4220
- import { homedir } from "node:os";
4246
+ import { homedir as homedir2 } from "node:os";
4221
4247
  import { join as join2 } from "node:path";
4222
- import { existsSync as existsSync2, mkdirSync, copyFileSync } from "node:fs";
4248
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, cpSync as cpSync2 } from "node:fs";
4223
4249
 
4224
4250
  // src/lib/log.ts
4225
4251
  var c = {
@@ -4248,42 +4274,46 @@ var log = {
4248
4274
  };
4249
4275
 
4250
4276
  // src/commands/skills.ts
4251
- var SKILL_NAME = "gtrk-oralcut";
4252
- var SRC = join2(packageRoot(), "skills", SKILL_NAME, "SKILL.md");
4277
+ var SKILL_NAMES = ["gtrk-oralcut", "gtrk-splitter"];
4253
4278
  function installSkill(opts = {}) {
4254
- if (!existsSync2(SRC)) {
4255
- log.warn(`找不到打包的 skill 源:${SRC}(跳过 skill 安装,不影响命令行)`);
4256
- return false;
4257
- }
4258
- const dest = join2(opts.dir ?? join2(homedir(), ".claude", "skills"), SKILL_NAME);
4259
- try {
4260
- mkdirSync(dest, { recursive: true });
4261
- copyFileSync(SRC, join2(dest, "SKILL.md"));
4262
- log.ok(`已安装 /${SKILL_NAME} → ${join2(dest, "SKILL.md")}`);
4263
- log.info("在 Claude Code 里打 /gtrk-oralcut,或直接说「帮我剪个口播」即可触发(可能需重载会话)。");
4264
- return true;
4265
- } catch (e) {
4266
- log.warn(`skill 安装失败(不影响命令行使用):${e instanceof Error ? e.message : String(e)}`);
4267
- return false;
4279
+ const destRoot = opts.dir ?? join2(homedir2(), ".claude", "skills");
4280
+ let allOk = true;
4281
+ for (const name of SKILL_NAMES) {
4282
+ const src = join2(packageRoot(), "skills", name);
4283
+ if (!existsSync2(join2(src, "SKILL.md"))) {
4284
+ log.warn(`找不到打包的 skill 源:${join2(src, "SKILL.md")}(跳过 ${name},不影响命令行)`);
4285
+ allOk = false;
4286
+ continue;
4287
+ }
4288
+ const dest = join2(destRoot, name);
4289
+ try {
4290
+ mkdirSync2(dest, { recursive: true });
4291
+ cpSync2(src, dest, { recursive: true });
4292
+ log.ok(`已安装 /${name} → ${join2(dest, "SKILL.md")}`);
4293
+ } catch (e) {
4294
+ allOk = false;
4295
+ log.warn(`skill 安装失败(${name},不影响命令行使用):${e instanceof Error ? e.message : String(e)}`);
4296
+ }
4268
4297
  }
4298
+ log.info("在 Claude Code 里打 /gtrk-oralcut 或 /gtrk-splitter,也可直接说「帮我剪个口播 / 拆个分镜」触发(可能需重载会话)。");
4299
+ return allOk;
4269
4300
  }
4270
4301
  function registerSkills(program2) {
4271
4302
  const skills = program2.command("skills").description("管理 agent skill(安装到 Claude Code)");
4272
- skills.command("install").description(`把 /${SKILL_NAME} 安装到 ~/.claude/skills(对标飞书 skills add)`).option("--dir <dir>", "自定义 skills 目录(缺省 ~/.claude/skills)").action((opts) => {
4303
+ skills.command("install").description("把 /gtrk-oralcut 与 /gtrk-splitter 安装到 ~/.claude/skills(对标飞书 skills add)").option("--dir <dir>", "自定义 skills 目录(缺省 ~/.claude/skills)").action((opts) => {
4273
4304
  installSkill({ dir: opts.dir });
4274
4305
  });
4275
4306
  }
4276
4307
 
4277
4308
  // src/commands/init.ts
4278
- import { join as join6, resolve as resolve2 } from "node:path";
4279
- import { existsSync as existsSync6 } from "node:fs";
4309
+ import { join as join7, resolve as resolve2 } from "node:path";
4310
+ import { existsSync as existsSync7 } from "node:fs";
4280
4311
 
4281
4312
  // src/lib/user-config.ts
4282
- import { homedir as homedir2 } from "node:os";
4283
4313
  import { join as join3 } from "node:path";
4284
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "node:fs";
4314
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, writeFileSync } from "node:fs";
4285
4315
  var DEFAULT_API_BASE = "https://api.ai-mcn.tv:10000";
4286
- var DIR = join3(homedir2(), ".gtrk-cli");
4316
+ var DIR = gitruckHome();
4287
4317
  var FILE = join3(DIR, "config.json");
4288
4318
  function configPath() {
4289
4319
  return FILE;
@@ -4298,7 +4328,7 @@ function readUserConfig() {
4298
4328
  }
4299
4329
  }
4300
4330
  function writeUserConfig(patch) {
4301
- mkdirSync2(DIR, { recursive: true });
4331
+ mkdirSync3(DIR, { recursive: true });
4302
4332
  const merged = { ...readUserConfig(), ...patch };
4303
4333
  writeFileSync(FILE, JSON.stringify(merged, null, 2));
4304
4334
  }
@@ -4436,15 +4466,122 @@ async function promptConfirm(message, defaultYes = true) {
4436
4466
  }
4437
4467
 
4438
4468
  // src/commands/doctor.ts
4469
+ import { existsSync as existsSync6 } from "node:fs";
4470
+
4471
+ // src/lib/ffmpeg.ts
4472
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
4439
4473
  import { existsSync as existsSync5 } from "node:fs";
4474
+ 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
+ }
4485
+ }
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;
4508
+ }
4509
+ function requireFfmpeg(ffmpegPath) {
4510
+ const r = resolveFfmpeg(ffmpegPath);
4511
+ if (!r)
4512
+ throw new Error(FFMPEG_INSTALL_HINT);
4513
+ return r;
4514
+ }
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)}`);
4519
+ }
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);
4533
+ }
4534
+ });
4535
+ p.on("error", (e) => reject(e));
4536
+ p.on("close", (code) => {
4537
+ if (code === 0)
4538
+ resolve2();
4539
+ 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
4554
+ });
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]);
4566
+ }
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
+ };
4576
+ }
4440
4577
 
4441
4578
  // src/lib/version.ts
4442
4579
  import { readFileSync as readFileSync2 } from "node:fs";
4443
- import { join as join5 } from "node:path";
4580
+ import { join as join6 } from "node:path";
4444
4581
  var REGISTRY = "https://registry.npmjs.org/@gitruck%2Fcli";
4445
4582
  function currentVersion() {
4446
4583
  try {
4447
- const { version } = JSON.parse(readFileSync2(join5(packageRoot(), "package.json"), "utf8"));
4584
+ const { version } = JSON.parse(readFileSync2(join6(packageRoot(), "package.json"), "utf8"));
4448
4585
  return version;
4449
4586
  } catch {
4450
4587
  return "0.0.0";
@@ -4526,7 +4663,7 @@ async function runDoctor() {
4526
4663
  }
4527
4664
  rows.push({ name: "云端连通 + 鉴权", status: apiStatus, detail: apiDetail });
4528
4665
  const draftDir = resolveJianyingDraftDir(undefined);
4529
- const draftOk = !!draftDir && existsSync5(draftDir);
4666
+ const draftOk = !!draftDir && existsSync6(draftDir);
4530
4667
  rows.push({
4531
4668
  name: "剪映草稿目录",
4532
4669
  status: draftOk ? "ok" : "warn",
@@ -4534,9 +4671,30 @@ async function runDoctor() {
4534
4671
  });
4535
4672
  rows.push({
4536
4673
  name: "配置文件",
4537
- status: existsSync5(configPath()) ? "ok" : "warn",
4538
- detail: existsSync5(configPath()) ? configPath() : `未生成 —— 跑 gtrk init(${configPath()})`
4674
+ status: existsSync6(configPath()) ? "ok" : "warn",
4675
+ detail: existsSync6(configPath()) ? configPath() : `未生成 —— 跑 gtrk init(${configPath()})`
4539
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
+ }
4540
4698
  const cur = currentVersion();
4541
4699
  const latest = await latestP;
4542
4700
  rows.splice(1, 0, {
@@ -4561,7 +4719,7 @@ gtrk 体检:
4561
4719
  }
4562
4720
 
4563
4721
  // src/commands/init.ts
4564
- var GUIDE_IMAGE = join6(packageRoot(), "assets", "jianying-draft-path.png");
4722
+ var GUIDE_IMAGE = join7(packageRoot(), "assets", "jianying-draft-path.png");
4565
4723
  function registerInit(program2) {
4566
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);
4567
4725
  }
@@ -4600,7 +4758,7 @@ async function runInit(opts) {
4600
4758
  defaultValue: existing.apiBase ?? DEFAULT_API_BASE
4601
4759
  })).trim();
4602
4760
  let jianyingDraftDir;
4603
- if (existing.jianyingDraftDir && existsSync6(existing.jianyingDraftDir)) {
4761
+ if (existing.jianyingDraftDir && existsSync7(existing.jianyingDraftDir)) {
4604
4762
  if (await promptConfirm(`剪映草稿目录现为 ${existing.jianyingDraftDir},保留吗?`, true)) {
4605
4763
  jianyingDraftDir = existing.jianyingDraftDir;
4606
4764
  }
@@ -4616,7 +4774,7 @@ async function runInit(opts) {
4616
4774
  openFile(GUIDE_IMAGE);
4617
4775
  const manual = (await promptText("剪映草稿根目录(…\\com.lveditor.draft),留空跳过:")).trim();
4618
4776
  if (manual) {
4619
- if (existsSync6(manual))
4777
+ if (existsSync7(manual))
4620
4778
  jianyingDraftDir = resolve2(manual);
4621
4779
  else
4622
4780
  log.warn(`目录不存在,已跳过:${manual}`);
@@ -4682,9 +4840,9 @@ function registerInstall(program2) {
4682
4840
  }
4683
4841
 
4684
4842
  // src/commands/oralcut.ts
4685
- import { resolve as resolve3, join as join8, dirname as dirname2, basename as basename3, extname } from "node:path";
4686
- import { mkdir as mkdir2, cp, readFile as readFile2 } from "node:fs/promises";
4687
- import { existsSync as existsSync8 } from "node:fs";
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";
4688
4846
 
4689
4847
  // src/lib/config.ts
4690
4848
  function loadConfig() {
@@ -4763,6 +4921,21 @@ async function submitTask(cfg, taskType, payload) {
4763
4921
  return String(r.data.task_id);
4764
4922
  throw new Error(`提交失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
4765
4923
  }
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
+ };
4938
+ }
4766
4939
  async function pollTask(cfg, taskType, taskId, onTick) {
4767
4940
  const start = Date.now();
4768
4941
  const TIMEOUT_MS = 30 * 60 * 1000;
@@ -4771,29 +4944,22 @@ async function pollTask(cfg, taskType, taskId, onTick) {
4771
4944
  if (Date.now() - start > TIMEOUT_MS) {
4772
4945
  throw new Error("任务超时(超过 30 分钟)。可稍后在云端查任务或重试。");
4773
4946
  }
4774
- await new Promise((r2) => setTimeout(r2, INTERVAL_MS));
4775
- let r;
4947
+ await new Promise((r) => setTimeout(r, INTERVAL_MS));
4948
+ let got;
4776
4949
  try {
4777
- const res = await fetch(`${cfg.base}/task/${taskType}/${taskId}`, {
4778
- headers: { Authorization: cfg.apiKey }
4779
- });
4780
- r = await parseJson(res);
4781
- } catch {
4950
+ got = await getTaskResult(cfg, taskType, taskId);
4951
+ } catch (e) {
4952
+ if (e instanceof CloudError)
4953
+ throw e;
4782
4954
  continue;
4783
4955
  }
4784
- if (r.code != null && r.code !== 200) {
4785
- throw new Error(`任务错误 (code=${r.code}):${r.msg ?? ""}`);
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" ? "任务失败" : "任务已取消"));
4786
4961
  }
4787
- const data = r.data ?? {};
4788
- const status = String(data.status ?? "");
4789
- if (status === "completed") {
4790
- return data.output_result ?? {};
4791
- }
4792
- if (status === "failed" || status === "cancelled") {
4793
- const out = data.output_result;
4794
- throw new Error(out?.error ?? (status === "failed" ? "任务失败" : "任务已取消"));
4795
- }
4796
- onTick?.(status || "处理中", typeof data.progress === "number" ? data.progress : undefined);
4962
+ onTick?.(got.status || "处理中", got.progress);
4797
4963
  }
4798
4964
  }
4799
4965
  async function download(url, dest) {
@@ -4805,10 +4971,9 @@ async function download(url, dest) {
4805
4971
  }
4806
4972
 
4807
4973
  // src/lib/upload-cache.ts
4808
- import { homedir as homedir3 } from "node:os";
4809
- import { join as join7 } from "node:path";
4974
+ import { join as join8 } from "node:path";
4810
4975
  import { stat as stat3, mkdir, readFile, writeFile as writeFile2 } from "node:fs/promises";
4811
- import { existsSync as existsSync7 } from "node:fs";
4976
+ import { existsSync as existsSync8 } from "node:fs";
4812
4977
 
4813
4978
  // src/lib/chunk-upload.ts
4814
4979
  var import_hash_wasm = __toESM(require_index_umd(), 1);
@@ -5039,15 +5204,15 @@ async function putPart(cfg, uploadId, idx, view) {
5039
5204
  }
5040
5205
 
5041
5206
  // src/lib/upload-cache.ts
5042
- var CACHE_DIR = join7(homedir3(), ".gtrk-cli");
5043
- var CACHE_FILE = join7(CACHE_DIR, "upload-cache.json");
5044
- var SESSION_FILE = join7(CACHE_DIR, "upload-sessions.json");
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");
5045
5210
  async function fingerprint(path) {
5046
5211
  const s = await stat3(path);
5047
5212
  return `${s.size}:${Math.round(s.mtimeMs)}`;
5048
5213
  }
5049
5214
  async function load() {
5050
- if (!existsSync7(CACHE_FILE))
5215
+ if (!existsSync8(CACHE_FILE))
5051
5216
  return {};
5052
5217
  try {
5053
5218
  return JSON.parse(await readFile(CACHE_FILE, "utf8"));
@@ -5068,7 +5233,7 @@ async function invalidateUpload(path) {
5068
5233
  }
5069
5234
  }
5070
5235
  async function loadSessions() {
5071
- if (!existsSync7(SESSION_FILE))
5236
+ if (!existsSync8(SESSION_FILE))
5072
5237
  return {};
5073
5238
  try {
5074
5239
  return JSON.parse(await readFile(SESSION_FILE, "utf8"));
@@ -5121,13 +5286,303 @@ async function uploadCached(cfg, path, opts) {
5121
5286
  return { fileId, cached: false };
5122
5287
  }
5123
5288
 
5124
- // src/commands/oralcut.ts
5125
- var TASK_TYPE = "video_oral_cut";
5126
- function timestamp() {
5127
- const d = new Date;
5128
- const p = (n) => String(n).padStart(2, "0");
5129
- return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
5289
+ // src/lib/media.ts
5290
+ 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";
5293
+ function parseFps(rate) {
5294
+ if (typeof rate !== "string")
5295
+ return 0;
5296
+ const [n, d] = rate.split("/").map(Number);
5297
+ if (!n || !d)
5298
+ return Number(rate) || 0;
5299
+ return n / d;
5300
+ }
5301
+ function probeGeometry(inputAbs, ffmpegPath) {
5302
+ const { ffprobe } = requireFfmpeg(ffmpegPath);
5303
+ const info = ffprobeJson(ffprobe, [
5304
+ "-v",
5305
+ "error",
5306
+ "-select_streams",
5307
+ "v:0",
5308
+ "-show_entries",
5309
+ "stream=width,height,r_frame_rate",
5310
+ "-show_entries",
5311
+ "format=duration",
5312
+ "-of",
5313
+ "json",
5314
+ inputAbs
5315
+ ]);
5316
+ const s = info.streams && info.streams[0] || {};
5317
+ const duration = Number(info.format?.duration) || 0;
5318
+ return {
5319
+ width: Number(s.width) || 0,
5320
+ height: Number(s.height) || 0,
5321
+ fps: parseFps(s.r_frame_rate),
5322
+ duration
5323
+ };
5324
+ }
5325
+ function probeDuration(path, ffmpegPath) {
5326
+ const { ffprobe } = requireFfmpeg(ffmpegPath);
5327
+ const info = ffprobeJson(ffprobe, [
5328
+ "-v",
5329
+ "error",
5330
+ "-show_entries",
5331
+ "format=duration",
5332
+ "-of",
5333
+ "json",
5334
+ path
5335
+ ]);
5336
+ return Number(info.format?.duration) || 0;
5337
+ }
5338
+ async function artifactPath(inputAbs, ext) {
5339
+ const s = await stat4(inputAbs);
5340
+ const base = basename3(inputAbs, extname(inputAbs));
5341
+ await mkdir2(audioCacheDir(), { recursive: true });
5342
+ return join9(audioCacheDir(), `${base}.${s.size}_${Math.round(s.mtimeMs)}.${ext}`);
5130
5343
  }
5344
+ async function extractAudio(inputAbs, ffmpegPath) {
5345
+ const { ffmpeg } = requireFfmpeg(ffmpegPath);
5346
+ const out = await artifactPath(inputAbs, "mp3");
5347
+ if (existsSync9(out))
5348
+ return out;
5349
+ await runFfmpeg(ffmpeg, [
5350
+ "-y",
5351
+ "-v",
5352
+ "error",
5353
+ "-i",
5354
+ inputAbs,
5355
+ "-vn",
5356
+ "-ac",
5357
+ "1",
5358
+ "-ar",
5359
+ "16000",
5360
+ "-c:a",
5361
+ "libmp3lame",
5362
+ "-b:a",
5363
+ "64k",
5364
+ out
5365
+ ]);
5366
+ return out;
5367
+ }
5368
+ async function compress720p(inputAbs, ffmpegPath) {
5369
+ const { ffmpeg } = requireFfmpeg(ffmpegPath);
5370
+ const out = await artifactPath(inputAbs, "720p.mp4");
5371
+ if (existsSync9(out))
5372
+ return out;
5373
+ await runFfmpeg(ffmpeg, [
5374
+ "-y",
5375
+ "-v",
5376
+ "error",
5377
+ "-i",
5378
+ inputAbs,
5379
+ "-vf",
5380
+ "scale=-2:720",
5381
+ "-c:v",
5382
+ "libx264",
5383
+ "-preset",
5384
+ "veryfast",
5385
+ "-crf",
5386
+ "28",
5387
+ "-c:a",
5388
+ "aac",
5389
+ "-movflags",
5390
+ "+faststart",
5391
+ out
5392
+ ]);
5393
+ return out;
5394
+ }
5395
+ function assertDurationConsistent(originalDuration, artifactAbs, ffmpegPath, tolSec = 1) {
5396
+ const got = probeDuration(artifactAbs, ffmpegPath);
5397
+ if (originalDuration > 0 && Math.abs(got - originalDuration) > tolSec) {
5398
+ throw new Error(`抽出物时长(${got.toFixed(2)}s)与原片(${originalDuration.toFixed(2)}s)不一致(容差 ${tolSec}s),` + `疑似抽取异常,已中止上传`);
5399
+ }
5400
+ }
5401
+
5402
+ // src/lib/materialize.ts
5403
+ import { join as join11, basename as basename4 } from "node:path";
5404
+ import { mkdir as mkdir3, cp, writeFile as writeFile4 } from "node:fs/promises";
5405
+
5406
+ // src/lib/render.ts
5407
+ import { writeFile as writeFile3, unlink, readFile as readFile2 } from "node:fs/promises";
5408
+ import { existsSync as existsSync10 } from "node:fs";
5409
+ import { tmpdir } from "node:os";
5410
+ import { join as join10 } from "node:path";
5411
+ var AUDIO_SAMPLE_RATE = 48000;
5412
+ var AUDIO_LAYOUT = "stereo";
5413
+ var DEFAULT_CRF = 18;
5414
+ var DEFAULT_AUDIO_CROSSFADE_MS = 8;
5415
+ var MAX_CLIPS = 500;
5416
+ var g = (n) => String(Number(n.toPrecision(6)));
5417
+ var f6 = (n) => n.toFixed(6);
5418
+ var f3 = (n) => n.toFixed(3);
5419
+ var isGap = (clip) => clip.material === null || clip.material === undefined;
5420
+ function sortedTracks(tracks) {
5421
+ return tracks.map((t, i) => ({ key: t.track_index != null ? t.track_index : i, t })).sort((a, b) => a.key - b.key).map((x) => x.t);
5422
+ }
5423
+ function normalizeTrack(trackTimeline) {
5424
+ const items = [...trackTimeline].sort((a, b) => Number(a.track_st) - Number(b.track_st));
5425
+ const elements = [];
5426
+ let cursor = 0;
5427
+ for (const clip of items) {
5428
+ const trackSt = Number(clip.track_st);
5429
+ const duration = Number(clip.duration);
5430
+ if (duration <= 0)
5431
+ throw new Error(`clip duration 非法: ${JSON.stringify(clip)}`);
5432
+ if (trackSt < cursor - 0.000001) {
5433
+ throw new Error(`track_timeline 时间重叠: track_st=${trackSt} < cursor=${cursor.toFixed(6)}`);
5434
+ }
5435
+ if (trackSt > cursor + 0.000001)
5436
+ elements.push({ kind: "gap", duration: trackSt - cursor });
5437
+ if (!isGap(clip)) {
5438
+ elements.push({
5439
+ kind: "clip",
5440
+ material: clip.material,
5441
+ clip_st: Number(clip.clip_st),
5442
+ duration
5443
+ });
5444
+ } else {
5445
+ elements.push({ kind: "gap", duration });
5446
+ }
5447
+ cursor = trackSt + duration;
5448
+ }
5449
+ return [elements, cursor];
5450
+ }
5451
+ function buildFilterGraph(gtrk, materialPaths, params = {}) {
5452
+ const fadeMs = Math.trunc(params.audio_crossfade_ms ?? DEFAULT_AUDIO_CROSSFADE_MS);
5453
+ const fade = Math.max(fadeMs, 0) / 1000;
5454
+ const sortedV = sortedTracks(gtrk.video_track || []);
5455
+ if (sortedV.length === 0)
5456
+ throw new Error("gtrk v1 缺少 video_track");
5457
+ const mainVideoTrack = sortedV[0];
5458
+ const audioTracks = sortedTracks(gtrk.audio_track || []);
5459
+ const totalClips = [mainVideoTrack, ...audioTracks].reduce((n, t) => n + (t.track_timeline?.length || 0), 0);
5460
+ if (totalClips > MAX_CLIPS)
5461
+ throw new Error(`clip 总数 ${totalClips} 超过上限 ${MAX_CLIPS}`);
5462
+ const width = Math.trunc(gtrk.video_size[0]);
5463
+ const height = Math.trunc(gtrk.video_size[1]);
5464
+ const rate = Number(gtrk.video_rate);
5465
+ const inputs = [];
5466
+ const inputIdx = {};
5467
+ const inputOf = (materialId) => {
5468
+ const path = materialPaths[String(materialId)];
5469
+ if (path === undefined)
5470
+ throw new Error(`gtrk 引用素材 ${materialId} 缺本地路径`);
5471
+ if (!(path in inputIdx)) {
5472
+ inputIdx[path] = inputs.length;
5473
+ inputs.push(path);
5474
+ }
5475
+ return inputIdx[path];
5476
+ };
5477
+ const chains = [];
5478
+ let labelN = 0;
5479
+ const label = () => `s${++labelN}`;
5480
+ const [vElements, vEnd] = normalizeTrack(mainVideoTrack.track_timeline);
5481
+ const normTracks = [];
5482
+ const aLens = [];
5483
+ for (const t of audioTracks) {
5484
+ const [els, end] = normalizeTrack(t.track_timeline);
5485
+ normTracks.push(els);
5486
+ aLens.push(end);
5487
+ }
5488
+ const total = aLens.length ? Math.max(vEnd, ...aLens) : vEnd;
5489
+ if (total <= 0)
5490
+ throw new Error("时间线总时长为 0");
5491
+ if (total > vEnd + 0.000001)
5492
+ vElements.push({ kind: "gap", duration: total - vEnd });
5493
+ const vLabels = [];
5494
+ for (const el of vElements) {
5495
+ const lab = label();
5496
+ if (el.kind === "clip") {
5497
+ const idx = inputOf(el.material);
5498
+ const st = el.clip_st;
5499
+ const ed = el.clip_st + el.duration;
5500
+ chains.push(`[${idx}:v]trim=start=${f6(st)}:end=${f6(ed)},setpts=PTS-STARTPTS,` + `fps=${g(rate)},scale=${width}:${height}:force_original_aspect_ratio=decrease,` + `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1,format=yuv420p[${lab}]`);
5501
+ } else {
5502
+ chains.push(`color=black:s=${width}x${height}:r=${g(rate)}:d=${f6(el.duration)},format=yuv420p[${lab}]`);
5503
+ }
5504
+ vLabels.push(lab);
5505
+ }
5506
+ chains.push(vLabels.map((x) => `[${x}]`).join("") + `concat=n=${vLabels.length}:v=1:a=0[vout]`);
5507
+ const trackLabels = [];
5508
+ for (let ti = 0;ti < normTracks.length; ti++) {
5509
+ const els = normTracks[ti];
5510
+ const end = aLens[ti];
5511
+ if (total > end + 0.000001)
5512
+ els.push({ kind: "gap", duration: total - end });
5513
+ const segLabels = [];
5514
+ for (const el of els) {
5515
+ const lab2 = label();
5516
+ if (el.kind === "clip") {
5517
+ const idx = inputOf(el.material);
5518
+ const st = el.clip_st;
5519
+ const ed = el.clip_st + el.duration;
5520
+ const steps = [
5521
+ `[${idx}:a]atrim=start=${f6(st)}:end=${f6(ed)}`,
5522
+ "asetpts=PTS-STARTPTS",
5523
+ `aresample=${AUDIO_SAMPLE_RATE}`,
5524
+ `aformat=sample_fmts=fltp:channel_layouts=${AUDIO_LAYOUT}`
5525
+ ];
5526
+ if (fade > 0) {
5527
+ steps.push(`afade=t=in:d=${f3(fade)}`);
5528
+ steps.push(`afade=t=out:st=${f6(Math.max(el.duration - fade, 0))}:d=${f3(fade)}`);
5529
+ }
5530
+ chains.push(steps.join(",") + `[${lab2}]`);
5531
+ } else {
5532
+ chains.push(`anullsrc=r=${AUDIO_SAMPLE_RATE}:cl=${AUDIO_LAYOUT},atrim=end=${f6(el.duration)}[${lab2}]`);
5533
+ }
5534
+ segLabels.push(lab2);
5535
+ }
5536
+ const lab = label();
5537
+ chains.push(segLabels.map((x) => `[${x}]`).join("") + `concat=n=${segLabels.length}:v=0:a=1[${lab}]`);
5538
+ trackLabels.push(lab);
5539
+ }
5540
+ if (trackLabels.length === 0) {
5541
+ chains.push(`anullsrc=r=${AUDIO_SAMPLE_RATE}:cl=${AUDIO_LAYOUT},atrim=end=${f6(total)}[aout]`);
5542
+ } else if (trackLabels.length === 1) {
5543
+ chains.push(`[${trackLabels[0]}]anull[aout]`);
5544
+ } else {
5545
+ chains.push(trackLabels.map((x) => `[${x}]`).join("") + `amix=inputs=${trackLabels.length}:duration=longest:normalize=0[aout]`);
5546
+ }
5547
+ return { inputs, graph: chains.join(";"), total };
5548
+ }
5549
+ function materialPathsFromGtrk(gtrk) {
5550
+ const map = {};
5551
+ for (const m of gtrk.materials || []) {
5552
+ if (!m.path)
5553
+ throw new Error(`gtrk 素材 ${m.id} 缺 path(source_path),无法本地渲染`);
5554
+ if (!existsSync10(m.path))
5555
+ throw new Error(`gtrk 素材文件不存在:${m.path}`);
5556
+ map[String(m.id)] = m.path;
5557
+ }
5558
+ return map;
5559
+ }
5560
+ async function renderGtrk(gtrk, outputPath, opts = {}) {
5561
+ const codec = opts.codec ?? "h264";
5562
+ if (codec !== "h264")
5563
+ throw new Error(`v1 仅支持 h264,实际 ${codec}`);
5564
+ const crf = opts.crf ?? DEFAULT_CRF;
5565
+ const { ffmpeg } = requireFfmpeg(opts.ffmpegPath);
5566
+ const materialPaths = materialPathsFromGtrk(gtrk);
5567
+ const { inputs, graph, total } = buildFilterGraph(gtrk, materialPaths, { crf });
5568
+ const filterFile = join10(tmpdir(), `gtrk-filter-${process.pid}-${inputs.length}.txt`);
5569
+ await writeFile3(filterFile, graph, "utf8");
5570
+ try {
5571
+ const args = ["-y"];
5572
+ for (const p of inputs)
5573
+ args.push("-i", p);
5574
+ args.push("-filter_complex_script", filterFile, "-map", "[vout]", "-map", "[aout]", "-c:v", "libx264", "-preset", "medium", "-crf", String(crf), "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", outputPath);
5575
+ await runFfmpeg(ffmpeg, args, opts.onLine);
5576
+ return { outputPath, duration: total };
5577
+ } finally {
5578
+ await unlink(filterFile).catch(() => {});
5579
+ }
5580
+ }
5581
+ async function readGtrkFile(gtrkPath) {
5582
+ return JSON.parse(await readFile2(gtrkPath, "utf8"));
5583
+ }
5584
+
5585
+ // src/lib/materialize.ts
5131
5586
  function baseFormat(fmt) {
5132
5587
  if (fmt.startsWith("jianying"))
5133
5588
  return "jianying";
@@ -5143,6 +5598,128 @@ var FORMAT_META = {
5143
5598
  fcpxml: { label: "Final Cut (fcpxml)", openHint: (p) => `Final Cut Pro:导入 ${p}` },
5144
5599
  otio: { label: "OpenTimelineIO", openHint: (p) => `用支持 OTIO 的工具打开 ${p}` }
5145
5600
  };
5601
+ var isExpired404 = (msg) => /HTTP 404/.test(msg);
5602
+ function gtrkSourceName(gtrk) {
5603
+ const p = gtrk.materials?.[0]?.path;
5604
+ if (!p)
5605
+ return;
5606
+ const b = basename4(p);
5607
+ const dot = b.lastIndexOf(".");
5608
+ return dot > 0 ? b.slice(0, dot) : b;
5609
+ }
5610
+ async function materializeResult(opts) {
5611
+ const { outDir, output, taskId } = opts;
5612
+ const dl = opts.download ?? download;
5613
+ const files = output.files ?? [];
5614
+ if (!files.length)
5615
+ throw new Error("任务无工程文件产物(检查 project_formats / 任务是否产出)");
5616
+ const errors = { ...output.errors ?? {} };
5617
+ await mkdir3(outDir, { recursive: true });
5618
+ const resultPath = join11(outDir, "result.json");
5619
+ const writeResult = async (extra) => {
5620
+ const r = {
5621
+ ok: Object.keys(errors).length === 0,
5622
+ outDir,
5623
+ files: {},
5624
+ jianyingDraftPath: null,
5625
+ rendered: null,
5626
+ report: output.report ?? null,
5627
+ errors,
5628
+ taskId,
5629
+ fileId: opts.fileId ?? null,
5630
+ ...extra
5631
+ };
5632
+ await writeFile4(resultPath, JSON.stringify(r, null, 2));
5633
+ return r;
5634
+ };
5635
+ await writeResult({});
5636
+ log.step("拉回产物到本地…");
5637
+ const byFormat = {};
5638
+ for (const f of files) {
5639
+ const base = baseFormat(f.format);
5640
+ const fmtDir = join11(outDir, base);
5641
+ await mkdir3(fmtDir, { recursive: true });
5642
+ const dest = join11(fmtDir, f.filename);
5643
+ try {
5644
+ await dl(f.download_url, dest);
5645
+ (byFormat[base] ??= []).push(dest);
5646
+ log.info(`${FORMAT_META[base]?.label ?? f.format} ← ${f.filename}`);
5647
+ } catch (e) {
5648
+ const msg = e instanceof Error ? e.message : String(e);
5649
+ errors[`${f.format}:${f.filename}`] = msg;
5650
+ if (isExpired404(msg))
5651
+ log.warn(`产物已过期(${f.filename}):文件已被清理,报告仍可用`);
5652
+ else
5653
+ log.warn(`产物下载失败(${f.filename}):${msg}`);
5654
+ }
5655
+ }
5656
+ let jianyingDraftPath = null;
5657
+ if (byFormat.jianying && opts.draftDir) {
5658
+ try {
5659
+ jianyingDraftPath = join11(opts.draftDir, basename4(outDir));
5660
+ await mkdir3(jianyingDraftPath, { recursive: true });
5661
+ await cp(join11(outDir, "jianying"), jianyingDraftPath, { recursive: true });
5662
+ log.info(`剪映草稿已落到:${jianyingDraftPath}`);
5663
+ } catch (e) {
5664
+ jianyingDraftPath = null;
5665
+ errors["jianying:draft"] = e instanceof Error ? e.message : String(e);
5666
+ log.warn(`剪映草稿落盘失败:${errors["jianying:draft"]}`);
5667
+ }
5668
+ }
5669
+ let rendered = null;
5670
+ if (opts.render) {
5671
+ const gtrkPath = (byFormat.gtrk ?? [])[0];
5672
+ if (!gtrkPath) {
5673
+ log.warn("已请求 --render,但无可用 gtrk 工程(未产出或已过期),跳过渲染;报告仍已落盘");
5674
+ } else {
5675
+ log.step("本地渲染成片(ffmpeg)…");
5676
+ const project = await readGtrkFile(gtrkPath);
5677
+ const name = opts.projName ?? gtrkSourceName(project) ?? taskId;
5678
+ const outMp4 = join11(outDir, `${name}.mp4`);
5679
+ const r = await renderGtrk(project, outMp4, {
5680
+ crf: opts.crf != null ? Number(opts.crf) : undefined,
5681
+ codec: opts.codec,
5682
+ ffmpegPath: opts.ffmpegPath,
5683
+ onLine: (l) => {
5684
+ const m = l.match(/time=(\S+)/);
5685
+ if (m)
5686
+ log.tick(`渲染中 ${m[1]}`);
5687
+ }
5688
+ });
5689
+ log.tickEnd();
5690
+ rendered = r.outputPath;
5691
+ log.info(`成片:${rendered}(${r.duration.toFixed(1)}s)`);
5692
+ }
5693
+ }
5694
+ const result = await writeResult({ files: byFormat, jianyingDraftPath, rendered });
5695
+ if (!opts.json) {
5696
+ if (Object.keys(byFormat).length)
5697
+ log.step("三方打开(产物已就位,按需自取):");
5698
+ for (const base of Object.keys(byFormat)) {
5699
+ const meta = FORMAT_META[base];
5700
+ const target = base === "jianying" ? jianyingDraftPath ?? join11(outDir, "jianying") : byFormat[base][0];
5701
+ console.log(` • ${meta?.label ?? base}:${meta?.openHint(target) ?? target}`);
5702
+ }
5703
+ if (rendered)
5704
+ console.log(` • 成片 (mp4):${rendered}`);
5705
+ console.log(` • 结果清单:${resultPath}`);
5706
+ }
5707
+ if (opts.open) {
5708
+ openFolder(outDir);
5709
+ log.info("已打开产物目录文件夹");
5710
+ }
5711
+ if (opts.json)
5712
+ console.log(JSON.stringify(result));
5713
+ return result;
5714
+ }
5715
+
5716
+ // src/commands/oralcut.ts
5717
+ var TASK_TYPE = "cli/video_oral_cut_for_cli";
5718
+ function timestamp() {
5719
+ const d = new Date;
5720
+ const p = (n) => String(n).padStart(2, "0");
5721
+ return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
5722
+ }
5146
5723
  var collectParam = (v, acc) => {
5147
5724
  acc.push(v);
5148
5725
  return acc;
@@ -5179,7 +5756,7 @@ function parseExtraParams(pairs, jsonStr) {
5179
5756
  return out;
5180
5757
  }
5181
5758
  function registerOralCut(program2) {
5182
- program2.command("oralcut <input>").description("智能口播剪辑闭环:上传毛片 → 云端剪辑 → 拉回 gtrk/剪映/PR 工程文件 → 打开").option("-s, --script <file>", "文稿 txt 路径(缺省走无稿智能重建)").option("-p, --preset <preset>", "节奏预设 steady|concise|compact", "concise").option("-o, --out <dir>", "工程产物目录(缺省 = <毛片同目录>/<毛片名>-video-project-<YYMMDD-HHMMSS>)").option("-f, --formats <list>", "三方格式(逗号分隔)", "gtrk,jianying,xml").option("--jianying-draft-dir <dir>", "剪映草稿根目录;传路径或 auto(默认读 gtrk init 配置 / 自动探测)").option("--lang <code>", "语言代码(默认 zh-CN;如 en-US / ja-JP)").option("--visual-assist", "视觉兜底:ASR 漏字时用人脸/说话检测保护并重识别(剪不准/怕剪掉真内容时开)").option("--no-adaptive-rhythm", "关闭自适应节奏(默认开;关了改用固定标点停顿表)").option("--render", "额外渲染成片视频(outputs video)").option("--crf <n>", "视频质量 CRF 14-28(越小越清晰/文件越大,默认 18;需配 --render)").option("--codec <c>", "视频编码(默认 h264;需配 --render)").option("--param <k=v>", "透传任意云端参数(标量、可重复;如 --param intra_gap_max=0.4 --param visual_assist=true)", collectParam, []).option("--params-json <json>", `透传任意云端参数(JSON 对象、支持嵌套;如 '{"punctuation_breaks":{"。":0.3},"render":{"crf":20}}')`).option("--reupload", "强制重新上传,忽略本地上传缓存(毛片改了但指纹意外没变时用)").option("--no-open", "完成后不自动打开产物目录(默认会自动打开,省得你找文件去哪了)").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON(给 agent/脚本解析)").action(async (input, opts) => {
5759
+ program2.command("oralcut <input>").description("智能口播剪辑闭环:本地抽音频/720p只传抽出物 → 云端剪辑 → 拉回 gtrk/剪映/PR →(可选)本地渲染").option("-s, --script <file>", "文稿 txt 路径(缺省走无稿智能重建)").option("-p, --preset <preset>", "节奏预设 steady|concise|compact", "concise").option("-o, --out <dir>", "工程产物目录(缺省 = <毛片同目录>/<毛片名>-video-project-<YYMMDD-HHMMSS>)").option("-f, --formats <list>", "三方格式(逗号分隔)", "gtrk,jianying,xml").option("--jianying-draft-dir <dir>", "剪映草稿根目录;传路径或 auto(默认读 gtrk init 配置 / 自动探测)").option("--lang <code>", "语言代码(默认 zh-CN;如 en-US / ja-JP)").option("--visual-assist", "视觉兜底:本地改传 720p 代理,云端用人脸/说话检测保护并重识别(剪不准/怕剪掉真内容时开)").option("--no-adaptive-rhythm", "关闭自适应节奏(默认开;关了改用固定标点停顿表)").option("--render", "额外本地渲染成片(ffmpeg gtrk EDL 出 mp4;毛片仍不出本地)").option("--crf <n>", "本地渲染视频质量 CRF 14-28(越小越清晰/文件越大,默认 18;需配 --render)").option("--codec <c>", "本地渲染视频编码(默认 h264;需配 --render)").option("--ffmpeg-path <dir>", "指定 ffmpeg/ffprobe 所在目录(缺省 ~/.gitruck/ffmpeg → 系统 PATH)").option("--param <k=v>", "透传任意云端参数(标量、可重复;如 --param intra_gap_max=0.4)", collectParam, []).option("--params-json <json>", `透传任意云端参数(JSON 对象、支持嵌套;如 '{"punctuation_breaks":{"。":0.3}}')`).option("--reupload", "强制重新上传,忽略本地上传缓存").option("--no-open", "完成后不自动打开产物目录(默认会自动打开)").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON(给 agent/脚本解析)").action(async (input, opts) => {
5183
5760
  await runOralCut(input, opts);
5184
5761
  });
5185
5762
  }
@@ -5188,42 +5765,51 @@ async function runOralCut(input, opts) {
5188
5765
  routeLogsToStderr();
5189
5766
  const cfg = loadConfig();
5190
5767
  const inputAbs = resolve3(input);
5191
- if (!existsSync8(inputAbs))
5768
+ if (!existsSync11(inputAbs))
5192
5769
  throw new Error(`毛片不存在:${inputAbs}`);
5193
- const projName = basename3(inputAbs, extname(inputAbs));
5770
+ const projName = basename5(inputAbs, extname2(inputAbs));
5194
5771
  const formats = opts.formats.split(",").map((s) => s.trim()).filter(Boolean);
5772
+ if (opts.render && !formats.includes("gtrk"))
5773
+ formats.push("gtrk");
5195
5774
  const wantJianying = formats.some((f) => f === "jianying" || f === "capcut");
5196
- const outDir = resolve3(opts.out ?? join8(dirname2(inputAbs), `${projName}-video-project-${timestamp()}`));
5197
- await mkdir2(outDir, { recursive: true });
5775
+ const outDir = resolve3(opts.out ?? join12(dirname2(inputAbs), `${projName}-video-project-${timestamp()}`));
5198
5776
  let scriptPath = opts.script ? resolve3(opts.script) : undefined;
5199
5777
  if (!scriptPath) {
5200
- const sibling = join8(dirname2(inputAbs), `${projName}.txt`);
5201
- if (existsSync8(sibling)) {
5778
+ const sibling = join12(dirname2(inputAbs), `${projName}.txt`);
5779
+ if (existsSync11(sibling)) {
5202
5780
  scriptPath = sibling;
5203
5781
  log.info(`自动识别到同名文稿:${sibling}(按有稿剪辑;不想用就改名或显式 --script)`);
5204
5782
  }
5205
5783
  }
5206
- const script = scriptPath ? await readFile2(scriptPath, "utf8") : undefined;
5784
+ const script = scriptPath ? await readFile3(scriptPath, "utf8") : undefined;
5207
5785
  let draftDir;
5208
5786
  if (wantJianying) {
5209
5787
  draftDir = resolveJianyingDraftDir(opts.jianyingDraftDir);
5210
5788
  if (draftDir)
5211
5789
  log.info(`剪映草稿目录:${draftDir}`);
5212
5790
  else
5213
- log.warn("没找到剪映草稿目录(剪映/CapCut 未装在标准位置)→ 将只产 draft_content.json、缺 meta,剪映无法直接打开。可加 --jianying-draft-dir <你的草稿目录> 重跑。");
5791
+ log.warn("没找到剪映草稿目录 将只产 draft_content.json、缺 meta。可加 --jianying-draft-dir <你的草稿目录> 重跑。");
5214
5792
  }
5215
- log.step(`▶ 智能口播剪辑:${basename3(inputAbs)}(预设 ${opts.preset}${opts.visualAssist ? " · 视觉兜底" : ""},格式 ${formats.join("/")})`);
5793
+ log.step(`▶ 智能口播剪辑:${basename5(inputAbs)}(预设 ${opts.preset}${opts.visualAssist ? " · 视觉兜底(720p)" : ""},格式 ${formats.join("/")}${opts.render ? " · 本地渲染" : ""})`);
5216
5794
  const extraParams = parseExtraParams(opts.param, opts.paramsJson);
5217
- log.step("① 上传毛片到云端…");
5218
- let up = await uploadCached(cfg, inputAbs, { force: opts.reupload });
5795
+ log.step("① 本地预处理(探几何 + 抽音频/720p)…");
5796
+ const geo = probeGeometry(inputAbs, opts.ffmpegPath);
5797
+ log.info(`原片几何 ${geo.width}x${geo.height} @ ${geo.fps.toFixed(2)}fps · ${geo.duration.toFixed(1)}s`);
5798
+ const artifact = opts.visualAssist ? await compress720p(inputAbs, opts.ffmpegPath) : await extractAudio(inputAbs, opts.ffmpegPath);
5799
+ assertDurationConsistent(geo.duration, artifact, opts.ffmpegPath);
5800
+ log.info(opts.visualAssist ? `已压 720p 代理(上传物):${basename5(artifact)}` : `已抽 16k 单声道 mp3(上传物):${basename5(artifact)}`);
5801
+ log.step("② 上传抽出物到云端…");
5802
+ let up = await uploadCached(cfg, artifact, { force: opts.reupload });
5219
5803
  log.info(up.cached ? `命中上传缓存,复用 file_id = ${up.fileId}(免二次上传)` : `file_id = ${up.fileId}`);
5220
5804
  const buildPayload = (fid) => {
5221
5805
  const p = {
5222
5806
  file_id: fid,
5223
5807
  la: opts.lang ?? "zh-CN",
5224
- outputs: opts.render ? ["project", "video"] : ["project"],
5225
5808
  project_formats: formats,
5226
5809
  source_path: inputAbs,
5810
+ video_size: [geo.width, geo.height],
5811
+ video_rate: geo.fps,
5812
+ video_duration: geo.duration,
5227
5813
  rhythm_preset: opts.preset
5228
5814
  };
5229
5815
  if (script)
@@ -5234,15 +5820,6 @@ async function runOralCut(input, opts) {
5234
5820
  p.visual_assist = true;
5235
5821
  if (opts.adaptiveRhythm === false)
5236
5822
  p.adaptive_rhythm = false;
5237
- if (opts.render) {
5238
- const r = {};
5239
- if (opts.crf != null)
5240
- r.crf = Number(opts.crf);
5241
- if (opts.codec)
5242
- r.codec = opts.codec;
5243
- if (Object.keys(r).length)
5244
- p.render = r;
5245
- }
5246
5823
  for (const [k, v] of Object.entries(extraParams)) {
5247
5824
  const cur = p[k];
5248
5825
  const bothObj = !!cur && !!v && typeof cur === "object" && typeof v === "object" && !Array.isArray(cur) && !Array.isArray(v);
@@ -5250,82 +5827,101 @@ async function runOralCut(input, opts) {
5250
5827
  }
5251
5828
  return p;
5252
5829
  };
5253
- log.step(" 提交智能口播剪辑任务…");
5830
+ log.step(" 提交智能口播剪辑任务…");
5254
5831
  let taskId;
5255
5832
  try {
5256
5833
  taskId = await submitTask(cfg, TASK_TYPE, buildPayload(up.fileId));
5257
5834
  } catch (e) {
5258
5835
  if (up.cached && e instanceof CloudError && e.code === 6004) {
5259
5836
  log.warn("缓存的 file_id 在云端已失效,重新上传后重试…");
5260
- await invalidateUpload(inputAbs);
5261
- up = await uploadCached(cfg, inputAbs, { force: true });
5837
+ await invalidateUpload(artifact);
5838
+ up = await uploadCached(cfg, artifact, { force: true });
5262
5839
  taskId = await submitTask(cfg, TASK_TYPE, buildPayload(up.fileId));
5263
5840
  } else
5264
5841
  throw e;
5265
5842
  }
5266
5843
  log.info(`task_id = ${taskId}`);
5267
- log.step("③ 云端处理中(每 5s 轮询)…");
5844
+ 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));
5846
+ log.step("④ 云端处理中(每 5s 轮询)…");
5268
5847
  const result = await pollTask(cfg, TASK_TYPE, taskId, (status, progress) => {
5269
5848
  log.tick(`${status}${progress != null ? ` ${Math.round(progress)}%` : ""}`);
5270
5849
  });
5271
5850
  log.tickEnd();
5272
- const files = result.files ?? [];
5273
- if (!files.length)
5274
- throw new Error("任务完成但无工程文件产物(检查 project_formats)");
5275
- log.step(`④ 拉回 ${files.length} 个产物到本地…`);
5276
- const byFormat = {};
5277
- for (const f of files) {
5278
- const base = baseFormat(f.format);
5279
- const fmtDir = join8(outDir, base);
5280
- await mkdir2(fmtDir, { recursive: true });
5281
- const dest = join8(fmtDir, f.filename);
5282
- await download(f.download_url, dest);
5283
- (byFormat[base] ??= []).push(dest);
5284
- log.info(`${FORMAT_META[base]?.label ?? f.format} ← ${f.filename}`);
5285
- }
5286
- if (result.errors && Object.keys(result.errors).length) {
5287
- log.warn(`部分产物失败:${JSON.stringify(result.errors)}`);
5288
- }
5289
- let jianyingDraftPath;
5290
- if (byFormat.jianying && draftDir) {
5291
- jianyingDraftPath = join8(draftDir, basename3(outDir));
5292
- await mkdir2(jianyingDraftPath, { recursive: true });
5293
- await cp(join8(outDir, "jianying"), jianyingDraftPath, { recursive: true });
5294
- log.info(`剪映草稿已落到:${jianyingDraftPath}`);
5295
- }
5296
- if (!opts.json) {
5297
- log.step("⑤ 三方打开(产物已就位,按需自取):");
5298
- for (const base of Object.keys(byFormat)) {
5299
- const meta = FORMAT_META[base];
5300
- const target = base === "jianying" ? jianyingDraftPath ?? join8(outDir, "jianying") : byFormat[base][0];
5301
- console.log(` • ${meta?.label ?? base}:${meta?.openHint(target) ?? target}`);
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。`);
5302
5892
  }
5893
+ throw e;
5303
5894
  }
5304
- if (opts.open) {
5305
- openFolder(outDir);
5306
- log.info("已打开产物目录文件夹");
5307
- }
5308
- log.ok(`闭环完成。产物目录:${outDir}`);
5309
- if (opts.json) {
5310
- const errors = result.errors ?? {};
5311
- console.log(JSON.stringify({
5312
- ok: Object.keys(errors).length === 0,
5313
- outDir,
5314
- files: byFormat,
5315
- jianyingDraftPath: jianyingDraftPath ?? null,
5316
- report: result.report ?? null,
5317
- errors,
5318
- taskId,
5319
- fileId: up.fileId
5320
- }));
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}),暂无法取回结果;请稍后再试。`);
5321
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}`);
5322
5918
  }
5323
5919
 
5324
5920
  // src/commands/upgrade.ts
5325
- import { spawnSync as spawnSync2 } from "node:child_process";
5921
+ import { spawnSync as spawnSync3 } from "node:child_process";
5326
5922
  var CLIENT_UPGRADE = "irm https://api.ai-mcn.tv:9000/broadcast/exe/install.ps1 | iex";
5327
5923
  function run(cmd) {
5328
- const r = spawnSync2(cmd, { stdio: "inherit", shell: true });
5924
+ const r = spawnSync3(cmd, { stdio: "inherit", shell: true });
5329
5925
  return r.status ?? 1;
5330
5926
  }
5331
5927
  function registerUpgrade(program2) {
@@ -5363,19 +5959,677 @@ function registerUpgrade(program2) {
5363
5959
  });
5364
5960
  }
5365
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;
6111
+ });
6112
+ return {
6113
+ transcript_hash: transcript.text_hash,
6114
+ projected_at: opts.projectedAt ?? new Date().toISOString(),
6115
+ utterances
6116
+ };
6117
+ }
6118
+
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;
6153
+ }
6154
+ function enumOk(v, list) {
6155
+ return typeof v === "string" && list.includes(v);
6156
+ }
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}——转写已变更,请重新导出视图并重拆`);
6171
+ }
6172
+ if (!Array.isArray(d.beats) || d.beats.length === 0) {
6173
+ errors.push("beats 必须是非空数组");
6174
+ return { errors, warnings };
6175
+ }
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 必须是对象`);
6187
+ return;
6188
+ }
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
+ }
6232
+ }
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));
6239
+ }
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 之间不允许交集)`);
6247
+ }
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 必填(秒,数值)`);
6262
+ }
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 必须为非空字符串数组)`);
6269
+ }
6270
+ return;
6271
+ }
6272
+ }
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")
6285
+ 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;
6302
+ }
6303
+ }
6304
+ errors.push(`${tag}:mount 非法(应为 "same_beat" | {from,to} | {trigger})`);
6305
+ }
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 });
6349
+ }
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
6362
+ });
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
6372
+ });
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
+ }
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` : ""}`);
6424
+ }
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
+ `);
6436
+ }
6437
+
6438
+ // 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";
6441
+ import { randomBytes as randomBytes2 } from "node:crypto";
6442
+ function readGtrk(path) {
6443
+ const raw = readFileSync3(path, "utf8");
6444
+ let gtrk;
6445
+ try {
6446
+ gtrk = JSON.parse(raw);
6447
+ } catch (e) {
6448
+ throw new Error(`工程文件不是合法 JSON:${path}(${e instanceof Error ? e.message : String(e)})`);
6449
+ }
6450
+ if (typeof gtrk !== "object" || gtrk === null || Array.isArray(gtrk)) {
6451
+ throw new Error(`工程文件结构异常(顶层非对象):${path}`);
6452
+ }
6453
+ return { gtrk, mtimeMs: statSync(path).mtimeMs };
6454
+ }
6455
+ function assertGtrkV1(gtrk) {
6456
+ if (gtrk.version !== "v1") {
6457
+ throw new Error(`工程文件不是 v1(version=${JSON.stringify(gtrk.version)}):请用新链路重产 v1 工程后再拆分`);
6458
+ }
6459
+ }
6460
+ function writeStructMetaSplit(path, gtrk, splitObj, expectedMtimeMs) {
6461
+ const cur = statSync(path).mtimeMs;
6462
+ if (cur !== expectedMtimeMs) {
6463
+ throw new Error("工程文件在 split 运行期间被外部修改(保存冲突),已拒绝写入;请重新导出视图后重试(客户端侧需先保存、发起后等重载)");
6464
+ }
6465
+ const nextStructMeta = { ...gtrk.struct_meta ?? {}, split: splitObj };
6466
+ const next = { ...gtrk, struct_meta: nextStructMeta };
6467
+ const tmp = join15(dirname4(path), `.${basename7(path)}.${randomBytes2(6).toString("hex")}.tmp`);
6468
+ try {
6469
+ writeFileSync2(tmp, JSON.stringify(next, null, 2));
6470
+ renameSync(tmp, path);
6471
+ } catch (e) {
6472
+ try {
6473
+ unlinkSync(tmp);
6474
+ } catch {}
6475
+ throw e;
6476
+ }
6477
+ }
6478
+
6479
+ // src/commands/split.ts
6480
+ var TRANSCRIPT_MISSING = "工程目录内找不到 transcript.json(可能是旧任务产物):请用新版本重跑 gtrk oralcut(恒出 transcript)," + "或(规划中)用 transcribe 生成后再拆分;本命令不做降级猜测。";
6481
+ 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) => {
6483
+ await runSplit(splitdoc, opts);
6484
+ });
6485
+ }
6486
+ function firstExisting(cands) {
6487
+ return cands.find((p) => existsSync13(p));
6488
+ }
6489
+ function resolvePaths(opts) {
6490
+ const project = opts.project ? resolve6(opts.project) : undefined;
6491
+ let gtrkPath;
6492
+ if (opts.gtrk) {
6493
+ gtrkPath = resolve6(opts.gtrk);
6494
+ } else if (project) {
6495
+ gtrkPath = firstExisting([join16(project, "gtrk", "project.gtrk"), join16(project, "project.gtrk")]) ?? join16(project, "gtrk", "project.gtrk");
6496
+ } else {
6497
+ throw new Error("需 --project <目录> 或显式 --gtrk <path>");
6498
+ }
6499
+ if (!existsSync13(gtrkPath))
6500
+ throw new Error(`找不到工程文件:${gtrkPath}`);
6501
+ let transcriptPath;
6502
+ if (opts.transcript)
6503
+ transcriptPath = resolve6(opts.transcript);
6504
+ else if (project)
6505
+ transcriptPath = firstExisting([
6506
+ join16(project, "transcript", "transcript.json"),
6507
+ join16(project, "json", "transcript.json"),
6508
+ join16(project, "transcript.json")
6509
+ ]);
6510
+ const baseDir = project ?? dirname5(gtrkPath);
6511
+ return { baseDir, gtrkPath, transcriptPath };
6512
+ }
6513
+ function slugify(name) {
6514
+ const s = name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
6515
+ return s || "project";
6516
+ }
6517
+ async function loadTranscript(path) {
6518
+ const t = JSON.parse(await readFile4(path, "utf8"));
6519
+ if (!t || !Array.isArray(t.utterances) || typeof t.material_id !== "string" || typeof t.text_hash !== "string") {
6520
+ throw new Error(`transcript.json 结构异常(缺 utterances/material_id/text_hash):${path}`);
6521
+ }
6522
+ t.text_hash = createHash("sha256").update(t.utterances.map((u) => u.text ?? "").join(`
6523
+ `), "utf8").digest("hex");
6524
+ return t;
6525
+ }
6526
+ async function runSplit(splitdoc, opts) {
6527
+ if (opts.json)
6528
+ routeLogsToStderr();
6529
+ const { baseDir, gtrkPath, transcriptPath } = resolvePaths(opts);
6530
+ return splitdoc ? runLand(resolve6(splitdoc), baseDir, gtrkPath, transcriptPath, opts) : runView(baseDir, gtrkPath, transcriptPath, opts);
6531
+ }
6532
+ async function runView(baseDir, gtrkPath, transcriptPath, opts) {
6533
+ if (!transcriptPath || !existsSync13(transcriptPath))
6534
+ throw new Error(TRANSCRIPT_MISSING);
6535
+ log.step("▶ 导出投影视图(transcript × 当刻 .gtrk)…");
6536
+ const transcript = await loadTranscript(transcriptPath);
6537
+ const { gtrk } = readGtrk(gtrkPath);
6538
+ const view = projectTranscript(transcript, gtrk, { words: opts.words });
6539
+ const splitDir = join16(baseDir, "split");
6540
+ await mkdir5(splitDir, { recursive: true });
6541
+ const viewPath = join16(splitDir, "view.json");
6542
+ await writeFile6(viewPath, JSON.stringify(view, null, 2));
6543
+ const dropped = view.utterances.filter((u) => u.dropped).length;
6544
+ log.ok(`投影视图已生成:${viewPath}(${view.utterances.length} 条,其中 ${dropped} 条被剪)`);
6545
+ const result = {
6546
+ ok: true,
6547
+ mode: "view",
6548
+ viewPath,
6549
+ transcript_hash: view.transcript_hash,
6550
+ projected_at: view.projected_at,
6551
+ counts: { entries: view.utterances.length, dropped },
6552
+ view
6553
+ };
6554
+ if (opts.json)
6555
+ console.log(JSON.stringify(result));
6556
+ return result;
6557
+ }
6558
+ async function runLand(splitdocPath, baseDir, gtrkPath, transcriptPath, opts) {
6559
+ if (!existsSync13(splitdocPath))
6560
+ throw new Error(`找不到拆分稿:${splitdocPath}`);
6561
+ if (!transcriptPath || !existsSync13(transcriptPath))
6562
+ throw new Error(TRANSCRIPT_MISSING);
6563
+ log.step("▶ 校验拆分稿并落地…");
6564
+ const doc = JSON.parse(await readFile4(splitdocPath, "utf8"));
6565
+ const transcript = await loadTranscript(transcriptPath);
6566
+ const { gtrk, mtimeMs } = readGtrk(gtrkPath);
6567
+ assertGtrkV1(gtrk);
6568
+ const ctx = { utteranceIds: transcript.utterances.map((u) => u.id), transcriptHash: transcript.text_hash };
6569
+ const { errors, warnings } = validateSplitDoc(doc, ctx);
6570
+ for (const w of warnings)
6571
+ log.warn(w);
6572
+ if (errors.length) {
6573
+ throw new Error(`拆分稿校验失败(${errors.length} 条,未写入任何产物):
6574
+ ` + errors.map((e) => ` - ${e}`).join(`
6575
+ `));
6576
+ }
6577
+ const projectedAt = new Date().toISOString();
6578
+ const view = projectTranscript(transcript, gtrk, { projectedAt });
6579
+ const projectSlug = slugify(basename8(baseDir));
6580
+ const landing = buildLanding(doc, view, { utteranceIds: ctx.utteranceIds, projectSlug, projectedAt });
6581
+ writeStructMetaSplit(gtrkPath, gtrk, landing.split, mtimeMs);
6582
+ const splitDir = join16(baseDir, "split");
6583
+ await mkdir5(splitDir, { recursive: true });
6584
+ const dispatchPath = join16(splitDir, "dispatch.json");
6585
+ await writeFile6(dispatchPath, JSON.stringify(landing.dispatch, null, 2));
6586
+ let mdPath = null;
6587
+ if (opts.md) {
6588
+ mdPath = join16(splitDir, "visual-split.md");
6589
+ await writeFile6(mdPath, renderSplitMarkdown(doc, landing, { projectSlug, projectedAt }));
6590
+ }
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})`);
6592
+ for (const s of landing.skipped)
6593
+ log.warn(`跳过 ${s.beat}:${s.reason}`);
6594
+ for (const s of landing.shrunk)
6595
+ log.warn(`收缩 ${s.beat}:${s.dropped} 句被剪,按存活 ${s.kept} 句包络 → ${s.track_st}s…${s.track_ed}s(建议人工复核)`);
6596
+ const result = {
6597
+ ok: true,
6598
+ mode: "land",
6599
+ gtrk: gtrkPath,
6600
+ dispatchPath,
6601
+ mdPath,
6602
+ transcript_hash: doc.transcript_hash,
6603
+ projected_at: projectedAt,
6604
+ beats: { total: doc.beats.length, landed: landing.split.beats.length, skipped: landing.skipped, shrunk: landing.shrunk },
6605
+ queues: {
6606
+ rrv_mg: landing.dispatch.rrv_mg.length,
6607
+ film_broll: landing.dispatch.film_broll.length,
6608
+ ai_drama: landing.dispatch.ai_drama.length
6609
+ }
6610
+ };
6611
+ if (opts.json)
6612
+ console.log(JSON.stringify(result));
6613
+ return result;
6614
+ }
6615
+
5366
6616
  // src/index.ts
5367
6617
  try {
5368
6618
  process.loadEnvFile?.();
5369
6619
  } catch {}
5370
- var { version } = JSON.parse(readFileSync3(join9(packageRoot(), "package.json"), "utf8"));
6620
+ migrateLegacyHome();
6621
+ var { version } = JSON.parse(readFileSync4(join17(packageRoot(), "package.json"), "utf8"));
5371
6622
  var program2 = new Command;
5372
6623
  program2.name("gtrk").description("同合云成片流水线 CLI —— agent 驱动云端任务、产物拉回本地、三方工程文件(客户端/剪映/PR)互通").version(version);
5373
6624
  registerInstall(program2);
5374
6625
  registerInit(program2);
5375
6626
  registerOralCut(program2);
6627
+ registerOralCutResult(program2);
5376
6628
  registerDoctor(program2);
5377
6629
  registerSkills(program2);
5378
6630
  registerUpgrade(program2);
6631
+ registerRender(program2);
6632
+ registerSplit(program2);
5379
6633
  program2.parseAsync(process.argv).catch((e) => {
5380
6634
  console.error(`
5381
6635
  ❌ ${e instanceof Error ? e.message : String(e)}`);