@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
|
|
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
|
-
|
|
55
|
-
|
|
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:
|
|
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
|
-
|
|
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", "
|
|
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));
|