@gitruck/cli 0.2.2 → 0.2.3

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
@@ -4197,12 +4197,13 @@ var {
4197
4197
 
4198
4198
  // src/index.ts
4199
4199
  import { readFileSync as readFileSync3 } from "node:fs";
4200
- import { join as join9 } from "node:path";
4200
+ import { join as join15 } 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, copyFileSync } from "node:fs";
4223
4249
 
4224
4250
  // src/lib/log.ts
4225
4251
  var c = {
@@ -4255,9 +4281,9 @@ function installSkill(opts = {}) {
4255
4281
  log.warn(`找不到打包的 skill 源:${SRC}(跳过 skill 安装,不影响命令行)`);
4256
4282
  return false;
4257
4283
  }
4258
- const dest = join2(opts.dir ?? join2(homedir(), ".claude", "skills"), SKILL_NAME);
4284
+ const dest = join2(opts.dir ?? join2(homedir2(), ".claude", "skills"), SKILL_NAME);
4259
4285
  try {
4260
- mkdirSync(dest, { recursive: true });
4286
+ mkdirSync2(dest, { recursive: true });
4261
4287
  copyFileSync(SRC, join2(dest, "SKILL.md"));
4262
4288
  log.ok(`已安装 /${SKILL_NAME} → ${join2(dest, "SKILL.md")}`);
4263
4289
  log.info("在 Claude Code 里打 /gtrk-oralcut,或直接说「帮我剪个口播」即可触发(可能需重载会话)。");
@@ -4275,15 +4301,14 @@ function registerSkills(program2) {
4275
4301
  }
4276
4302
 
4277
4303
  // src/commands/init.ts
4278
- import { join as join6, resolve as resolve2 } from "node:path";
4279
- import { existsSync as existsSync6 } from "node:fs";
4304
+ import { join as join7, resolve as resolve2 } from "node:path";
4305
+ import { existsSync as existsSync7 } from "node:fs";
4280
4306
 
4281
4307
  // src/lib/user-config.ts
4282
- import { homedir as homedir2 } from "node:os";
4283
4308
  import { join as join3 } from "node:path";
4284
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "node:fs";
4309
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, writeFileSync } from "node:fs";
4285
4310
  var DEFAULT_API_BASE = "https://api.ai-mcn.tv:10000";
4286
- var DIR = join3(homedir2(), ".gtrk-cli");
4311
+ var DIR = gitruckHome();
4287
4312
  var FILE = join3(DIR, "config.json");
4288
4313
  function configPath() {
4289
4314
  return FILE;
@@ -4298,7 +4323,7 @@ function readUserConfig() {
4298
4323
  }
4299
4324
  }
4300
4325
  function writeUserConfig(patch) {
4301
- mkdirSync2(DIR, { recursive: true });
4326
+ mkdirSync3(DIR, { recursive: true });
4302
4327
  const merged = { ...readUserConfig(), ...patch };
4303
4328
  writeFileSync(FILE, JSON.stringify(merged, null, 2));
4304
4329
  }
@@ -4436,15 +4461,122 @@ async function promptConfirm(message, defaultYes = true) {
4436
4461
  }
4437
4462
 
4438
4463
  // src/commands/doctor.ts
4464
+ import { existsSync as existsSync6 } from "node:fs";
4465
+
4466
+ // src/lib/ffmpeg.ts
4467
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
4439
4468
  import { existsSync as existsSync5 } from "node:fs";
4469
+ import { join as join5 } from "node:path";
4470
+ var isWin = process.platform === "win32";
4471
+ var bin = (base) => isWin ? `${base}.exe` : base;
4472
+ var FFMPEG_INSTALL_HINT = `未找到 ffmpeg/ffprobe。请把二者放到 ${ffmpegDir()}(agent 可代办:先查本地确实缺失才拉,` + `面向国内用户优先国内加速站点——GitHub 代理 pass-through 拉 BtbN/gyan.dev 官方静态构建,或同合云自建镜像,` + `并做 sha256 校验),或用 --ffmpeg-path <目录> 指定已装位置。`;
4473
+ var _cache = new Map;
4474
+ function onSystemPath(cmd) {
4475
+ try {
4476
+ return spawnSync2(cmd, ["-version"], { stdio: "ignore" }).status === 0;
4477
+ } catch {
4478
+ return false;
4479
+ }
4480
+ }
4481
+ function resolveFfmpeg(ffmpegPath) {
4482
+ const key = ffmpegPath ?? "";
4483
+ if (_cache.has(key))
4484
+ return _cache.get(key) ?? null;
4485
+ const dirs = [];
4486
+ if (ffmpegPath)
4487
+ dirs.push([ffmpegPath, ffmpegPath]);
4488
+ dirs.push([ffmpegDir(), "~/.gitruck/ffmpeg"]);
4489
+ let found = null;
4490
+ for (const [dir, label] of dirs) {
4491
+ const ff = join5(dir, bin("ffmpeg"));
4492
+ const fp = join5(dir, bin("ffprobe"));
4493
+ if (existsSync5(ff) && existsSync5(fp)) {
4494
+ found = { ffmpeg: ff, ffprobe: fp, source: label };
4495
+ break;
4496
+ }
4497
+ }
4498
+ if (!found && onSystemPath("ffmpeg") && onSystemPath("ffprobe")) {
4499
+ found = { ffmpeg: "ffmpeg", ffprobe: "ffprobe", source: "system" };
4500
+ }
4501
+ _cache.set(key, found);
4502
+ return found;
4503
+ }
4504
+ function requireFfmpeg(ffmpegPath) {
4505
+ const r = resolveFfmpeg(ffmpegPath);
4506
+ if (!r)
4507
+ throw new Error(FFMPEG_INSTALL_HINT);
4508
+ return r;
4509
+ }
4510
+ function ffprobeJson(ffprobePath, args) {
4511
+ const r = spawnSync2(ffprobePath, args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
4512
+ if (r.status !== 0) {
4513
+ throw new Error(`ffprobe 失败(code=${r.status}):${(r.stderr || "").slice(-300)}`);
4514
+ }
4515
+ return JSON.parse(r.stdout || "{}");
4516
+ }
4517
+ function runFfmpeg(ffmpegPath, args, onLine) {
4518
+ return new Promise((resolve2, reject) => {
4519
+ const p = spawn2(ffmpegPath, args, { env: process.env });
4520
+ let tail = "";
4521
+ p.stderr.on("data", (buf) => {
4522
+ const s = buf.toString("utf8");
4523
+ tail = (tail + s).slice(-4000);
4524
+ if (onLine) {
4525
+ for (const ln of s.split(/\r?\n/))
4526
+ if (ln)
4527
+ onLine(ln);
4528
+ }
4529
+ });
4530
+ p.on("error", (e) => reject(e));
4531
+ p.on("close", (code) => {
4532
+ if (code === 0)
4533
+ resolve2();
4534
+ else
4535
+ reject(new Error(`ffmpeg 退出码 ${code}:${tail.slice(-600)}`));
4536
+ });
4537
+ });
4538
+ }
4539
+ function probeCapabilities(res) {
4540
+ const ver = spawnSync2(res.ffmpeg, ["-version"], { encoding: "utf8" });
4541
+ const version = (ver.stdout || "").split(/\r?\n/)[0]?.trim() || "unknown";
4542
+ const enc = spawnSync2(res.ffmpeg, ["-hide_banner", "-encoders"], {
4543
+ encoding: "utf8",
4544
+ maxBuffer: 16 * 1024 * 1024
4545
+ });
4546
+ const fil = spawnSync2(res.ffmpeg, ["-hide_banner", "-filters"], {
4547
+ encoding: "utf8",
4548
+ maxBuffer: 16 * 1024 * 1024
4549
+ });
4550
+ const encoders = new Set;
4551
+ for (const ln of (enc.stdout || "").split(/\r?\n/)) {
4552
+ const m = ln.trim().match(/^\S+\s+(\S+)/);
4553
+ if (m)
4554
+ encoders.add(m[1]);
4555
+ }
4556
+ const filters = new Set;
4557
+ for (const ln of (fil.stdout || "").split(/\r?\n/)) {
4558
+ const m = ln.trim().match(/^\S+\s+(\S+)/);
4559
+ if (m)
4560
+ filters.add(m[1]);
4561
+ }
4562
+ return {
4563
+ version,
4564
+ encoders,
4565
+ filters,
4566
+ hasLibx264: encoders.has("libx264"),
4567
+ hasAac: encoders.has("aac"),
4568
+ hasAfade: filters.has("afade"),
4569
+ hasAresample: filters.has("aresample")
4570
+ };
4571
+ }
4440
4572
 
4441
4573
  // src/lib/version.ts
4442
4574
  import { readFileSync as readFileSync2 } from "node:fs";
4443
- import { join as join5 } from "node:path";
4575
+ import { join as join6 } from "node:path";
4444
4576
  var REGISTRY = "https://registry.npmjs.org/@gitruck%2Fcli";
4445
4577
  function currentVersion() {
4446
4578
  try {
4447
- const { version } = JSON.parse(readFileSync2(join5(packageRoot(), "package.json"), "utf8"));
4579
+ const { version } = JSON.parse(readFileSync2(join6(packageRoot(), "package.json"), "utf8"));
4448
4580
  return version;
4449
4581
  } catch {
4450
4582
  return "0.0.0";
@@ -4526,7 +4658,7 @@ async function runDoctor() {
4526
4658
  }
4527
4659
  rows.push({ name: "云端连通 + 鉴权", status: apiStatus, detail: apiDetail });
4528
4660
  const draftDir = resolveJianyingDraftDir(undefined);
4529
- const draftOk = !!draftDir && existsSync5(draftDir);
4661
+ const draftOk = !!draftDir && existsSync6(draftDir);
4530
4662
  rows.push({
4531
4663
  name: "剪映草稿目录",
4532
4664
  status: draftOk ? "ok" : "warn",
@@ -4534,9 +4666,30 @@ async function runDoctor() {
4534
4666
  });
4535
4667
  rows.push({
4536
4668
  name: "配置文件",
4537
- status: existsSync5(configPath()) ? "ok" : "warn",
4538
- detail: existsSync5(configPath()) ? configPath() : `未生成 —— 跑 gtrk init(${configPath()})`
4669
+ status: existsSync6(configPath()) ? "ok" : "warn",
4670
+ detail: existsSync6(configPath()) ? configPath() : `未生成 —— 跑 gtrk init(${configPath()})`
4539
4671
  });
4672
+ const ff = resolveFfmpeg();
4673
+ if (ff) {
4674
+ let hasX264 = false;
4675
+ let ver = "";
4676
+ try {
4677
+ const cap = probeCapabilities(ff);
4678
+ hasX264 = cap.hasLibx264;
4679
+ ver = cap.version.replace(/^ffmpeg version\s*/i, "v");
4680
+ } catch {}
4681
+ rows.push({
4682
+ name: "本地渲染 (ffmpeg)",
4683
+ status: hasX264 ? "ok" : "warn",
4684
+ detail: hasX264 ? `就绪(来源 ${ff.source}${ver ? `,${ver.split(/\s/)[0]}` : ""})` : `找到 ffmpeg(${ff.source})但缺 libx264 —— 本地渲染需换含 libx264 的构建`
4685
+ });
4686
+ } else {
4687
+ rows.push({
4688
+ name: "本地渲染 (ffmpeg)",
4689
+ status: "warn",
4690
+ detail: "未找到 —— 只出工程文件可忽略;要本地渲染成片,让 agent 装 ffmpeg/ffprobe 到 ~/.gitruck/ffmpeg 或 --ffmpeg-path"
4691
+ });
4692
+ }
4540
4693
  const cur = currentVersion();
4541
4694
  const latest = await latestP;
4542
4695
  rows.splice(1, 0, {
@@ -4561,7 +4714,7 @@ gtrk 体检:
4561
4714
  }
4562
4715
 
4563
4716
  // src/commands/init.ts
4564
- var GUIDE_IMAGE = join6(packageRoot(), "assets", "jianying-draft-path.png");
4717
+ var GUIDE_IMAGE = join7(packageRoot(), "assets", "jianying-draft-path.png");
4565
4718
  function registerInit(program2) {
4566
4719
  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
4720
  }
@@ -4600,7 +4753,7 @@ async function runInit(opts) {
4600
4753
  defaultValue: existing.apiBase ?? DEFAULT_API_BASE
4601
4754
  })).trim();
4602
4755
  let jianyingDraftDir;
4603
- if (existing.jianyingDraftDir && existsSync6(existing.jianyingDraftDir)) {
4756
+ if (existing.jianyingDraftDir && existsSync7(existing.jianyingDraftDir)) {
4604
4757
  if (await promptConfirm(`剪映草稿目录现为 ${existing.jianyingDraftDir},保留吗?`, true)) {
4605
4758
  jianyingDraftDir = existing.jianyingDraftDir;
4606
4759
  }
@@ -4616,7 +4769,7 @@ async function runInit(opts) {
4616
4769
  openFile(GUIDE_IMAGE);
4617
4770
  const manual = (await promptText("剪映草稿根目录(…\\com.lveditor.draft),留空跳过:")).trim();
4618
4771
  if (manual) {
4619
- if (existsSync6(manual))
4772
+ if (existsSync7(manual))
4620
4773
  jianyingDraftDir = resolve2(manual);
4621
4774
  else
4622
4775
  log.warn(`目录不存在,已跳过:${manual}`);
@@ -4682,9 +4835,9 @@ function registerInstall(program2) {
4682
4835
  }
4683
4836
 
4684
4837
  // 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";
4838
+ import { resolve as resolve3, join as join12, dirname as dirname2, basename as basename5, extname as extname2 } from "node:path";
4839
+ import { mkdir as mkdir4, writeFile as writeFile5, readFile as readFile3 } from "node:fs/promises";
4840
+ import { existsSync as existsSync11 } from "node:fs";
4688
4841
 
4689
4842
  // src/lib/config.ts
4690
4843
  function loadConfig() {
@@ -4763,6 +4916,21 @@ async function submitTask(cfg, taskType, payload) {
4763
4916
  return String(r.data.task_id);
4764
4917
  throw new Error(`提交失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
4765
4918
  }
4919
+ async function getTaskResult(cfg, taskType, taskId) {
4920
+ const res = await fetch(`${cfg.base}/task/${taskType}/${taskId}`, {
4921
+ headers: { Authorization: cfg.apiKey }
4922
+ });
4923
+ const r = await parseJson(res);
4924
+ if (r.code != null && r.code !== 200) {
4925
+ throw new CloudError(r.code, `任务查询失败 (code=${r.code}):${r.msg ?? ""}`);
4926
+ }
4927
+ const data = r.data ?? {};
4928
+ return {
4929
+ status: String(data.status ?? ""),
4930
+ progress: typeof data.progress === "number" ? data.progress : undefined,
4931
+ output: data.output_result ?? {}
4932
+ };
4933
+ }
4766
4934
  async function pollTask(cfg, taskType, taskId, onTick) {
4767
4935
  const start = Date.now();
4768
4936
  const TIMEOUT_MS = 30 * 60 * 1000;
@@ -4771,29 +4939,22 @@ async function pollTask(cfg, taskType, taskId, onTick) {
4771
4939
  if (Date.now() - start > TIMEOUT_MS) {
4772
4940
  throw new Error("任务超时(超过 30 分钟)。可稍后在云端查任务或重试。");
4773
4941
  }
4774
- await new Promise((r2) => setTimeout(r2, INTERVAL_MS));
4775
- let r;
4942
+ await new Promise((r) => setTimeout(r, INTERVAL_MS));
4943
+ let got;
4776
4944
  try {
4777
- const res = await fetch(`${cfg.base}/task/${taskType}/${taskId}`, {
4778
- headers: { Authorization: cfg.apiKey }
4779
- });
4780
- r = await parseJson(res);
4781
- } catch {
4945
+ got = await getTaskResult(cfg, taskType, taskId);
4946
+ } catch (e) {
4947
+ if (e instanceof CloudError)
4948
+ throw e;
4782
4949
  continue;
4783
4950
  }
4784
- if (r.code != null && r.code !== 200) {
4785
- throw new Error(`任务错误 (code=${r.code}):${r.msg ?? ""}`);
4951
+ if (got.status === "completed")
4952
+ return got.output;
4953
+ if (got.status === "failed" || got.status === "cancelled") {
4954
+ const out = got.output;
4955
+ throw new Error(out?.error ?? (got.status === "failed" ? "任务失败" : "任务已取消"));
4786
4956
  }
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);
4957
+ onTick?.(got.status || "处理中", got.progress);
4797
4958
  }
4798
4959
  }
4799
4960
  async function download(url, dest) {
@@ -4805,10 +4966,9 @@ async function download(url, dest) {
4805
4966
  }
4806
4967
 
4807
4968
  // src/lib/upload-cache.ts
4808
- import { homedir as homedir3 } from "node:os";
4809
- import { join as join7 } from "node:path";
4969
+ import { join as join8 } from "node:path";
4810
4970
  import { stat as stat3, mkdir, readFile, writeFile as writeFile2 } from "node:fs/promises";
4811
- import { existsSync as existsSync7 } from "node:fs";
4971
+ import { existsSync as existsSync8 } from "node:fs";
4812
4972
 
4813
4973
  // src/lib/chunk-upload.ts
4814
4974
  var import_hash_wasm = __toESM(require_index_umd(), 1);
@@ -5039,15 +5199,15 @@ async function putPart(cfg, uploadId, idx, view) {
5039
5199
  }
5040
5200
 
5041
5201
  // 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");
5202
+ var CACHE_DIR = gitruckHome();
5203
+ var CACHE_FILE = join8(CACHE_DIR, "upload-cache.json");
5204
+ var SESSION_FILE = join8(CACHE_DIR, "upload-sessions.json");
5045
5205
  async function fingerprint(path) {
5046
5206
  const s = await stat3(path);
5047
5207
  return `${s.size}:${Math.round(s.mtimeMs)}`;
5048
5208
  }
5049
5209
  async function load() {
5050
- if (!existsSync7(CACHE_FILE))
5210
+ if (!existsSync8(CACHE_FILE))
5051
5211
  return {};
5052
5212
  try {
5053
5213
  return JSON.parse(await readFile(CACHE_FILE, "utf8"));
@@ -5068,7 +5228,7 @@ async function invalidateUpload(path) {
5068
5228
  }
5069
5229
  }
5070
5230
  async function loadSessions() {
5071
- if (!existsSync7(SESSION_FILE))
5231
+ if (!existsSync8(SESSION_FILE))
5072
5232
  return {};
5073
5233
  try {
5074
5234
  return JSON.parse(await readFile(SESSION_FILE, "utf8"));
@@ -5121,13 +5281,303 @@ async function uploadCached(cfg, path, opts) {
5121
5281
  return { fileId, cached: false };
5122
5282
  }
5123
5283
 
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())}`;
5284
+ // src/lib/media.ts
5285
+ import { mkdir as mkdir2, stat as stat4 } from "node:fs/promises";
5286
+ import { existsSync as existsSync9 } from "node:fs";
5287
+ import { basename as basename3, extname, join as join9 } from "node:path";
5288
+ function parseFps(rate) {
5289
+ if (typeof rate !== "string")
5290
+ return 0;
5291
+ const [n, d] = rate.split("/").map(Number);
5292
+ if (!n || !d)
5293
+ return Number(rate) || 0;
5294
+ return n / d;
5295
+ }
5296
+ function probeGeometry(inputAbs, ffmpegPath) {
5297
+ const { ffprobe } = requireFfmpeg(ffmpegPath);
5298
+ const info = ffprobeJson(ffprobe, [
5299
+ "-v",
5300
+ "error",
5301
+ "-select_streams",
5302
+ "v:0",
5303
+ "-show_entries",
5304
+ "stream=width,height,r_frame_rate",
5305
+ "-show_entries",
5306
+ "format=duration",
5307
+ "-of",
5308
+ "json",
5309
+ inputAbs
5310
+ ]);
5311
+ const s = info.streams && info.streams[0] || {};
5312
+ const duration = Number(info.format?.duration) || 0;
5313
+ return {
5314
+ width: Number(s.width) || 0,
5315
+ height: Number(s.height) || 0,
5316
+ fps: parseFps(s.r_frame_rate),
5317
+ duration
5318
+ };
5319
+ }
5320
+ function probeDuration(path, ffmpegPath) {
5321
+ const { ffprobe } = requireFfmpeg(ffmpegPath);
5322
+ const info = ffprobeJson(ffprobe, [
5323
+ "-v",
5324
+ "error",
5325
+ "-show_entries",
5326
+ "format=duration",
5327
+ "-of",
5328
+ "json",
5329
+ path
5330
+ ]);
5331
+ return Number(info.format?.duration) || 0;
5332
+ }
5333
+ async function artifactPath(inputAbs, ext) {
5334
+ const s = await stat4(inputAbs);
5335
+ const base = basename3(inputAbs, extname(inputAbs));
5336
+ await mkdir2(audioCacheDir(), { recursive: true });
5337
+ return join9(audioCacheDir(), `${base}.${s.size}_${Math.round(s.mtimeMs)}.${ext}`);
5338
+ }
5339
+ async function extractAudio(inputAbs, ffmpegPath) {
5340
+ const { ffmpeg } = requireFfmpeg(ffmpegPath);
5341
+ const out = await artifactPath(inputAbs, "mp3");
5342
+ if (existsSync9(out))
5343
+ return out;
5344
+ await runFfmpeg(ffmpeg, [
5345
+ "-y",
5346
+ "-v",
5347
+ "error",
5348
+ "-i",
5349
+ inputAbs,
5350
+ "-vn",
5351
+ "-ac",
5352
+ "1",
5353
+ "-ar",
5354
+ "16000",
5355
+ "-c:a",
5356
+ "libmp3lame",
5357
+ "-b:a",
5358
+ "64k",
5359
+ out
5360
+ ]);
5361
+ return out;
5362
+ }
5363
+ async function compress720p(inputAbs, ffmpegPath) {
5364
+ const { ffmpeg } = requireFfmpeg(ffmpegPath);
5365
+ const out = await artifactPath(inputAbs, "720p.mp4");
5366
+ if (existsSync9(out))
5367
+ return out;
5368
+ await runFfmpeg(ffmpeg, [
5369
+ "-y",
5370
+ "-v",
5371
+ "error",
5372
+ "-i",
5373
+ inputAbs,
5374
+ "-vf",
5375
+ "scale=-2:720",
5376
+ "-c:v",
5377
+ "libx264",
5378
+ "-preset",
5379
+ "veryfast",
5380
+ "-crf",
5381
+ "28",
5382
+ "-c:a",
5383
+ "aac",
5384
+ "-movflags",
5385
+ "+faststart",
5386
+ out
5387
+ ]);
5388
+ return out;
5389
+ }
5390
+ function assertDurationConsistent(originalDuration, artifactAbs, ffmpegPath, tolSec = 1) {
5391
+ const got = probeDuration(artifactAbs, ffmpegPath);
5392
+ if (originalDuration > 0 && Math.abs(got - originalDuration) > tolSec) {
5393
+ throw new Error(`抽出物时长(${got.toFixed(2)}s)与原片(${originalDuration.toFixed(2)}s)不一致(容差 ${tolSec}s),` + `疑似抽取异常,已中止上传`);
5394
+ }
5395
+ }
5396
+
5397
+ // src/lib/materialize.ts
5398
+ import { join as join11, basename as basename4 } from "node:path";
5399
+ import { mkdir as mkdir3, cp, writeFile as writeFile4 } from "node:fs/promises";
5400
+
5401
+ // src/lib/render.ts
5402
+ import { writeFile as writeFile3, unlink, readFile as readFile2 } from "node:fs/promises";
5403
+ import { existsSync as existsSync10 } from "node:fs";
5404
+ import { tmpdir } from "node:os";
5405
+ import { join as join10 } from "node:path";
5406
+ var AUDIO_SAMPLE_RATE = 48000;
5407
+ var AUDIO_LAYOUT = "stereo";
5408
+ var DEFAULT_CRF = 18;
5409
+ var DEFAULT_AUDIO_CROSSFADE_MS = 8;
5410
+ var MAX_CLIPS = 500;
5411
+ var g = (n) => String(Number(n.toPrecision(6)));
5412
+ var f6 = (n) => n.toFixed(6);
5413
+ var f3 = (n) => n.toFixed(3);
5414
+ var isGap = (clip) => clip.material === null || clip.material === undefined;
5415
+ function sortedTracks(tracks) {
5416
+ 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);
5417
+ }
5418
+ function normalizeTrack(trackTimeline) {
5419
+ const items = [...trackTimeline].sort((a, b) => Number(a.track_st) - Number(b.track_st));
5420
+ const elements = [];
5421
+ let cursor = 0;
5422
+ for (const clip of items) {
5423
+ const trackSt = Number(clip.track_st);
5424
+ const duration = Number(clip.duration);
5425
+ if (duration <= 0)
5426
+ throw new Error(`clip duration 非法: ${JSON.stringify(clip)}`);
5427
+ if (trackSt < cursor - 0.000001) {
5428
+ throw new Error(`track_timeline 时间重叠: track_st=${trackSt} < cursor=${cursor.toFixed(6)}`);
5429
+ }
5430
+ if (trackSt > cursor + 0.000001)
5431
+ elements.push({ kind: "gap", duration: trackSt - cursor });
5432
+ if (!isGap(clip)) {
5433
+ elements.push({
5434
+ kind: "clip",
5435
+ material: clip.material,
5436
+ clip_st: Number(clip.clip_st),
5437
+ duration
5438
+ });
5439
+ } else {
5440
+ elements.push({ kind: "gap", duration });
5441
+ }
5442
+ cursor = trackSt + duration;
5443
+ }
5444
+ return [elements, cursor];
5445
+ }
5446
+ function buildFilterGraph(gtrk, materialPaths, params = {}) {
5447
+ const fadeMs = Math.trunc(params.audio_crossfade_ms ?? DEFAULT_AUDIO_CROSSFADE_MS);
5448
+ const fade = Math.max(fadeMs, 0) / 1000;
5449
+ const sortedV = sortedTracks(gtrk.video_track || []);
5450
+ if (sortedV.length === 0)
5451
+ throw new Error("gtrk v1 缺少 video_track");
5452
+ const mainVideoTrack = sortedV[0];
5453
+ const audioTracks = sortedTracks(gtrk.audio_track || []);
5454
+ const totalClips = [mainVideoTrack, ...audioTracks].reduce((n, t) => n + (t.track_timeline?.length || 0), 0);
5455
+ if (totalClips > MAX_CLIPS)
5456
+ throw new Error(`clip 总数 ${totalClips} 超过上限 ${MAX_CLIPS}`);
5457
+ const width = Math.trunc(gtrk.video_size[0]);
5458
+ const height = Math.trunc(gtrk.video_size[1]);
5459
+ const rate = Number(gtrk.video_rate);
5460
+ const inputs = [];
5461
+ const inputIdx = {};
5462
+ const inputOf = (materialId) => {
5463
+ const path = materialPaths[String(materialId)];
5464
+ if (path === undefined)
5465
+ throw new Error(`gtrk 引用素材 ${materialId} 缺本地路径`);
5466
+ if (!(path in inputIdx)) {
5467
+ inputIdx[path] = inputs.length;
5468
+ inputs.push(path);
5469
+ }
5470
+ return inputIdx[path];
5471
+ };
5472
+ const chains = [];
5473
+ let labelN = 0;
5474
+ const label = () => `s${++labelN}`;
5475
+ const [vElements, vEnd] = normalizeTrack(mainVideoTrack.track_timeline);
5476
+ const normTracks = [];
5477
+ const aLens = [];
5478
+ for (const t of audioTracks) {
5479
+ const [els, end] = normalizeTrack(t.track_timeline);
5480
+ normTracks.push(els);
5481
+ aLens.push(end);
5482
+ }
5483
+ const total = aLens.length ? Math.max(vEnd, ...aLens) : vEnd;
5484
+ if (total <= 0)
5485
+ throw new Error("时间线总时长为 0");
5486
+ if (total > vEnd + 0.000001)
5487
+ vElements.push({ kind: "gap", duration: total - vEnd });
5488
+ const vLabels = [];
5489
+ for (const el of vElements) {
5490
+ const lab = label();
5491
+ if (el.kind === "clip") {
5492
+ const idx = inputOf(el.material);
5493
+ const st = el.clip_st;
5494
+ const ed = el.clip_st + el.duration;
5495
+ 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}]`);
5496
+ } else {
5497
+ chains.push(`color=black:s=${width}x${height}:r=${g(rate)}:d=${f6(el.duration)},format=yuv420p[${lab}]`);
5498
+ }
5499
+ vLabels.push(lab);
5500
+ }
5501
+ chains.push(vLabels.map((x) => `[${x}]`).join("") + `concat=n=${vLabels.length}:v=1:a=0[vout]`);
5502
+ const trackLabels = [];
5503
+ for (let ti = 0;ti < normTracks.length; ti++) {
5504
+ const els = normTracks[ti];
5505
+ const end = aLens[ti];
5506
+ if (total > end + 0.000001)
5507
+ els.push({ kind: "gap", duration: total - end });
5508
+ const segLabels = [];
5509
+ for (const el of els) {
5510
+ const lab2 = label();
5511
+ if (el.kind === "clip") {
5512
+ const idx = inputOf(el.material);
5513
+ const st = el.clip_st;
5514
+ const ed = el.clip_st + el.duration;
5515
+ const steps = [
5516
+ `[${idx}:a]atrim=start=${f6(st)}:end=${f6(ed)}`,
5517
+ "asetpts=PTS-STARTPTS",
5518
+ `aresample=${AUDIO_SAMPLE_RATE}`,
5519
+ `aformat=sample_fmts=fltp:channel_layouts=${AUDIO_LAYOUT}`
5520
+ ];
5521
+ if (fade > 0) {
5522
+ steps.push(`afade=t=in:d=${f3(fade)}`);
5523
+ steps.push(`afade=t=out:st=${f6(Math.max(el.duration - fade, 0))}:d=${f3(fade)}`);
5524
+ }
5525
+ chains.push(steps.join(",") + `[${lab2}]`);
5526
+ } else {
5527
+ chains.push(`anullsrc=r=${AUDIO_SAMPLE_RATE}:cl=${AUDIO_LAYOUT},atrim=end=${f6(el.duration)}[${lab2}]`);
5528
+ }
5529
+ segLabels.push(lab2);
5530
+ }
5531
+ const lab = label();
5532
+ chains.push(segLabels.map((x) => `[${x}]`).join("") + `concat=n=${segLabels.length}:v=0:a=1[${lab}]`);
5533
+ trackLabels.push(lab);
5534
+ }
5535
+ if (trackLabels.length === 0) {
5536
+ chains.push(`anullsrc=r=${AUDIO_SAMPLE_RATE}:cl=${AUDIO_LAYOUT},atrim=end=${f6(total)}[aout]`);
5537
+ } else if (trackLabels.length === 1) {
5538
+ chains.push(`[${trackLabels[0]}]anull[aout]`);
5539
+ } else {
5540
+ chains.push(trackLabels.map((x) => `[${x}]`).join("") + `amix=inputs=${trackLabels.length}:duration=longest:normalize=0[aout]`);
5541
+ }
5542
+ return { inputs, graph: chains.join(";"), total };
5543
+ }
5544
+ function materialPathsFromGtrk(gtrk) {
5545
+ const map = {};
5546
+ for (const m of gtrk.materials || []) {
5547
+ if (!m.path)
5548
+ throw new Error(`gtrk 素材 ${m.id} 缺 path(source_path),无法本地渲染`);
5549
+ if (!existsSync10(m.path))
5550
+ throw new Error(`gtrk 素材文件不存在:${m.path}`);
5551
+ map[String(m.id)] = m.path;
5552
+ }
5553
+ return map;
5554
+ }
5555
+ async function renderGtrk(gtrk, outputPath, opts = {}) {
5556
+ const codec = opts.codec ?? "h264";
5557
+ if (codec !== "h264")
5558
+ throw new Error(`v1 仅支持 h264,实际 ${codec}`);
5559
+ const crf = opts.crf ?? DEFAULT_CRF;
5560
+ const { ffmpeg } = requireFfmpeg(opts.ffmpegPath);
5561
+ const materialPaths = materialPathsFromGtrk(gtrk);
5562
+ const { inputs, graph, total } = buildFilterGraph(gtrk, materialPaths, { crf });
5563
+ const filterFile = join10(tmpdir(), `gtrk-filter-${process.pid}-${inputs.length}.txt`);
5564
+ await writeFile3(filterFile, graph, "utf8");
5565
+ try {
5566
+ const args = ["-y"];
5567
+ for (const p of inputs)
5568
+ args.push("-i", p);
5569
+ 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);
5570
+ await runFfmpeg(ffmpeg, args, opts.onLine);
5571
+ return { outputPath, duration: total };
5572
+ } finally {
5573
+ await unlink(filterFile).catch(() => {});
5574
+ }
5130
5575
  }
5576
+ async function readGtrkFile(gtrkPath) {
5577
+ return JSON.parse(await readFile2(gtrkPath, "utf8"));
5578
+ }
5579
+
5580
+ // src/lib/materialize.ts
5131
5581
  function baseFormat(fmt) {
5132
5582
  if (fmt.startsWith("jianying"))
5133
5583
  return "jianying";
@@ -5143,6 +5593,128 @@ var FORMAT_META = {
5143
5593
  fcpxml: { label: "Final Cut (fcpxml)", openHint: (p) => `Final Cut Pro:导入 ${p}` },
5144
5594
  otio: { label: "OpenTimelineIO", openHint: (p) => `用支持 OTIO 的工具打开 ${p}` }
5145
5595
  };
5596
+ var isExpired404 = (msg) => /HTTP 404/.test(msg);
5597
+ function gtrkSourceName(gtrk) {
5598
+ const p = gtrk.materials?.[0]?.path;
5599
+ if (!p)
5600
+ return;
5601
+ const b = basename4(p);
5602
+ const dot = b.lastIndexOf(".");
5603
+ return dot > 0 ? b.slice(0, dot) : b;
5604
+ }
5605
+ async function materializeResult(opts) {
5606
+ const { outDir, output, taskId } = opts;
5607
+ const dl = opts.download ?? download;
5608
+ const files = output.files ?? [];
5609
+ if (!files.length)
5610
+ throw new Error("任务无工程文件产物(检查 project_formats / 任务是否产出)");
5611
+ const errors = { ...output.errors ?? {} };
5612
+ await mkdir3(outDir, { recursive: true });
5613
+ const resultPath = join11(outDir, "result.json");
5614
+ const writeResult = async (extra) => {
5615
+ const r = {
5616
+ ok: Object.keys(errors).length === 0,
5617
+ outDir,
5618
+ files: {},
5619
+ jianyingDraftPath: null,
5620
+ rendered: null,
5621
+ report: output.report ?? null,
5622
+ errors,
5623
+ taskId,
5624
+ fileId: opts.fileId ?? null,
5625
+ ...extra
5626
+ };
5627
+ await writeFile4(resultPath, JSON.stringify(r, null, 2));
5628
+ return r;
5629
+ };
5630
+ await writeResult({});
5631
+ log.step("拉回产物到本地…");
5632
+ const byFormat = {};
5633
+ for (const f of files) {
5634
+ const base = baseFormat(f.format);
5635
+ const fmtDir = join11(outDir, base);
5636
+ await mkdir3(fmtDir, { recursive: true });
5637
+ const dest = join11(fmtDir, f.filename);
5638
+ try {
5639
+ await dl(f.download_url, dest);
5640
+ (byFormat[base] ??= []).push(dest);
5641
+ log.info(`${FORMAT_META[base]?.label ?? f.format} ← ${f.filename}`);
5642
+ } catch (e) {
5643
+ const msg = e instanceof Error ? e.message : String(e);
5644
+ errors[`${f.format}:${f.filename}`] = msg;
5645
+ if (isExpired404(msg))
5646
+ log.warn(`产物已过期(${f.filename}):文件已被清理,报告仍可用`);
5647
+ else
5648
+ log.warn(`产物下载失败(${f.filename}):${msg}`);
5649
+ }
5650
+ }
5651
+ let jianyingDraftPath = null;
5652
+ if (byFormat.jianying && opts.draftDir) {
5653
+ try {
5654
+ jianyingDraftPath = join11(opts.draftDir, basename4(outDir));
5655
+ await mkdir3(jianyingDraftPath, { recursive: true });
5656
+ await cp(join11(outDir, "jianying"), jianyingDraftPath, { recursive: true });
5657
+ log.info(`剪映草稿已落到:${jianyingDraftPath}`);
5658
+ } catch (e) {
5659
+ jianyingDraftPath = null;
5660
+ errors["jianying:draft"] = e instanceof Error ? e.message : String(e);
5661
+ log.warn(`剪映草稿落盘失败:${errors["jianying:draft"]}`);
5662
+ }
5663
+ }
5664
+ let rendered = null;
5665
+ if (opts.render) {
5666
+ const gtrkPath = (byFormat.gtrk ?? [])[0];
5667
+ if (!gtrkPath) {
5668
+ log.warn("已请求 --render,但无可用 gtrk 工程(未产出或已过期),跳过渲染;报告仍已落盘");
5669
+ } else {
5670
+ log.step("本地渲染成片(ffmpeg)…");
5671
+ const project = await readGtrkFile(gtrkPath);
5672
+ const name = opts.projName ?? gtrkSourceName(project) ?? taskId;
5673
+ const outMp4 = join11(outDir, `${name}.mp4`);
5674
+ const r = await renderGtrk(project, outMp4, {
5675
+ crf: opts.crf != null ? Number(opts.crf) : undefined,
5676
+ codec: opts.codec,
5677
+ ffmpegPath: opts.ffmpegPath,
5678
+ onLine: (l) => {
5679
+ const m = l.match(/time=(\S+)/);
5680
+ if (m)
5681
+ log.tick(`渲染中 ${m[1]}`);
5682
+ }
5683
+ });
5684
+ log.tickEnd();
5685
+ rendered = r.outputPath;
5686
+ log.info(`成片:${rendered}(${r.duration.toFixed(1)}s)`);
5687
+ }
5688
+ }
5689
+ const result = await writeResult({ files: byFormat, jianyingDraftPath, rendered });
5690
+ if (!opts.json) {
5691
+ if (Object.keys(byFormat).length)
5692
+ log.step("三方打开(产物已就位,按需自取):");
5693
+ for (const base of Object.keys(byFormat)) {
5694
+ const meta = FORMAT_META[base];
5695
+ const target = base === "jianying" ? jianyingDraftPath ?? join11(outDir, "jianying") : byFormat[base][0];
5696
+ console.log(` • ${meta?.label ?? base}:${meta?.openHint(target) ?? target}`);
5697
+ }
5698
+ if (rendered)
5699
+ console.log(` • 成片 (mp4):${rendered}`);
5700
+ console.log(` • 结果清单:${resultPath}`);
5701
+ }
5702
+ if (opts.open) {
5703
+ openFolder(outDir);
5704
+ log.info("已打开产物目录文件夹");
5705
+ }
5706
+ if (opts.json)
5707
+ console.log(JSON.stringify(result));
5708
+ return result;
5709
+ }
5710
+
5711
+ // src/commands/oralcut.ts
5712
+ var TASK_TYPE = "cli/video_oral_cut_for_cli";
5713
+ function timestamp() {
5714
+ const d = new Date;
5715
+ const p = (n) => String(n).padStart(2, "0");
5716
+ return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
5717
+ }
5146
5718
  var collectParam = (v, acc) => {
5147
5719
  acc.push(v);
5148
5720
  return acc;
@@ -5179,7 +5751,7 @@ function parseExtraParams(pairs, jsonStr) {
5179
5751
  return out;
5180
5752
  }
5181
5753
  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) => {
5754
+ 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
5755
  await runOralCut(input, opts);
5184
5756
  });
5185
5757
  }
@@ -5188,42 +5760,51 @@ async function runOralCut(input, opts) {
5188
5760
  routeLogsToStderr();
5189
5761
  const cfg = loadConfig();
5190
5762
  const inputAbs = resolve3(input);
5191
- if (!existsSync8(inputAbs))
5763
+ if (!existsSync11(inputAbs))
5192
5764
  throw new Error(`毛片不存在:${inputAbs}`);
5193
- const projName = basename3(inputAbs, extname(inputAbs));
5765
+ const projName = basename5(inputAbs, extname2(inputAbs));
5194
5766
  const formats = opts.formats.split(",").map((s) => s.trim()).filter(Boolean);
5767
+ if (opts.render && !formats.includes("gtrk"))
5768
+ formats.push("gtrk");
5195
5769
  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 });
5770
+ const outDir = resolve3(opts.out ?? join12(dirname2(inputAbs), `${projName}-video-project-${timestamp()}`));
5198
5771
  let scriptPath = opts.script ? resolve3(opts.script) : undefined;
5199
5772
  if (!scriptPath) {
5200
- const sibling = join8(dirname2(inputAbs), `${projName}.txt`);
5201
- if (existsSync8(sibling)) {
5773
+ const sibling = join12(dirname2(inputAbs), `${projName}.txt`);
5774
+ if (existsSync11(sibling)) {
5202
5775
  scriptPath = sibling;
5203
5776
  log.info(`自动识别到同名文稿:${sibling}(按有稿剪辑;不想用就改名或显式 --script)`);
5204
5777
  }
5205
5778
  }
5206
- const script = scriptPath ? await readFile2(scriptPath, "utf8") : undefined;
5779
+ const script = scriptPath ? await readFile3(scriptPath, "utf8") : undefined;
5207
5780
  let draftDir;
5208
5781
  if (wantJianying) {
5209
5782
  draftDir = resolveJianyingDraftDir(opts.jianyingDraftDir);
5210
5783
  if (draftDir)
5211
5784
  log.info(`剪映草稿目录:${draftDir}`);
5212
5785
  else
5213
- log.warn("没找到剪映草稿目录(剪映/CapCut 未装在标准位置)→ 将只产 draft_content.json、缺 meta,剪映无法直接打开。可加 --jianying-draft-dir <你的草稿目录> 重跑。");
5786
+ log.warn("没找到剪映草稿目录 将只产 draft_content.json、缺 meta。可加 --jianying-draft-dir <你的草稿目录> 重跑。");
5214
5787
  }
5215
- log.step(`▶ 智能口播剪辑:${basename3(inputAbs)}(预设 ${opts.preset}${opts.visualAssist ? " · 视觉兜底" : ""},格式 ${formats.join("/")})`);
5788
+ log.step(`▶ 智能口播剪辑:${basename5(inputAbs)}(预设 ${opts.preset}${opts.visualAssist ? " · 视觉兜底(720p)" : ""},格式 ${formats.join("/")}${opts.render ? " · 本地渲染" : ""})`);
5216
5789
  const extraParams = parseExtraParams(opts.param, opts.paramsJson);
5217
- log.step("① 上传毛片到云端…");
5218
- let up = await uploadCached(cfg, inputAbs, { force: opts.reupload });
5790
+ log.step("① 本地预处理(探几何 + 抽音频/720p)…");
5791
+ const geo = probeGeometry(inputAbs, opts.ffmpegPath);
5792
+ log.info(`原片几何 ${geo.width}x${geo.height} @ ${geo.fps.toFixed(2)}fps · ${geo.duration.toFixed(1)}s`);
5793
+ const artifact = opts.visualAssist ? await compress720p(inputAbs, opts.ffmpegPath) : await extractAudio(inputAbs, opts.ffmpegPath);
5794
+ assertDurationConsistent(geo.duration, artifact, opts.ffmpegPath);
5795
+ log.info(opts.visualAssist ? `已压 720p 代理(上传物):${basename5(artifact)}` : `已抽 16k 单声道 mp3(上传物):${basename5(artifact)}`);
5796
+ log.step("② 上传抽出物到云端…");
5797
+ let up = await uploadCached(cfg, artifact, { force: opts.reupload });
5219
5798
  log.info(up.cached ? `命中上传缓存,复用 file_id = ${up.fileId}(免二次上传)` : `file_id = ${up.fileId}`);
5220
5799
  const buildPayload = (fid) => {
5221
5800
  const p = {
5222
5801
  file_id: fid,
5223
5802
  la: opts.lang ?? "zh-CN",
5224
- outputs: opts.render ? ["project", "video"] : ["project"],
5225
5803
  project_formats: formats,
5226
5804
  source_path: inputAbs,
5805
+ video_size: [geo.width, geo.height],
5806
+ video_rate: geo.fps,
5807
+ video_duration: geo.duration,
5227
5808
  rhythm_preset: opts.preset
5228
5809
  };
5229
5810
  if (script)
@@ -5234,15 +5815,6 @@ async function runOralCut(input, opts) {
5234
5815
  p.visual_assist = true;
5235
5816
  if (opts.adaptiveRhythm === false)
5236
5817
  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
5818
  for (const [k, v] of Object.entries(extraParams)) {
5247
5819
  const cur = p[k];
5248
5820
  const bothObj = !!cur && !!v && typeof cur === "object" && typeof v === "object" && !Array.isArray(cur) && !Array.isArray(v);
@@ -5250,82 +5822,101 @@ async function runOralCut(input, opts) {
5250
5822
  }
5251
5823
  return p;
5252
5824
  };
5253
- log.step(" 提交智能口播剪辑任务…");
5825
+ log.step(" 提交智能口播剪辑任务…");
5254
5826
  let taskId;
5255
5827
  try {
5256
5828
  taskId = await submitTask(cfg, TASK_TYPE, buildPayload(up.fileId));
5257
5829
  } catch (e) {
5258
5830
  if (up.cached && e instanceof CloudError && e.code === 6004) {
5259
5831
  log.warn("缓存的 file_id 在云端已失效,重新上传后重试…");
5260
- await invalidateUpload(inputAbs);
5261
- up = await uploadCached(cfg, inputAbs, { force: true });
5832
+ await invalidateUpload(artifact);
5833
+ up = await uploadCached(cfg, artifact, { force: true });
5262
5834
  taskId = await submitTask(cfg, TASK_TYPE, buildPayload(up.fileId));
5263
5835
  } else
5264
5836
  throw e;
5265
5837
  }
5266
5838
  log.info(`task_id = ${taskId}`);
5267
- log.step("③ 云端处理中(每 5s 轮询)…");
5839
+ await mkdir4(outDir, { recursive: true });
5840
+ await writeFile5(join12(outDir, "task.json"), JSON.stringify({ taskId, taskType: TASK_TYPE, fileId: up.fileId, source: inputAbs, formats, createdAt: new Date().toISOString() }, null, 2));
5841
+ log.step("④ 云端处理中(每 5s 轮询)…");
5268
5842
  const result = await pollTask(cfg, TASK_TYPE, taskId, (status, progress) => {
5269
5843
  log.tick(`${status}${progress != null ? ` ${Math.round(progress)}%` : ""}`);
5270
5844
  });
5271
5845
  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}`);
5846
+ await materializeResult({
5847
+ outDir,
5848
+ output: result,
5849
+ taskId,
5850
+ fileId: up.fileId,
5851
+ draftDir,
5852
+ render: opts.render,
5853
+ crf: opts.crf,
5854
+ codec: opts.codec,
5855
+ ffmpegPath: opts.ffmpegPath,
5856
+ projName,
5857
+ json: opts.json,
5858
+ open: opts.open
5859
+ });
5860
+ log.ok(`闭环完成。产物目录:${outDir}`);
5861
+ }
5862
+
5863
+ // src/commands/oralcut-result.ts
5864
+ import { resolve as resolve4, join as join13 } from "node:path";
5865
+ var TASK_TYPE2 = "cli/video_oral_cut_for_cli";
5866
+ function timestamp2() {
5867
+ const d = new Date;
5868
+ const p = (n) => String(n).padStart(2, "0");
5869
+ return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
5870
+ }
5871
+ function registerOralCutResult(program2) {
5872
+ 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) => {
5873
+ await runOralCutResult(taskId, opts);
5874
+ });
5875
+ }
5876
+ async function runOralCutResult(taskId, opts) {
5877
+ if (opts.json)
5878
+ routeLogsToStderr();
5879
+ const cfg = loadConfig();
5880
+ log.step(`▶ 按 task_id 取回口播剪辑结果:${taskId}`);
5881
+ let got;
5882
+ try {
5883
+ got = await getTaskResult(cfg, TASK_TYPE2, taskId);
5884
+ } catch (e) {
5885
+ if (e instanceof CloudError) {
5886
+ throw new Error(`取任务结果失败(code=${e.code}):${e.message}。` + `注意:取结果需用「提交该任务的同一账号」的 API Key;异账号或已删任务会报 TASK_NOT_FOUND。`);
5302
5887
  }
5888
+ throw e;
5303
5889
  }
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
- }));
5890
+ if (got.status !== "completed") {
5891
+ if (got.status === "failed" || got.status === "cancelled") {
5892
+ const out = got.output;
5893
+ throw new Error(`任务未成功(${got.status}):${out?.error ?? "无产物可取回"}`);
5894
+ }
5895
+ const pct = got.progress != null ? ` ${Math.round(got.progress)}%` : "";
5896
+ throw new Error(`任务尚未完成(当前 ${got.status || "未知"}${pct}),暂无法取回结果;请稍后再试。`);
5321
5897
  }
5898
+ const outDir = resolve4(opts.out ?? join13(process.cwd(), `${taskId}-video-project-${timestamp2()}`));
5899
+ const draftDir = resolveJianyingDraftDir(opts.jianyingDraftDir);
5900
+ await materializeResult({
5901
+ outDir,
5902
+ output: got.output,
5903
+ taskId,
5904
+ draftDir,
5905
+ render: opts.render,
5906
+ crf: opts.crf,
5907
+ codec: opts.codec,
5908
+ ffmpegPath: opts.ffmpegPath,
5909
+ json: opts.json,
5910
+ open: opts.open
5911
+ });
5912
+ log.ok(`已取回。产物目录:${outDir}`);
5322
5913
  }
5323
5914
 
5324
5915
  // src/commands/upgrade.ts
5325
- import { spawnSync as spawnSync2 } from "node:child_process";
5916
+ import { spawnSync as spawnSync3 } from "node:child_process";
5326
5917
  var CLIENT_UPGRADE = "irm https://api.ai-mcn.tv:9000/broadcast/exe/install.ps1 | iex";
5327
5918
  function run(cmd) {
5328
- const r = spawnSync2(cmd, { stdio: "inherit", shell: true });
5919
+ const r = spawnSync3(cmd, { stdio: "inherit", shell: true });
5329
5920
  return r.status ?? 1;
5330
5921
  }
5331
5922
  function registerUpgrade(program2) {
@@ -5363,19 +5954,55 @@ function registerUpgrade(program2) {
5363
5954
  });
5364
5955
  }
5365
5956
 
5957
+ // src/commands/render.ts
5958
+ import { resolve as resolve5, dirname as dirname3, join as join14, basename as basename6, extname as extname3 } from "node:path";
5959
+ import { existsSync as existsSync12 } from "node:fs";
5960
+ function registerRender(program2) {
5961
+ program2.command("render <gtrk>").description("本地渲染:gtrk 工程按 EDL 用本地 ffmpeg 渲染成片 mp4(素材取原片本地路径)").option("-o, --out <file>", "输出 mp4 路径(缺省 = <gtrk 同目录>/<gtrk 名>.mp4)").option("--crf <n>", "视频质量 CRF 14-28(越小越清晰/文件越大,默认 18)").option("--codec <c>", "视频编码(默认 h264)").option("--ffmpeg-path <dir>", "指定 ffmpeg/ffprobe 所在目录(缺省 ~/.gitruck/ffmpeg → 系统)").option("--no-open", "完成后不自动打开产物目录").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON").action(async (gtrk, opts) => {
5962
+ if (opts.json)
5963
+ routeLogsToStderr();
5964
+ const gtrkAbs = resolve5(gtrk);
5965
+ if (!existsSync12(gtrkAbs))
5966
+ throw new Error(`gtrk 工程不存在:${gtrkAbs}`);
5967
+ const outMp4 = resolve5(opts.out ?? join14(dirname3(gtrkAbs), `${basename6(gtrkAbs, extname3(gtrkAbs))}.mp4`));
5968
+ log.step(`▶ 本地渲染:${basename6(gtrkAbs)} → ${basename6(outMp4)}`);
5969
+ const project = await readGtrkFile(gtrkAbs);
5970
+ const result = await renderGtrk(project, outMp4, {
5971
+ crf: opts.crf != null ? Number(opts.crf) : undefined,
5972
+ codec: opts.codec,
5973
+ ffmpegPath: opts.ffmpegPath,
5974
+ onLine: (l) => {
5975
+ const m = l.match(/time=(\S+)/);
5976
+ if (m)
5977
+ log.tick(`渲染中 ${m[1]}`);
5978
+ }
5979
+ });
5980
+ log.tickEnd();
5981
+ log.ok(`渲染完成:${outMp4}(${result.duration.toFixed(1)}s)`);
5982
+ if (opts.open)
5983
+ openFolder(dirname3(outMp4));
5984
+ if (opts.json) {
5985
+ console.log(JSON.stringify({ ok: true, output: outMp4, duration: result.duration }));
5986
+ }
5987
+ });
5988
+ }
5989
+
5366
5990
  // src/index.ts
5367
5991
  try {
5368
5992
  process.loadEnvFile?.();
5369
5993
  } catch {}
5370
- var { version } = JSON.parse(readFileSync3(join9(packageRoot(), "package.json"), "utf8"));
5994
+ migrateLegacyHome();
5995
+ var { version } = JSON.parse(readFileSync3(join15(packageRoot(), "package.json"), "utf8"));
5371
5996
  var program2 = new Command;
5372
5997
  program2.name("gtrk").description("同合云成片流水线 CLI —— agent 驱动云端任务、产物拉回本地、三方工程文件(客户端/剪映/PR)互通").version(version);
5373
5998
  registerInstall(program2);
5374
5999
  registerInit(program2);
5375
6000
  registerOralCut(program2);
6001
+ registerOralCutResult(program2);
5376
6002
  registerDoctor(program2);
5377
6003
  registerSkills(program2);
5378
6004
  registerUpgrade(program2);
6005
+ registerRender(program2);
5379
6006
  program2.parseAsync(process.argv).catch((e) => {
5380
6007
  console.error(`
5381
6008
  ❌ ${e instanceof Error ? e.message : String(e)}`);