@gitruck/cli 0.2.9 → 0.2.10
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 +36 -8
- package/README.md +71 -22
- package/dist/index.js +988 -95
- package/package.json +2 -2
- package/skills/gtrk-tools/SKILL.md +37 -9
- package/skills/gtrk-transcript/SKILL.md +53 -0
- package/skills/gtrk-transcript/agents/openai.yaml +4 -0
package/dist/index.js
CHANGED
|
@@ -4197,7 +4197,7 @@ var {
|
|
|
4197
4197
|
|
|
4198
4198
|
// src/index.ts
|
|
4199
4199
|
import { readFileSync as readFileSync5 } from "node:fs";
|
|
4200
|
-
import { join as
|
|
4200
|
+
import { join as join27 } from "node:path";
|
|
4201
4201
|
|
|
4202
4202
|
// src/lib/paths.ts
|
|
4203
4203
|
import { dirname, join } from "node:path";
|
|
@@ -4284,6 +4284,7 @@ var SKILL_NAMES = [
|
|
|
4284
4284
|
"gtrk-mg",
|
|
4285
4285
|
"gtrk-ai-drama",
|
|
4286
4286
|
"gtrk-style-maker",
|
|
4287
|
+
"gtrk-transcript",
|
|
4287
4288
|
"gtrk-tools"
|
|
4288
4289
|
];
|
|
4289
4290
|
function installSkill(opts = {}) {
|
|
@@ -4306,7 +4307,7 @@ function installSkill(opts = {}) {
|
|
|
4306
4307
|
log.warn(`skill 安装失败(${name},不影响命令行使用):${e instanceof Error ? e.message : String(e)}`);
|
|
4307
4308
|
}
|
|
4308
4309
|
}
|
|
4309
|
-
log.info("在 Claude Code 里打 /gtrk-oralcut、/gtrk-
|
|
4310
|
+
log.info("在 Claude Code 里打 /gtrk-oralcut、/gtrk-transcript 或 /gtrk-tools,也可直接说「帮我剪个口播 / 把本地视频转成文字稿 / 给视频声音降噪」触发(可能需重载会话)。");
|
|
4310
4311
|
return allOk;
|
|
4311
4312
|
}
|
|
4312
4313
|
function registerSkills(program2) {
|
|
@@ -5548,6 +5549,10 @@ class CloudError extends Error {
|
|
|
5548
5549
|
this.name = "CloudError";
|
|
5549
5550
|
}
|
|
5550
5551
|
}
|
|
5552
|
+
function cloudErrorCode(error) {
|
|
5553
|
+
const code = error && typeof error === "object" ? error.code : undefined;
|
|
5554
|
+
return typeof code === "number" ? code : undefined;
|
|
5555
|
+
}
|
|
5551
5556
|
async function parseJson(res) {
|
|
5552
5557
|
try {
|
|
5553
5558
|
return await res.json();
|
|
@@ -5555,38 +5560,58 @@ async function parseJson(res) {
|
|
|
5555
5560
|
throw new Error(`服务响应解析失败 (HTTP ${res.status})`);
|
|
5556
5561
|
}
|
|
5557
5562
|
}
|
|
5558
|
-
|
|
5559
|
-
const
|
|
5560
|
-
|
|
5561
|
-
|
|
5563
|
+
function globalBunFile() {
|
|
5564
|
+
const bun = globalThis.Bun;
|
|
5565
|
+
return bun ? bun.file.bind(bun) : undefined;
|
|
5566
|
+
}
|
|
5567
|
+
async function uploadFile(cfg, path, runtime = {}) {
|
|
5568
|
+
const fetchFn = runtime.fetchFn ?? fetch;
|
|
5569
|
+
const bunFile = runtime.bunFile ?? globalBunFile();
|
|
5570
|
+
const useBun = runtime.runtime ? runtime.runtime === "bun" : bunFile != null;
|
|
5571
|
+
let res;
|
|
5572
|
+
if (useBun) {
|
|
5573
|
+
if (!bunFile)
|
|
5574
|
+
throw new Error("Bun 上传运行时缺少 Bun.file");
|
|
5575
|
+
const form = new FormData;
|
|
5576
|
+
form.append("file", bunFile(path), basename(path));
|
|
5577
|
+
res = await fetchFn(`${cfg.base}/base/file/upload`, {
|
|
5578
|
+
method: "POST",
|
|
5579
|
+
headers: { Authorization: cfg.apiKey },
|
|
5580
|
+
body: form
|
|
5581
|
+
});
|
|
5582
|
+
} else {
|
|
5583
|
+
const size = (await stat(path)).size;
|
|
5584
|
+
const boundary = `----gtrkFormBoundary${randomBytes(16).toString("hex")}`;
|
|
5585
|
+
const head = Buffer.from(`--${boundary}\r
|
|
5562
5586
|
` + `Content-Disposition: form-data; name="file"; filename="${basename(path)}"\r
|
|
5563
5587
|
` + `Content-Type: application/octet-stream\r
|
|
5564
5588
|
\r
|
|
5565
5589
|
`, "utf8");
|
|
5566
|
-
|
|
5590
|
+
const tail = Buffer.from(`\r
|
|
5567
5591
|
--${boundary}--\r
|
|
5568
5592
|
`, "utf8");
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5593
|
+
async function* multipart() {
|
|
5594
|
+
yield head;
|
|
5595
|
+
for await (const chunk of createReadStream(path))
|
|
5596
|
+
yield chunk;
|
|
5597
|
+
yield tail;
|
|
5598
|
+
}
|
|
5599
|
+
res = await fetchFn(`${cfg.base}/base/file/upload`, {
|
|
5600
|
+
method: "POST",
|
|
5601
|
+
headers: {
|
|
5602
|
+
Authorization: cfg.apiKey,
|
|
5603
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
5604
|
+
"Content-Length": String(head.length + size + tail.length)
|
|
5605
|
+
},
|
|
5606
|
+
body: Readable.toWeb(Readable.from(multipart())),
|
|
5607
|
+
duplex: "half"
|
|
5608
|
+
});
|
|
5574
5609
|
}
|
|
5575
|
-
const res = await fetch(`${cfg.base}/base/file/upload`, {
|
|
5576
|
-
method: "POST",
|
|
5577
|
-
headers: {
|
|
5578
|
-
Authorization: cfg.apiKey,
|
|
5579
|
-
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
5580
|
-
"Content-Length": String(head.length + size + tail.length)
|
|
5581
|
-
},
|
|
5582
|
-
body: Readable.toWeb(Readable.from(multipart())),
|
|
5583
|
-
duplex: "half"
|
|
5584
|
-
});
|
|
5585
5610
|
const r = await parseJson(res);
|
|
5586
5611
|
const fid = r.data?.file_id ?? r.data?.id;
|
|
5587
5612
|
if (r.code === 200 && fid)
|
|
5588
5613
|
return String(fid);
|
|
5589
|
-
throw new
|
|
5614
|
+
throw new CloudError(r.code, `上传失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
|
|
5590
5615
|
}
|
|
5591
5616
|
async function submitTask(cfg, taskType, payload) {
|
|
5592
5617
|
const res = await fetch(`${cfg.base}/task/${taskType}`, {
|
|
@@ -5597,7 +5622,7 @@ async function submitTask(cfg, taskType, payload) {
|
|
|
5597
5622
|
const r = await parseJson(res);
|
|
5598
5623
|
if (r.code === 200 && r.data?.task_id)
|
|
5599
5624
|
return String(r.data.task_id);
|
|
5600
|
-
throw new
|
|
5625
|
+
throw new CloudError(r.code, `提交失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
|
|
5601
5626
|
}
|
|
5602
5627
|
async function getTaskResult(cfg, taskType, taskId) {
|
|
5603
5628
|
const res = await fetch(`${cfg.base}/task/${taskType}/${taskId}`, {
|
|
@@ -5885,10 +5910,12 @@ async function putPart(cfg, uploadId, idx, view) {
|
|
|
5885
5910
|
var CACHE_DIR = gitruckHome();
|
|
5886
5911
|
var CACHE_FILE = join10(CACHE_DIR, "upload-cache.json");
|
|
5887
5912
|
var SESSION_FILE = join10(CACHE_DIR, "upload-sessions.json");
|
|
5888
|
-
|
|
5889
|
-
const s = await stat3(path);
|
|
5913
|
+
function fingerprintFromStat(s) {
|
|
5890
5914
|
return `${s.size}:${Math.round(s.mtimeMs)}`;
|
|
5891
5915
|
}
|
|
5916
|
+
async function fingerprint(path) {
|
|
5917
|
+
return fingerprintFromStat(await stat3(path));
|
|
5918
|
+
}
|
|
5892
5919
|
async function load() {
|
|
5893
5920
|
if (!existsSync9(CACHE_FILE))
|
|
5894
5921
|
return {};
|
|
@@ -5902,6 +5929,12 @@ async function save(cache) {
|
|
|
5902
5929
|
await mkdir(CACHE_DIR, { recursive: true });
|
|
5903
5930
|
await writeFile2(CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
5904
5931
|
}
|
|
5932
|
+
var defaultUploadCacheDeps = {
|
|
5933
|
+
stat: stat3,
|
|
5934
|
+
uploadFile,
|
|
5935
|
+
uploadChunked,
|
|
5936
|
+
cacheStore: { load, save }
|
|
5937
|
+
};
|
|
5905
5938
|
async function invalidateUpload(path) {
|
|
5906
5939
|
const fp = await fingerprint(path);
|
|
5907
5940
|
const cache = await load();
|
|
@@ -5940,19 +5973,22 @@ var fileSessionStore = {
|
|
|
5940
5973
|
}
|
|
5941
5974
|
}
|
|
5942
5975
|
};
|
|
5943
|
-
async function uploadCached(cfg, path, opts) {
|
|
5944
|
-
const
|
|
5945
|
-
const
|
|
5976
|
+
async function uploadCached(cfg, path, opts, deps = defaultUploadCacheDeps) {
|
|
5977
|
+
const s0 = await deps.stat(path);
|
|
5978
|
+
const fp = fingerprintFromStat(s0);
|
|
5979
|
+
const cache = await deps.cacheStore.load();
|
|
5946
5980
|
const hit = cache[fp]?.fileId;
|
|
5947
5981
|
if (!opts?.force && hit)
|
|
5948
5982
|
return { fileId: hit, cached: true };
|
|
5949
|
-
const
|
|
5950
|
-
const fileId = s0.size >= CHUNK_THRESHOLD ? await uploadChunked(cfg, path, {
|
|
5983
|
+
const fileId = s0.size >= CHUNK_THRESHOLD ? await deps.uploadChunked(cfg, path, {
|
|
5951
5984
|
fingerprint: fp,
|
|
5952
5985
|
store: fileSessionStore,
|
|
5953
5986
|
force: opts?.force
|
|
5954
|
-
}) : await uploadFile(cfg, path);
|
|
5955
|
-
const s = await
|
|
5987
|
+
}) : await deps.uploadFile(cfg, path);
|
|
5988
|
+
const s = await deps.stat(path);
|
|
5989
|
+
if (s.size !== s0.size || Math.round(s.mtimeMs) !== Math.round(s0.mtimeMs)) {
|
|
5990
|
+
throw new Error("上传过程中输入文件发生变化,请等待文件写入完成后重试");
|
|
5991
|
+
}
|
|
5956
5992
|
cache[fp] = {
|
|
5957
5993
|
fileId,
|
|
5958
5994
|
size: s.size,
|
|
@@ -5960,10 +5996,50 @@ async function uploadCached(cfg, path, opts) {
|
|
|
5960
5996
|
path,
|
|
5961
5997
|
uploadedAt: Date.now()
|
|
5962
5998
|
};
|
|
5963
|
-
await save(cache);
|
|
5999
|
+
await deps.cacheStore.save(cache);
|
|
5964
6000
|
return { fileId, cached: false };
|
|
5965
6001
|
}
|
|
5966
6002
|
|
|
6003
|
+
// src/lib/upload-submit.ts
|
|
6004
|
+
var MATERIAL_NOT_FOUND = 6004;
|
|
6005
|
+
var DEFAULT_VISIBILITY_BACKOFF_MS = [250, 750, 1500, 3000];
|
|
6006
|
+
var defaultDeps = {
|
|
6007
|
+
uploadCached,
|
|
6008
|
+
invalidateUpload,
|
|
6009
|
+
submitTask,
|
|
6010
|
+
sleep: (ms) => new Promise((resolve3) => setTimeout(resolve3, ms))
|
|
6011
|
+
};
|
|
6012
|
+
async function submitFreshFile(cfg, taskType, fileId, buildPayload, backoffMs, deps) {
|
|
6013
|
+
for (let attempt = 0;; attempt++) {
|
|
6014
|
+
try {
|
|
6015
|
+
return await deps.submitTask(cfg, taskType, buildPayload(fileId));
|
|
6016
|
+
} catch (error) {
|
|
6017
|
+
if (cloudErrorCode(error) !== MATERIAL_NOT_FOUND || attempt >= backoffMs.length)
|
|
6018
|
+
throw error;
|
|
6019
|
+
await deps.sleep(backoffMs[attempt]);
|
|
6020
|
+
}
|
|
6021
|
+
}
|
|
6022
|
+
}
|
|
6023
|
+
async function uploadAndSubmitTask(cfg, path, taskType, buildPayload, options = {}, deps = defaultDeps) {
|
|
6024
|
+
let uploaded = await deps.uploadCached(cfg, path, { force: options.force });
|
|
6025
|
+
options.onUploaded?.(uploaded);
|
|
6026
|
+
const backoffMs = options.visibilityBackoffMs ?? DEFAULT_VISIBILITY_BACKOFF_MS;
|
|
6027
|
+
if (uploaded.cached) {
|
|
6028
|
+
try {
|
|
6029
|
+
const taskId2 = await deps.submitTask(cfg, taskType, buildPayload(uploaded.fileId));
|
|
6030
|
+
return { taskId: taskId2, fileId: uploaded.fileId, cached: true };
|
|
6031
|
+
} catch (error) {
|
|
6032
|
+
if (cloudErrorCode(error) !== MATERIAL_NOT_FOUND)
|
|
6033
|
+
throw error;
|
|
6034
|
+
options.onCacheInvalid?.();
|
|
6035
|
+
await deps.invalidateUpload(path);
|
|
6036
|
+
uploaded = await deps.uploadCached(cfg, path, { force: true });
|
|
6037
|
+
}
|
|
6038
|
+
}
|
|
6039
|
+
const taskId = await submitFreshFile(cfg, taskType, uploaded.fileId, buildPayload, backoffMs, deps);
|
|
6040
|
+
return { taskId, fileId: uploaded.fileId, cached: false };
|
|
6041
|
+
}
|
|
6042
|
+
|
|
5967
6043
|
// src/lib/media.ts
|
|
5968
6044
|
import { mkdir as mkdir2, stat as stat4 } from "node:fs/promises";
|
|
5969
6045
|
import { existsSync as existsSync10 } from "node:fs";
|
|
@@ -6491,8 +6567,6 @@ async function runOralCut(input, opts) {
|
|
|
6491
6567
|
assertDurationConsistent(geo.duration, artifact, opts.ffmpegPath);
|
|
6492
6568
|
log.info(opts.visualAssist ? `已压 720p 代理(上传物):${basename5(artifact)}` : `已抽 16k 单声道 mp3(上传物):${basename5(artifact)}`);
|
|
6493
6569
|
log.step("② 上传抽出物到云端…");
|
|
6494
|
-
let up = await uploadCached(cfg, artifact, { force: opts.reupload });
|
|
6495
|
-
log.info(up.cached ? `命中上传缓存,复用 file_id = ${up.fileId}(免二次上传)` : `file_id = ${up.fileId}`);
|
|
6496
6570
|
const buildPayload = (fid) => {
|
|
6497
6571
|
const p = {
|
|
6498
6572
|
file_id: fid,
|
|
@@ -6519,19 +6593,16 @@ async function runOralCut(input, opts) {
|
|
|
6519
6593
|
}
|
|
6520
6594
|
return p;
|
|
6521
6595
|
};
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
} else
|
|
6533
|
-
throw e;
|
|
6534
|
-
}
|
|
6596
|
+
const submitted = await uploadAndSubmitTask(cfg, artifact, TASK_TYPE, buildPayload, {
|
|
6597
|
+
force: opts.reupload,
|
|
6598
|
+
onUploaded: (uploaded) => {
|
|
6599
|
+
log.info(uploaded.cached ? `命中上传缓存,复用 file_id = ${uploaded.fileId}(免二次上传)` : `file_id = ${uploaded.fileId}`);
|
|
6600
|
+
log.step("③ 提交智能口播剪辑任务…");
|
|
6601
|
+
},
|
|
6602
|
+
onCacheInvalid: () => log.warn("缓存的 file_id 在云端已失效,重新上传后重试…")
|
|
6603
|
+
});
|
|
6604
|
+
const { taskId } = submitted;
|
|
6605
|
+
const up = { fileId: submitted.fileId, cached: submitted.cached };
|
|
6535
6606
|
log.info(`task_id = ${taskId}`);
|
|
6536
6607
|
await mkdir4(outDir, { recursive: true });
|
|
6537
6608
|
await writeFile5(join14(outDir, "task.json"), JSON.stringify({ taskId, taskType: TASK_TYPE, fileId: up.fileId, source: inputAbs, formats, createdAt: new Date().toISOString() }, null, 2));
|
|
@@ -8180,6 +8251,21 @@ import { extname as extname4 } from "node:path";
|
|
|
8180
8251
|
var IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tif", ".tiff", ".heic", ".heif", ".avif"];
|
|
8181
8252
|
var VIDEO_EXTS = [".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg", ".ts", ".m2ts"];
|
|
8182
8253
|
var AUDIO_EXTS = [".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma"];
|
|
8254
|
+
var PUBLIC_VIDEO_EXTS = [
|
|
8255
|
+
".mp4",
|
|
8256
|
+
".avi",
|
|
8257
|
+
".mpg",
|
|
8258
|
+
".mov",
|
|
8259
|
+
".flv",
|
|
8260
|
+
".mxf",
|
|
8261
|
+
".mpeg",
|
|
8262
|
+
".ogg",
|
|
8263
|
+
".3gp",
|
|
8264
|
+
".wmv",
|
|
8265
|
+
".h264",
|
|
8266
|
+
".m4v",
|
|
8267
|
+
".ts"
|
|
8268
|
+
];
|
|
8183
8269
|
function defaultExtsFor(kind) {
|
|
8184
8270
|
if (kind === "image")
|
|
8185
8271
|
return IMAGE_EXTS;
|
|
@@ -8228,7 +8314,7 @@ var imageMove = {
|
|
|
8228
8314
|
description: "把一张静态图生成带运镜的短视频。",
|
|
8229
8315
|
kind: "cloud",
|
|
8230
8316
|
input: { kind: "image" },
|
|
8231
|
-
|
|
8317
|
+
priceKey: "image_move_local",
|
|
8232
8318
|
outputHint: "运镜视频",
|
|
8233
8319
|
enabled: true,
|
|
8234
8320
|
taskType: "image_move",
|
|
@@ -8261,7 +8347,7 @@ var imageMatting = {
|
|
|
8261
8347
|
description: "把图片主体从背景抠出,产透明背景 png(可经 --param 请求额外背景底板输出)。",
|
|
8262
8348
|
kind: "cloud",
|
|
8263
8349
|
input: { kind: "image" },
|
|
8264
|
-
|
|
8350
|
+
priceKey: "image_matting",
|
|
8265
8351
|
outputHint: "透明 png",
|
|
8266
8352
|
enabled: true,
|
|
8267
8353
|
taskType: "image_matting",
|
|
@@ -8279,13 +8365,382 @@ var imageMatting = {
|
|
|
8279
8365
|
return files;
|
|
8280
8366
|
}
|
|
8281
8367
|
};
|
|
8368
|
+
var imageBlackborderRemove = {
|
|
8369
|
+
name: "image_blackborder_remove",
|
|
8370
|
+
title: "图片去黑边",
|
|
8371
|
+
description: "自动检测并裁去单张图片四周的黑边,保留有效画面。",
|
|
8372
|
+
kind: "cloud",
|
|
8373
|
+
input: { kind: "image" },
|
|
8374
|
+
priceKey: "image_blackborder_remove",
|
|
8375
|
+
outputHint: "去黑边图片",
|
|
8376
|
+
enabled: true,
|
|
8377
|
+
taskType: "image_blackborder_remove",
|
|
8378
|
+
buildPayload(fileId) {
|
|
8379
|
+
return { file_id: fileId };
|
|
8380
|
+
},
|
|
8381
|
+
mapOutputs(out, ctx) {
|
|
8382
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8383
|
+
return url ? [{ url, filename: `${ctx.baseName}-blackborder-removed${extFromUrl(url, ".jpg")}` }] : [];
|
|
8384
|
+
}
|
|
8385
|
+
};
|
|
8386
|
+
var imageCanvasAdapt = {
|
|
8387
|
+
name: "image_canvas_adapt",
|
|
8388
|
+
title: "图片比例转换",
|
|
8389
|
+
description: "把单张图片适配到目标画布尺寸,可选适配、矩形裁剪或方形裁剪。",
|
|
8390
|
+
kind: "cloud",
|
|
8391
|
+
input: { kind: "image" },
|
|
8392
|
+
priceKey: "image_canvas_adapt",
|
|
8393
|
+
outputHint: "比例适配图片",
|
|
8394
|
+
enabled: true,
|
|
8395
|
+
taskType: "image_canvas_adapt",
|
|
8396
|
+
options: [
|
|
8397
|
+
{ flag: "--canvas-width <px>", desc: "目标画布宽度(像素;未传则使用服务端默认)" },
|
|
8398
|
+
{ flag: "--canvas-height <px>", desc: "目标画布高度(像素;未传则使用服务端默认)" },
|
|
8399
|
+
{
|
|
8400
|
+
flag: "--canvas-type <normal|rectangle|square>",
|
|
8401
|
+
desc: "画布模式:normal、rectangle 或 square(未传则使用服务端默认)"
|
|
8402
|
+
}
|
|
8403
|
+
],
|
|
8404
|
+
buildPayload(fileId, ctx) {
|
|
8405
|
+
const payload = { file_id: fileId };
|
|
8406
|
+
for (const [optKey, payloadKey, flag] of [
|
|
8407
|
+
["canvasWidth", "canvas_width", "--canvas-width"],
|
|
8408
|
+
["canvasHeight", "canvas_height", "--canvas-height"]
|
|
8409
|
+
]) {
|
|
8410
|
+
if (ctx.opts[optKey] == null)
|
|
8411
|
+
continue;
|
|
8412
|
+
const value = Number(ctx.opts[optKey]);
|
|
8413
|
+
if (!Number.isFinite(value))
|
|
8414
|
+
throw new Error(`${flag} 必须是数字`);
|
|
8415
|
+
payload[payloadKey] = value;
|
|
8416
|
+
}
|
|
8417
|
+
if (ctx.opts.canvasType != null) {
|
|
8418
|
+
const value = String(ctx.opts.canvasType);
|
|
8419
|
+
if (value !== "normal" && value !== "rectangle" && value !== "square") {
|
|
8420
|
+
throw new Error("--canvas-type 只支持 normal、rectangle 或 square");
|
|
8421
|
+
}
|
|
8422
|
+
payload.canvas_type = value;
|
|
8423
|
+
}
|
|
8424
|
+
return payload;
|
|
8425
|
+
},
|
|
8426
|
+
mapOutputs(out, ctx) {
|
|
8427
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8428
|
+
return url ? [{ url, filename: `${ctx.baseName}-canvas-adapted${extFromUrl(url, ".jpg")}` }] : [];
|
|
8429
|
+
}
|
|
8430
|
+
};
|
|
8431
|
+
var imagePurify = {
|
|
8432
|
+
name: "image_purify",
|
|
8433
|
+
title: "图片净化",
|
|
8434
|
+
description: "清理你有权处理的图片中的水印、Logo 或叠加元素。",
|
|
8435
|
+
kind: "cloud",
|
|
8436
|
+
input: { kind: "image" },
|
|
8437
|
+
priceKey: "image_purify",
|
|
8438
|
+
outputHint: "净化图片",
|
|
8439
|
+
enabled: true,
|
|
8440
|
+
taskType: "image_purify",
|
|
8441
|
+
buildPayload(fileId) {
|
|
8442
|
+
return { file_id: fileId };
|
|
8443
|
+
},
|
|
8444
|
+
mapOutputs(out, ctx) {
|
|
8445
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8446
|
+
return url ? [{ url, filename: `${ctx.baseName}-purified${extFromUrl(url, ".jpg")}` }] : [];
|
|
8447
|
+
}
|
|
8448
|
+
};
|
|
8449
|
+
var videoBlackborderRemove = {
|
|
8450
|
+
name: "video_blackborder_remove",
|
|
8451
|
+
title: "视频去黑边",
|
|
8452
|
+
description: "自动检测并裁去单条视频四周的黑边,保留有效画面与原音轨。",
|
|
8453
|
+
kind: "cloud",
|
|
8454
|
+
input: { kind: "video", exts: PUBLIC_VIDEO_EXTS },
|
|
8455
|
+
priceKey: "video_blackborder_remove",
|
|
8456
|
+
outputHint: "去黑边视频",
|
|
8457
|
+
enabled: true,
|
|
8458
|
+
taskType: "video_blackborder_remove",
|
|
8459
|
+
buildPayload(fileId) {
|
|
8460
|
+
return { file_id: fileId };
|
|
8461
|
+
},
|
|
8462
|
+
mapOutputs(out, ctx) {
|
|
8463
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8464
|
+
return url ? [{ url, filename: `${ctx.baseName}-blackborder-removed${extFromUrl(url, ".mp4")}` }] : [];
|
|
8465
|
+
}
|
|
8466
|
+
};
|
|
8467
|
+
var videoCanvasAdapt = {
|
|
8468
|
+
name: "video_canvas_adapt",
|
|
8469
|
+
title: "视频比例转换",
|
|
8470
|
+
description: "把单条视频适配到目标画布,可选截取时间片段并移除音轨。",
|
|
8471
|
+
kind: "cloud",
|
|
8472
|
+
input: { kind: "video", exts: PUBLIC_VIDEO_EXTS },
|
|
8473
|
+
priceKey: "video_canvas_adapt",
|
|
8474
|
+
outputHint: "比例适配视频",
|
|
8475
|
+
enabled: true,
|
|
8476
|
+
taskType: "video_canvas_adapt",
|
|
8477
|
+
options: [
|
|
8478
|
+
{ flag: "--canvas-width <px>", desc: "目标画布宽度(像素;未传则使用服务端默认)" },
|
|
8479
|
+
{ flag: "--canvas-height <px>", desc: "目标画布高度(像素;未传则使用服务端默认)" },
|
|
8480
|
+
{
|
|
8481
|
+
flag: "--canvas-type <normal|rectangle|square>",
|
|
8482
|
+
desc: "画布模式:normal、rectangle 或 square(未传则使用服务端默认)"
|
|
8483
|
+
},
|
|
8484
|
+
{ flag: "--clip-start <frame>", desc: "截取起始帧序号(未传则使用服务端默认)" },
|
|
8485
|
+
{ flag: "--clip-end <frame>", desc: "截取结束帧序号(未传则使用服务端默认)" },
|
|
8486
|
+
{ flag: "--without-audio", desc: "输出视频不保留音轨" }
|
|
8487
|
+
],
|
|
8488
|
+
buildPayload(fileId, ctx) {
|
|
8489
|
+
const payload = { file_id: fileId };
|
|
8490
|
+
for (const [optKey, payloadKey, flag] of [
|
|
8491
|
+
["canvasWidth", "target_width", "--canvas-width"],
|
|
8492
|
+
["canvasHeight", "target_height", "--canvas-height"]
|
|
8493
|
+
]) {
|
|
8494
|
+
if (ctx.opts[optKey] == null)
|
|
8495
|
+
continue;
|
|
8496
|
+
const value = Number(ctx.opts[optKey]);
|
|
8497
|
+
if (!Number.isFinite(value) || !Number.isInteger(value)) {
|
|
8498
|
+
throw new Error(`${flag} 必须是有限整数`);
|
|
8499
|
+
}
|
|
8500
|
+
payload[payloadKey] = value;
|
|
8501
|
+
}
|
|
8502
|
+
for (const [optKey, payloadKey, flag] of [
|
|
8503
|
+
["clipStart", "start", "--clip-start"],
|
|
8504
|
+
["clipEnd", "end", "--clip-end"]
|
|
8505
|
+
]) {
|
|
8506
|
+
if (ctx.opts[optKey] == null)
|
|
8507
|
+
continue;
|
|
8508
|
+
const value = Number(ctx.opts[optKey]);
|
|
8509
|
+
if (!Number.isFinite(value) || !Number.isInteger(value)) {
|
|
8510
|
+
throw new Error(`${flag} 必须是有限整数帧号`);
|
|
8511
|
+
}
|
|
8512
|
+
payload[payloadKey] = value;
|
|
8513
|
+
}
|
|
8514
|
+
if (ctx.opts.canvasType != null) {
|
|
8515
|
+
const value = String(ctx.opts.canvasType);
|
|
8516
|
+
if (value !== "normal" && value !== "rectangle" && value !== "square") {
|
|
8517
|
+
throw new Error("--canvas-type 只支持 normal、rectangle 或 square");
|
|
8518
|
+
}
|
|
8519
|
+
payload.canvas_type = value;
|
|
8520
|
+
}
|
|
8521
|
+
if (ctx.opts.withoutAudio === true)
|
|
8522
|
+
payload.need_audio = false;
|
|
8523
|
+
return payload;
|
|
8524
|
+
},
|
|
8525
|
+
mapOutputs(out, ctx) {
|
|
8526
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8527
|
+
return url ? [{ url, filename: `${ctx.baseName}-canvas-adapted${extFromUrl(url, ".mp4")}` }] : [];
|
|
8528
|
+
}
|
|
8529
|
+
};
|
|
8530
|
+
var videoStabilizer = {
|
|
8531
|
+
name: "video_stabilizer",
|
|
8532
|
+
title: "视频防抖",
|
|
8533
|
+
description: "稳定手持或运动拍摄画面;exp 为实验方式,产物观感需自行检查。",
|
|
8534
|
+
kind: "cloud",
|
|
8535
|
+
input: { kind: "video", exts: PUBLIC_VIDEO_EXTS },
|
|
8536
|
+
priceKey: "video_stabilizer",
|
|
8537
|
+
outputHint: "防抖视频",
|
|
8538
|
+
enabled: true,
|
|
8539
|
+
taskType: "video_stabilizer",
|
|
8540
|
+
options: [{ flag: "--stabilizer-method <fast|exp|turbo>", desc: "防抖方式(未传则使用服务端 turbo 默认值)" }],
|
|
8541
|
+
buildPayload(fileId, ctx) {
|
|
8542
|
+
const payload = { file_id: fileId };
|
|
8543
|
+
if (ctx.opts.stabilizerMethod == null)
|
|
8544
|
+
return payload;
|
|
8545
|
+
const method = String(ctx.opts.stabilizerMethod);
|
|
8546
|
+
if (method !== "fast" && method !== "exp" && method !== "turbo") {
|
|
8547
|
+
throw new Error("--stabilizer-method 只支持 fast、exp 或 turbo");
|
|
8548
|
+
}
|
|
8549
|
+
payload.method = method;
|
|
8550
|
+
return payload;
|
|
8551
|
+
},
|
|
8552
|
+
mapOutputs(out, ctx) {
|
|
8553
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8554
|
+
return url ? [{ url, filename: `${ctx.baseName}-stabilized${extFromUrl(url, ".mp4")}` }] : [];
|
|
8555
|
+
}
|
|
8556
|
+
};
|
|
8557
|
+
var videoVaporwave = {
|
|
8558
|
+
name: "video_vaporwave",
|
|
8559
|
+
title: "视频蒸汽波滤镜",
|
|
8560
|
+
description: "按精确预设名称为单条视频应用蒸汽波风格滤镜。",
|
|
8561
|
+
kind: "cloud",
|
|
8562
|
+
input: { kind: "video", exts: PUBLIC_VIDEO_EXTS },
|
|
8563
|
+
priceKey: "video_vaporwave",
|
|
8564
|
+
outputHint: "蒸汽波滤镜视频",
|
|
8565
|
+
enabled: true,
|
|
8566
|
+
taskType: "video_vaporwave",
|
|
8567
|
+
options: [{ flag: "--vaporwave-filter <name>", desc: "精确滤镜名称(默认:愈漸升溫)" }],
|
|
8568
|
+
buildPayload(fileId, ctx) {
|
|
8569
|
+
const raw = ctx.opts.vaporwaveFilter;
|
|
8570
|
+
const filter = raw == null ? "愈漸升溫" : String(raw);
|
|
8571
|
+
if (!filter.trim())
|
|
8572
|
+
throw new Error("--vaporwave-filter 不能为空");
|
|
8573
|
+
return { file_id: fileId, filter };
|
|
8574
|
+
},
|
|
8575
|
+
mapOutputs(out, ctx) {
|
|
8576
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8577
|
+
return url ? [{ url, filename: `${ctx.baseName}-vaporwave${extFromUrl(url, ".mp4")}` }] : [];
|
|
8578
|
+
}
|
|
8579
|
+
};
|
|
8580
|
+
function parseNormalizedRoi(value) {
|
|
8581
|
+
let rawValues;
|
|
8582
|
+
let acceptNumericStrings = false;
|
|
8583
|
+
if (typeof value === "string") {
|
|
8584
|
+
acceptNumericStrings = true;
|
|
8585
|
+
const parts = value.split(",");
|
|
8586
|
+
if (parts.length !== 4 || parts.some((part) => !part.trim())) {
|
|
8587
|
+
throw new Error("--purify-roi 必须是 x,y,w,h 四个归一化数字");
|
|
8588
|
+
}
|
|
8589
|
+
rawValues = parts;
|
|
8590
|
+
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
8591
|
+
const roi = value;
|
|
8592
|
+
rawValues = [roi.x, roi.y, roi.w, roi.h];
|
|
8593
|
+
} else {
|
|
8594
|
+
throw new Error("ROI 必须包含归一化数字 x、y、w、h");
|
|
8595
|
+
}
|
|
8596
|
+
const numbers = rawValues.map((item) => {
|
|
8597
|
+
if (typeof item === "number")
|
|
8598
|
+
return item;
|
|
8599
|
+
if (acceptNumericStrings && typeof item === "string")
|
|
8600
|
+
return Number(item);
|
|
8601
|
+
return Number.NaN;
|
|
8602
|
+
});
|
|
8603
|
+
if (numbers.some((item) => !Number.isFinite(item))) {
|
|
8604
|
+
throw new Error("ROI 的 x、y、w、h 必须是有限数字");
|
|
8605
|
+
}
|
|
8606
|
+
const [x, y, w, h] = numbers;
|
|
8607
|
+
if (x < 0 || x > 1 || y < 0 || y > 1 || w <= 0 || w > 1 || h <= 0 || h > 1 || x + w > 1 || y + h > 1) {
|
|
8608
|
+
throw new Error("ROI 必须满足 0≤x,y≤1、0<w,h≤1、x+w≤1、y+h≤1");
|
|
8609
|
+
}
|
|
8610
|
+
return { x, y, w, h };
|
|
8611
|
+
}
|
|
8612
|
+
var videoPurify = {
|
|
8613
|
+
name: "video_purify",
|
|
8614
|
+
title: "视频净化",
|
|
8615
|
+
description: "清理你有权处理的视频中的水印、字幕或指定区域;不承诺还原被遮挡的原始内容。",
|
|
8616
|
+
kind: "cloud",
|
|
8617
|
+
input: { kind: "video", exts: PUBLIC_VIDEO_EXTS },
|
|
8618
|
+
priceKey: "video_purify",
|
|
8619
|
+
outputHint: "净化视频",
|
|
8620
|
+
enabled: true,
|
|
8621
|
+
taskType: "video_purify",
|
|
8622
|
+
pollTimeoutMs: 4 * 60 * 60 * 1000,
|
|
8623
|
+
options: [
|
|
8624
|
+
{
|
|
8625
|
+
flag: "--purify-scope <full_screen|subtitle|custom>",
|
|
8626
|
+
desc: "净化范围;未传时使用服务端 full_screen 默认值"
|
|
8627
|
+
},
|
|
8628
|
+
{
|
|
8629
|
+
flag: "--purify-method <ffmpeg|raft>",
|
|
8630
|
+
desc: "净化方式;未传时使用服务端 ffmpeg 默认值,raft 仅支持 20 分钟以内视频"
|
|
8631
|
+
},
|
|
8632
|
+
{ flag: "--purify-roi <x,y,w,h>", desc: "custom 模式的归一化矩形区域" }
|
|
8633
|
+
],
|
|
8634
|
+
buildPayload(fileId, ctx) {
|
|
8635
|
+
const payload = { file_id: fileId };
|
|
8636
|
+
const scopeRaw = ctx.opts.purifyScope;
|
|
8637
|
+
const scope = scopeRaw == null ? undefined : String(scopeRaw);
|
|
8638
|
+
if (scope != null && scope !== "full_screen" && scope !== "subtitle" && scope !== "custom") {
|
|
8639
|
+
throw new Error("--purify-scope 只支持 full_screen、subtitle 或 custom");
|
|
8640
|
+
}
|
|
8641
|
+
if (scope != null)
|
|
8642
|
+
payload.purify_scope = scope;
|
|
8643
|
+
const methodRaw = ctx.opts.purifyMethod;
|
|
8644
|
+
const method = methodRaw == null ? undefined : String(methodRaw);
|
|
8645
|
+
if (method != null && method !== "ffmpeg" && method !== "raft") {
|
|
8646
|
+
throw new Error("--purify-method 只支持 ffmpeg 或 raft");
|
|
8647
|
+
}
|
|
8648
|
+
if (method != null)
|
|
8649
|
+
payload.purify_func_type = method;
|
|
8650
|
+
const roiRaw = ctx.opts.purifyRoi;
|
|
8651
|
+
if (roiRaw != null) {
|
|
8652
|
+
if (scope !== "custom") {
|
|
8653
|
+
throw new Error("--purify-roi 只能与 --purify-scope custom 一起使用");
|
|
8654
|
+
}
|
|
8655
|
+
payload.roi = parseNormalizedRoi(roiRaw);
|
|
8656
|
+
} else if (scope === "custom") {
|
|
8657
|
+
if (ctx.extraParams.roi == null) {
|
|
8658
|
+
throw new Error("--purify-scope custom 必须同时提供 --purify-roi 或 params-json.roi");
|
|
8659
|
+
}
|
|
8660
|
+
parseNormalizedRoi(ctx.extraParams.roi);
|
|
8661
|
+
}
|
|
8662
|
+
return payload;
|
|
8663
|
+
},
|
|
8664
|
+
mapOutputs(out, ctx) {
|
|
8665
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8666
|
+
return url ? [{ url, filename: `${ctx.baseName}-purified${extFromUrl(url, ".mp4")}` }] : [];
|
|
8667
|
+
}
|
|
8668
|
+
};
|
|
8669
|
+
var videoUpscale = {
|
|
8670
|
+
name: "video_upscale",
|
|
8671
|
+
title: "视频超分",
|
|
8672
|
+
description: "对一分钟以内的低分辨率视频做实验性 GPU 超分;效果需自行检查。",
|
|
8673
|
+
kind: "cloud",
|
|
8674
|
+
input: { kind: "video", exts: PUBLIC_VIDEO_EXTS, maxDurationSec: 60 },
|
|
8675
|
+
priceKey: "video_upscale",
|
|
8676
|
+
outputHint: "超分视频",
|
|
8677
|
+
enabled: true,
|
|
8678
|
+
taskType: "video_upscale",
|
|
8679
|
+
pollTimeoutMs: 4 * 60 * 60 * 1000,
|
|
8680
|
+
options: [
|
|
8681
|
+
{ flag: "--upscale-times <2|3|4>", desc: "超分倍数;未传时使用服务端 2 倍默认值" },
|
|
8682
|
+
{ flag: "--upscale-type <Reality|Anime>", desc: "写实或动漫类型;未传时使用服务端 Reality 默认值" }
|
|
8683
|
+
],
|
|
8684
|
+
buildPayload(fileId, ctx) {
|
|
8685
|
+
const payload = { file_id: fileId };
|
|
8686
|
+
if (ctx.opts.upscaleTimes != null) {
|
|
8687
|
+
const times = Number(ctx.opts.upscaleTimes);
|
|
8688
|
+
if (!Number.isInteger(times) || times !== 2 && times !== 3 && times !== 4) {
|
|
8689
|
+
throw new Error("--upscale-times 只支持 2、3 或 4");
|
|
8690
|
+
}
|
|
8691
|
+
payload.times = times;
|
|
8692
|
+
}
|
|
8693
|
+
if (ctx.opts.upscaleType != null) {
|
|
8694
|
+
const upscaleType = String(ctx.opts.upscaleType);
|
|
8695
|
+
if (upscaleType !== "Reality" && upscaleType !== "Anime") {
|
|
8696
|
+
throw new Error("--upscale-type 只支持 Reality 或 Anime");
|
|
8697
|
+
}
|
|
8698
|
+
payload.upscale_type = upscaleType;
|
|
8699
|
+
}
|
|
8700
|
+
return payload;
|
|
8701
|
+
},
|
|
8702
|
+
mapOutputs(out, ctx) {
|
|
8703
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8704
|
+
return url ? [{ url, filename: `${ctx.baseName}-upscaled${extFromUrl(url, ".mp4")}` }] : [];
|
|
8705
|
+
}
|
|
8706
|
+
};
|
|
8707
|
+
var videoInterpolate = {
|
|
8708
|
+
name: "video_interpolate",
|
|
8709
|
+
title: "视频插帧",
|
|
8710
|
+
description: "以 GPU 帧插值提升视频流畅度;不附加未经服务端声明的时长限制。",
|
|
8711
|
+
kind: "cloud",
|
|
8712
|
+
input: { kind: "video", exts: PUBLIC_VIDEO_EXTS },
|
|
8713
|
+
priceKey: "video_interpolate",
|
|
8714
|
+
outputHint: "插帧视频",
|
|
8715
|
+
enabled: true,
|
|
8716
|
+
taskType: "video_interpolate",
|
|
8717
|
+
pollTimeoutMs: 4 * 60 * 60 * 1000,
|
|
8718
|
+
options: [
|
|
8719
|
+
{ flag: "--interpolate-multiplier <2|3|4>", desc: "插帧倍数;未传时使用服务端 2 倍默认值" }
|
|
8720
|
+
],
|
|
8721
|
+
buildPayload(fileId, ctx) {
|
|
8722
|
+
const payload = { file_id: fileId };
|
|
8723
|
+
if (ctx.opts.interpolateMultiplier != null) {
|
|
8724
|
+
const multiplier = Number(ctx.opts.interpolateMultiplier);
|
|
8725
|
+
if (!Number.isInteger(multiplier) || multiplier !== 2 && multiplier !== 3 && multiplier !== 4) {
|
|
8726
|
+
throw new Error("--interpolate-multiplier 只支持 2、3 或 4");
|
|
8727
|
+
}
|
|
8728
|
+
payload.multiplier = multiplier;
|
|
8729
|
+
}
|
|
8730
|
+
return payload;
|
|
8731
|
+
},
|
|
8732
|
+
mapOutputs(out, ctx) {
|
|
8733
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8734
|
+
return url ? [{ url, filename: `${ctx.baseName}-interpolated${extFromUrl(url, ".mp4")}` }] : [];
|
|
8735
|
+
}
|
|
8736
|
+
};
|
|
8282
8737
|
var videoMatting = {
|
|
8283
8738
|
name: "video_matting",
|
|
8284
8739
|
title: "视频抠像",
|
|
8285
8740
|
description: "把视频主体从背景抠出,产透明背景 webm(像素级、原片直传不压代理;单片 ≤10 分钟)。",
|
|
8286
8741
|
kind: "cloud",
|
|
8287
8742
|
input: { kind: "video", maxDurationSec: 600 },
|
|
8288
|
-
|
|
8743
|
+
priceKey: "video_matting",
|
|
8289
8744
|
outputHint: "透明 webm",
|
|
8290
8745
|
enabled: true,
|
|
8291
8746
|
taskType: "video_matting",
|
|
@@ -8304,13 +8759,109 @@ var videoMatting = {
|
|
|
8304
8759
|
return files;
|
|
8305
8760
|
}
|
|
8306
8761
|
};
|
|
8762
|
+
var audioSeparation = {
|
|
8763
|
+
name: "audio_separation",
|
|
8764
|
+
title: "人声伴奏分离",
|
|
8765
|
+
description: "把单条音频分离为人声与伴奏,可返回其中一项或两项。",
|
|
8766
|
+
kind: "cloud",
|
|
8767
|
+
input: { kind: "audio" },
|
|
8768
|
+
priceKey: "audio_separation",
|
|
8769
|
+
outputHint: "人声与伴奏音频",
|
|
8770
|
+
enabled: true,
|
|
8771
|
+
taskType: "audio_separation",
|
|
8772
|
+
options: [{ flag: "--mode <fast|turbo>", desc: "处理档位(默认 fast,可选 turbo)" }],
|
|
8773
|
+
buildPayload(fileId, ctx) {
|
|
8774
|
+
const raw = ctx.opts.mode;
|
|
8775
|
+
const mode = raw == null ? "fast" : String(raw);
|
|
8776
|
+
if (mode !== "fast" && mode !== "turbo")
|
|
8777
|
+
throw new Error("--mode 只支持 fast 或 turbo");
|
|
8778
|
+
return { file_id: fileId, mode };
|
|
8779
|
+
},
|
|
8780
|
+
mapOutputs(out, ctx) {
|
|
8781
|
+
if (!Array.isArray(out.files))
|
|
8782
|
+
return [];
|
|
8783
|
+
const items = [];
|
|
8784
|
+
for (const raw of out.files) {
|
|
8785
|
+
if (!raw || typeof raw !== "object")
|
|
8786
|
+
continue;
|
|
8787
|
+
const file = raw;
|
|
8788
|
+
const type = file.type;
|
|
8789
|
+
const url = file.download_url;
|
|
8790
|
+
if (type !== "vocals" && type !== "instrumental" || typeof url !== "string" || !url.trim())
|
|
8791
|
+
continue;
|
|
8792
|
+
items.push({ url, filename: `${ctx.baseName}-${type}${extFromUrl(url, ".wav")}` });
|
|
8793
|
+
}
|
|
8794
|
+
return items;
|
|
8795
|
+
}
|
|
8796
|
+
};
|
|
8797
|
+
var audioNoiseReduce = {
|
|
8798
|
+
name: "audio_noise_reduce",
|
|
8799
|
+
title: "音频降噪",
|
|
8800
|
+
description: "对音频或视频中的声音降噪,输出降噪后的音频。",
|
|
8801
|
+
kind: "cloud",
|
|
8802
|
+
input: { kind: "audio", exts: [...AUDIO_EXTS, ...VIDEO_EXTS] },
|
|
8803
|
+
priceKey: "audio_noise_reduce",
|
|
8804
|
+
outputHint: "降噪音频",
|
|
8805
|
+
enabled: true,
|
|
8806
|
+
taskType: "audio_noise_reduce",
|
|
8807
|
+
options: [{ flag: "--prop-decrease <0..1>", desc: "降噪强度(0 到 1;未传则使用服务端默认)" }],
|
|
8808
|
+
buildPayload(fileId, ctx) {
|
|
8809
|
+
const payload = { file_id: fileId };
|
|
8810
|
+
if (ctx.opts.propDecrease != null) {
|
|
8811
|
+
const value = Number(ctx.opts.propDecrease);
|
|
8812
|
+
if (!Number.isFinite(value) || value < 0 || value > 1)
|
|
8813
|
+
throw new Error("--prop-decrease 必须是 0 到 1 的数字");
|
|
8814
|
+
payload.prop_decrease = value;
|
|
8815
|
+
}
|
|
8816
|
+
return payload;
|
|
8817
|
+
},
|
|
8818
|
+
mapOutputs(out, ctx) {
|
|
8819
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8820
|
+
return url ? [{ url, filename: `${ctx.baseName}-denoise${extFromUrl(url, ".wav")}` }] : [];
|
|
8821
|
+
}
|
|
8822
|
+
};
|
|
8823
|
+
var audioSilenceRemove = {
|
|
8824
|
+
name: "audio_silence_remove",
|
|
8825
|
+
title: "静音片段移除",
|
|
8826
|
+
description: "移除音频中过长的静音片段,输出压缩停顿后的音频。",
|
|
8827
|
+
kind: "cloud",
|
|
8828
|
+
input: { kind: "audio" },
|
|
8829
|
+
priceKey: "audio_silence_remove",
|
|
8830
|
+
outputHint: "去静音音频",
|
|
8831
|
+
enabled: true,
|
|
8832
|
+
taskType: "audio_silence_remove",
|
|
8833
|
+
options: [
|
|
8834
|
+
{ flag: "--min-silence-len <ms>", desc: "被视为静音片段的最短毫秒数(未传则使用服务端默认)" },
|
|
8835
|
+
{ flag: "--desired-silence-len <ms>", desc: "处理后保留的静音毫秒数(未传则使用服务端默认)" }
|
|
8836
|
+
],
|
|
8837
|
+
buildPayload(fileId, ctx) {
|
|
8838
|
+
const payload = { file_id: fileId };
|
|
8839
|
+
for (const [optKey, payloadKey, flag] of [
|
|
8840
|
+
["minSilenceLen", "min_silence_len", "--min-silence-len"],
|
|
8841
|
+
["desiredSilenceLen", "desired_silence_len", "--desired-silence-len"]
|
|
8842
|
+
]) {
|
|
8843
|
+
if (ctx.opts[optKey] == null)
|
|
8844
|
+
continue;
|
|
8845
|
+
const value = Number(ctx.opts[optKey]);
|
|
8846
|
+
if (!Number.isFinite(value) || value < 0)
|
|
8847
|
+
throw new Error(`${flag} 必须是非负毫秒数`);
|
|
8848
|
+
payload[payloadKey] = value;
|
|
8849
|
+
}
|
|
8850
|
+
return payload;
|
|
8851
|
+
},
|
|
8852
|
+
mapOutputs(out, ctx) {
|
|
8853
|
+
const url = pickUrl(out, ["download_url"]);
|
|
8854
|
+
return url ? [{ url, filename: `${ctx.baseName}-desilence${extFromUrl(url, ".wav")}` }] : [];
|
|
8855
|
+
}
|
|
8856
|
+
};
|
|
8307
8857
|
var mad = {
|
|
8308
8858
|
name: "mad",
|
|
8309
8859
|
title: "一键剪 MAD",
|
|
8310
8860
|
description: "素材文件夹(3~10 条视频)+ 可选 BGM → 自动选技法 → 单一 .jsx,AE 2020+ 跑一遍出 15~30s 卡点成片工程。仅支持 AE。",
|
|
8311
8861
|
kind: "local",
|
|
8312
8862
|
input: { kind: "directory" },
|
|
8313
|
-
|
|
8863
|
+
priceKey: "audio_music_analyze",
|
|
8864
|
+
pricingContext: "仅 --bgm 卡点时",
|
|
8314
8865
|
outputHint: "AE 母合成工程 .jsx",
|
|
8315
8866
|
enabled: true,
|
|
8316
8867
|
options: [
|
|
@@ -8321,7 +8872,25 @@ var mad = {
|
|
|
8321
8872
|
]
|
|
8322
8873
|
};
|
|
8323
8874
|
var RESERVED_NAMES = new Set(["list"]);
|
|
8324
|
-
var TOOL_REGISTRY = [
|
|
8875
|
+
var TOOL_REGISTRY = [
|
|
8876
|
+
imageMove,
|
|
8877
|
+
imageMatting,
|
|
8878
|
+
imageBlackborderRemove,
|
|
8879
|
+
imageCanvasAdapt,
|
|
8880
|
+
imagePurify,
|
|
8881
|
+
videoMatting,
|
|
8882
|
+
videoBlackborderRemove,
|
|
8883
|
+
videoCanvasAdapt,
|
|
8884
|
+
videoStabilizer,
|
|
8885
|
+
videoVaporwave,
|
|
8886
|
+
videoPurify,
|
|
8887
|
+
videoUpscale,
|
|
8888
|
+
videoInterpolate,
|
|
8889
|
+
audioSeparation,
|
|
8890
|
+
audioNoiseReduce,
|
|
8891
|
+
audioSilenceRemove,
|
|
8892
|
+
mad
|
|
8893
|
+
];
|
|
8325
8894
|
function findTool(name, registry = TOOL_REGISTRY) {
|
|
8326
8895
|
return registry.find((d) => d.name === name);
|
|
8327
8896
|
}
|
|
@@ -8339,6 +8908,8 @@ function validateRegistry(registry = TOOL_REGISTRY) {
|
|
|
8339
8908
|
throw new Error(`未启用工具缺 disabledReason:「${d.name}」`);
|
|
8340
8909
|
if (d.kind === "cloud" && !d.taskType)
|
|
8341
8910
|
throw new Error(`cloud 型工具缺 taskType:「${d.name}」`);
|
|
8911
|
+
if (d.kind === "cloud" && !d.priceKey)
|
|
8912
|
+
throw new Error(`cloud 型工具缺 priceKey:「${d.name}」`);
|
|
8342
8913
|
}
|
|
8343
8914
|
}
|
|
8344
8915
|
|
|
@@ -8348,6 +8919,87 @@ import { mkdir as mkdir8, writeFile as writeFile8, stat as stat5 } from "node:fs
|
|
|
8348
8919
|
import { createWriteStream, existsSync as existsSync17 } from "node:fs";
|
|
8349
8920
|
import { Readable as Readable2 } from "node:stream";
|
|
8350
8921
|
import { pipeline } from "node:stream/promises";
|
|
8922
|
+
|
|
8923
|
+
// src/lib/tool-pricing.ts
|
|
8924
|
+
var TOOL_PRICE_LIST_URL = "https://cloud.ai-mcn.tv/api/get_price_list";
|
|
8925
|
+
var PRICE_UNAVAILABLE_HINT = "实时价格暂不可用,以服务端结算为准";
|
|
8926
|
+
var PRICE_REQUEST_TIMEOUT_MS = 5000;
|
|
8927
|
+
function validNumber(v) {
|
|
8928
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
8929
|
+
}
|
|
8930
|
+
function parseToolPriceList(value) {
|
|
8931
|
+
if (!Array.isArray(value))
|
|
8932
|
+
throw new Error("价格表响应不是数组");
|
|
8933
|
+
const prices = new Map;
|
|
8934
|
+
for (const raw of value) {
|
|
8935
|
+
if (!raw || typeof raw !== "object")
|
|
8936
|
+
continue;
|
|
8937
|
+
const item = raw;
|
|
8938
|
+
const key = typeof item.key === "string" ? item.key.trim() : "";
|
|
8939
|
+
const measure = typeof item.measure === "string" ? item.measure.trim() : "";
|
|
8940
|
+
if (!key || !measure || !validNumber(item.price) || !validNumber(item.exPrice))
|
|
8941
|
+
continue;
|
|
8942
|
+
prices.set(key, {
|
|
8943
|
+
...validNumber(item.taskTypeId) ? { taskTypeId: item.taskTypeId } : {},
|
|
8944
|
+
...typeof item.name === "string" ? { name: item.name } : {},
|
|
8945
|
+
key,
|
|
8946
|
+
price: item.price,
|
|
8947
|
+
exPrice: item.exPrice,
|
|
8948
|
+
measure,
|
|
8949
|
+
...typeof item.note === "string" ? { note: item.note } : {}
|
|
8950
|
+
});
|
|
8951
|
+
}
|
|
8952
|
+
return prices;
|
|
8953
|
+
}
|
|
8954
|
+
async function fetchToolPrices(fetchFn = fetch) {
|
|
8955
|
+
const res = await fetchFn(TOOL_PRICE_LIST_URL, {
|
|
8956
|
+
method: "GET",
|
|
8957
|
+
headers: { Accept: "application/json" },
|
|
8958
|
+
signal: AbortSignal.timeout(PRICE_REQUEST_TIMEOUT_MS)
|
|
8959
|
+
});
|
|
8960
|
+
if (!res.ok)
|
|
8961
|
+
throw new Error(`价格表请求失败 HTTP ${res.status}`);
|
|
8962
|
+
return parseToolPriceList(await res.json());
|
|
8963
|
+
}
|
|
8964
|
+
function formatToolPrice(item, pricingContext) {
|
|
8965
|
+
let price;
|
|
8966
|
+
if (item.price === 0 && item.exPrice === 0) {
|
|
8967
|
+
price = `免费(0 积分/${item.measure})`;
|
|
8968
|
+
} else if (item.price === item.exPrice) {
|
|
8969
|
+
price = `${item.price} 积分/${item.measure}`;
|
|
8970
|
+
} else {
|
|
8971
|
+
price = `标准价 ${item.price} 积分/${item.measure};超额价 ${item.exPrice} 积分/${item.measure}`;
|
|
8972
|
+
}
|
|
8973
|
+
return pricingContext ? `${pricingContext}:${price}` : price;
|
|
8974
|
+
}
|
|
8975
|
+
function resolveToolPricingFromMap(priceKey, prices, pricingContext) {
|
|
8976
|
+
const item = prices?.get(priceKey);
|
|
8977
|
+
if (!item) {
|
|
8978
|
+
return {
|
|
8979
|
+
billingHint: pricingContext ? `${pricingContext}:${PRICE_UNAVAILABLE_HINT}` : PRICE_UNAVAILABLE_HINT,
|
|
8980
|
+
pricing: { key: priceKey, available: false }
|
|
8981
|
+
};
|
|
8982
|
+
}
|
|
8983
|
+
return {
|
|
8984
|
+
billingHint: formatToolPrice(item, pricingContext),
|
|
8985
|
+
pricing: {
|
|
8986
|
+
key: priceKey,
|
|
8987
|
+
available: true,
|
|
8988
|
+
price: item.price,
|
|
8989
|
+
exPrice: item.exPrice,
|
|
8990
|
+
measure: item.measure
|
|
8991
|
+
}
|
|
8992
|
+
};
|
|
8993
|
+
}
|
|
8994
|
+
async function resolveToolPricing(priceKey, pricingContext, fetchFn = fetch) {
|
|
8995
|
+
try {
|
|
8996
|
+
return resolveToolPricingFromMap(priceKey, await fetchToolPrices(fetchFn), pricingContext);
|
|
8997
|
+
} catch {
|
|
8998
|
+
return resolveToolPricingFromMap(priceKey, undefined, pricingContext);
|
|
8999
|
+
}
|
|
9000
|
+
}
|
|
9001
|
+
|
|
9002
|
+
// src/lib/tool-runner.ts
|
|
8351
9003
|
var DEFAULT_POLL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
8352
9004
|
var DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
8353
9005
|
function coerceValue2(v) {
|
|
@@ -8502,25 +9154,27 @@ async function runCloudTool(descriptor, inputArg, opts, deps) {
|
|
|
8502
9154
|
uploadPath = await descriptor.preprocess(ctx);
|
|
8503
9155
|
if (!uploadPath)
|
|
8504
9156
|
throw new Error(`${descriptor.name} 缺上传物(input=none 的 cloud 型工具需 preprocess 产上传物)`);
|
|
8505
|
-
|
|
8506
|
-
|
|
9157
|
+
let billingHint;
|
|
9158
|
+
try {
|
|
9159
|
+
billingHint = (await (deps.resolvePricing ?? resolveToolPricing)(descriptor.priceKey, descriptor.pricingContext)).billingHint;
|
|
9160
|
+
} catch {
|
|
9161
|
+
billingHint = "实时价格暂不可用,以服务端结算为准";
|
|
9162
|
+
}
|
|
9163
|
+
emitBilling(billingHint);
|
|
8507
9164
|
const buildPayload = (fid) => {
|
|
8508
9165
|
const p = descriptor.buildPayload ? descriptor.buildPayload(fid, ctx) : { file_id: fid };
|
|
8509
9166
|
mergeParams(p, extraParams);
|
|
8510
9167
|
return p;
|
|
8511
9168
|
};
|
|
8512
9169
|
const taskType = descriptor.taskType;
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
} else
|
|
8522
|
-
throw e;
|
|
8523
|
-
}
|
|
9170
|
+
const submitted = await uploadAndSubmitTask(deps.cfg, uploadPath, taskType, buildPayload, { force: opts.reupload }, {
|
|
9171
|
+
uploadCached: deps.uploadCached,
|
|
9172
|
+
invalidateUpload: deps.invalidateUpload,
|
|
9173
|
+
submitTask: deps.submitTask,
|
|
9174
|
+
sleep: deps.sleep ?? ((ms) => new Promise((resolve10) => setTimeout(resolve10, ms)))
|
|
9175
|
+
});
|
|
9176
|
+
const { taskId } = submitted;
|
|
9177
|
+
const up = { fileId: submitted.fileId, cached: submitted.cached };
|
|
8524
9178
|
await mkdir8(outDir, { recursive: true });
|
|
8525
9179
|
const fingerprint2 = inputAbs ? await safeFingerprint(inputAbs) : undefined;
|
|
8526
9180
|
await writeFile8(join21(outDir, "task.json"), JSON.stringify({ tool: descriptor.name, taskType, taskId, fileId: up.fileId, source: inputAbs, fingerprint: fingerprint2, createdAt: new Date().toISOString() }, null, 2));
|
|
@@ -9733,9 +10387,6 @@ function makeIrLoader(assetsBase, verDir, online, deps) {
|
|
|
9733
10387
|
|
|
9734
10388
|
// src/lib/mad/cloud-beat.ts
|
|
9735
10389
|
var ANALYZE_TASK = "audio_music_analyze";
|
|
9736
|
-
function isCode(e, code) {
|
|
9737
|
-
return !!e && typeof e === "object" && e.code === code;
|
|
9738
|
-
}
|
|
9739
10390
|
function extractAnalysis(output) {
|
|
9740
10391
|
const o = output ?? {};
|
|
9741
10392
|
const arr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "number" && Number.isFinite(x)) : [];
|
|
@@ -9746,19 +10397,14 @@ function extractAnalysis(output) {
|
|
|
9746
10397
|
};
|
|
9747
10398
|
}
|
|
9748
10399
|
async function analyzeBgm(cfg, bgmAbs, deps) {
|
|
9749
|
-
let up = await deps.uploadCached(cfg, bgmAbs, {});
|
|
9750
10400
|
const payload = (fid) => ({ file_id: fid });
|
|
9751
|
-
|
|
9752
|
-
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
taskId = await deps.submitTask(cfg, ANALYZE_TASK, payload(up.fileId));
|
|
9759
|
-
} else
|
|
9760
|
-
throw e;
|
|
9761
|
-
}
|
|
10401
|
+
const submitted = await uploadAndSubmitTask(cfg, bgmAbs, ANALYZE_TASK, payload, {}, {
|
|
10402
|
+
uploadCached: deps.uploadCached,
|
|
10403
|
+
invalidateUpload: deps.invalidateUpload,
|
|
10404
|
+
submitTask: deps.submitTask,
|
|
10405
|
+
sleep: deps.sleep ?? ((ms) => new Promise((resolve10) => setTimeout(resolve10, ms)))
|
|
10406
|
+
});
|
|
10407
|
+
const { taskId } = submitted;
|
|
9762
10408
|
const output = await deps.pollToolTask(cfg, ANALYZE_TASK, taskId, {});
|
|
9763
10409
|
return extractAnalysis(output);
|
|
9764
10410
|
}
|
|
@@ -9872,7 +10518,8 @@ async function runMad(inputArg, opts, deps = {}) {
|
|
|
9872
10518
|
level = 2;
|
|
9873
10519
|
bgmForTrack = bgmAbs;
|
|
9874
10520
|
} else {
|
|
9875
|
-
|
|
10521
|
+
const pricing = await (deps.resolvePricing ?? resolveToolPricing)("audio_music_analyze", "仅 --bgm 卡点时");
|
|
10522
|
+
emitBilling2(pricing.billingHint);
|
|
9876
10523
|
const beatCloud = deps.beatCloud ?? { uploadCached, invalidateUpload, submitTask, pollToolTask };
|
|
9877
10524
|
try {
|
|
9878
10525
|
analysis = await analyzeBgm(cfg, bgmAbs, beatCloud);
|
|
@@ -9984,7 +10631,7 @@ async function runToolCommand(words, opts, registry = TOOL_REGISTRY, deps) {
|
|
|
9984
10631
|
throw new Error("用法:`gtrk tool <name> [input]` 跑工具;`gtrk tool list` 查全部工具");
|
|
9985
10632
|
}
|
|
9986
10633
|
if (name === "list") {
|
|
9987
|
-
runList(opts, registry);
|
|
10634
|
+
await runList(opts, registry);
|
|
9988
10635
|
return;
|
|
9989
10636
|
}
|
|
9990
10637
|
const descriptor = findTool(name, registry);
|
|
@@ -9994,16 +10641,26 @@ async function runToolCommand(words, opts, registry = TOOL_REGISTRY, deps) {
|
|
|
9994
10641
|
}
|
|
9995
10642
|
return runTool(descriptor, words[1], opts, deps);
|
|
9996
10643
|
}
|
|
9997
|
-
function runList(opts, registry = TOOL_REGISTRY) {
|
|
9998
|
-
|
|
9999
|
-
|
|
10000
|
-
|
|
10001
|
-
|
|
10002
|
-
|
|
10003
|
-
|
|
10004
|
-
|
|
10005
|
-
|
|
10006
|
-
|
|
10644
|
+
async function runList(opts, registry = TOOL_REGISTRY, loadPrices = fetchToolPrices) {
|
|
10645
|
+
let prices;
|
|
10646
|
+
try {
|
|
10647
|
+
prices = await loadPrices();
|
|
10648
|
+
} catch {
|
|
10649
|
+
prices = undefined;
|
|
10650
|
+
}
|
|
10651
|
+
const rows = registry.map((d) => {
|
|
10652
|
+
const resolved = resolveToolPricingFromMap(d.priceKey ?? d.name, prices, d.pricingContext);
|
|
10653
|
+
return {
|
|
10654
|
+
name: d.name,
|
|
10655
|
+
title: d.title,
|
|
10656
|
+
input: d.input.kind,
|
|
10657
|
+
output: d.outputHint,
|
|
10658
|
+
billingHint: resolved.billingHint,
|
|
10659
|
+
pricing: resolved.pricing,
|
|
10660
|
+
enabled: d.enabled,
|
|
10661
|
+
...d.disabledReason ? { disabledReason: d.disabledReason } : {}
|
|
10662
|
+
};
|
|
10663
|
+
});
|
|
10007
10664
|
if (opts.json) {
|
|
10008
10665
|
console.log(JSON.stringify(rows));
|
|
10009
10666
|
return;
|
|
@@ -10066,12 +10723,247 @@ async function runMadInTool(inputArg, opts) {
|
|
|
10066
10723
|
return { ok: r.ok, tool: r.tool, outDir: r.outDir, files: r.files };
|
|
10067
10724
|
}
|
|
10068
10725
|
|
|
10726
|
+
// src/commands/transcript.ts
|
|
10727
|
+
import { existsSync as existsSync21 } from "node:fs";
|
|
10728
|
+
import { mkdir as mkdir12, rename as rename2, rm as rm2, stat as stat6, writeFile as writeFile11 } from "node:fs/promises";
|
|
10729
|
+
import { basename as basename12, dirname as dirname9, extname as extname7, join as join26, resolve as resolve11 } from "node:path";
|
|
10730
|
+
|
|
10731
|
+
// src/lib/transcript.ts
|
|
10732
|
+
var AGENT_SUMMARY_PENDING = "<!-- gtrk:agent-summary-pending -->";
|
|
10733
|
+
function finiteNumber(value) {
|
|
10734
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
10735
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
10736
|
+
}
|
|
10737
|
+
function timeFrom(item, secondsKey, msKey, shortKey) {
|
|
10738
|
+
const seconds = finiteNumber(item[secondsKey]);
|
|
10739
|
+
if (seconds != null)
|
|
10740
|
+
return seconds;
|
|
10741
|
+
const milliseconds = finiteNumber(item[msKey]);
|
|
10742
|
+
if (milliseconds != null)
|
|
10743
|
+
return milliseconds / 1000;
|
|
10744
|
+
return finiteNumber(item[shortKey]) ?? 0;
|
|
10745
|
+
}
|
|
10746
|
+
function normalizeTimedList(value) {
|
|
10747
|
+
if (!Array.isArray(value))
|
|
10748
|
+
return [];
|
|
10749
|
+
const items = [];
|
|
10750
|
+
for (const raw of value) {
|
|
10751
|
+
if (!raw || typeof raw !== "object")
|
|
10752
|
+
continue;
|
|
10753
|
+
const item = raw;
|
|
10754
|
+
const text = typeof item.text === "string" ? item.text.trim() : "";
|
|
10755
|
+
if (!text)
|
|
10756
|
+
continue;
|
|
10757
|
+
const start = timeFrom(item, "start_time", "begin_time_ms", "st");
|
|
10758
|
+
const end = timeFrom(item, "end_time", "end_time_ms", "ed");
|
|
10759
|
+
items.push({ text, start, end: Math.max(start, end) });
|
|
10760
|
+
}
|
|
10761
|
+
return items.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
10762
|
+
}
|
|
10763
|
+
function normalizeAsrOutput(output) {
|
|
10764
|
+
let sentences = normalizeTimedList(output.sentence_tc_list ?? output.sentence_list);
|
|
10765
|
+
const words = normalizeTimedList(output.word_tc_list ?? output.word_list);
|
|
10766
|
+
let text = "";
|
|
10767
|
+
for (const key of ["asr_text", "text"]) {
|
|
10768
|
+
const value = output[key];
|
|
10769
|
+
if (typeof value === "string" && value.trim()) {
|
|
10770
|
+
text = value.trim();
|
|
10771
|
+
break;
|
|
10772
|
+
}
|
|
10773
|
+
}
|
|
10774
|
+
if (sentences.length === 0 && words.length > 0) {
|
|
10775
|
+
sentences = [{
|
|
10776
|
+
text: text || words.map((word) => word.text).join(""),
|
|
10777
|
+
start: words[0]?.start ?? 0,
|
|
10778
|
+
end: words[words.length - 1]?.end ?? 0
|
|
10779
|
+
}];
|
|
10780
|
+
}
|
|
10781
|
+
if (!text && sentences.length > 0)
|
|
10782
|
+
text = sentences.map((sentence) => sentence.text).join(`
|
|
10783
|
+
`);
|
|
10784
|
+
if (!text.trim() || sentences.length === 0) {
|
|
10785
|
+
throw new Error("ASR 任务已完成,但没有返回可用的文字或句级时间戳");
|
|
10786
|
+
}
|
|
10787
|
+
return { text: text.trim(), sentences, words };
|
|
10788
|
+
}
|
|
10789
|
+
function formatTimestamp(seconds) {
|
|
10790
|
+
const total = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));
|
|
10791
|
+
const h = Math.floor(total / 3600);
|
|
10792
|
+
const m = Math.floor(total % 3600 / 60);
|
|
10793
|
+
const s = total % 60;
|
|
10794
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
10795
|
+
}
|
|
10796
|
+
function ensureTerminalPunctuation(text) {
|
|
10797
|
+
const value = text.trim();
|
|
10798
|
+
if (!value)
|
|
10799
|
+
return value;
|
|
10800
|
+
return /[。!?!?;;::]$/.test(value) ? value : `${value}。`;
|
|
10801
|
+
}
|
|
10802
|
+
function localDateTime(date) {
|
|
10803
|
+
const p = (value) => String(value).padStart(2, "0");
|
|
10804
|
+
return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ${p(date.getHours())}:${p(date.getMinutes())}`;
|
|
10805
|
+
}
|
|
10806
|
+
function renderTranscriptMarkdown(input) {
|
|
10807
|
+
const timed = input.asr.sentences.flatMap((sentence) => [
|
|
10808
|
+
`**[${formatTimestamp(sentence.start)}]**`,
|
|
10809
|
+
"",
|
|
10810
|
+
ensureTerminalPunctuation(sentence.text),
|
|
10811
|
+
""
|
|
10812
|
+
]);
|
|
10813
|
+
return [
|
|
10814
|
+
`# ${input.title}`,
|
|
10815
|
+
"",
|
|
10816
|
+
`> 生成时间:${localDateTime(input.generatedAt)} `,
|
|
10817
|
+
`> 视频时长:${formatTimestamp(input.durationSec)} `,
|
|
10818
|
+
`> 来源:本地视频 \`${input.sourceName}\` `,
|
|
10819
|
+
`> 识别语言:${input.language}`,
|
|
10820
|
+
"",
|
|
10821
|
+
"## 总结",
|
|
10822
|
+
"",
|
|
10823
|
+
AGENT_SUMMARY_PENDING,
|
|
10824
|
+
"> 待驱动 CLI 的 Agent 阅读下方完整文字稿后,在此生成总结。",
|
|
10825
|
+
"",
|
|
10826
|
+
"## 文字记录",
|
|
10827
|
+
"",
|
|
10828
|
+
...timed,
|
|
10829
|
+
"## 纯文本",
|
|
10830
|
+
"",
|
|
10831
|
+
input.asr.text.trim(),
|
|
10832
|
+
""
|
|
10833
|
+
].join(`
|
|
10834
|
+
`);
|
|
10835
|
+
}
|
|
10836
|
+
|
|
10837
|
+
// src/commands/transcript.ts
|
|
10838
|
+
var TASK_TYPE3 = "asr";
|
|
10839
|
+
var PRICE_KEY = "asr";
|
|
10840
|
+
function buildDeps(overrides = {}) {
|
|
10841
|
+
return {
|
|
10842
|
+
cfg: overrides.cfg ?? loadConfig(),
|
|
10843
|
+
probe: overrides.probe ?? probeGeometry,
|
|
10844
|
+
extract: overrides.extract ?? extractAudio,
|
|
10845
|
+
assertDuration: overrides.assertDuration ?? assertDurationConsistent,
|
|
10846
|
+
resolvePricing: overrides.resolvePricing ?? ((key) => resolveToolPricing(key)),
|
|
10847
|
+
upload: overrides.upload ?? uploadCached,
|
|
10848
|
+
invalidate: overrides.invalidate ?? invalidateUpload,
|
|
10849
|
+
submit: overrides.submit ?? submitTask,
|
|
10850
|
+
sleep: overrides.sleep ?? ((ms) => new Promise((resolve12) => setTimeout(resolve12, ms))),
|
|
10851
|
+
poll: overrides.poll ?? (async (cfg, taskType, taskId, onTick) => await pollToolTask(cfg, taskType, taskId, { onTick })),
|
|
10852
|
+
writeMarkdown: overrides.writeMarkdown ?? writeMarkdownAtomic,
|
|
10853
|
+
now: overrides.now ?? (() => new Date)
|
|
10854
|
+
};
|
|
10855
|
+
}
|
|
10856
|
+
function looksLikeRemote(value) {
|
|
10857
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value.trim());
|
|
10858
|
+
}
|
|
10859
|
+
async function validateTranscriptInput(input) {
|
|
10860
|
+
if (!input.trim())
|
|
10861
|
+
throw new Error("缺少本地视频路径。用法:gtrk transcript <本地视频>");
|
|
10862
|
+
if (looksLikeRemote(input)) {
|
|
10863
|
+
throw new Error("视频转文字稿仅支持本地视频文件,不支持 URL、平台视频地址或远端下载");
|
|
10864
|
+
}
|
|
10865
|
+
const inputAbs = resolve11(input);
|
|
10866
|
+
if (!existsSync21(inputAbs))
|
|
10867
|
+
throw new Error(`本地视频不存在:${inputAbs}`);
|
|
10868
|
+
const info = await stat6(inputAbs);
|
|
10869
|
+
if (!info.isFile())
|
|
10870
|
+
throw new Error(`输入不是文件:${inputAbs}`);
|
|
10871
|
+
const extension = extname7(inputAbs).toLowerCase();
|
|
10872
|
+
if (!(defaultExtsFor("video") ?? []).includes(extension)) {
|
|
10873
|
+
throw new Error(`不支持的视频格式「${extension || "无扩展名"}」;请输入本地视频文件`);
|
|
10874
|
+
}
|
|
10875
|
+
return inputAbs;
|
|
10876
|
+
}
|
|
10877
|
+
function resolveTranscriptOutput(inputAbs, out) {
|
|
10878
|
+
const base = basename12(inputAbs, extname7(inputAbs));
|
|
10879
|
+
const output = out ? resolve11(out) : join26(dirname9(inputAbs), `${base}-transcript.md`);
|
|
10880
|
+
if (extname7(output).toLowerCase() !== ".md")
|
|
10881
|
+
throw new Error("--out 必须指向一个 .md 文件");
|
|
10882
|
+
return output;
|
|
10883
|
+
}
|
|
10884
|
+
async function writeMarkdownAtomic(path, markdown) {
|
|
10885
|
+
await mkdir12(dirname9(path), { recursive: true });
|
|
10886
|
+
const temp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
10887
|
+
try {
|
|
10888
|
+
await writeFile11(temp, markdown, "utf8");
|
|
10889
|
+
await rename2(temp, path);
|
|
10890
|
+
} finally {
|
|
10891
|
+
await rm2(temp, { force: true });
|
|
10892
|
+
}
|
|
10893
|
+
}
|
|
10894
|
+
async function runTranscript(input, opts = {}, depsOverride) {
|
|
10895
|
+
if (opts.json)
|
|
10896
|
+
routeLogsToStderr();
|
|
10897
|
+
const inputAbs = await validateTranscriptInput(input);
|
|
10898
|
+
const output = resolveTranscriptOutput(inputAbs, opts.out);
|
|
10899
|
+
const deps = buildDeps(depsOverride);
|
|
10900
|
+
const language = opts.lang?.trim() || "zh-CN";
|
|
10901
|
+
const sourceName = basename12(inputAbs);
|
|
10902
|
+
const title = basename12(inputAbs, extname7(inputAbs));
|
|
10903
|
+
log.step(`▶ 视频转文字稿:${sourceName}`);
|
|
10904
|
+
log.step("① 本地探测视频…");
|
|
10905
|
+
const geometry = deps.probe(inputAbs, opts.ffmpegPath);
|
|
10906
|
+
if (!(geometry.duration > 0))
|
|
10907
|
+
throw new Error("未探测到有效视频时长,无法转写");
|
|
10908
|
+
log.info(`视频时长 ${geometry.duration.toFixed(1)}s`);
|
|
10909
|
+
const pricing = await deps.resolvePricing(PRICE_KEY);
|
|
10910
|
+
log.info(`实时计费:${pricing.billingHint}`);
|
|
10911
|
+
log.step("② 本地抽取 16k 单声道音频(原视频不上传)…");
|
|
10912
|
+
const audio = await deps.extract(inputAbs, opts.ffmpegPath);
|
|
10913
|
+
deps.assertDuration(geometry.duration, audio, opts.ffmpegPath);
|
|
10914
|
+
log.info(`上传物:${basename12(audio)}(仅音频衍生物)`);
|
|
10915
|
+
log.step("③ 上传音频并提交 ASR…");
|
|
10916
|
+
const payload = (fileId) => ({ file_id: fileId, language, word_level: true });
|
|
10917
|
+
const submitted = await uploadAndSubmitTask(deps.cfg, audio, TASK_TYPE3, payload, {
|
|
10918
|
+
force: opts.reupload,
|
|
10919
|
+
onCacheInvalid: () => log.warn("缓存的 file_id 已失效,重新上传后重试…")
|
|
10920
|
+
}, {
|
|
10921
|
+
uploadCached: deps.upload,
|
|
10922
|
+
invalidateUpload: deps.invalidate,
|
|
10923
|
+
submitTask: deps.submit,
|
|
10924
|
+
sleep: deps.sleep
|
|
10925
|
+
});
|
|
10926
|
+
const { taskId } = submitted;
|
|
10927
|
+
const uploaded = { fileId: submitted.fileId, cached: submitted.cached };
|
|
10928
|
+
log.info(`task_id = ${taskId}`);
|
|
10929
|
+
log.step("④ 云端识别中…");
|
|
10930
|
+
const raw = await deps.poll(deps.cfg, TASK_TYPE3, taskId, (status, progress) => {
|
|
10931
|
+
log.tick(`${status}${progress != null ? ` ${Math.round(progress)}%` : ""}`);
|
|
10932
|
+
});
|
|
10933
|
+
log.tickEnd();
|
|
10934
|
+
const asr = normalizeAsrOutput(raw);
|
|
10935
|
+
const markdown = renderTranscriptMarkdown({
|
|
10936
|
+
title,
|
|
10937
|
+
sourceName,
|
|
10938
|
+
durationSec: geometry.duration,
|
|
10939
|
+
language,
|
|
10940
|
+
generatedAt: deps.now(),
|
|
10941
|
+
asr
|
|
10942
|
+
});
|
|
10943
|
+
await deps.writeMarkdown(output, markdown);
|
|
10944
|
+
return { ok: true, taskId, fileId: uploaded.fileId, output, summaryPending: true };
|
|
10945
|
+
}
|
|
10946
|
+
function configureTranscriptCommand(cmd, deps) {
|
|
10947
|
+
return cmd.description("本地视频转文字稿:原视频不上传,只上传抽取音频,生成单个待 Agent 补总结的 Markdown").option("-o, --out <file>", "输出 Markdown 文件(缺省 <视频同目录>/<视频名>-transcript.md)").option("--lang <code>", "识别语言代码(默认 zh-CN)", "zh-CN").option("--ffmpeg-path <dir>", "指定 ffmpeg/ffprobe 所在目录").option("--reupload", "强制重新上传抽取音频,忽略上传缓存").option("--json", "机读模式:stdout 只输出最终结果 JSON").action(async (video, opts) => {
|
|
10948
|
+
const result = await runTranscript(video, opts, deps);
|
|
10949
|
+
if (opts.json)
|
|
10950
|
+
console.log(JSON.stringify(result));
|
|
10951
|
+
else {
|
|
10952
|
+
log.ok(`带时码文字稿已生成:${result.output}`);
|
|
10953
|
+
log.warn("总结仍待驱动 CLI 的 Agent 阅读全文后写回同一个 Markdown");
|
|
10954
|
+
}
|
|
10955
|
+
});
|
|
10956
|
+
}
|
|
10957
|
+
function registerTranscript(program2) {
|
|
10958
|
+
configureTranscriptCommand(program2.command("transcript <video>"));
|
|
10959
|
+
}
|
|
10960
|
+
|
|
10069
10961
|
// src/index.ts
|
|
10070
10962
|
try {
|
|
10071
10963
|
process.loadEnvFile?.();
|
|
10072
10964
|
} catch {}
|
|
10073
10965
|
migrateLegacyHome();
|
|
10074
|
-
var { version } = JSON.parse(readFileSync5(
|
|
10966
|
+
var { version } = JSON.parse(readFileSync5(join27(packageRoot(), "package.json"), "utf8"));
|
|
10075
10967
|
var program2 = new Command;
|
|
10076
10968
|
program2.name("gtrk").description("同合云成片流水线 CLI —— agent 驱动云端任务、产物拉回本地、三方工程文件(客户端/剪映/PR)互通").version(version);
|
|
10077
10969
|
registerInstall(program2);
|
|
@@ -10086,6 +10978,7 @@ registerSplit(program2);
|
|
|
10086
10978
|
registerMatrix(program2);
|
|
10087
10979
|
registerMg(program2);
|
|
10088
10980
|
registerTool(program2);
|
|
10981
|
+
registerTranscript(program2);
|
|
10089
10982
|
program2.parseAsync(process.argv).catch((e) => {
|
|
10090
10983
|
console.error(`
|
|
10091
10984
|
❌ ${e instanceof Error ? e.message : String(e)}`);
|