@xiaohhhh1/canvas-agent 0.4.34 → 0.4.36

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: {
@@ -34,3 +55,5 @@ export declare class LocalVideoIntelligence {
34
55
  export declare function localVideoAnalysisPrompt(source: LocalVideoLearningSource, durationMs: number, transcript: string, attachments: AgentAttachment[]): string;
35
56
  export declare function assertJointVisualAndSpokenEvidence(analysis: Record<string, unknown>): void;
36
57
  export declare function timedTranscriptFromVtt(value: string): string;
58
+ export declare function resolveLocalCodexEntrypoint(): string;
59
+ export {};
@@ -1,8 +1,9 @@
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";
4
+ import { createRequire } from "node:module";
3
5
  import os from "node:os";
4
6
  import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
7
  const ANALYSIS_TIMEOUT_MS = 10 * 60_000;
7
8
  const TRANSCRIPT_LANGUAGES = "en.*,es.*,zh.*,pt.*,fr.*,de.*,vi.*,th.*,id.*,ms.*,ja.*,ko.*";
8
9
  export class LocalVideoIntelligence {
@@ -10,16 +11,48 @@ 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 mediaUrl = archivedVideoUrl ? verifiedHttpsUrl(archivedVideoUrl, "站内视频副本地址无效") : sourceUrl;
50
+ const platformVideoId = String(source.platformVideoId || source.platform_video_id || "").trim();
18
51
  const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-intelligence-"));
19
52
  try {
20
53
  const [visualEvidence, transcript] = await Promise.all([
21
- this.fastmoss.capturePublicVideoFrames(sourceUrl),
22
- extractTimedTranscript(sourceUrl, workDir),
54
+ this.fastmoss.capturePublicVideoFrames(mediaUrl, platformVideoId),
55
+ archivedTranscript ? Promise.resolve(archivedTranscript) : extractTimedTranscript(sourceUrl, workDir),
23
56
  ]);
24
57
  if (!visualEvidence.frames.length)
25
58
  throw new Error("没有取得任何真实视频画面,已停止分析");
@@ -35,8 +68,8 @@ export class LocalVideoIntelligence {
35
68
  durationMs: visualEvidence.durationMs,
36
69
  frameCount: attachments.length,
37
70
  transcriptCueCount: transcript.split("\n").filter(Boolean).length,
38
- visualSource: visualEvidence.playerUrl,
39
- transcriptSource: "TikTok native/automatic timed captions via local yt-dlp",
71
+ visualSource: archivedVideoUrl ? "website-archive" : visualEvidence.playerUrl,
72
+ transcriptSource: archivedTranscript ? "website-archive" : "TikTok native/automatic timed captions via local yt-dlp",
40
73
  },
41
74
  };
42
75
  }
@@ -45,6 +78,24 @@ export class LocalVideoIntelligence {
45
78
  }
46
79
  }
47
80
  }
81
+ function verifiedTikTokSourceUrl(source) {
82
+ const sourceUrl = String(source.sourceUrl || source.source_url || "").trim();
83
+ if (!/^https:\/\/(?:www\.)?tiktok\.com\/@[^/]+\/video\/\d+(?:[/?#]|$)/i.test(sourceUrl)) {
84
+ throw new Error("本机视频处理只接受已验证的 TikTok 公开作品地址");
85
+ }
86
+ return sourceUrl;
87
+ }
88
+ function verifiedHttpsUrl(value, message) {
89
+ try {
90
+ const url = new URL(value);
91
+ if (url.protocol !== "https:" || url.username || url.password)
92
+ throw new Error(message);
93
+ return url.toString();
94
+ }
95
+ catch {
96
+ throw new Error(message);
97
+ }
98
+ }
48
99
  export function localVideoAnalysisPrompt(source, durationMs, transcript, attachments) {
49
100
  const frameTimeline = attachments.map((item) => `${item.name}: ${item.id}ms`).join("\n");
50
101
  return `你是 TikTok 电商短视频的多模态取证分析器。你必须同时理解画面、屏幕文字和人物口播,并把它们对齐到同一时间轴。\n\n` +
@@ -150,6 +201,48 @@ async function extractTimedTranscript(sourceUrl, workDir) {
150
201
  break;
151
202
  lastError = result.error;
152
203
  }
204
+ return transcriptFromDirectory(workDir, lastError);
205
+ }
206
+ async function downloadVideoArchive(sourceUrl, workDir, maxBytes) {
207
+ const output = path.join(workDir, "%(id)s.%(ext)s");
208
+ const args = [
209
+ "--no-playlist", "--no-warnings",
210
+ "--format", "bv*+ba/b", "--merge-output-format", "mp4", "--remux-video", "mp4",
211
+ "--write-subs", "--write-auto-subs", "--sub-langs", TRANSCRIPT_LANGUAGES, "--sub-format", "vtt",
212
+ "--max-filesize", String(maxBytes), "--output", output, sourceUrl,
213
+ ];
214
+ const attempts = process.platform === "win32"
215
+ ? [["yt-dlp.exe", args], ["py.exe", ["-m", "yt_dlp", ...args]]]
216
+ : [["yt-dlp", args], ["python3", ["-m", "yt_dlp", ...args]]];
217
+ let lastError = "";
218
+ let completed = false;
219
+ for (const [command, commandArgs] of attempts) {
220
+ const result = await runProcess(command, commandArgs, { cwd: workDir, timeoutMs: 5 * 60_000, allowMissing: true });
221
+ if (result.ok) {
222
+ completed = true;
223
+ break;
224
+ }
225
+ lastError = result.error;
226
+ }
227
+ if (!completed)
228
+ throw new Error(`视频下载失败${lastError ? `:${lastError}` : ""}`);
229
+ const names = await readdir(workDir);
230
+ const candidates = [];
231
+ for (const name of names.filter((value) => value.toLowerCase().endsWith(".mp4"))) {
232
+ const file = path.join(workDir, name);
233
+ const info = await stat(file);
234
+ if (info.isFile())
235
+ candidates.push({ file, size: info.size });
236
+ }
237
+ candidates.sort((left, right) => right.size - left.size);
238
+ const video = candidates[0];
239
+ if (!video?.size)
240
+ throw new Error("视频下载完成但没有生成 MP4 文件");
241
+ if (video.size > maxBytes)
242
+ throw new Error(`视频超过站内存储上限(${Math.ceil(maxBytes / 1024 / 1024)} MiB)`);
243
+ return { file: video.file, transcript: await transcriptFromDirectory(workDir, lastError) };
244
+ }
245
+ async function transcriptFromDirectory(workDir, lastError = "") {
153
246
  const subtitleFiles = (await readdir(workDir)).filter((name) => name.toLowerCase().endsWith(".vtt"));
154
247
  if (!subtitleFiles.length)
155
248
  throw new Error(`未取得视频口播字幕${lastError ? `:${lastError}` : ""}`);
@@ -173,7 +266,10 @@ function transcriptPreference(file) {
173
266
  }
174
267
  async function runLocalCodexAnalysis(prompt, attachments, workDir) {
175
268
  const outputFile = path.join(workDir, "analysis.json");
176
- const codexEntrypoint = fileURLToPath(new URL("../../node_modules/@openai/codex/bin/codex.js", import.meta.url));
269
+ // npm hoists dependencies next to the installed package in production, while
270
+ // local development may keep them at a different ancestor. Resolve from this
271
+ // module instead of assuming a nested node_modules directory.
272
+ const codexEntrypoint = resolveLocalCodexEntrypoint();
177
273
  const args = [codexEntrypoint, "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--color", "never", "--output-last-message", outputFile];
178
274
  for (const attachment of attachments)
179
275
  args.push("--image", path.join(workDir, String(attachment.name)));
@@ -189,6 +285,9 @@ async function runLocalCodexAnalysis(prompt, attachments, workDir) {
189
285
  throw new Error("本机 Codex 没有返回可校验的 JSON 视频分析结果");
190
286
  }
191
287
  }
288
+ export function resolveLocalCodexEntrypoint() {
289
+ return createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");
290
+ }
192
291
  async function runProcess(command, args, options) {
193
292
  return await new Promise((resolve) => {
194
293
  let stderr = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.34",
3
+ "version": "0.4.36",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",