@gitruck/cli 0.2.8 → 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 join21 } 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";
@@ -4221,6 +4221,9 @@ var GITRUCK_HOME = join(homedir(), ".gitruck");
4221
4221
  function gitruckHome() {
4222
4222
  return GITRUCK_HOME;
4223
4223
  }
4224
+ function homeFile(name) {
4225
+ return join(GITRUCK_HOME, name);
4226
+ }
4224
4227
  function ffmpegDir() {
4225
4228
  return join(GITRUCK_HOME, "ffmpeg");
4226
4229
  }
@@ -4280,7 +4283,9 @@ var SKILL_NAMES = [
4280
4283
  "gtrk-matrix",
4281
4284
  "gtrk-mg",
4282
4285
  "gtrk-ai-drama",
4283
- "gtrk-style-maker"
4286
+ "gtrk-style-maker",
4287
+ "gtrk-transcript",
4288
+ "gtrk-tools"
4284
4289
  ];
4285
4290
  function installSkill(opts = {}) {
4286
4291
  const destRoot = opts.dir ?? join2(homedir2(), ".claude", "skills");
@@ -4302,7 +4307,7 @@ function installSkill(opts = {}) {
4302
4307
  log.warn(`skill 安装失败(${name},不影响命令行使用):${e instanceof Error ? e.message : String(e)}`);
4303
4308
  }
4304
4309
  }
4305
- log.info("在 Claude Code 里打 /gtrk-oralcut、/gtrk-splitter 或 /gtrk-style-maker,也可直接说「帮我剪个口播 / 拆个分镜 / 造我栏目的风格 skill」触发(可能需重载会话)。");
4310
+ log.info("在 Claude Code 里打 /gtrk-oralcut、/gtrk-transcript 或 /gtrk-tools,也可直接说「帮我剪个口播 / 把本地视频转成文字稿 / 给视频声音降噪」触发(可能需重载会话)。");
4306
4311
  return allOk;
4307
4312
  }
4308
4313
  function registerSkills(program2) {
@@ -5544,6 +5549,10 @@ class CloudError extends Error {
5544
5549
  this.name = "CloudError";
5545
5550
  }
5546
5551
  }
5552
+ function cloudErrorCode(error) {
5553
+ const code = error && typeof error === "object" ? error.code : undefined;
5554
+ return typeof code === "number" ? code : undefined;
5555
+ }
5547
5556
  async function parseJson(res) {
5548
5557
  try {
5549
5558
  return await res.json();
@@ -5551,38 +5560,58 @@ async function parseJson(res) {
5551
5560
  throw new Error(`服务响应解析失败 (HTTP ${res.status})`);
5552
5561
  }
5553
5562
  }
5554
- async function uploadFile(cfg, path) {
5555
- const size = (await stat(path)).size;
5556
- const boundary = `----gtrkFormBoundary${randomBytes(16).toString("hex")}`;
5557
- 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
5558
5586
  ` + `Content-Disposition: form-data; name="file"; filename="${basename(path)}"\r
5559
5587
  ` + `Content-Type: application/octet-stream\r
5560
5588
  \r
5561
5589
  `, "utf8");
5562
- const tail = Buffer.from(`\r
5590
+ const tail = Buffer.from(`\r
5563
5591
  --${boundary}--\r
5564
5592
  `, "utf8");
5565
- async function* multipart() {
5566
- yield head;
5567
- for await (const chunk of createReadStream(path))
5568
- yield chunk;
5569
- 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
+ });
5570
5609
  }
5571
- const res = await fetch(`${cfg.base}/base/file/upload`, {
5572
- method: "POST",
5573
- headers: {
5574
- Authorization: cfg.apiKey,
5575
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
5576
- "Content-Length": String(head.length + size + tail.length)
5577
- },
5578
- body: Readable.toWeb(Readable.from(multipart())),
5579
- duplex: "half"
5580
- });
5581
5610
  const r = await parseJson(res);
5582
5611
  const fid = r.data?.file_id ?? r.data?.id;
5583
5612
  if (r.code === 200 && fid)
5584
5613
  return String(fid);
5585
- throw new Error(`上传失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5614
+ throw new CloudError(r.code, `上传失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5586
5615
  }
5587
5616
  async function submitTask(cfg, taskType, payload) {
5588
5617
  const res = await fetch(`${cfg.base}/task/${taskType}`, {
@@ -5593,7 +5622,7 @@ async function submitTask(cfg, taskType, payload) {
5593
5622
  const r = await parseJson(res);
5594
5623
  if (r.code === 200 && r.data?.task_id)
5595
5624
  return String(r.data.task_id);
5596
- throw new Error(`提交失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5625
+ throw new CloudError(r.code, `提交失败 (code=${r.code ?? "?"}):${r.msg ?? "未知错误"}`);
5597
5626
  }
5598
5627
  async function getTaskResult(cfg, taskType, taskId) {
5599
5628
  const res = await fetch(`${cfg.base}/task/${taskType}/${taskId}`, {
@@ -5881,10 +5910,12 @@ async function putPart(cfg, uploadId, idx, view) {
5881
5910
  var CACHE_DIR = gitruckHome();
5882
5911
  var CACHE_FILE = join10(CACHE_DIR, "upload-cache.json");
5883
5912
  var SESSION_FILE = join10(CACHE_DIR, "upload-sessions.json");
5884
- async function fingerprint(path) {
5885
- const s = await stat3(path);
5913
+ function fingerprintFromStat(s) {
5886
5914
  return `${s.size}:${Math.round(s.mtimeMs)}`;
5887
5915
  }
5916
+ async function fingerprint(path) {
5917
+ return fingerprintFromStat(await stat3(path));
5918
+ }
5888
5919
  async function load() {
5889
5920
  if (!existsSync9(CACHE_FILE))
5890
5921
  return {};
@@ -5898,6 +5929,12 @@ async function save(cache) {
5898
5929
  await mkdir(CACHE_DIR, { recursive: true });
5899
5930
  await writeFile2(CACHE_FILE, JSON.stringify(cache, null, 2));
5900
5931
  }
5932
+ var defaultUploadCacheDeps = {
5933
+ stat: stat3,
5934
+ uploadFile,
5935
+ uploadChunked,
5936
+ cacheStore: { load, save }
5937
+ };
5901
5938
  async function invalidateUpload(path) {
5902
5939
  const fp = await fingerprint(path);
5903
5940
  const cache = await load();
@@ -5936,19 +5973,22 @@ var fileSessionStore = {
5936
5973
  }
5937
5974
  }
5938
5975
  };
5939
- async function uploadCached(cfg, path, opts) {
5940
- const fp = await fingerprint(path);
5941
- 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();
5942
5980
  const hit = cache[fp]?.fileId;
5943
5981
  if (!opts?.force && hit)
5944
5982
  return { fileId: hit, cached: true };
5945
- const s0 = await stat3(path);
5946
- const fileId = s0.size >= CHUNK_THRESHOLD ? await uploadChunked(cfg, path, {
5983
+ const fileId = s0.size >= CHUNK_THRESHOLD ? await deps.uploadChunked(cfg, path, {
5947
5984
  fingerprint: fp,
5948
5985
  store: fileSessionStore,
5949
5986
  force: opts?.force
5950
- }) : await uploadFile(cfg, path);
5951
- 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
+ }
5952
5992
  cache[fp] = {
5953
5993
  fileId,
5954
5994
  size: s.size,
@@ -5956,10 +5996,50 @@ async function uploadCached(cfg, path, opts) {
5956
5996
  path,
5957
5997
  uploadedAt: Date.now()
5958
5998
  };
5959
- await save(cache);
5999
+ await deps.cacheStore.save(cache);
5960
6000
  return { fileId, cached: false };
5961
6001
  }
5962
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
+
5963
6043
  // src/lib/media.ts
5964
6044
  import { mkdir as mkdir2, stat as stat4 } from "node:fs/promises";
5965
6045
  import { existsSync as existsSync10 } from "node:fs";
@@ -6487,8 +6567,6 @@ async function runOralCut(input, opts) {
6487
6567
  assertDurationConsistent(geo.duration, artifact, opts.ffmpegPath);
6488
6568
  log.info(opts.visualAssist ? `已压 720p 代理(上传物):${basename5(artifact)}` : `已抽 16k 单声道 mp3(上传物):${basename5(artifact)}`);
6489
6569
  log.step("② 上传抽出物到云端…");
6490
- let up = await uploadCached(cfg, artifact, { force: opts.reupload });
6491
- log.info(up.cached ? `命中上传缓存,复用 file_id = ${up.fileId}(免二次上传)` : `file_id = ${up.fileId}`);
6492
6570
  const buildPayload = (fid) => {
6493
6571
  const p = {
6494
6572
  file_id: fid,
@@ -6515,19 +6593,16 @@ async function runOralCut(input, opts) {
6515
6593
  }
6516
6594
  return p;
6517
6595
  };
6518
- log.step("③ 提交智能口播剪辑任务…");
6519
- let taskId;
6520
- try {
6521
- taskId = await submitTask(cfg, TASK_TYPE, buildPayload(up.fileId));
6522
- } catch (e) {
6523
- if (up.cached && e instanceof CloudError && e.code === 6004) {
6524
- log.warn("缓存的 file_id 在云端已失效,重新上传后重试…");
6525
- await invalidateUpload(artifact);
6526
- up = await uploadCached(cfg, artifact, { force: true });
6527
- taskId = await submitTask(cfg, TASK_TYPE, buildPayload(up.fileId));
6528
- } else
6529
- throw e;
6530
- }
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 };
6531
6606
  log.info(`task_id = ${taskId}`);
6532
6607
  await mkdir4(outDir, { recursive: true });
6533
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));
@@ -8171,12 +8246,2724 @@ function done(opts, result) {
8171
8246
  return result;
8172
8247
  }
8173
8248
 
8249
+ // src/lib/tool-descriptors.ts
8250
+ import { extname as extname4 } from "node:path";
8251
+ var IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tif", ".tiff", ".heic", ".heif", ".avif"];
8252
+ var VIDEO_EXTS = [".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg", ".ts", ".m2ts"];
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
+ ];
8269
+ function defaultExtsFor(kind) {
8270
+ if (kind === "image")
8271
+ return IMAGE_EXTS;
8272
+ if (kind === "video")
8273
+ return VIDEO_EXTS;
8274
+ if (kind === "audio")
8275
+ return AUDIO_EXTS;
8276
+ return;
8277
+ }
8278
+ function pickUrl(out, keys) {
8279
+ for (const k of keys) {
8280
+ const v = out[k];
8281
+ if (typeof v === "string" && v.trim())
8282
+ return v;
8283
+ }
8284
+ return;
8285
+ }
8286
+ function extFromUrl(url, fallback) {
8287
+ let path = url;
8288
+ try {
8289
+ path = new URL(url).pathname;
8290
+ } catch {
8291
+ path = url.split("?")[0] ?? url;
8292
+ }
8293
+ const e = extname4(path);
8294
+ return e || fallback;
8295
+ }
8296
+ function deriveMoveGeometry(dims) {
8297
+ if (!dims || !(dims.width > 0) || !(dims.height > 0))
8298
+ return { fellBack: true };
8299
+ return dims.height > dims.width ? { width: 1080, height: 1920, fellBack: false } : { width: 1920, height: 1080, fellBack: false };
8300
+ }
8301
+ function probeImageDims(inputAbs, ffmpegPath) {
8302
+ if (!resolveFfmpeg(ffmpegPath))
8303
+ return;
8304
+ try {
8305
+ const g2 = probeGeometry(inputAbs, ffmpegPath);
8306
+ if (g2.width > 0 && g2.height > 0)
8307
+ return { width: g2.width, height: g2.height };
8308
+ } catch {}
8309
+ return;
8310
+ }
8311
+ var imageMove = {
8312
+ name: "image_move",
8313
+ title: "图转运镜",
8314
+ description: "把一张静态图生成带运镜的短视频。",
8315
+ kind: "cloud",
8316
+ input: { kind: "image" },
8317
+ priceKey: "image_move_local",
8318
+ outputHint: "运镜视频",
8319
+ enabled: true,
8320
+ taskType: "image_move",
8321
+ buildPayload(fileId, ctx) {
8322
+ const p = { file_id: fileId };
8323
+ const explicit = ctx.extraParams.width != null || ctx.extraParams.height != null;
8324
+ if (!explicit && ctx.inputAbs) {
8325
+ const dims = probeImageDims(ctx.inputAbs, ctx.ffmpegPath);
8326
+ const geo = deriveMoveGeometry(dims);
8327
+ if (geo.width && geo.height) {
8328
+ p.width = geo.width;
8329
+ p.height = geo.height;
8330
+ }
8331
+ if (geo.fellBack) {
8332
+ ctx.warn("未探测到图片朝向(缺 ffprobe),将按云端默认横屏 1920×1080 出片;" + "可用 --param width=… --param height=… 显式指定几何。");
8333
+ }
8334
+ }
8335
+ return p;
8336
+ },
8337
+ mapOutputs(out, ctx) {
8338
+ const url = pickUrl(out, ["download_url", "video_download_url", "url"]);
8339
+ if (!url)
8340
+ return [];
8341
+ return [{ url, filename: `${ctx.baseName}-image_move${extFromUrl(url, ".mp4")}` }];
8342
+ }
8343
+ };
8344
+ var imageMatting = {
8345
+ name: "image_matting",
8346
+ title: "图片抠像",
8347
+ description: "把图片主体从背景抠出,产透明背景 png(可经 --param 请求额外背景底板输出)。",
8348
+ kind: "cloud",
8349
+ input: { kind: "image" },
8350
+ priceKey: "image_matting",
8351
+ outputHint: "透明 png",
8352
+ enabled: true,
8353
+ taskType: "image_matting",
8354
+ buildPayload(fileId) {
8355
+ return { file_id: fileId, output_format: "png" };
8356
+ },
8357
+ mapOutputs(out, ctx) {
8358
+ const files = [];
8359
+ const main = pickUrl(out, ["download_url", "image_download_url", "url"]);
8360
+ if (main)
8361
+ files.push({ url: main, filename: `${ctx.baseName}-matte${extFromUrl(main, ".png")}` });
8362
+ const bg = pickUrl(out, ["background_download_url", "bg_download_url"]);
8363
+ if (bg)
8364
+ files.push({ url: bg, filename: `${ctx.baseName}-bg${extFromUrl(bg, ".png")}` });
8365
+ return files;
8366
+ }
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
+ };
8737
+ var videoMatting = {
8738
+ name: "video_matting",
8739
+ title: "视频抠像",
8740
+ description: "把视频主体从背景抠出,产透明背景 webm(像素级、原片直传不压代理;单片 ≤10 分钟)。",
8741
+ kind: "cloud",
8742
+ input: { kind: "video", maxDurationSec: 600 },
8743
+ priceKey: "video_matting",
8744
+ outputHint: "透明 webm",
8745
+ enabled: true,
8746
+ taskType: "video_matting",
8747
+ pollTimeoutMs: 60 * 60 * 1000,
8748
+ buildPayload(fileId) {
8749
+ return { file_id: fileId, target_mode: "auto", output_format: "webm" };
8750
+ },
8751
+ mapOutputs(out, ctx) {
8752
+ const files = [];
8753
+ const main = pickUrl(out, ["download_url", "video_download_url", "url"]);
8754
+ if (main)
8755
+ files.push({ url: main, filename: `${ctx.baseName}-matte${extFromUrl(main, ".webm")}` });
8756
+ const mask2 = pickUrl(out, ["mask_download_url"]);
8757
+ if (mask2)
8758
+ files.push({ url: mask2, filename: `${ctx.baseName}-mask${extFromUrl(mask2, ".webm")}` });
8759
+ return files;
8760
+ }
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
+ };
8857
+ var mad = {
8858
+ name: "mad",
8859
+ title: "一键剪 MAD",
8860
+ description: "素材文件夹(3~10 条视频)+ 可选 BGM → 自动选技法 → 单一 .jsx,AE 2020+ 跑一遍出 15~30s 卡点成片工程。仅支持 AE。",
8861
+ kind: "local",
8862
+ input: { kind: "directory" },
8863
+ priceKey: "audio_music_analyze",
8864
+ pricingContext: "仅 --bgm 卡点时",
8865
+ outputHint: "AE 母合成工程 .jsx",
8866
+ enabled: true,
8867
+ options: [
8868
+ { flag: "--bgm <音频文件>", desc: "可选 BGM,卡点到 downbeat(需 API Key,计费一次;无 Key 则 BGM 仍入轨、固定节奏)" },
8869
+ { flag: "--duration <秒>", desc: "成片目标时长(默认 20,文案口径 15~30)" },
8870
+ { flag: "--seed <n>", desc: "选窗随机种子(同素材同种子同数据版本 → 可复现同序列)" },
8871
+ { flag: "--refresh", desc: "强制忽略本地缓存、重拉当前 manifest 版本数据" }
8872
+ ]
8873
+ };
8874
+ var RESERVED_NAMES = new Set(["list"]);
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
+ ];
8894
+ function findTool(name, registry = TOOL_REGISTRY) {
8895
+ return registry.find((d) => d.name === name);
8896
+ }
8897
+ function validateRegistry(registry = TOOL_REGISTRY) {
8898
+ const seen = new Set;
8899
+ for (const d of registry) {
8900
+ if (!d.name)
8901
+ throw new Error("descriptor 缺 name");
8902
+ if (RESERVED_NAMES.has(d.name))
8903
+ throw new Error(`descriptor 名与保留字冲突:「${d.name}」`);
8904
+ if (seen.has(d.name))
8905
+ throw new Error(`descriptor 名重复:「${d.name}」`);
8906
+ seen.add(d.name);
8907
+ if (!d.enabled && !d.disabledReason)
8908
+ throw new Error(`未启用工具缺 disabledReason:「${d.name}」`);
8909
+ if (d.kind === "cloud" && !d.taskType)
8910
+ throw new Error(`cloud 型工具缺 taskType:「${d.name}」`);
8911
+ if (d.kind === "cloud" && !d.priceKey)
8912
+ throw new Error(`cloud 型工具缺 priceKey:「${d.name}」`);
8913
+ }
8914
+ }
8915
+
8916
+ // src/lib/tool-runner.ts
8917
+ import { resolve as resolve9, join as join21, dirname as dirname8, basename as basename11, extname as extname5 } from "node:path";
8918
+ import { mkdir as mkdir8, writeFile as writeFile8, stat as stat5 } from "node:fs/promises";
8919
+ import { createWriteStream, existsSync as existsSync17 } from "node:fs";
8920
+ import { Readable as Readable2 } from "node:stream";
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
9003
+ var DEFAULT_POLL_TIMEOUT_MS = 30 * 60 * 1000;
9004
+ var DEFAULT_POLL_INTERVAL_MS = 5000;
9005
+ function coerceValue2(v) {
9006
+ if (v === "true")
9007
+ return true;
9008
+ if (v === "false")
9009
+ return false;
9010
+ if (v.trim() !== "" && !Number.isNaN(Number(v)))
9011
+ return Number(v);
9012
+ return v;
9013
+ }
9014
+ function parseExtraParams2(pairs, jsonStr) {
9015
+ const out = {};
9016
+ for (const pair of pairs) {
9017
+ const i = pair.indexOf("=");
9018
+ if (i < 0)
9019
+ throw new Error(`--param 需要 key=value 格式:「${pair}」`);
9020
+ out[pair.slice(0, i).trim()] = coerceValue2(pair.slice(i + 1));
9021
+ }
9022
+ if (jsonStr) {
9023
+ let parsed;
9024
+ try {
9025
+ parsed = JSON.parse(jsonStr);
9026
+ } catch {
9027
+ throw new Error(`--params-json 不是合法 JSON:${jsonStr}`);
9028
+ }
9029
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
9030
+ throw new Error("--params-json 必须是一个 JSON 对象");
9031
+ }
9032
+ Object.assign(out, parsed);
9033
+ }
9034
+ return out;
9035
+ }
9036
+ function mergeParams(payload, extra) {
9037
+ for (const [k, v] of Object.entries(extra)) {
9038
+ const cur = payload[k];
9039
+ const bothObj = !!cur && !!v && typeof cur === "object" && typeof v === "object" && !Array.isArray(cur) && !Array.isArray(v);
9040
+ payload[k] = bothObj ? { ...cur, ...v } : v;
9041
+ }
9042
+ }
9043
+ function validateToolInput(descriptor, inputAbs) {
9044
+ const spec = descriptor.input;
9045
+ if (spec.kind === "none")
9046
+ return;
9047
+ if (!inputAbs)
9048
+ throw new Error(`${descriptor.name} 需要输入${spec.kind === "directory" ? "目录" : "文件"}`);
9049
+ if (!existsSync17(inputAbs))
9050
+ throw new Error(`输入不存在:${inputAbs}`);
9051
+ if (spec.kind === "directory")
9052
+ return;
9053
+ const exts = spec.exts ?? defaultExtsFor(spec.kind);
9054
+ if (exts && exts.length) {
9055
+ const e = extname5(inputAbs).toLowerCase();
9056
+ if (!exts.includes(e)) {
9057
+ throw new Error(`${descriptor.name} 需要 ${spec.kind} 输入,但拿到「${e || "无扩展名"}」(支持:${exts.join(" ")})`);
9058
+ }
9059
+ }
9060
+ }
9061
+ function guardDuration(descriptor, inputAbs, probe, ffmpegPath) {
9062
+ const max = descriptor.input.maxDurationSec;
9063
+ if (max == null || !inputAbs)
9064
+ return;
9065
+ const sec = probe(inputAbs, ffmpegPath);
9066
+ if (sec > max) {
9067
+ throw new Error(`视频超过 ${Math.round(max / 60)} 分钟上限,请先裁剪`);
9068
+ }
9069
+ }
9070
+ async function downloadStream(url, dest) {
9071
+ const res = await fetch(url);
9072
+ if (!res.ok || !res.body)
9073
+ throw new Error(`下载失败 HTTP ${res.status}:${url}`);
9074
+ await pipeline(Readable2.fromWeb(res.body), createWriteStream(dest));
9075
+ }
9076
+ async function pollToolTask(cfg, taskType, taskId, opts = {}) {
9077
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
9078
+ const intervalMs = opts.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
9079
+ const getResult = opts.getResult ?? getTaskResult;
9080
+ const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
9081
+ const now = opts.now ?? Date.now;
9082
+ const start = now();
9083
+ for (;; ) {
9084
+ if (now() - start > timeoutMs) {
9085
+ throw new Error(`任务超时(超过 ${Math.round(timeoutMs / 60000)} 分钟)。可稍后凭 task_id(${taskId})在云端查询或重试。`);
9086
+ }
9087
+ await sleep2(intervalMs);
9088
+ let got;
9089
+ try {
9090
+ got = await getResult(cfg, taskType, taskId);
9091
+ } catch (e) {
9092
+ if (isCloudErrorCode(e) != null)
9093
+ throw e;
9094
+ continue;
9095
+ }
9096
+ if (got.status === "completed")
9097
+ return got.output;
9098
+ if (got.status === "failed" || got.status === "cancelled") {
9099
+ const out = got.output;
9100
+ throw new Error(out?.error ?? (got.status === "failed" ? "任务失败" : "任务已取消"));
9101
+ }
9102
+ opts.onTick?.(got.status || "处理中", got.progress);
9103
+ }
9104
+ }
9105
+ function isCloudErrorCode(e) {
9106
+ const c3 = e && typeof e === "object" ? e.code : undefined;
9107
+ return typeof c3 === "number" ? c3 : undefined;
9108
+ }
9109
+ function timestamp3() {
9110
+ const d = new Date;
9111
+ const p = (n) => String(n).padStart(2, "0");
9112
+ return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
9113
+ }
9114
+ function resolveOutDir(descriptor, inputAbs, out) {
9115
+ if (out)
9116
+ return resolve9(out);
9117
+ if (inputAbs) {
9118
+ const base = basename11(inputAbs, extname5(inputAbs));
9119
+ return join21(dirname8(inputAbs), `${base}-${descriptor.name}`);
9120
+ }
9121
+ return join21(process.cwd(), `${descriptor.name}-${timestamp3()}`);
9122
+ }
9123
+ async function safeFingerprint(inputAbs) {
9124
+ try {
9125
+ const s = await stat5(inputAbs);
9126
+ return `${s.size}:${Math.round(s.mtimeMs)}`;
9127
+ } catch {
9128
+ return;
9129
+ }
9130
+ }
9131
+ function emitBilling(hint) {
9132
+ process.stderr.write(`\x1B[33m⚠️ 计费提示:${hint}\x1B[0m
9133
+ `);
9134
+ }
9135
+ async function runCloudTool(descriptor, inputArg, opts, deps) {
9136
+ const inputAbs = inputArg ? resolve9(inputArg) : undefined;
9137
+ const baseName = inputAbs ? basename11(inputAbs, extname5(inputAbs)) : descriptor.name;
9138
+ validateToolInput(descriptor, inputAbs);
9139
+ const probe = deps.probeDurationSec ?? probeDuration;
9140
+ guardDuration(descriptor, inputAbs, probe, opts.ffmpegPath);
9141
+ const extraParams = parseExtraParams2(opts.param ?? [], opts.paramsJson);
9142
+ const ctx = {
9143
+ inputAbs,
9144
+ baseName,
9145
+ ffmpegPath: opts.ffmpegPath,
9146
+ opts,
9147
+ extraParams,
9148
+ warn: (m) => process.stderr.write(`\x1B[2m ${m}\x1B[0m
9149
+ `)
9150
+ };
9151
+ const outDir = resolveOutDir(descriptor, inputAbs, opts.out);
9152
+ let uploadPath = inputAbs;
9153
+ if (descriptor.preprocess)
9154
+ uploadPath = await descriptor.preprocess(ctx);
9155
+ if (!uploadPath)
9156
+ throw new Error(`${descriptor.name} 缺上传物(input=none 的 cloud 型工具需 preprocess 产上传物)`);
9157
+ let billingHint;
9158
+ try {
9159
+ billingHint = (await (deps.resolvePricing ?? resolveToolPricing)(descriptor.priceKey, descriptor.pricingContext)).billingHint;
9160
+ } catch {
9161
+ billingHint = "实时价格暂不可用,以服务端结算为准";
9162
+ }
9163
+ emitBilling(billingHint);
9164
+ const buildPayload = (fid) => {
9165
+ const p = descriptor.buildPayload ? descriptor.buildPayload(fid, ctx) : { file_id: fid };
9166
+ mergeParams(p, extraParams);
9167
+ return p;
9168
+ };
9169
+ const taskType = descriptor.taskType;
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 };
9178
+ await mkdir8(outDir, { recursive: true });
9179
+ const fingerprint2 = inputAbs ? await safeFingerprint(inputAbs) : undefined;
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));
9181
+ const output = await pollToolTask(deps.cfg, taskType, taskId, {
9182
+ timeoutMs: descriptor.pollTimeoutMs,
9183
+ intervalMs: deps.pollIntervalMs,
9184
+ getResult: deps.getTaskResult,
9185
+ sleep: deps.sleep,
9186
+ now: deps.now
9187
+ });
9188
+ const items = descriptor.mapOutputs ? descriptor.mapOutputs(output, ctx) : [];
9189
+ const files = [];
9190
+ const errors = {};
9191
+ if (items.length === 0)
9192
+ errors["output"] = "任务完成但未解析到产物下载链接(output_result 形态异常)";
9193
+ for (const it of items) {
9194
+ const dest = join21(outDir, it.filename);
9195
+ try {
9196
+ await deps.downloadStream(it.url, dest);
9197
+ files.push(dest);
9198
+ } catch (e) {
9199
+ errors[it.filename] = e instanceof Error ? e.message : String(e);
9200
+ }
9201
+ }
9202
+ const ok = files.length > 0 && Object.keys(errors).length === 0;
9203
+ const result = {
9204
+ ok,
9205
+ tool: descriptor.name,
9206
+ taskType,
9207
+ taskId,
9208
+ fileId: up.fileId,
9209
+ outDir,
9210
+ files,
9211
+ ...Object.keys(errors).length ? { errors } : {}
9212
+ };
9213
+ await writeFile8(join21(outDir, "result.json"), JSON.stringify({ ...result, finishedAt: new Date().toISOString() }, null, 2));
9214
+ return result;
9215
+ }
9216
+
9217
+ // src/lib/mad/mad.ts
9218
+ import { mkdir as mkdir11, writeFile as writeFile10 } from "node:fs/promises";
9219
+ import { existsSync as existsSync20, statSync as statSync3 } from "node:fs";
9220
+ import { resolve as resolve10, join as join25 } from "node:path";
9221
+
9222
+ // src/lib/convert/types.ts
9223
+ function num(v, d = 0) {
9224
+ return typeof v === "number" && isFinite(v) ? v : d;
9225
+ }
9226
+ function parseCubicBezier(e) {
9227
+ if (!e || typeof e !== "string")
9228
+ return null;
9229
+ const m = e.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/);
9230
+ if (!m)
9231
+ return null;
9232
+ const v = m.slice(1, 5).map(Number);
9233
+ return v.every((x) => isFinite(x)) ? v : null;
9234
+ }
9235
+ function fontPx(font, d = 90) {
9236
+ if (!font)
9237
+ return d;
9238
+ const m = font.match(/([\d.]+)px/);
9239
+ return m ? Math.max(1, parseFloat(m[1])) : d;
9240
+ }
9241
+ function parseCssColor(c3) {
9242
+ if (!c3 || typeof c3 !== "string")
9243
+ return null;
9244
+ const s = c3.trim();
9245
+ let m = s.match(/^#([0-9a-fA-F]{3})$/);
9246
+ if (m) {
9247
+ const h = m[1];
9248
+ return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16), 1];
9249
+ }
9250
+ m = s.match(/^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/);
9251
+ if (m) {
9252
+ const h = m[1];
9253
+ const a = m[2] ? parseInt(m[2], 16) / 255 : 1;
9254
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), a];
9255
+ }
9256
+ m = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
9257
+ if (m) {
9258
+ return [
9259
+ Math.min(255, parseFloat(m[1])),
9260
+ Math.min(255, parseFloat(m[2])),
9261
+ Math.min(255, parseFloat(m[3])),
9262
+ m[4] !== undefined ? Math.min(1, parseFloat(m[4])) : 1
9263
+ ];
9264
+ }
9265
+ return null;
9266
+ }
9267
+ function mergeChannelTracks(ta, tb, defA, defB) {
9268
+ const sample = (track, t, dflt) => {
9269
+ if (!track || track.length === 0)
9270
+ return dflt;
9271
+ if (t <= track[0].t)
9272
+ return num(track[0].v, dflt);
9273
+ for (let i = 1;i < track.length; i++) {
9274
+ if (t <= track[i].t) {
9275
+ const p = track[i - 1];
9276
+ const q = track[i];
9277
+ const span = q.t - p.t;
9278
+ if (span <= 0)
9279
+ return num(q.v, dflt);
9280
+ const k = (t - p.t) / span;
9281
+ return num(p.v, dflt) + (num(q.v, dflt) - num(p.v, dflt)) * k;
9282
+ }
9283
+ }
9284
+ return num(track[track.length - 1].v, dflt);
9285
+ };
9286
+ const times = new Set;
9287
+ for (const k of ta ?? [])
9288
+ times.add(k.t);
9289
+ for (const k of tb ?? [])
9290
+ times.add(k.t);
9291
+ const sorted = [...times].sort((x, y) => x - y);
9292
+ return sorted.map((t) => {
9293
+ const ea = (ta ?? []).find((k) => k.t === t)?.e;
9294
+ const eb = (tb ?? []).find((k) => k.t === t)?.e;
9295
+ return { t, a: sample(ta, t, defA), b: sample(tb, t, defB), e: ea ?? eb ?? null };
9296
+ });
9297
+ }
9298
+
9299
+ // src/lib/convert/bake_ops.ts
9300
+ var TIME_DOMAIN_OPS = new Set(["shake", "pulsate", "oscillate", "flicker", "zoom"]);
9301
+ function sampleTrack(track, t, def) {
9302
+ if (!track || track.length === 0)
9303
+ return def;
9304
+ if (t <= track[0].t)
9305
+ return num(track[0].v, def);
9306
+ for (let i = 1;i < track.length; i++) {
9307
+ const a = track[i - 1];
9308
+ const b = track[i];
9309
+ if (t <= b.t) {
9310
+ const span = Math.max(b.t - a.t, 0.000001);
9311
+ const k = (t - a.t) / span;
9312
+ return num(a.v, def) + (num(b.v, def) - num(a.v, def)) * k;
9313
+ }
9314
+ }
9315
+ return num(track[track.length - 1].v, def);
9316
+ }
9317
+ function phasePair(i) {
9318
+ return [Math.sin(i * 2.399), Math.cos(i * 3.11)];
9319
+ }
9320
+ var round = (v, p) => {
9321
+ const k = 10 ** p;
9322
+ return Math.round(v * k) / k;
9323
+ };
9324
+ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
9325
+ var dn = (n) => Math.max(2, Math.floor(n));
9326
+ function basePos(anim, pos, t) {
9327
+ return [sampleTrack(anim.x, t, pos[0]), sampleTrack(anim.y, t, pos[1])];
9328
+ }
9329
+ function baseScale(anim, t) {
9330
+ if (anim.scale?.length) {
9331
+ const s = sampleTrack(anim.scale, t, 1);
9332
+ return [s, s];
9333
+ }
9334
+ if (anim.sx?.length || anim.sy?.length) {
9335
+ return [sampleTrack(anim.sx, t, 1), sampleTrack(anim.sy, t, 1)];
9336
+ }
9337
+ return [1, 1];
9338
+ }
9339
+ function baseRot(anim, t) {
9340
+ return sampleTrack(anim.rot, t, 0);
9341
+ }
9342
+ function opWindow(fx, ly) {
9343
+ const t0 = num(fx.t0, num(ly.in, 0));
9344
+ const t1 = num(fx.t1, num(ly.out, t0 + 1));
9345
+ return [t0, t1];
9346
+ }
9347
+ function bakeLayerOps(ly) {
9348
+ const anim = ly.anim ?? {};
9349
+ const pos = [num(ly.pos?.[0], 0), num(ly.pos?.[1], 0)];
9350
+ const tracks = [];
9351
+ const warnings = [];
9352
+ for (const fx of ly.fx ?? []) {
9353
+ const op = fx.op;
9354
+ if (!TIME_DOMAIN_OPS.has(op))
9355
+ continue;
9356
+ if (op === "zoom") {
9357
+ const tr = fx.tracks?.scale;
9358
+ if (!tr || tr.length < 1) {
9359
+ warnings.push(`bake: zoom 无 scale 参数轨,跳过(layer ${ly.id})`);
9360
+ continue;
9361
+ }
9362
+ const times = [];
9363
+ const values = [];
9364
+ for (const k of tr) {
9365
+ const t = num(k.t, 0);
9366
+ const v = num(k.v, 1);
9367
+ const [bx, by] = baseScale(anim, t);
9368
+ times.push(round(t, 3));
9369
+ values.push([round(bx * v * 100, 3), round(by * v * 100, 3)]);
9370
+ }
9371
+ const w = [times[0], times[times.length - 1]];
9372
+ tracks.push({ prop: "scale", times, values, window: w, ease: "power2.out" });
9373
+ continue;
9374
+ }
9375
+ const [t0, t1] = opWindow(fx, ly);
9376
+ if (op === "flicker") {
9377
+ if (t1 - t0 < 0.05)
9378
+ continue;
9379
+ let freq = num(fx.freq, 8);
9380
+ freq = clamp(freq >= 0.5 ? freq : freq * 30, 0.5, 20);
9381
+ const mag = clamp(num(fx.mag, 0.6), 0.05, 1);
9382
+ const hi = 1 + mag * 0.8;
9383
+ const lo = Math.max(0.3, 1 - mag * 0.6);
9384
+ const n = Math.min(dn(Math.max(2, Math.floor((t1 - t0) * freq * 2))), 160);
9385
+ const times = [round(t0, 3)];
9386
+ const values = [[0]];
9387
+ for (let j = 1;j <= n; j++) {
9388
+ const tt = t0 + (t1 - t0) * j / n;
9389
+ const v = j % 2 ? hi : lo;
9390
+ const b = clamp((v - 1) * 100, -100, 100);
9391
+ times.push(round(tt, 3));
9392
+ values.push([round(b, 3)]);
9393
+ }
9394
+ tracks.push({ prop: "brightness", times, values, window: [t0, t1] });
9395
+ continue;
9396
+ }
9397
+ if (t1 - t0 < 0.05)
9398
+ continue;
9399
+ if (op === "shake") {
9400
+ const amp0 = num(fx.mag ?? fx.amp, 18);
9401
+ const freq = clamp(num(fx.freq, 20), 1, 40);
9402
+ const n = Math.min(dn(Math.max(2, Math.floor((t1 - t0) * freq))), 160);
9403
+ const tr = fx.tracks?.mag;
9404
+ const seqX = [];
9405
+ const seqY = [];
9406
+ const ttArr = [];
9407
+ for (let i = 0;i <= n; i++) {
9408
+ const tt = t0 + (t1 - t0) * i / n;
9409
+ let a = sampleTrack(tr, tt, amp0);
9410
+ if (fx.decay)
9411
+ a *= 1 - i / n;
9412
+ const [px, py] = phasePair(i);
9413
+ seqX.push(round(a * px, 1));
9414
+ seqY.push(round(a * py, 1));
9415
+ ttArr.push(round(tt, 3));
9416
+ }
9417
+ const off0x = seqX[0];
9418
+ const off0y = seqY[0];
9419
+ const times = [];
9420
+ const values = [];
9421
+ for (let i = 0;i <= n; i++) {
9422
+ const [bx, by] = basePos(anim, pos, ttArr[i]);
9423
+ times.push(ttArr[i]);
9424
+ values.push([round(bx + (seqX[i] - off0x), 3), round(by + (seqY[i] - off0y), 3)]);
9425
+ }
9426
+ tracks.push({ prop: "position", times, values, window: [t0, t1] });
9427
+ continue;
9428
+ }
9429
+ if (op === "oscillate") {
9430
+ if (fx.rot === true) {
9431
+ const a1 = num(fx.a1, -3);
9432
+ const a2 = num(fx.a2, 3);
9433
+ const freq = clamp(num(fx.freq, 2), 0.2, 20);
9434
+ const n = Math.min(dn(Math.max(4, Math.floor((t1 - t0) * freq * 4))), 120);
9435
+ const times = [round(t0, 3)];
9436
+ const values = [[round(baseRot(anim, t0), 3)]];
9437
+ let prev = 0;
9438
+ let acc = 0;
9439
+ for (let i = 1;i <= n; i++) {
9440
+ const tt = t0 + (t1 - t0) * i / n;
9441
+ const ph = Math.sin(2 * Math.PI * freq * (tt - t0));
9442
+ const cur = (a1 + a2) / 2 + (a2 - a1) / 2 * ph;
9443
+ acc += round(cur - prev, 2);
9444
+ prev = cur;
9445
+ times.push(round(tt, 3));
9446
+ values.push([round(baseRot(anim, tt) + acc, 3)]);
9447
+ }
9448
+ tracks.push({ prop: "rotation", times, values, window: [t0, t1] });
9449
+ } else {
9450
+ const mag0 = num(fx.mag, 40);
9451
+ const ang = num(fx.angle, 90) * Math.PI / 180;
9452
+ const freq = clamp(num(fx.freq, 4), 0.2, 30);
9453
+ const n = Math.min(dn(Math.max(4, Math.floor((t1 - t0) * freq * 4))), 160);
9454
+ const tr = fx.tracks?.mag;
9455
+ const times = [round(t0, 3)];
9456
+ const [b0x, b0y] = basePos(anim, pos, t0);
9457
+ const values = [[round(b0x, 3), round(b0y, 3)]];
9458
+ let prevX = 0;
9459
+ let prevY = 0;
9460
+ let accX = 0;
9461
+ let accY = 0;
9462
+ for (let i = 1;i <= n; i++) {
9463
+ const tt = t0 + (t1 - t0) * i / n;
9464
+ const a = sampleTrack(tr, tt, mag0);
9465
+ const ph = Math.sin(2 * Math.PI * freq * (tt - t0));
9466
+ const curX = a * ph * Math.cos(ang);
9467
+ const curY = a * ph * Math.sin(ang);
9468
+ accX += round(curX - prevX, 2);
9469
+ accY += round(curY - prevY, 2);
9470
+ prevX = curX;
9471
+ prevY = curY;
9472
+ const [bx, by] = basePos(anim, pos, tt);
9473
+ times.push(round(tt, 3));
9474
+ values.push([round(bx + accX, 3), round(by + accY, 3)]);
9475
+ }
9476
+ tracks.push({ prop: "position", times, values, window: [t0, t1] });
9477
+ }
9478
+ continue;
9479
+ }
9480
+ if (op === "pulsate") {
9481
+ let lo;
9482
+ let hi;
9483
+ if (fx.amp != null) {
9484
+ lo = 1 - num(fx.amp, 0);
9485
+ hi = 1 + num(fx.amp, 0);
9486
+ } else {
9487
+ lo = num(fx.min, 1);
9488
+ hi = num(fx.max, 1.06);
9489
+ }
9490
+ const freq = clamp(num(fx.freq, 2), 0.2, 20);
9491
+ const n = Math.min(dn(Math.max(4, Math.floor((t1 - t0) * freq * 4))), 160);
9492
+ const tr = fx.tracks?.max;
9493
+ const times = [round(t0, 3)];
9494
+ const [bs0x, bs0y] = baseScale(anim, t0);
9495
+ const values = [[round(bs0x * 100, 3), round(bs0y * 100, 3)]];
9496
+ for (let i = 1;i <= n; i++) {
9497
+ const tt = t0 + (t1 - t0) * i / n;
9498
+ const h = sampleTrack(tr, tt, hi);
9499
+ const ph = (Math.sin(2 * Math.PI * freq * (tt - t0)) + 1) / 2;
9500
+ const s = round(lo + (Math.max(h, lo) - lo) * ph, 3);
9501
+ const [bx, by] = baseScale(anim, tt);
9502
+ times.push(round(tt, 3));
9503
+ values.push([round(bx * s * 100, 3), round(by * s * 100, 3)]);
9504
+ }
9505
+ tracks.push({ prop: "scale", times, values, window: [t0, t1] });
9506
+ continue;
9507
+ }
9508
+ }
9509
+ return { tracks, warnings };
9510
+ }
9511
+
9512
+ // src/lib/convert/ir_to_jsx.ts
9513
+ var HEADER = `// 由技法图鉴重建生成,仅供学习研究;素材为占位,请替换
9514
+ // Generated by gitruck-creation IR->JSX converter. For study only; placeholder footage, replace before use.
9515
+ // 用法: AE 菜单 文件>脚本>运行脚本文件 选择本文件(AE 2020+ 建议)`;
9516
+ var FX_MATCHNAME = {
9517
+ glow: "ADBE Glo2",
9518
+ blur: "ADBE Gaussian Blur 2",
9519
+ dirBlur: "ADBE Motion Blur",
9520
+ turbulence: "ADBE Turbulent Displace",
9521
+ tile: "ADBE Tile",
9522
+ dropShadow: "ADBE Drop Shadow",
9523
+ invert: "ADBE Invert",
9524
+ wipe: "ADBE Linear Wipe",
9525
+ bulge: "ADBE Bulge",
9526
+ noise: "ADBE Noise",
9527
+ fill: "ADBE Fill",
9528
+ vignette: "ADBE Vignette",
9529
+ colorAdjust: "ADBE Brightness & Contrast 2"
9530
+ };
9531
+ function esc(s) {
9532
+ return String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(new RegExp(String.fromCharCode(8232), "g"), "\\u2028").replace(new RegExp(String.fromCharCode(8233), "g"), "\\u2029");
9533
+ }
9534
+ function r35(v) {
9535
+ return Math.round(v * 1000) / 1000;
9536
+ }
9537
+ function colorArr(css, fallback) {
9538
+ const c3 = parseCssColor(css);
9539
+ if (!c3)
9540
+ return fallback;
9541
+ return [r35(c3[0] / 255), r35(c3[1] / 255), r35(c3[2] / 255)];
9542
+ }
9543
+ function easeInfluence(e) {
9544
+ const cb = parseCubicBezier(e);
9545
+ if (!cb)
9546
+ return null;
9547
+ const clamp2 = (v) => Math.min(100, Math.max(0.1, v));
9548
+ return { out: clamp2(cb[0] * 100 || 33), inn: clamp2((1 - cb[2]) * 100 || 33) };
9549
+ }
9550
+ function emitKeys(ctx, propExpr, keys) {
9551
+ if (keys.length === 0)
9552
+ return;
9553
+ const L = ctx.lines;
9554
+ L.push(` try {`);
9555
+ L.push(` var p = ${propExpr};`);
9556
+ for (const k of keys) {
9557
+ L.push(` p.setValueAtTime(${r35(k.t)}, ${k.value});`);
9558
+ }
9559
+ keys.forEach((k, i) => {
9560
+ const inf = easeInfluence(k.e);
9561
+ if (!inf)
9562
+ return;
9563
+ L.push(` try {`);
9564
+ L.push(` var dim = 1; try { dim = p.value.length || 1; } catch (e) { dim = 1; }`);
9565
+ L.push(` var eo = [], ei = [];`);
9566
+ L.push(` for (var d = 0; d < Math.min(dim, 3); d++) { eo.push(new KeyframeEase(0, ${r35(inf.out)})); ei.push(new KeyframeEase(0, ${r35(inf.inn)})); }`);
9567
+ L.push(` p.setTemporalEaseAtKey(${i + 1}, ei, eo);`);
9568
+ L.push(` } catch (e) {}`);
9569
+ });
9570
+ L.push(` } catch (e) {}`);
9571
+ }
9572
+ function fxComment(fx) {
9573
+ const params = Object.keys(fx).filter((k) => !["op", "src", "tracks"].includes(k)).map((k) => `${k}=${JSON.stringify(fx[k])}`).join(" ");
9574
+ const tracks = fx.tracks ? ` tracks:[${Object.keys(fx.tracks).join(",")}]` : "";
9575
+ return `${fx.op}${fx.src ? ` (原特效: ${fx.src})` : ""}${params ? " " + params : ""}${tracks}`;
9576
+ }
9577
+ function emitEffects(ctx, layerVar, fxList) {
9578
+ if (!fxList || fxList.length === 0)
9579
+ return;
9580
+ const L = ctx.lines;
9581
+ L.push(` // —— 特效(原工程特效清单,已知 matchName 自动 applyEffect,其余 TODO 手动补)——`);
9582
+ for (const fx of fxList) {
9583
+ const mn = FX_MATCHNAME[fx.op];
9584
+ if (ctx.bakeOps && TIME_DOMAIN_OPS.has(fx.op))
9585
+ continue;
9586
+ if (mn && !TIME_DOMAIN_OPS.has(fx.op)) {
9587
+ L.push(` // fx: ${esc(fxComment(fx))}`);
9588
+ L.push(` try { ${layerVar}.property("ADBE Effect Parade").addProperty("${mn}"); } catch (e) {}`);
9589
+ } else if (TIME_DOMAIN_OPS.has(fx.op)) {
9590
+ L.push(` // TODO fx(时序算子,请用表达式或关键帧手动复现): ${esc(fxComment(fx))}`);
9591
+ ctx.warnings.push(`jsx: 时序算子 ${fx.op} 未自动铺帧(已写 TODO 注释)`);
9592
+ } else {
9593
+ L.push(` // TODO fx(无对应 AE 原生 matchName): ${esc(fxComment(fx))}`);
9594
+ ctx.warnings.push(`jsx: 未映射特效 ${fx.op}(已写 TODO 注释)`);
9595
+ }
9596
+ }
9597
+ }
9598
+ function samplePos(anim, pos, t) {
9599
+ return [sampleTrack(anim.x, t, pos[0]), sampleTrack(anim.y, t, pos[1])];
9600
+ }
9601
+ function sampleScaleMul(anim, t) {
9602
+ if (anim.scale?.length) {
9603
+ const s = sampleTrack(anim.scale, t, 1);
9604
+ return [s, s];
9605
+ }
9606
+ if (anim.sx?.length || anim.sy?.length)
9607
+ return [sampleTrack(anim.sx, t, 1), sampleTrack(anim.sy, t, 1)];
9608
+ return [1, 1];
9609
+ }
9610
+ function mergeBaked(baseKeys, bakedKeys, windows, anchorAt, layerIn, layerOut) {
9611
+ const inWindow = (t) => {
9612
+ for (const w of windows)
9613
+ if (t > w[0] + 0.000001 && t < w[1] - 0.000001)
9614
+ return true;
9615
+ return false;
9616
+ };
9617
+ const out = [];
9618
+ for (const k of baseKeys)
9619
+ if (!inWindow(k.t))
9620
+ out.push(k);
9621
+ const EPS = 0.033;
9622
+ for (const w of windows) {
9623
+ if (w[1] < layerOut - EPS) {
9624
+ const at = Math.min(w[1] + EPS, layerOut);
9625
+ out.push({ t: at, value: anchorAt(at), e: null });
9626
+ }
9627
+ if (w[0] > layerIn + EPS) {
9628
+ const at = Math.max(w[0] - EPS, layerIn);
9629
+ out.push({ t: at, value: anchorAt(at), e: null });
9630
+ }
9631
+ }
9632
+ out.push(...bakedKeys);
9633
+ out.sort((a, b) => a.t - b.t);
9634
+ const ded = [];
9635
+ for (const k of out) {
9636
+ if (ded.length && Math.abs(k.t - ded[ded.length - 1].t) < 0.000001)
9637
+ ded[ded.length - 1] = k;
9638
+ else
9639
+ ded.push(k);
9640
+ }
9641
+ return ded;
9642
+ }
9643
+ function positionBaseKeys(anim, pos) {
9644
+ if (!(anim.x?.length || anim.y?.length))
9645
+ return [];
9646
+ const merged = mergeChannelTracks(anim.x, anim.y, pos[0], pos[1]);
9647
+ return merged.map((k) => ({ t: k.t, value: `[${r35(k.a)}, ${r35(k.b)}]`, e: k.e }));
9648
+ }
9649
+ function scaleBaseKeys(anim, coverExpr) {
9650
+ const sc = (v) => coverExpr ? `${r35(v)}*${coverExpr}` : `${r35(v)}`;
9651
+ if (anim.scale?.length) {
9652
+ return anim.scale.map((k) => ({ t: k.t, value: `[${sc(num(k.v, 1) * 100)}, ${sc(num(k.v, 1) * 100)}]`, e: k.e }));
9653
+ }
9654
+ if (anim.sx?.length || anim.sy?.length) {
9655
+ const merged = mergeChannelTracks(anim.sx, anim.sy, 1, 1);
9656
+ return merged.map((k) => ({ t: k.t, value: `[${sc(k.a * 100)}, ${sc(k.b * 100)}]`, e: k.e }));
9657
+ }
9658
+ return [];
9659
+ }
9660
+ function emitBakedOps(ctx, ly, layerVar, tf, coverExpr) {
9661
+ const { tracks, warnings } = bakeLayerOps(ly);
9662
+ for (const w of warnings)
9663
+ ctx.warnings.push(w);
9664
+ if (tracks.length === 0)
9665
+ return;
9666
+ const anim = ly.anim ?? {};
9667
+ const pos = [num(ly.pos?.[0], 0), num(ly.pos?.[1], 0)];
9668
+ const inn = num(ly.in, 0);
9669
+ const out = Math.max(inn + 0.01, num(ly.out, inn + 1));
9670
+ const sc = (v) => coverExpr ? `${r35(v)}*${coverExpr}` : `${r35(v)}`;
9671
+ const brights = tracks.filter((t) => t.prop === "brightness");
9672
+ if (brights.length) {
9673
+ ctx.lines.push(` // fx: flicker → 亮度脉冲(ADBE Brightness & Contrast 2,避开 opacity 通道)`);
9674
+ ctx.lines.push(` var flkFx = null; try { flkFx = ${layerVar}.property("ADBE Effect Parade").addProperty("ADBE Brightness & Contrast 2"); } catch (e) { flkFx = null; }`);
9675
+ for (const bt of brights) {
9676
+ const keys = bt.times.map((t, i) => ({ t, value: `${r35(bt.values[i][0])}`, e: null }));
9677
+ emitKeys(ctx, `flkFx.property(1)`, keys);
9678
+ }
9679
+ }
9680
+ const posBaked = tracks.filter((t) => t.prop === "position");
9681
+ if (posBaked.length) {
9682
+ const baseKeys = positionBaseKeys(anim, pos);
9683
+ const bakedKeys = [];
9684
+ const windows = [];
9685
+ for (const bt of posBaked) {
9686
+ windows.push(bt.window);
9687
+ for (let i = 0;i < bt.times.length; i++) {
9688
+ bakedKeys.push({ t: bt.times[i], value: `[${r35(bt.values[i][0])}, ${r35(bt.values[i][1])}]`, e: null });
9689
+ }
9690
+ }
9691
+ const merged = mergeBaked(baseKeys, bakedKeys, windows, (t) => {
9692
+ const [x, y] = samplePos(anim, pos, t);
9693
+ return `[${r35(x)}, ${r35(y)}]`;
9694
+ }, inn, out);
9695
+ emitKeys(ctx, `${tf}.property("ADBE Position")`, merged);
9696
+ }
9697
+ const scaleBaked = tracks.filter((t) => t.prop === "scale");
9698
+ if (scaleBaked.length) {
9699
+ const baseKeys = scaleBaseKeys(anim, coverExpr);
9700
+ const bakedKeys = [];
9701
+ const windows = [];
9702
+ for (const bt of scaleBaked) {
9703
+ windows.push(bt.window);
9704
+ for (let i = 0;i < bt.times.length; i++) {
9705
+ bakedKeys.push({ t: bt.times[i], value: `[${sc(bt.values[i][0])}, ${sc(bt.values[i][1])}]`, e: null });
9706
+ }
9707
+ }
9708
+ const merged = mergeBaked(baseKeys, bakedKeys, windows, (t) => {
9709
+ const [mx, my] = sampleScaleMul(anim, t);
9710
+ return `[${sc(mx * 100)}, ${sc(my * 100)}]`;
9711
+ }, inn, out);
9712
+ emitKeys(ctx, `${tf}.property("ADBE Scale")`, merged);
9713
+ }
9714
+ const rotBaked = tracks.filter((t) => t.prop === "rotation");
9715
+ if (rotBaked.length) {
9716
+ const baseKeys = (anim.rot ?? []).map((k) => ({ t: k.t, value: `${r35(num(k.v, 0))}`, e: k.e }));
9717
+ const bakedKeys = [];
9718
+ const windows = [];
9719
+ for (const bt of rotBaked) {
9720
+ windows.push(bt.window);
9721
+ for (let i = 0;i < bt.times.length; i++)
9722
+ bakedKeys.push({ t: bt.times[i], value: `${r35(bt.values[i][0])}`, e: null });
9723
+ }
9724
+ const merged = mergeBaked(baseKeys, bakedKeys, windows, (t) => `${r35(sampleTrack(anim.rot, t, 0))}`, inn, out);
9725
+ emitKeys(ctx, `${tf}.property("ADBE Rotate Z")`, merged);
9726
+ }
9727
+ }
9728
+ function emitLayer(ctx, ir, ly, parentNullVar) {
9729
+ const L = ctx.lines;
9730
+ const cv = ctx.compVar;
9731
+ const c3 = ir.canvas;
9732
+ const id = ++ctx.layerSeq;
9733
+ const v = `ly${id}`;
9734
+ const inn = num(ly.in, 0);
9735
+ const out = Math.max(inn + 0.01, num(ly.out, c3.duration));
9736
+ const px = num(ly.pos?.[0], c3.w / 2);
9737
+ const py = num(ly.pos?.[1], c3.h / 2);
9738
+ const name = esc(`${ly.id || "L" + id} [${ly.type}]`);
9739
+ L.push(``);
9740
+ L.push(` // ---- 层 ${name} in=${r35(inn)} out=${r35(out)} ----`);
9741
+ if (ly.type === "group") {
9742
+ L.push(` var ${v} = ${cv}.layers.addNull(${r35(c3.duration)});`);
9743
+ L.push(` ${v}.name = "${name}";`);
9744
+ L.push(` ${v}.inPoint = ${r35(inn)}; ${v}.outPoint = ${r35(out)};`);
9745
+ L.push(` ${v}.property("ADBE Transform Group").property("ADBE Anchor Point").setValue([0,0]);`);
9746
+ L.push(` ${v}.property("ADBE Transform Group").property("ADBE Position").setValue([0,0]);`);
9747
+ if (parentNullVar)
9748
+ L.push(` try { ${v}.parent = ${parentNullVar}; } catch (e) {}`);
9749
+ emitGroupAnim(ctx, v, ly);
9750
+ emitEffects(ctx, v, ly.fx);
9751
+ for (const child of ly.children ?? [])
9752
+ emitLayer(ctx, ir, child, v);
9753
+ return;
9754
+ }
9755
+ let coverExpr = null;
9756
+ const footageHit = ly.type === "image" || ly.type === "video" ? ctx.footage[ly.id] : undefined;
9757
+ const fgVar = footageHit ? ctx.footageVars.get(footageHit.path) : undefined;
9758
+ if (footageHit && fgVar) {
9759
+ const slotW = Math.max(2, Math.round(num(ly.w, c3.w)));
9760
+ const slotH = Math.max(2, Math.round(num(ly.h, c3.h)));
9761
+ const srcOffset = num(footageHit.srcOffset, 0);
9762
+ const cvv = `cv${id}`;
9763
+ coverExpr = cvv;
9764
+ L.push(` // 素材注入(${esc(String(ly.type))}): footage srcOffset=${r35(srcOffset)}`);
9765
+ L.push(` var ${v} = null, ${cvv} = 1;`);
9766
+ L.push(` if (${fgVar} != null) {`);
9767
+ L.push(` try {`);
9768
+ L.push(` ${v} = ${cv}.layers.add(${fgVar});`);
9769
+ L.push(` var _fw = ${fgVar}.width || ${slotW}, _fh = ${fgVar}.height || ${slotH};`);
9770
+ L.push(` ${cvv} = Math.max(${slotW} / _fw, ${slotH} / _fh);`);
9771
+ L.push(` ${v}.startTime = ${r35(inn)} - ${r35(srcOffset)};`);
9772
+ L.push(` } catch (e) { ${v} = null; }`);
9773
+ L.push(` }`);
9774
+ L.push(` if (${v} == null) {`);
9775
+ L.push(` ${v} = ${cv}.layers.addSolid([0.35,0.35,0.4], "${name} [占位]", ${slotW}, ${slotH}, 1, ${r35(c3.duration)});`);
9776
+ L.push(` ${cvv} = 1;`);
9777
+ L.push(` }`);
9778
+ } else if (ly.type === "text") {
9779
+ const size = fontPx(ly.font);
9780
+ const col = colorArr(ly.color, [1, 1, 1]);
9781
+ L.push(` var ${v} = ${cv}.layers.addText("${esc(ly.text ?? "")}");`);
9782
+ L.push(` try {`);
9783
+ L.push(` var td = ${v}.property("ADBE Text Properties").property("ADBE Text Document");`);
9784
+ L.push(` var tdv = td.value;`);
9785
+ L.push(` tdv.fontSize = ${Math.round(size)};`);
9786
+ L.push(` tdv.fillColor = [${col.join(",")}];`);
9787
+ L.push(` tdv.justification = ParagraphJustification.CENTER_JUSTIFY;`);
9788
+ L.push(` td.setValue(tdv);`);
9789
+ L.push(` } catch (e) {}`);
9790
+ } else if (ly.type === "shape") {
9791
+ const col = colorArr(ly.fill, [0.2, 0.33, 0.67]);
9792
+ const w = Math.max(2, Math.round(num(ly.w, 400)));
9793
+ const h = Math.max(2, Math.round(num(ly.h, 400)));
9794
+ if (ly.shape === "ellipse")
9795
+ L.push(` // TODO: 原层为椭圆形状,固态占位,可手动换 shape layer`);
9796
+ L.push(` var ${v} = ${cv}.layers.addSolid([${col.join(",")}], "${name}", ${w}, ${h}, 1, ${r35(c3.duration)});`);
9797
+ } else {
9798
+ const w = Math.max(2, Math.round(num(ly.w, c3.w)));
9799
+ const h = Math.max(2, Math.round(num(ly.h, c3.h)));
9800
+ L.push(` // 占位素材(${esc(String(ly.type))}${ly.asset ? ` asset=${esc(String(ly.asset))}` : ""}): 请替换为真实素材`);
9801
+ L.push(` var ${v} = ${cv}.layers.addSolid([0.35,0.35,0.4], "${name} [占位]", ${w}, ${h}, 1, ${r35(c3.duration)});`);
9802
+ }
9803
+ L.push(` ${v}.name = "${name}";`);
9804
+ L.push(` ${v}.inPoint = ${r35(inn)}; ${v}.outPoint = ${r35(out)};`);
9805
+ if (parentNullVar)
9806
+ L.push(` try { ${v}.parent = ${parentNullVar}; } catch (e) {}`);
9807
+ if (ly.blend && ly.blend !== "normal") {
9808
+ L.push(` // 混合模式: ${esc(ly.blend)}`);
9809
+ const bm = BLEND_JSX[ly.blend];
9810
+ if (bm)
9811
+ L.push(` try { ${v}.blendingMode = BlendingMode.${bm}; } catch (e) {}`);
9812
+ }
9813
+ const tf = `${v}.property("ADBE Transform Group")`;
9814
+ L.push(` ${tf}.property("ADBE Position").setValue([${r35(px)}, ${r35(py)}]);`);
9815
+ const anim = ly.anim ?? {};
9816
+ const bakedProps = new Set(ctx.bakeOps ? bakeLayerOps(ly).tracks.map((t) => t.prop) : []);
9817
+ if ((anim.x?.length || anim.y?.length) && !bakedProps.has("position")) {
9818
+ emitKeys(ctx, `${tf}.property("ADBE Position")`, positionBaseKeys(anim, [px, py]));
9819
+ }
9820
+ if (!bakedProps.has("scale")) {
9821
+ const scKeys = scaleBaseKeys(anim, coverExpr);
9822
+ if (scKeys.length)
9823
+ emitKeys(ctx, `${tf}.property("ADBE Scale")`, scKeys);
9824
+ else if (coverExpr) {
9825
+ L.push(` ${tf}.property("ADBE Scale").setValue([100*${coverExpr}, 100*${coverExpr}]);`);
9826
+ }
9827
+ }
9828
+ if (anim.rot?.length && !bakedProps.has("rotation")) {
9829
+ emitKeys(ctx, `${tf}.property("ADBE Rotate Z")`, anim.rot.map((k) => ({ t: k.t, value: `${r35(num(k.v, 0))}`, e: k.e })));
9830
+ }
9831
+ if (anim.opacity?.length) {
9832
+ emitKeys(ctx, `${tf}.property("ADBE Opacity")`, anim.opacity.map((k) => ({ t: k.t, value: `${r35(Math.min(1, Math.max(0, num(k.v, 1))) * 100)}`, e: k.e })));
9833
+ }
9834
+ if (anim.ls?.length) {
9835
+ L.push(` // TODO: letterspacing 轨未自动映射(AE 需 Animator>Tracking),共 ${anim.ls.length} 帧`);
9836
+ }
9837
+ emitEffects(ctx, v, ly.fx);
9838
+ if (ctx.bakeOps)
9839
+ emitBakedOps(ctx, ly, v, tf, coverExpr);
9840
+ if (ly.raw_fx?.length) {
9841
+ L.push(` // TODO 原工程还有未解析特效: ${esc(ly.raw_fx.join(", "))}`);
9842
+ }
9843
+ }
9844
+ function emitGroupAnim(ctx, v, ly) {
9845
+ const anim = ly.anim ?? {};
9846
+ const px = num(ly.pos?.[0], 0);
9847
+ const py = num(ly.pos?.[1], 0);
9848
+ if (anim.x?.length || anim.y?.length) {
9849
+ const merged = mergeChannelTracks(anim.x, anim.y, px, py);
9850
+ emitKeys(ctx, `${v}.property("ADBE Transform Group").property("ADBE Position")`, merged.map((k) => ({ t: k.t, value: `[${r35(k.a - px)}, ${r35(k.b - py)}]`, e: k.e })));
9851
+ }
9852
+ for (const ch of ["scale", "rot", "opacity"]) {
9853
+ if (anim[ch]?.length) {
9854
+ ctx.lines.push(` // TODO: 组自身 ${ch} 动画未映射到 null(子层坐标为画布绝对值,直接缩放会偏移)`);
9855
+ ctx.warnings.push(`jsx: 组 ${ly.id} 的 ${ch} 动画需手动复现`);
9856
+ }
9857
+ }
9858
+ }
9859
+ var BLEND_JSX = {
9860
+ screen: "SCREEN",
9861
+ add: "ADD",
9862
+ multiply: "MULTIPLY",
9863
+ overlay: "OVERLAY",
9864
+ lighten: "LIGHTEN",
9865
+ darken: "DARKEN",
9866
+ "soft-light": "SOFT_LIGHT",
9867
+ "hard-light": "HARD_LIGHT",
9868
+ difference: "DIFFERENCE",
9869
+ exclude: "EXCLUSION",
9870
+ hue: "HUE",
9871
+ color: "COLOR"
9872
+ };
9873
+ function fwdSlash(p) {
9874
+ return p.replace(/\\/g, "/");
9875
+ }
9876
+ function emitFootageImports(ctx, paths) {
9877
+ const L = ctx.lines;
9878
+ const uniq2 = [...new Set(paths)];
9879
+ if (uniq2.length === 0)
9880
+ return;
9881
+ L.push(` // —— 素材导入(每素材仅导入一次、变量缓存复用)——`);
9882
+ let i = 0;
9883
+ for (const p of uniq2) {
9884
+ const vn = `fg${++i}`;
9885
+ ctx.footageVars.set(p, vn);
9886
+ L.push(` var ${vn} = null;`);
9887
+ L.push(` try { ${vn} = app.project.importFile(new ImportOptions(new File("${esc(fwdSlash(p))}"))); } catch (e) { ${vn} = null; }`);
9888
+ }
9889
+ }
9890
+ function newCtx(opts) {
9891
+ return {
9892
+ lines: [],
9893
+ warnings: [],
9894
+ layerSeq: 0,
9895
+ compVar: opts?.compVar ?? "comp",
9896
+ footage: opts?.footage ?? {},
9897
+ bakeOps: !!opts?.bakeOps,
9898
+ footageVars: new Map
9899
+ };
9900
+ }
9901
+ function madJsx(opts) {
9902
+ const { master, windows } = opts;
9903
+ const mw = Math.max(4, Math.round(master.w));
9904
+ const mh = Math.max(4, Math.round(master.h));
9905
+ const mfps = Math.min(99, Math.max(1, master.fps || 30));
9906
+ const masterName = esc(master.name ?? "MAD Master");
9907
+ const allPaths = [];
9908
+ for (const win of windows) {
9909
+ for (const f of Object.values(win.footage ?? {}))
9910
+ allPaths.push(f.path);
9911
+ }
9912
+ const ctx = newCtx({ bakeOps: true });
9913
+ const L = ctx.lines;
9914
+ L.push(opts.header ?? HEADER);
9915
+ L.push(``);
9916
+ L.push(`app.beginUndoGroup("MAD Rebuild ${masterName}");`);
9917
+ L.push(`(function () {`);
9918
+ emitFootageImports(ctx, allPaths);
9919
+ const globalFootageVars = ctx.footageVars;
9920
+ let totalDur = 0.1;
9921
+ for (const win of windows) {
9922
+ const len = win.outLen ?? win.t1 - win.t0;
9923
+ totalDur = Math.max(totalDur, win.dropAt + Math.max(0.01, len));
9924
+ }
9925
+ L.push(``);
9926
+ L.push(` var master = app.project.items.addComp("${masterName}", ${mw}, ${mh}, 1, ${r35(totalDur)}, ${r35(mfps)});`);
9927
+ L.push(` try { master.bgColor = [0.04,0.04,0.07]; } catch (e) {}`);
9928
+ const subVars = [];
9929
+ windows.forEach((win, wi) => {
9930
+ const c3 = win.ir.canvas;
9931
+ const sw = Math.max(4, Math.round(num(c3.w, 1920)));
9932
+ const sh = Math.max(4, Math.round(num(c3.h, 1080)));
9933
+ const sfps = Math.min(99, Math.max(1, num(c3.fps, 30)));
9934
+ const sdur = Math.max(0.1, num(c3.duration, 3));
9935
+ const subName = esc(`${win.uid}-${win.seq}`);
9936
+ const subVar = `sub${wi}`;
9937
+ L.push(``);
9938
+ L.push(` // ==== 子合成 #${wi} 技法 ${subName}(源窗 t0=${r35(win.t0)} t1=${r35(win.t1)})====`);
9939
+ L.push(` var ${subVar} = app.project.items.addComp("${subName}", ${sw}, ${sh}, 1, ${r35(sdur)}, ${r35(sfps)});`);
9940
+ const winFootage = { ...win.footage ?? {} };
9941
+ const subCtx = {
9942
+ lines: L,
9943
+ warnings: ctx.warnings,
9944
+ layerSeq: 0,
9945
+ compVar: subVar,
9946
+ footage: winFootage,
9947
+ bakeOps: true,
9948
+ footageVars: globalFootageVars
9949
+ };
9950
+ for (const ly of win.ir.layers ?? [])
9951
+ emitLayer(subCtx, win.ir, ly, null);
9952
+ subVars.push({ subVar, win });
9953
+ });
9954
+ L.push(``);
9955
+ L.push(` // ==== 母合成裁窗串接(startTime=落点−t0,inPoint=落点,outPoint=落点+窗长)====`);
9956
+ subVars.forEach(({ subVar, win }, wi) => {
9957
+ const len = Math.max(0.01, win.outLen ?? win.t1 - win.t0);
9958
+ const lv = `mL${wi}`;
9959
+ L.push(` var ${lv} = master.layers.add(${subVar});`);
9960
+ L.push(` ${lv}.startTime = ${r35(win.dropAt - win.t0)};`);
9961
+ L.push(` ${lv}.inPoint = ${r35(win.dropAt)};`);
9962
+ L.push(` ${lv}.outPoint = ${r35(win.dropAt + len)};`);
9963
+ L.push(` try {`);
9964
+ L.push(` var _cov = Math.max(${mw} / ${subVar}.width, ${mh} / ${subVar}.height) * 100;`);
9965
+ L.push(` ${lv}.property("ADBE Transform Group").property("ADBE Scale").setValue([_cov, _cov]);`);
9966
+ L.push(` ${lv}.property("ADBE Transform Group").property("ADBE Position").setValue([${mw / 2}, ${mh / 2}]);`);
9967
+ L.push(` } catch (e) {}`);
9968
+ });
9969
+ if (opts.bgm) {
9970
+ L.push(``);
9971
+ L.push(` // ==== BGM 入轨(importFile 包 try/catch,AE 侧文件缺失/损坏脚本继续跑完仅缺音轨)====`);
9972
+ L.push(` try {`);
9973
+ L.push(` var bgm = app.project.importFile(new ImportOptions(new File("${esc(fwdSlash(opts.bgm.path))}")));`);
9974
+ L.push(` var bgmL = master.layers.add(bgm);`);
9975
+ L.push(` bgmL.startTime = 0;`);
9976
+ L.push(` } catch (e) {}`);
9977
+ const markers = opts.bgm.markers ?? [];
9978
+ if (markers.length) {
9979
+ L.push(` // beat marker(downbeat 带标签区分)`);
9980
+ L.push(` try {`);
9981
+ L.push(` var mk = master.property("ADBE Marker");`);
9982
+ for (const m of markers) {
9983
+ const label = m.downbeat ? "downbeat" : "beat";
9984
+ L.push(` mk.setValueAtTime(${r35(m.t)}, new MarkerValue("${label}"));`);
9985
+ }
9986
+ L.push(` } catch (e) {}`);
9987
+ }
9988
+ }
9989
+ L.push(``);
9990
+ L.push(` master.openInViewer();`);
9991
+ L.push(`})();`);
9992
+ L.push(`app.endUndoGroup();`);
9993
+ return { jsx: L.join(`
9994
+ `) + `
9995
+ `, warnings: ctx.warnings };
9996
+ }
9997
+
9998
+ // src/lib/mad/scan.ts
9999
+ import { readdirSync, statSync as statSync2 } from "node:fs";
10000
+ import { extname as extname6, join as join22 } from "node:path";
10001
+ var VIDEO_EXTS2 = new Set(defaultExtsFor("video") ?? []);
10002
+ function scanFolder(dirAbs, opts = {}) {
10003
+ const probe = opts.probe ?? probeGeometry;
10004
+ const warn = opts.warn ?? (() => {});
10005
+ let entries;
10006
+ try {
10007
+ entries = readdirSync(dirAbs);
10008
+ } catch {
10009
+ throw new Error(`素材文件夹不存在或无法读取:${dirAbs}`);
10010
+ }
10011
+ const files = entries.filter((n) => VIDEO_EXTS2.has(extname6(n).toLowerCase())).map((n) => join22(dirAbs, n)).filter((p) => {
10012
+ try {
10013
+ return statSync2(p).isFile();
10014
+ } catch {
10015
+ return false;
10016
+ }
10017
+ }).sort();
10018
+ const videos = [];
10019
+ const skipped = [];
10020
+ for (const p of files) {
10021
+ try {
10022
+ const g2 = probe(p, opts.ffmpegPath);
10023
+ if (!(g2.width > 0) || !(g2.height > 0)) {
10024
+ skipped.push(p);
10025
+ warn(`跳过无法探测几何的文件:${p}`);
10026
+ continue;
10027
+ }
10028
+ videos.push({ path: p, width: g2.width, height: g2.height, duration: g2.duration || 0 });
10029
+ } catch {
10030
+ skipped.push(p);
10031
+ warn(`跳过 ffprobe 失败的文件:${p}`);
10032
+ }
10033
+ }
10034
+ if (videos.length === 0) {
10035
+ throw new Error(`未在「${dirAbs}」发现可用素材视频。支持的扩展名:${[...VIDEO_EXTS2].join(" ")}。请确认文件夹内含 3~10 条视频。`);
10036
+ }
10037
+ const portrait = videos.filter((v) => v.height > v.width).length;
10038
+ const orientation = portrait > videos.length / 2 ? "portrait" : "landscape";
10039
+ return { videos, orientation, skipped };
10040
+ }
10041
+ function masterCanvas(orientation) {
10042
+ return orientation === "portrait" ? { w: 1080, h: 1920, fps: 30 } : { w: 1920, h: 1080, fps: 30 };
10043
+ }
10044
+
10045
+ // src/lib/mad/selector.ts
10046
+ function mulberry322(seed) {
10047
+ let a = seed >>> 0;
10048
+ return () => {
10049
+ a |= 0;
10050
+ a = a + 1831565813 | 0;
10051
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
10052
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
10053
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
10054
+ };
10055
+ }
10056
+ function tierWeight(format) {
10057
+ if (format === "am" || format === "nv")
10058
+ return 1;
10059
+ return 0.55;
10060
+ }
10061
+ function entryOrientation(e) {
10062
+ return e.h > e.w ? "portrait" : "landscape";
10063
+ }
10064
+ function baseWeight(e, orientation) {
10065
+ const nSeen = Math.sqrt(Math.max(1, e.n_seen));
10066
+ const tier = tierWeight(e.format);
10067
+ const orient = entryOrientation(e) === orientation ? 1 : 0.5;
10068
+ const fxPenalty = Math.max(0.1, 1 - Math.min(1, Math.max(0, e.unmapped_fx_ratio)));
10069
+ return nSeen * tier * orient * fxPenalty;
10070
+ }
10071
+ function weightedPick(weights, rnd) {
10072
+ const total = weights.reduce((s, w) => s + Math.max(0, w), 0);
10073
+ if (total <= 0)
10074
+ return weights.length ? Math.floor(rnd() * weights.length) : -1;
10075
+ let r = rnd() * total;
10076
+ for (let i = 0;i < weights.length; i++) {
10077
+ r -= Math.max(0, weights[i]);
10078
+ if (r <= 0)
10079
+ return i;
10080
+ }
10081
+ return weights.length - 1;
10082
+ }
10083
+ function budgetWindowCount(durationSec, targetAvgSec = 2.2) {
10084
+ const n = Math.round(durationSec / Math.max(0.5, targetAvgSec));
10085
+ return Math.min(12, Math.max(6, n));
10086
+ }
10087
+ function selectWindows(opts) {
10088
+ const { pool, videos, durationSec, orientation, seed } = opts;
10089
+ const rnd = mulberry322(seed || 1);
10090
+ const want = budgetWindowCount(durationSec, opts.targetAvgSec);
10091
+ if (pool.length === 0 || videos.length === 0)
10092
+ return [];
10093
+ const cats = [...new Set(pool.map((e) => e.cat))];
10094
+ const byCat = new Map;
10095
+ for (const c3 of cats)
10096
+ byCat.set(c3, pool.filter((e) => e.cat === c3));
10097
+ const chosen = [];
10098
+ const usedPatterns = new Set;
10099
+ const usedUids = new Set;
10100
+ let catCursor = 0;
10101
+ let videoCursor = 0;
10102
+ const srcCursor = new Map;
10103
+ const tryPickFrom = (candidates, relaxDedup) => {
10104
+ const pool2 = candidates.filter((e) => !usedUids.has(e.uid) && (relaxDedup || !usedPatterns.has(e.pid)));
10105
+ if (pool2.length === 0)
10106
+ return null;
10107
+ const weights = pool2.map((e) => baseWeight(e, orientation));
10108
+ const idx = weightedPick(weights, rnd);
10109
+ return idx >= 0 ? pool2[idx] : null;
10110
+ };
10111
+ let guard = 0;
10112
+ while (chosen.length < want && guard < want * (cats.length + 4) + 50) {
10113
+ guard++;
10114
+ let entry = null;
10115
+ for (let k = 0;k < cats.length && !entry; k++) {
10116
+ const cat = cats[(catCursor + k) % cats.length];
10117
+ entry = tryPickFrom(byCat.get(cat) ?? [], false);
10118
+ if (entry)
10119
+ catCursor = (catCursor + k + 1) % cats.length;
10120
+ }
10121
+ if (!entry)
10122
+ entry = tryPickFrom(pool, true);
10123
+ if (!entry)
10124
+ break;
10125
+ usedUids.add(entry.uid);
10126
+ usedPatterns.add(entry.pid);
10127
+ const video = videos[videoCursor % videos.length];
10128
+ videoCursor++;
10129
+ const winLen = Math.max(0.4, entry.t1 - entry.t0);
10130
+ const prev = srcCursor.get(video.path) ?? 0;
10131
+ const room = Math.max(0.1, video.duration - winLen);
10132
+ const srcOffsetBase = room > 0 ? prev % room : 0;
10133
+ srcCursor.set(video.path, prev + winLen);
10134
+ chosen.push({ entry, video, srcOffsetBase });
10135
+ }
10136
+ return chosen;
10137
+ }
10138
+
10139
+ // src/lib/mad/beat.ts
10140
+ var clamp2 = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
10141
+ var r36 = (v) => Math.round(v * 1000) / 1000;
10142
+ var MIN_WIN = 0.4;
10143
+ var MAX_WIN = 6;
10144
+ function fixedRhythm(natLens) {
10145
+ const placements = [];
10146
+ let t = 0;
10147
+ for (const nl of natLens) {
10148
+ const outLen = clamp2(nl, MIN_WIN, MAX_WIN);
10149
+ placements.push({ dropAt: r36(t), outLen: r36(outLen) });
10150
+ t += outLen;
10151
+ }
10152
+ return { placements, markers: [] };
10153
+ }
10154
+ function beatQuantized(natLens, analysis) {
10155
+ const dbs = (analysis.downbeats ?? []).filter((t2) => Number.isFinite(t2) && t2 >= 0).sort((a, b) => a - b);
10156
+ const bts = (analysis.beats ?? []).filter((t2) => Number.isFinite(t2) && t2 >= 0).sort((a, b) => a - b);
10157
+ const dbSpanOk = dbs.length >= 2 && dbs[1] - dbs[0] <= MAX_WIN;
10158
+ const snap = dbSpanOk ? dbs : bts;
10159
+ const snapSet = new Set(dbs.map((t2) => r36(t2)));
10160
+ if (snap.length < 2) {
10161
+ return { plan: fixedRhythm(natLens), level: 2 };
10162
+ }
10163
+ const placements = [];
10164
+ let t = 0;
10165
+ let si = 0;
10166
+ for (let i = 0;i < natLens.length; i++) {
10167
+ const dropAt = t;
10168
+ while (si < snap.length && snap[si] <= dropAt + MIN_WIN)
10169
+ si++;
10170
+ if (si >= snap.length) {
10171
+ const outLen = clamp2(natLens[i], MIN_WIN, MAX_WIN);
10172
+ placements.push({ dropAt: r36(dropAt), outLen: r36(outLen) });
10173
+ t = dropAt + outLen;
10174
+ continue;
10175
+ }
10176
+ const nextSnap = snap[si];
10177
+ const slotLen = clamp2(nextSnap - dropAt, MIN_WIN, MAX_WIN);
10178
+ placements.push({ dropAt: r36(dropAt), outLen: r36(slotLen) });
10179
+ t = dropAt + slotLen;
10180
+ si++;
10181
+ }
10182
+ const totalDur = placements.length ? placements[placements.length - 1].dropAt + placements[placements.length - 1].outLen : 0;
10183
+ const allBeats = [...new Set([...bts, ...dbs].map((x) => r36(x)))].sort((a, b) => a - b);
10184
+ const markers = allBeats.filter((tt) => tt >= 0 && tt <= totalDur + 0.000001).map((tt) => ({ t: tt, downbeat: snapSet.has(tt) }));
10185
+ return { plan: { placements, markers }, level: 1 };
10186
+ }
10187
+
10188
+ // src/lib/mad/data.ts
10189
+ import { createHash as createHash2 } from "node:crypto";
10190
+ import { mkdir as mkdir9, readFile as readFile7, rename, rm, writeFile as writeFile9, readdir } from "node:fs/promises";
10191
+ import { existsSync as existsSync18 } from "node:fs";
10192
+ import { join as join23 } from "node:path";
10193
+ function madCacheDir() {
10194
+ return homeFile("mad-cache");
10195
+ }
10196
+ function madContentBase() {
10197
+ return (process.env.GITRUCK_MAD_BASE ?? "https://api.ai-mcn.tv:10000").replace(/\/+$/, "");
10198
+ }
10199
+ function manifestUrl() {
10200
+ return `${madContentBase()}/task/mad/manifest`;
10201
+ }
10202
+ var REQUIRED_KEYS = ["mad_pool"];
10203
+ function sha256Hex(buf) {
10204
+ return createHash2("sha256").update(buf).digest("hex");
10205
+ }
10206
+ async function fetchWithTimeout(fetchFn, url, timeoutMs) {
10207
+ const ctrl = new AbortController;
10208
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
10209
+ try {
10210
+ return await fetchFn(url, { signal: ctrl.signal });
10211
+ } finally {
10212
+ clearTimeout(timer);
10213
+ }
10214
+ }
10215
+ async function atomicWrite(dest, data) {
10216
+ const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
10217
+ await writeFile9(tmp, data);
10218
+ await rename(tmp, dest);
10219
+ }
10220
+ async function verifyFile(path, sha256) {
10221
+ if (!existsSync18(path))
10222
+ return false;
10223
+ try {
10224
+ const buf = await readFile7(path);
10225
+ return sha256Hex(buf) === sha256;
10226
+ } catch {
10227
+ return false;
10228
+ }
10229
+ }
10230
+ function validateManifest(obj) {
10231
+ if (!obj || typeof obj !== "object")
10232
+ throw new Error("manifest 结构非法");
10233
+ const m = obj;
10234
+ if (typeof m.version !== "number")
10235
+ throw new Error("manifest 缺 version");
10236
+ if (!m.datasets || typeof m.datasets !== "object")
10237
+ throw new Error("manifest 缺 datasets");
10238
+ if (typeof m.assets_base !== "string" || !/^https:\/\//i.test(m.assets_base)) {
10239
+ throw new Error("manifest 缺 assets_base(须 HTTPS)");
10240
+ }
10241
+ return m;
10242
+ }
10243
+ async function cleanupOldVersions(cacheRoot, keepVersion, warn) {
10244
+ try {
10245
+ const entries = await readdir(cacheRoot);
10246
+ for (const e of entries) {
10247
+ const m = /^v(\d+)$/.exec(e);
10248
+ if (m && Number(m[1]) !== keepVersion) {
10249
+ await rm(join23(cacheRoot, e), { recursive: true, force: true }).catch(() => {});
10250
+ }
10251
+ }
10252
+ } catch {}
10253
+ }
10254
+ async function ensureMadData(opts, deps) {
10255
+ const { cacheRoot, warn } = deps;
10256
+ const timeout = deps.manifestTimeoutMs ?? 8000;
10257
+ const mfUrl = deps.manifestUrl ?? manifestUrl();
10258
+ const snapshotPath = join23(cacheRoot, "manifest.json");
10259
+ let manifest = null;
10260
+ let online = false;
10261
+ try {
10262
+ if (!/^https:\/\//i.test(mfUrl))
10263
+ throw new Error("manifest 必须 HTTPS");
10264
+ const res = await fetchWithTimeout(deps.fetchFn, mfUrl, timeout);
10265
+ if (!res.ok)
10266
+ throw new Error(`manifest HTTP ${res.status}`);
10267
+ manifest = validateManifest(await res.json());
10268
+ online = true;
10269
+ } catch (e) {
10270
+ warn(`manifest 拉取失败(${e instanceof Error ? e.message : String(e)}),回退本地缓存`);
10271
+ }
10272
+ if (!manifest) {
10273
+ if (!existsSync18(snapshotPath)) {
10274
+ throw new Error("首次使用需联网下载技法数据。请连网后重跑 `gtrk tool mad`(数据会缓存到本地,之后可离线出片)。");
10275
+ }
10276
+ try {
10277
+ manifest = validateManifest(JSON.parse(await readFile7(snapshotPath, "utf8")));
10278
+ } catch {
10279
+ throw new Error("本地 manifest 缓存损坏且当前离线。请连网重跑,或加 --refresh 强制重新下载。");
10280
+ }
10281
+ }
10282
+ for (const k of REQUIRED_KEYS) {
10283
+ if (!manifest.datasets[k]) {
10284
+ throw new Error("技法数据尚未就绪,请稍后再试。");
10285
+ }
10286
+ }
10287
+ const version = manifest.version;
10288
+ const verDir = join23(cacheRoot, `v${version}`);
10289
+ const poolPath = join23(verDir, "mad_pool.json");
10290
+ const poolMeta = manifest.datasets.mad_pool;
10291
+ const cacheValid = await verifyFile(poolPath, poolMeta.sha256);
10292
+ const needDownload = !!opts.refresh || !cacheValid;
10293
+ if (needDownload) {
10294
+ if (!online) {
10295
+ if (existsSync18(poolPath)) {
10296
+ throw new Error("本地技法数据缓存损坏且当前离线。请连网重跑,或加 --refresh 强制重新下载。");
10297
+ }
10298
+ throw new Error("首次使用需联网下载技法数据。请连网后重跑 `gtrk tool mad`(数据会缓存到本地,之后可离线出片)。");
10299
+ }
10300
+ await mkdir9(verDir, { recursive: true });
10301
+ warn(`下载技法池数据 mad_pool(版本 v${version},约 ${Math.round(poolMeta.size / 1024)} KB)…`);
10302
+ const res = await deps.fetchFn(poolMeta.url);
10303
+ if (!res.ok)
10304
+ throw new Error(`mad_pool 下载失败 HTTP ${res.status}`);
10305
+ const buf = new Uint8Array(await res.arrayBuffer());
10306
+ if (sha256Hex(buf) !== poolMeta.sha256) {
10307
+ throw new Error("mad_pool 下载校验不通过(sha256 不符),请重试或 --refresh。");
10308
+ }
10309
+ await atomicWrite(poolPath, buf);
10310
+ warn(`技法池数据就绪(v${version})`);
10311
+ }
10312
+ if (online) {
10313
+ await mkdir9(cacheRoot, { recursive: true });
10314
+ await atomicWrite(snapshotPath, new Uint8Array(Buffer.from(JSON.stringify(manifest), "utf8")));
10315
+ await cleanupOldVersions(cacheRoot, version, warn);
10316
+ }
10317
+ let pool;
10318
+ try {
10319
+ pool = JSON.parse(await readFile7(poolPath, "utf8"));
10320
+ if (!Array.isArray(pool))
10321
+ throw new Error("mad_pool 非数组");
10322
+ } catch (e) {
10323
+ throw new Error(`技法池数据装载失败:${e instanceof Error ? e.message : String(e)}(可加 --refresh 重拉)`);
10324
+ }
10325
+ return { version, assetsBase: manifest.assets_base, pool, verDir, online };
10326
+ }
10327
+
10328
+ // src/lib/mad/pool.ts
10329
+ import { gunzipSync } from "node:zlib";
10330
+ import { mkdir as mkdir10, readFile as readFile8 } from "node:fs/promises";
10331
+ import { existsSync as existsSync19 } from "node:fs";
10332
+ import { join as join24 } from "node:path";
10333
+ function shardPath(verDir, shard) {
10334
+ return join24(verDir, "ir", `${shard}.json.gz`);
10335
+ }
10336
+ function decodeShard(gz) {
10337
+ const json = gunzipSync(gz).toString("utf8");
10338
+ const obj = JSON.parse(json);
10339
+ if (!obj || typeof obj !== "object")
10340
+ throw new Error("IR 分片结构非法");
10341
+ return obj;
10342
+ }
10343
+ function makeIrLoader(assetsBase, verDir, online, deps) {
10344
+ const memo = new Map;
10345
+ const base = assetsBase.replace(/\/+$/, "");
10346
+ async function loadShard(shard) {
10347
+ const cached = memo.get(shard);
10348
+ if (cached)
10349
+ return cached;
10350
+ const path = shardPath(verDir, shard);
10351
+ if (existsSync19(path)) {
10352
+ try {
10353
+ const s2 = decodeShard(await readFile8(path));
10354
+ memo.set(shard, s2);
10355
+ return s2;
10356
+ } catch {
10357
+ deps.warn(`IR 分片缓存损坏(${shard}),尝试重拉`);
10358
+ }
10359
+ }
10360
+ if (!online) {
10361
+ throw new Error(`离线且 IR 分片「${shard}」无缓存。请连网重跑,或加 --refresh 预热数据。`);
10362
+ }
10363
+ const url = `${base}/ir/${shard}.json.gz`;
10364
+ const res = await deps.fetchFn(url);
10365
+ if (!res.ok)
10366
+ throw new Error(`IR 分片下载失败 HTTP ${res.status}:${url}`);
10367
+ const buf = new Uint8Array(await res.arrayBuffer());
10368
+ const s = decodeShard(buf);
10369
+ await mkdir10(join24(verDir, "ir"), { recursive: true });
10370
+ await atomicWrite(path, buf);
10371
+ memo.set(shard, s);
10372
+ return s;
10373
+ }
10374
+ return {
10375
+ async getIr(entry) {
10376
+ const shard = await loadShard(entry.shard);
10377
+ const ir = shard[entry.ir];
10378
+ if (!ir)
10379
+ throw new Error(`IR 分片「${entry.shard}」中缺工程 ir=${entry.ir}`);
10380
+ return ir;
10381
+ },
10382
+ shardCached(shard) {
10383
+ return memo.has(shard) || existsSync19(shardPath(verDir, shard));
10384
+ }
10385
+ };
10386
+ }
10387
+
10388
+ // src/lib/mad/cloud-beat.ts
10389
+ var ANALYZE_TASK = "audio_music_analyze";
10390
+ function extractAnalysis(output) {
10391
+ const o = output ?? {};
10392
+ const arr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "number" && Number.isFinite(x)) : [];
10393
+ return {
10394
+ bpm: typeof o.bpm === "number" ? o.bpm : undefined,
10395
+ beats: arr(o.beats),
10396
+ downbeats: arr(o.downbeats)
10397
+ };
10398
+ }
10399
+ async function analyzeBgm(cfg, bgmAbs, deps) {
10400
+ const payload = (fid) => ({ file_id: fid });
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;
10408
+ const output = await deps.pollToolTask(cfg, ANALYZE_TASK, taskId, {});
10409
+ return extractAnalysis(output);
10410
+ }
10411
+
10412
+ // src/lib/mad/mad.ts
10413
+ function madHeader(version, generatedAt) {
10414
+ return [
10415
+ "/*═══════════════════════════════════════════════",
10416
+ " MAD 一键工程 — 由 gtrk 自动生成",
10417
+ " ───────────────────────────────────────────────",
10418
+ " 用法:After Effects 文件 › 脚本 › 运行脚本文件… 选择本文件",
10419
+ " 运行后自动生成母合成:素材已按技法段填入各素材位,",
10420
+ " 彩色占位块为素材位,可在合成里替换或微调。",
10421
+ "",
10422
+ " 生成工具:gtrk tool mad · 同合云 gitruck",
10423
+ " 了解一键成片流程:https://cloud.ai-mcn.tv/cli",
10424
+ ` 版本 ${version} · 生成于 ${generatedAt}`,
10425
+ "═══════════════════════════════════════════════*/"
10426
+ ].join(`
10427
+ `);
10428
+ }
10429
+ function completionMessage(jsxPath, level) {
10430
+ const beat = level === 1 ? "已按 BGM downbeat 卡点" : level === 2 ? "BGM 已入轨、按固定节奏切窗" : "按固定节奏切窗";
10431
+ return [
10432
+ `工程文件已生成:${jsxPath}(${beat})`,
10433
+ "用法:装 After Effects 2020+ 后,文件 › 脚本 › 运行脚本文件… 选它,30 秒重建整条时间线。"
10434
+ ].join(`
10435
+ `);
10436
+ }
10437
+ function collectSlotIds(ir) {
10438
+ const ids = [];
10439
+ const walk = (layers) => {
10440
+ for (const l of layers) {
10441
+ const ly = l;
10442
+ if (ly.type === "image" || ly.type === "video")
10443
+ ids.push(String(ly.id ?? ""));
10444
+ if (ly.type === "group" && Array.isArray(ly.children))
10445
+ walk(ly.children);
10446
+ }
10447
+ };
10448
+ walk(ir.layers ?? []);
10449
+ return ids.filter((x) => x);
10450
+ }
10451
+ var SLOT_STAGGER = 0.3;
10452
+ function timestamp4(now) {
10453
+ const p = (n) => String(n).padStart(2, "0");
10454
+ return `${p(now.getFullYear() % 100)}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
10455
+ }
10456
+ async function runMad(inputArg, opts, deps = {}) {
10457
+ const warn = deps.warn ?? ((m) => process.stderr.write(`\x1B[2m ${m}\x1B[0m
10458
+ `));
10459
+ const emitBilling2 = deps.emitBilling ?? ((h) => process.stderr.write(`\x1B[33m⚠️ 计费提示:${h}\x1B[0m
10460
+ `));
10461
+ const now = (deps.now ?? (() => new Date))();
10462
+ const fetchFn = deps.fetchFn ?? fetch;
10463
+ const probeDur = deps.probeDurationFn ?? probeDuration;
10464
+ if (!inputArg)
10465
+ throw new Error("用法:gtrk tool mad <素材文件夹> [--bgm 歌.mp3]");
10466
+ const dirAbs = resolve10(inputArg);
10467
+ if (!existsSync20(dirAbs) || !statSync3(dirAbs).isDirectory()) {
10468
+ throw new Error(`素材文件夹不存在或不是目录:${dirAbs}`);
10469
+ }
10470
+ const { videos, orientation, skipped } = scanFolder(dirAbs, {
10471
+ ffmpegPath: opts.ffmpegPath,
10472
+ probe: deps.probeGeometry,
10473
+ warn
10474
+ });
10475
+ for (const s of skipped)
10476
+ ;
10477
+ const master = masterCanvas(orientation);
10478
+ const dataDeps = { fetchFn, cacheRoot: deps.cacheRoot ?? madCacheDir(), warn };
10479
+ const data = await ensureMadData({ refresh: opts.refresh }, dataDeps);
10480
+ const irLoader = makeIrLoader(data.assetsBase, data.verDir, data.online, { fetchFn, warn });
10481
+ const durationSec = opts.duration && opts.duration > 0 ? opts.duration : 20;
10482
+ const seed = opts.seed && Number.isFinite(opts.seed) ? opts.seed : Math.floor(Math.random() * 2 ** 31);
10483
+ warn(`选窗种子 seed=${seed}(复现加 --seed ${seed})`);
10484
+ const selectablePool = data.online ? data.pool : data.pool.filter((e) => irLoader.shardCached(e.shard));
10485
+ if (!data.online && selectablePool.length === 0) {
10486
+ throw new Error("当前离线且无已缓存的技法数据分片。请连网重跑首拉,或加 --refresh 预热。");
10487
+ }
10488
+ const chosen = selectWindows({ pool: selectablePool, videos, durationSec, orientation, seed });
10489
+ if (chosen.length === 0) {
10490
+ throw new Error("技法池为空或无可用条目(数据版本可能异常,可加 --refresh 重拉)。");
10491
+ }
10492
+ const irs = [];
10493
+ for (const c3 of chosen)
10494
+ irs.push(await irLoader.getIr(c3.entry));
10495
+ let level = 3;
10496
+ let analysis = null;
10497
+ let bgmForTrack;
10498
+ const bgmAbs = opts.bgm ? resolve10(opts.bgm) : undefined;
10499
+ if (bgmAbs) {
10500
+ let dur = -1;
10501
+ try {
10502
+ dur = probeDur(bgmAbs, opts.ffmpegPath);
10503
+ } catch {
10504
+ dur = -1;
10505
+ }
10506
+ if (!(dur > 0)) {
10507
+ warn(`BGM 无法读取(ffprobe 校验失败),按无 BGM 固定节奏出片:${bgmAbs}`);
10508
+ level = 3;
10509
+ } else {
10510
+ let cfg = null;
10511
+ try {
10512
+ cfg = (deps.loadConfig ?? loadConfig)();
10513
+ } catch {
10514
+ cfg = null;
10515
+ }
10516
+ if (!cfg) {
10517
+ warn("未配置 API Key,无法解锁 BGM 卡点。跑 `gtrk init` 配置后可卡点;本次 BGM 已入轨、按固定节奏出片。");
10518
+ level = 2;
10519
+ bgmForTrack = bgmAbs;
10520
+ } else {
10521
+ const pricing = await (deps.resolvePricing ?? resolveToolPricing)("audio_music_analyze", "仅 --bgm 卡点时");
10522
+ emitBilling2(pricing.billingHint);
10523
+ const beatCloud = deps.beatCloud ?? { uploadCached, invalidateUpload, submitTask, pollToolTask };
10524
+ try {
10525
+ analysis = await analyzeBgm(cfg, bgmAbs, beatCloud);
10526
+ level = 1;
10527
+ bgmForTrack = bgmAbs;
10528
+ } catch (e) {
10529
+ warn(`BGM 云端分析失败(${e instanceof Error ? e.message : String(e)}),BGM 已入轨、切点回退固定节奏`);
10530
+ level = 2;
10531
+ bgmForTrack = bgmAbs;
10532
+ }
10533
+ }
10534
+ }
10535
+ }
10536
+ const natLens = chosen.map((c3) => Math.max(MIN_WIN, c3.entry.t1 - c3.entry.t0));
10537
+ let placements;
10538
+ let markers = [];
10539
+ if (level === 1 && analysis) {
10540
+ const q = beatQuantized(natLens, analysis);
10541
+ placements = q.plan.placements;
10542
+ markers = q.plan.markers;
10543
+ if (q.level === 2) {
10544
+ warn("BGM 无有效节拍(beats/downbeats 均空),切点回退固定节奏。");
10545
+ level = 2;
10546
+ markers = [];
10547
+ }
10548
+ } else {
10549
+ placements = fixedRhythm(natLens).placements;
10550
+ }
10551
+ const windows = chosen.map((c3, i) => {
10552
+ const ir = irs[i];
10553
+ const slotIds = collectSlotIds(ir);
10554
+ const footage = {};
10555
+ const vidDur = c3.video.duration || 0;
10556
+ const winLen = Math.max(MIN_WIN, c3.entry.t1 - c3.entry.t0);
10557
+ slotIds.forEach((lid, idx) => {
10558
+ let off = c3.srcOffsetBase + idx * SLOT_STAGGER;
10559
+ const room = Math.max(0, vidDur - winLen);
10560
+ if (room > 0)
10561
+ off = off % room;
10562
+ else
10563
+ off = 0;
10564
+ footage[lid] = { path: c3.video.path, srcOffset: Math.round(off * 1000) / 1000 };
10565
+ });
10566
+ return {
10567
+ ir,
10568
+ uid: c3.entry.uid,
10569
+ seq: i,
10570
+ t0: c3.entry.t0,
10571
+ t1: c3.entry.t1,
10572
+ dropAt: placements[i].dropAt,
10573
+ outLen: placements[i].outLen,
10574
+ footage
10575
+ };
10576
+ });
10577
+ const version = deps.cliVersion ?? "";
10578
+ const header = madHeader(version, now.toISOString());
10579
+ const bgm = level <= 2 && bgmForTrack ? { path: bgmForTrack, markers } : undefined;
10580
+ const { jsx } = madJsx({ master, windows, header, bgm });
10581
+ const outDir = opts.out ? resolve10(opts.out) : join25(process.cwd(), `mad-${timestamp4(now)}`);
10582
+ await mkdir11(outDir, { recursive: true });
10583
+ const jsxPath = join25(outDir, "mad.jsx");
10584
+ await writeFile10(jsxPath, jsx);
10585
+ const result = {
10586
+ ok: true,
10587
+ tool: "mad",
10588
+ outDir,
10589
+ files: [jsxPath],
10590
+ seed,
10591
+ dataVersion: data.version,
10592
+ degradeLevel: level,
10593
+ techniques: chosen.map((c3) => ({ uid: c3.entry.uid, pid: c3.entry.pid, cat: c3.entry.cat, t0: c3.entry.t0, t1: c3.entry.t1 }))
10594
+ };
10595
+ await writeFile10(join25(outDir, "result.json"), JSON.stringify({ ...result, finishedAt: now.toISOString() }, null, 2));
10596
+ warn(completionMessage(jsxPath, level));
10597
+ return result;
10598
+ }
10599
+
10600
+ // src/commands/tool.ts
10601
+ var collectParam2 = (v, acc) => {
10602
+ acc.push(v);
10603
+ return acc;
10604
+ };
10605
+ function configureToolCommand(cmd, registry = TOOL_REGISTRY) {
10606
+ cmd.description("单点工具族:`gtrk tool <name> [input]` 跑单个能力;`gtrk tool list` 查全部(含输入/产物/计费/状态)").option("-o, --out <dir>", "产物目录(缺省 = <输入名>-<tool>/;input=none 落 cwd 下 <tool>-<时间戳>/)").option("--param <k=v>", "透传任意云端参数(标量、可重复;如 --param width=1080)", collectParam2, []).option("--params-json <json>", "透传任意云端参数(JSON 对象)").option("--ffmpeg-path <dir>", "指定 ffmpeg/ffprobe 所在目录(缺省 ~/.gitruck/ffmpeg → 系统 PATH)").option("--reupload", "强制重新上传,忽略本地上传缓存").option("--json", "机读模式:人读日志转 stderr,stdout 只输出结果 JSON(给 agent/脚本解析)");
10607
+ const seen = new Set;
10608
+ for (const d of registry) {
10609
+ for (const o of d.options ?? []) {
10610
+ if (seen.has(o.flag))
10611
+ continue;
10612
+ seen.add(o.flag);
10613
+ cmd.option(o.flag, o.desc);
10614
+ }
10615
+ }
10616
+ cmd.action(async (words, opts) => {
10617
+ await runToolCommand(words ?? [], opts, registry);
10618
+ });
10619
+ return cmd;
10620
+ }
10621
+ function registerTool(program2, registry = TOOL_REGISTRY) {
10622
+ validateRegistry(registry);
10623
+ const cmd = program2.command("tool [words...]");
10624
+ configureToolCommand(cmd, registry);
10625
+ }
10626
+ async function runToolCommand(words, opts, registry = TOOL_REGISTRY, deps) {
10627
+ if (opts.json)
10628
+ routeLogsToStderr();
10629
+ const name = words[0];
10630
+ if (!name) {
10631
+ throw new Error("用法:`gtrk tool <name> [input]` 跑工具;`gtrk tool list` 查全部工具");
10632
+ }
10633
+ if (name === "list") {
10634
+ await runList(opts, registry);
10635
+ return;
10636
+ }
10637
+ const descriptor = findTool(name, registry);
10638
+ if (!descriptor) {
10639
+ const names = registry.map((d) => d.name).join(", ");
10640
+ throw new Error(`未知工具「${name}」。可用工具:${names || "(空)"}(用 gtrk tool list 查看详情)`);
10641
+ }
10642
+ return runTool(descriptor, words[1], opts, deps);
10643
+ }
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
+ });
10664
+ if (opts.json) {
10665
+ console.log(JSON.stringify(rows));
10666
+ return;
10667
+ }
10668
+ log.step("▶ gtrk 工具族(gtrk tool <name> [input]):");
10669
+ for (const r of rows) {
10670
+ const status = r.enabled ? "已上线" : `未开放(${r.disabledReason ?? "无原因"})`;
10671
+ log.info(`${r.name} — ${r.title}|输入 ${r.input}|产物 ${r.output}|${r.billingHint}|${status}`);
10672
+ }
10673
+ log.info("agent 一律带 --json;缺 API Key 先跑 `gtrk init`;跑前把计费提示转述给用户。");
10674
+ }
10675
+ async function runTool(descriptor, inputArg, opts, depsOverride) {
10676
+ if (!descriptor.enabled) {
10677
+ throw new Error(`能力未开放:${descriptor.disabledReason ?? "(未提供原因)"}(用 gtrk tool list 查看全部工具)`);
10678
+ }
10679
+ if (descriptor.kind === "local") {
10680
+ if (descriptor.name === "mad")
10681
+ return runMadInTool(inputArg, opts);
10682
+ throw new Error(`local 型工具「${descriptor.name}」由后续 change 实现,暂不可用`);
10683
+ }
10684
+ const cfg = loadConfig();
10685
+ const deps = {
10686
+ cfg,
10687
+ uploadCached,
10688
+ invalidateUpload,
10689
+ submitTask,
10690
+ getTaskResult,
10691
+ downloadStream,
10692
+ probeDurationSec: (p, ff) => probeDuration(p, ff),
10693
+ ...depsOverride
10694
+ };
10695
+ log.step(`▶ ${descriptor.title}(${descriptor.name})…`);
10696
+ const result = await runCloudTool(descriptor, inputArg, opts, deps);
10697
+ if (opts.json)
10698
+ console.log(JSON.stringify(result));
10699
+ if (result.ok)
10700
+ log.ok(`完成。产物目录:${result.outDir}`);
10701
+ else {
10702
+ log.err(`部分产物未落地(任务已完成、积分可能已扣)。task.json 已保留,可凭 task_id 恢复:${result.taskId}`);
10703
+ process.exitCode = 1;
10704
+ }
10705
+ return result;
10706
+ }
10707
+ async function runMadInTool(inputArg, opts) {
10708
+ log.step("▶ 一键剪 MAD(mad)…");
10709
+ const madOpts = {
10710
+ bgm: typeof opts.bgm === "string" ? opts.bgm : undefined,
10711
+ duration: opts.duration != null ? Number(opts.duration) : undefined,
10712
+ seed: opts.seed != null ? Number(opts.seed) : undefined,
10713
+ refresh: !!opts.refresh,
10714
+ out: opts.out,
10715
+ ffmpegPath: opts.ffmpegPath,
10716
+ json: !!opts.json
10717
+ };
10718
+ const r = await runMad(inputArg, madOpts, { cliVersion: currentVersion() });
10719
+ if (opts.json)
10720
+ console.log(JSON.stringify(r));
10721
+ if (r.ok)
10722
+ log.ok(`完成。产物目录:${r.outDir}`);
10723
+ return { ok: r.ok, tool: r.tool, outDir: r.outDir, files: r.files };
10724
+ }
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
+
8174
10961
  // src/index.ts
8175
10962
  try {
8176
10963
  process.loadEnvFile?.();
8177
10964
  } catch {}
8178
10965
  migrateLegacyHome();
8179
- var { version } = JSON.parse(readFileSync5(join21(packageRoot(), "package.json"), "utf8"));
10966
+ var { version } = JSON.parse(readFileSync5(join27(packageRoot(), "package.json"), "utf8"));
8180
10967
  var program2 = new Command;
8181
10968
  program2.name("gtrk").description("同合云成片流水线 CLI —— agent 驱动云端任务、产物拉回本地、三方工程文件(客户端/剪映/PR)互通").version(version);
8182
10969
  registerInstall(program2);
@@ -8190,6 +10977,8 @@ registerRender(program2);
8190
10977
  registerSplit(program2);
8191
10978
  registerMatrix(program2);
8192
10979
  registerMg(program2);
10980
+ registerTool(program2);
10981
+ registerTranscript(program2);
8193
10982
  program2.parseAsync(process.argv).catch((e) => {
8194
10983
  console.error(`
8195
10984
  ❌ ${e instanceof Error ? e.message : String(e)}`);