@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/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 join26 } from "node:path";
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-splitter 或 /gtrk-style-maker,也可直接说「帮我剪个口播 / 拆个分镜 / 造我栏目的风格 skill」触发(可能需重载会话)。");
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
- async function uploadFile(cfg, path) {
5559
- const size = (await stat(path)).size;
5560
- const boundary = `----gtrkFormBoundary${randomBytes(16).toString("hex")}`;
5561
- const head = Buffer.from(`--${boundary}\r
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
- const tail = Buffer.from(`\r
5590
+ const tail = Buffer.from(`\r
5567
5591
  --${boundary}--\r
5568
5592
  `, "utf8");
5569
- async function* multipart() {
5570
- yield head;
5571
- for await (const chunk of createReadStream(path))
5572
- yield chunk;
5573
- yield tail;
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 Error(`上传失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
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 Error(`提交失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
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
- async function fingerprint(path) {
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 fp = await fingerprint(path);
5945
- const cache = await load();
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 s0 = await stat3(path);
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 stat3(path);
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
- log.step("③ 提交智能口播剪辑任务…");
6523
- let taskId;
6524
- try {
6525
- taskId = await submitTask(cfg, TASK_TYPE, buildPayload(up.fileId));
6526
- } catch (e) {
6527
- if (up.cached && e instanceof CloudError && e.code === 6004) {
6528
- log.warn("缓存的 file_id 在云端已失效,重新上传后重试…");
6529
- await invalidateUpload(artifact);
6530
- up = await uploadCached(cfg, artifact, { force: true });
6531
- taskId = await submitTask(cfg, TASK_TYPE, buildPayload(up.fileId));
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
- billingHint: "2 积分/个",
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
- billingHint: "免费",
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
- billingHint: "免费",
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
- billingHint: "免费(--bgm 卡点时计费一次云端节拍分析 audio_music_analyze",
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 = [imageMove, imageMatting, videoMatting, mad];
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
- emitBilling(descriptor.billingHint);
8506
- let up = await deps.uploadCached(deps.cfg, uploadPath, { force: opts.reupload });
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
- let taskId;
8514
- try {
8515
- taskId = await deps.submitTask(deps.cfg, taskType, buildPayload(up.fileId));
8516
- } catch (e) {
8517
- if (up.cached && isCloudErrorCode(e) === 6004) {
8518
- await deps.invalidateUpload(uploadPath);
8519
- up = await deps.uploadCached(deps.cfg, uploadPath, { force: true });
8520
- taskId = await deps.submitTask(deps.cfg, taskType, buildPayload(up.fileId));
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
- let taskId;
9752
- try {
9753
- taskId = await deps.submitTask(cfg, ANALYZE_TASK, payload(up.fileId));
9754
- } catch (e) {
9755
- if (up.cached && isCode(e, 6004)) {
9756
- await deps.invalidateUpload(bgmAbs);
9757
- up = await deps.uploadCached(cfg, bgmAbs, { force: true });
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
- emitBilling2("BGM 卡点将调用一次云端节拍分析(audio_music_analyze),按现行价计费。");
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
- const rows = registry.map((d) => ({
9999
- name: d.name,
10000
- title: d.title,
10001
- input: d.input.kind,
10002
- output: d.outputHint,
10003
- billingHint: d.billingHint,
10004
- enabled: d.enabled,
10005
- ...d.disabledReason ? { disabledReason: d.disabledReason } : {}
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(join26(packageRoot(), "package.json"), "utf8"));
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)}`);