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