@xiaohhhh1/canvas-agent 0.4.37 → 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 {
|
|
@@ -52,7 +53,9 @@ export class LocalVideoIntelligence {
|
|
|
52
53
|
const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-intelligence-"));
|
|
53
54
|
try {
|
|
54
55
|
const [visualEvidence, transcript] = await Promise.all([
|
|
55
|
-
|
|
56
|
+
hasArchivedMedia
|
|
57
|
+
? captureArchivedVideoFrames(mediaUrl, platformVideoId, workDir)
|
|
58
|
+
: this.fastmoss.capturePublicVideoFrames(mediaUrl, platformVideoId),
|
|
56
59
|
hasArchivedMedia ? Promise.resolve(archivedTranscript) : extractTimedTranscript(sourceUrl, workDir),
|
|
57
60
|
]);
|
|
58
61
|
if (!visualEvidence.frames.length)
|
|
@@ -185,6 +188,48 @@ async function materializeFrames(frames, workDir) {
|
|
|
185
188
|
}
|
|
186
189
|
return attachments;
|
|
187
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
|
+
}
|
|
188
233
|
async function extractTimedTranscript(sourceUrl, workDir) {
|
|
189
234
|
const output = path.join(workDir, "%(id)s.%(ext)s");
|
|
190
235
|
const args = [
|
|
@@ -292,14 +337,15 @@ export function resolveLocalCodexEntrypoint() {
|
|
|
292
337
|
}
|
|
293
338
|
async function runProcess(command, args, options) {
|
|
294
339
|
return await new Promise((resolve) => {
|
|
340
|
+
let stdout = "";
|
|
295
341
|
let stderr = "";
|
|
296
342
|
let settled = false;
|
|
297
343
|
let child;
|
|
298
344
|
try {
|
|
299
|
-
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"] });
|
|
300
346
|
}
|
|
301
347
|
catch (error) {
|
|
302
|
-
resolve({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
348
|
+
resolve({ ok: false, error: error instanceof Error ? error.message : String(error), stdout: "" });
|
|
303
349
|
return;
|
|
304
350
|
}
|
|
305
351
|
const finish = (ok, error = "") => {
|
|
@@ -307,12 +353,14 @@ async function runProcess(command, args, options) {
|
|
|
307
353
|
return;
|
|
308
354
|
settled = true;
|
|
309
355
|
clearTimeout(timer);
|
|
310
|
-
resolve({ ok, error: error.trim().slice(-2_000) });
|
|
356
|
+
resolve({ ok, error: error.trim().slice(-2_000), stdout: stdout.trim().slice(-8_000) });
|
|
311
357
|
};
|
|
312
358
|
const timer = setTimeout(() => {
|
|
313
359
|
child.kill();
|
|
314
360
|
finish(false, `处理超过 ${Math.round(options.timeoutMs / 1000)} 秒`);
|
|
315
361
|
}, options.timeoutMs);
|
|
362
|
+
child.stdout.on("data", (chunk) => { stdout += String(chunk); if (stdout.length > 16_000)
|
|
363
|
+
stdout = stdout.slice(-16_000); });
|
|
316
364
|
child.stderr.on("data", (chunk) => { stderr += String(chunk); if (stderr.length > 8_000)
|
|
317
365
|
stderr = stderr.slice(-8_000); });
|
|
318
366
|
child.on("error", (error) => finish(false, options.allowMissing && error.code === "ENOENT" ? "未安装命令" : error.message));
|