@xiaohhhh1/canvas-agent 0.4.35 → 0.4.37

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.
@@ -125,7 +125,7 @@ export declare class FastMossIntegration {
125
125
  * deliberately separate from ranking collection: the learning pipeline
126
126
  * must see the actual moving picture, never a FastMoss cover or HTML page.
127
127
  */
128
- capturePublicVideoFrames(sourceUrl: string): Promise<{
128
+ capturePublicVideoFrames(sourceUrl: string, expectedVideoId?: string): Promise<{
129
129
  videoId: string;
130
130
  playerUrl: string;
131
131
  durationMs: number;
@@ -393,10 +393,19 @@ export class FastMossIntegration {
393
393
  * deliberately separate from ranking collection: the learning pipeline
394
394
  * must see the actual moving picture, never a FastMoss cover or HTML page.
395
395
  */
396
- async capturePublicVideoFrames(sourceUrl) {
397
- const videoId = videoIdFromValues([sourceUrl]);
396
+ async capturePublicVideoFrames(sourceUrl, expectedVideoId) {
397
+ const videoId = String(expectedVideoId || videoIdFromValues([sourceUrl])).trim();
398
398
  if (!videoId)
399
399
  throw new Error("公开视频地址缺少可验证的 TikTok video ID");
400
+ let parsedUrl;
401
+ try {
402
+ parsedUrl = new URL(sourceUrl);
403
+ }
404
+ catch {
405
+ throw new Error("视频画面地址无效");
406
+ }
407
+ if (parsedUrl.protocol !== "https:")
408
+ throw new Error("视频画面地址必须使用 HTTPS");
400
409
  if (!this.context)
401
410
  await this.start();
402
411
  if (!this.context)
@@ -140,6 +140,7 @@ export function startHttpServer() {
140
140
  app.post("/agent/integrations/fastmoss/switch-account", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.switchAccount() })));
141
141
  app.post("/agent/integrations/fastmoss/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.capture(req.body || {}) })));
142
142
  app.post("/agent/integrations/fastmoss/video-learning/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.captureLearningVideos(req.body || {}) })));
143
+ app.post("/agent/video-intelligence/archive", route(async (req, res) => res.json({ ok: true, ...await videoIntelligence.archive(req.body?.source || {}, req.body?.upload || {}) })));
143
144
  app.post("/agent/video-intelligence/analyze", route(async (req, res) => res.json({ ok: true, ...await videoIntelligence.analyze(req.body?.source || req.body || {}) })));
144
145
  app.post("/agent/integrations/fastmoss/close", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.close() })));
145
146
  app.get("/agent/codex/workspace", (_req, res) => {
@@ -16,10 +16,31 @@ export type LocalVideoLearningSource = {
16
16
  productTitle?: string;
17
17
  product_title?: string;
18
18
  metrics?: Record<string, unknown>;
19
+ archiveStatus?: string;
20
+ archive_status?: string;
21
+ archivedVideoUrl?: string;
22
+ archived_video_url?: string;
23
+ archivedTranscript?: string;
24
+ archived_transcript?: string;
25
+ };
26
+ type VideoArchiveUpload = {
27
+ uploadUrl: string;
28
+ publicUrl: string;
29
+ headers?: Record<string, string>;
30
+ maxBytes?: number;
19
31
  };
20
32
  export declare class LocalVideoIntelligence {
21
33
  private readonly fastmoss;
22
34
  constructor(fastmoss: FastMossIntegration);
35
+ archive(source: LocalVideoLearningSource, upload: VideoArchiveUpload): Promise<{
36
+ archive: {
37
+ publicUrl: string;
38
+ sha256: string;
39
+ bytes: number;
40
+ contentType: string;
41
+ transcript: string;
42
+ };
43
+ }>;
23
44
  analyze(source: LocalVideoLearningSource): Promise<{
24
45
  analysis: Record<string, unknown>;
25
46
  evidence: {
@@ -35,3 +56,4 @@ export declare function localVideoAnalysisPrompt(source: LocalVideoLearningSourc
35
56
  export declare function assertJointVisualAndSpokenEvidence(analysis: Record<string, unknown>): void;
36
57
  export declare function timedTranscriptFromVtt(value: string): string;
37
58
  export declare function resolveLocalCodexEntrypoint(): string;
59
+ export {};
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
4
  import { createRequire } from "node:module";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -10,16 +11,49 @@ export class LocalVideoIntelligence {
10
11
  constructor(fastmoss) {
11
12
  this.fastmoss = fastmoss;
12
13
  }
13
- async analyze(source) {
14
- const sourceUrl = String(source.sourceUrl || source.source_url || "").trim();
15
- if (!/^https:\/\/(?:www\.)?tiktok\.com\/@[^/]+\/video\/\d+(?:[/?#]|$)/i.test(sourceUrl)) {
16
- throw new Error("本机视频理解只接受已验证的 TikTok 公开作品地址");
14
+ async archive(source, upload) {
15
+ const sourceUrl = verifiedTikTokSourceUrl(source);
16
+ const uploadUrl = verifiedHttpsUrl(upload?.uploadUrl, "站内上传地址无效");
17
+ const publicUrl = verifiedHttpsUrl(upload?.publicUrl, "站内视频地址无效");
18
+ const maxBytes = Math.min(500 * 1024 * 1024, Math.max(1, Number(upload?.maxBytes || 100 * 1024 * 1024)));
19
+ const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-archive-"));
20
+ try {
21
+ const downloaded = await downloadVideoArchive(sourceUrl, workDir, maxBytes);
22
+ const bytes = await readFile(downloaded.file);
23
+ const response = await fetch(uploadUrl, {
24
+ method: "PUT",
25
+ headers: { "Content-Type": "video/mp4" },
26
+ body: bytes,
27
+ signal: AbortSignal.timeout(5 * 60_000),
28
+ });
29
+ if (!response.ok)
30
+ throw new Error(`站内视频上传失败(HTTP ${response.status})`);
31
+ return {
32
+ archive: {
33
+ publicUrl,
34
+ sha256: createHash("sha256").update(bytes).digest("hex"),
35
+ bytes: bytes.length,
36
+ contentType: "video/mp4",
37
+ transcript: downloaded.transcript,
38
+ },
39
+ };
17
40
  }
41
+ finally {
42
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
43
+ }
44
+ }
45
+ async analyze(source) {
46
+ const sourceUrl = verifiedTikTokSourceUrl(source);
47
+ const archivedVideoUrl = String(source.archivedVideoUrl || source.archived_video_url || "").trim();
48
+ const archivedTranscript = String(source.archivedTranscript || source.archived_transcript || "").trim();
49
+ const hasArchivedMedia = Boolean(archivedVideoUrl);
50
+ const mediaUrl = archivedVideoUrl ? verifiedHttpsUrl(archivedVideoUrl, "站内视频副本地址无效") : sourceUrl;
51
+ const platformVideoId = String(source.platformVideoId || source.platform_video_id || "").trim();
18
52
  const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-intelligence-"));
19
53
  try {
20
54
  const [visualEvidence, transcript] = await Promise.all([
21
- this.fastmoss.capturePublicVideoFrames(sourceUrl),
22
- extractTimedTranscript(sourceUrl, workDir),
55
+ this.fastmoss.capturePublicVideoFrames(mediaUrl, platformVideoId),
56
+ hasArchivedMedia ? Promise.resolve(archivedTranscript) : extractTimedTranscript(sourceUrl, workDir),
23
57
  ]);
24
58
  if (!visualEvidence.frames.length)
25
59
  throw new Error("没有取得任何真实视频画面,已停止分析");
@@ -35,8 +69,8 @@ export class LocalVideoIntelligence {
35
69
  durationMs: visualEvidence.durationMs,
36
70
  frameCount: attachments.length,
37
71
  transcriptCueCount: transcript.split("\n").filter(Boolean).length,
38
- visualSource: visualEvidence.playerUrl,
39
- transcriptSource: "TikTok native/automatic timed captions via local yt-dlp",
72
+ visualSource: archivedVideoUrl ? "website-archive" : visualEvidence.playerUrl,
73
+ transcriptSource: hasArchivedMedia ? "website-archive" : "TikTok native/automatic timed captions via local yt-dlp",
40
74
  },
41
75
  };
42
76
  }
@@ -45,6 +79,24 @@ export class LocalVideoIntelligence {
45
79
  }
46
80
  }
47
81
  }
82
+ function verifiedTikTokSourceUrl(source) {
83
+ const sourceUrl = String(source.sourceUrl || source.source_url || "").trim();
84
+ if (!/^https:\/\/(?:www\.)?tiktok\.com\/@[^/]+\/video\/\d+(?:[/?#]|$)/i.test(sourceUrl)) {
85
+ throw new Error("本机视频处理只接受已验证的 TikTok 公开作品地址");
86
+ }
87
+ return sourceUrl;
88
+ }
89
+ function verifiedHttpsUrl(value, message) {
90
+ try {
91
+ const url = new URL(value);
92
+ if (url.protocol !== "https:" || url.username || url.password)
93
+ throw new Error(message);
94
+ return url.toString();
95
+ }
96
+ catch {
97
+ throw new Error(message);
98
+ }
99
+ }
48
100
  export function localVideoAnalysisPrompt(source, durationMs, transcript, attachments) {
49
101
  const frameTimeline = attachments.map((item) => `${item.name}: ${item.id}ms`).join("\n");
50
102
  return `你是 TikTok 电商短视频的多模态取证分析器。你必须同时理解画面、屏幕文字和人物口播,并把它们对齐到同一时间轴。\n\n` +
@@ -150,6 +202,49 @@ async function extractTimedTranscript(sourceUrl, workDir) {
150
202
  break;
151
203
  lastError = result.error;
152
204
  }
205
+ return transcriptFromDirectory(workDir, lastError);
206
+ }
207
+ async function downloadVideoArchive(sourceUrl, workDir, maxBytes) {
208
+ const output = path.join(workDir, "%(id)s.%(ext)s");
209
+ const args = [
210
+ "--no-playlist", "--no-warnings",
211
+ "--format", "bv*+ba/b", "--merge-output-format", "mp4", "--remux-video", "mp4",
212
+ "--write-subs", "--write-auto-subs", "--sub-langs", TRANSCRIPT_LANGUAGES, "--sub-format", "vtt",
213
+ "--max-filesize", String(maxBytes), "--output", output, sourceUrl,
214
+ ];
215
+ const attempts = process.platform === "win32"
216
+ ? [["yt-dlp.exe", args], ["py.exe", ["-m", "yt_dlp", ...args]]]
217
+ : [["yt-dlp", args], ["python3", ["-m", "yt_dlp", ...args]]];
218
+ let lastError = "";
219
+ let completed = false;
220
+ for (const [command, commandArgs] of attempts) {
221
+ const result = await runProcess(command, commandArgs, { cwd: workDir, timeoutMs: 5 * 60_000, allowMissing: true });
222
+ if (result.ok) {
223
+ completed = true;
224
+ break;
225
+ }
226
+ lastError = result.error;
227
+ }
228
+ if (!completed)
229
+ throw new Error(`视频下载失败${lastError ? `:${lastError}` : ""}`);
230
+ const names = await readdir(workDir);
231
+ const candidates = [];
232
+ for (const name of names.filter((value) => value.toLowerCase().endsWith(".mp4"))) {
233
+ const file = path.join(workDir, name);
234
+ const info = await stat(file);
235
+ if (info.isFile())
236
+ candidates.push({ file, size: info.size });
237
+ }
238
+ candidates.sort((left, right) => right.size - left.size);
239
+ const video = candidates[0];
240
+ if (!video?.size)
241
+ throw new Error("视频下载完成但没有生成 MP4 文件");
242
+ if (video.size > maxBytes)
243
+ throw new Error(`视频超过站内存储上限(${Math.ceil(maxBytes / 1024 / 1024)} MiB)`);
244
+ const transcript = await transcriptFromDirectory(workDir, lastError).catch(() => "");
245
+ return { file: video.file, transcript };
246
+ }
247
+ async function transcriptFromDirectory(workDir, lastError = "") {
153
248
  const subtitleFiles = (await readdir(workDir)).filter((name) => name.toLowerCase().endsWith(".vtt"));
154
249
  if (!subtitleFiles.length)
155
250
  throw new Error(`未取得视频口播字幕${lastError ? `:${lastError}` : ""}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",