@xiaohhhh1/canvas-agent 0.4.36 → 0.4.38

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.
@@ -1,5 +1,5 @@
1
1
  import type { AgentAttachment } from "../agent/types.js";
2
- import type { FastMossIntegration } from "../integrations/fastmoss.js";
2
+ import { type FastMossIntegration } from "../integrations/fastmoss.js";
3
3
  export type LocalVideoLearningSource = {
4
4
  id?: string;
5
5
  sourceUrl?: string;
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promise
4
4
  import { createRequire } from "node:module";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { videoEvidenceTimestamps } from "../integrations/fastmoss.js";
7
8
  const ANALYSIS_TIMEOUT_MS = 10 * 60_000;
8
9
  const TRANSCRIPT_LANGUAGES = "en.*,es.*,zh.*,pt.*,fr.*,de.*,vi.*,th.*,id.*,ms.*,ja.*,ko.*";
9
10
  export class LocalVideoIntelligence {
@@ -46,13 +47,16 @@ export class LocalVideoIntelligence {
46
47
  const sourceUrl = verifiedTikTokSourceUrl(source);
47
48
  const archivedVideoUrl = String(source.archivedVideoUrl || source.archived_video_url || "").trim();
48
49
  const archivedTranscript = String(source.archivedTranscript || source.archived_transcript || "").trim();
50
+ const hasArchivedMedia = Boolean(archivedVideoUrl);
49
51
  const mediaUrl = archivedVideoUrl ? verifiedHttpsUrl(archivedVideoUrl, "站内视频副本地址无效") : sourceUrl;
50
52
  const platformVideoId = String(source.platformVideoId || source.platform_video_id || "").trim();
51
53
  const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-intelligence-"));
52
54
  try {
53
55
  const [visualEvidence, transcript] = await Promise.all([
54
- this.fastmoss.capturePublicVideoFrames(mediaUrl, platformVideoId),
55
- archivedTranscript ? Promise.resolve(archivedTranscript) : extractTimedTranscript(sourceUrl, workDir),
56
+ hasArchivedMedia
57
+ ? captureArchivedVideoFrames(mediaUrl, platformVideoId, workDir)
58
+ : this.fastmoss.capturePublicVideoFrames(mediaUrl, platformVideoId),
59
+ hasArchivedMedia ? Promise.resolve(archivedTranscript) : extractTimedTranscript(sourceUrl, workDir),
56
60
  ]);
57
61
  if (!visualEvidence.frames.length)
58
62
  throw new Error("没有取得任何真实视频画面,已停止分析");
@@ -69,7 +73,7 @@ export class LocalVideoIntelligence {
69
73
  frameCount: attachments.length,
70
74
  transcriptCueCount: transcript.split("\n").filter(Boolean).length,
71
75
  visualSource: archivedVideoUrl ? "website-archive" : visualEvidence.playerUrl,
72
- transcriptSource: archivedTranscript ? "website-archive" : "TikTok native/automatic timed captions via local yt-dlp",
76
+ transcriptSource: hasArchivedMedia ? "website-archive" : "TikTok native/automatic timed captions via local yt-dlp",
73
77
  },
74
78
  };
75
79
  }
@@ -184,6 +188,48 @@ async function materializeFrames(frames, workDir) {
184
188
  }
185
189
  return attachments;
186
190
  }
191
+ async function captureArchivedVideoFrames(playerUrl, platformVideoId, workDir) {
192
+ const response = await fetch(playerUrl, { signal: AbortSignal.timeout(5 * 60_000) });
193
+ if (!response.ok)
194
+ throw new Error(`读取站内视频副本失败(HTTP ${response.status})`);
195
+ const declaredLength = Number(response.headers.get("content-length") || 0);
196
+ if (declaredLength > 500 * 1024 * 1024)
197
+ throw new Error("站内视频副本超过 500 MiB,已停止分析");
198
+ const bytes = Buffer.from(await response.arrayBuffer());
199
+ if (!bytes.length)
200
+ throw new Error("站内视频副本为空");
201
+ if (bytes.length > 500 * 1024 * 1024)
202
+ throw new Error("站内视频副本超过 500 MiB,已停止分析");
203
+ if (!bytes.subarray(0, 64).includes(Buffer.from("ftyp")))
204
+ throw new Error("站内视频副本不是可识别的 MP4 文件");
205
+ const safeId = /^\d+$/.test(platformVideoId) ? platformVideoId : "video";
206
+ const videoFile = path.join(workDir, `${safeId}.mp4`);
207
+ await writeFile(videoFile, bytes);
208
+ const probe = await runProcess(process.platform === "win32" ? "ffprobe.exe" : "ffprobe", [
209
+ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoFile,
210
+ ], { cwd: workDir, timeoutMs: 60_000, allowMissing: true });
211
+ if (!probe.ok)
212
+ throw new Error(`无法读取站内 MP4 时长:${probe.error}`);
213
+ const durationMs = Math.round(Number.parseFloat(probe.stdout.trim()) * 1_000);
214
+ if (!Number.isFinite(durationMs) || durationMs < 1_000)
215
+ throw new Error("站内 MP4 时长无效");
216
+ const frames = [];
217
+ for (const [index, timestampMs] of videoEvidenceTimestamps(durationMs).entries()) {
218
+ const frameFile = path.join(workDir, `archive-frame-${String(index + 1).padStart(2, "0")}.jpg`);
219
+ const result = await runProcess(process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg", [
220
+ "-v", "error", "-ss", (timestampMs / 1_000).toFixed(3), "-i", videoFile,
221
+ "-frames:v", "1", "-q:v", "3", "-y", frameFile,
222
+ ], { cwd: workDir, timeoutMs: 60_000, allowMissing: true });
223
+ if (!result.ok)
224
+ throw new Error(`站内 MP4 抽帧失败(${timestampMs}ms):${result.error}`);
225
+ const image = await readFile(frameFile);
226
+ if (image.length)
227
+ frames.push({ timestampMs, dataUrl: `data:image/jpeg;base64,${image.toString("base64")}` });
228
+ }
229
+ if (!frames.length)
230
+ throw new Error("站内 MP4 没有生成可分析画面");
231
+ return { videoId: safeId, playerUrl, durationMs, frames };
232
+ }
187
233
  async function extractTimedTranscript(sourceUrl, workDir) {
188
234
  const output = path.join(workDir, "%(id)s.%(ext)s");
189
235
  const args = [
@@ -240,7 +286,8 @@ async function downloadVideoArchive(sourceUrl, workDir, maxBytes) {
240
286
  throw new Error("视频下载完成但没有生成 MP4 文件");
241
287
  if (video.size > maxBytes)
242
288
  throw new Error(`视频超过站内存储上限(${Math.ceil(maxBytes / 1024 / 1024)} MiB)`);
243
- return { file: video.file, transcript: await transcriptFromDirectory(workDir, lastError) };
289
+ const transcript = await transcriptFromDirectory(workDir, lastError).catch(() => "");
290
+ return { file: video.file, transcript };
244
291
  }
245
292
  async function transcriptFromDirectory(workDir, lastError = "") {
246
293
  const subtitleFiles = (await readdir(workDir)).filter((name) => name.toLowerCase().endsWith(".vtt"));
@@ -290,14 +337,15 @@ export function resolveLocalCodexEntrypoint() {
290
337
  }
291
338
  async function runProcess(command, args, options) {
292
339
  return await new Promise((resolve) => {
340
+ let stdout = "";
293
341
  let stderr = "";
294
342
  let settled = false;
295
343
  let child;
296
344
  try {
297
- child = spawn(command, args, { cwd: options.cwd, windowsHide: true, stdio: ["pipe", "ignore", "pipe"] });
345
+ child = spawn(command, args, { cwd: options.cwd, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
298
346
  }
299
347
  catch (error) {
300
- resolve({ ok: false, error: error instanceof Error ? error.message : String(error) });
348
+ resolve({ ok: false, error: error instanceof Error ? error.message : String(error), stdout: "" });
301
349
  return;
302
350
  }
303
351
  const finish = (ok, error = "") => {
@@ -305,12 +353,14 @@ async function runProcess(command, args, options) {
305
353
  return;
306
354
  settled = true;
307
355
  clearTimeout(timer);
308
- resolve({ ok, error: error.trim().slice(-2_000) });
356
+ resolve({ ok, error: error.trim().slice(-2_000), stdout: stdout.trim().slice(-8_000) });
309
357
  };
310
358
  const timer = setTimeout(() => {
311
359
  child.kill();
312
360
  finish(false, `处理超过 ${Math.round(options.timeoutMs / 1000)} 秒`);
313
361
  }, options.timeoutMs);
362
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); if (stdout.length > 16_000)
363
+ stdout = stdout.slice(-16_000); });
314
364
  child.stderr.on("data", (chunk) => { stderr += String(chunk); if (stderr.length > 8_000)
315
365
  stderr = stderr.slice(-8_000); });
316
366
  child.on("error", (error) => finish(false, options.allowMissing && error.code === "ENOENT" ? "未安装命令" : error.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.36",
3
+ "version": "0.4.38",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",