@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/AGENT.md +75 -12
- package/README.md +19 -3
- package/dist/index.js +1402 -148
- package/package.json +2 -2
- package/skills/gtrk-oralcut/SKILL.md +17 -6
- package/skills/gtrk-splitter/SKILL.md +148 -0
- package/skills/gtrk-splitter/references/example-visual-split.json +638 -0
- package/skills/gtrk-splitter/references/example-visual-split.md +439 -0
- package/skills/gtrk-splitter/references/field-schema.md +93 -0
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
|
|
4200
|
-
import { join as
|
|
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 {
|
|
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,
|
|
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
|
|
4252
|
-
var SRC = join2(packageRoot(), "skills", SKILL_NAME, "SKILL.md");
|
|
4277
|
+
var SKILL_NAMES = ["gtrk-oralcut", "gtrk-splitter"];
|
|
4253
4278
|
function installSkill(opts = {}) {
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
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(
|
|
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
|
|
4279
|
-
import { existsSync as
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 &&
|
|
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:
|
|
4538
|
-
detail:
|
|
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 =
|
|
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 &&
|
|
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 (
|
|
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
|
|
4686
|
-
import { mkdir as
|
|
4687
|
-
import { existsSync as
|
|
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((
|
|
4775
|
-
let
|
|
4947
|
+
await new Promise((r) => setTimeout(r, INTERVAL_MS));
|
|
4948
|
+
let got;
|
|
4776
4949
|
try {
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
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 (
|
|
4785
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 =
|
|
5043
|
-
var CACHE_FILE =
|
|
5044
|
-
var SESSION_FILE =
|
|
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 (!
|
|
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 (!
|
|
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/
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
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("
|
|
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 (!
|
|
5768
|
+
if (!existsSync11(inputAbs))
|
|
5192
5769
|
throw new Error(`毛片不存在:${inputAbs}`);
|
|
5193
|
-
const projName =
|
|
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 ??
|
|
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 =
|
|
5201
|
-
if (
|
|
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
|
|
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("
|
|
5791
|
+
log.warn("没找到剪映草稿目录 → 将只产 draft_content.json、缺 meta。可加 --jianying-draft-dir <你的草稿目录> 重跑。");
|
|
5214
5792
|
}
|
|
5215
|
-
log.step(`▶ 智能口播剪辑:${
|
|
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
|
-
|
|
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(
|
|
5261
|
-
up = await uploadCached(cfg,
|
|
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
|
-
|
|
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
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
}
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
}
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
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 (
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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)}`);
|