@xiaohhhh1/canvas-agent 0.4.35 → 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)
|
package/dist/server/http.js
CHANGED
|
@@ -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 {
|
|
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,48 @@ export class LocalVideoIntelligence {
|
|
|
10
11
|
constructor(fastmoss) {
|
|
11
12
|
this.fastmoss = fastmoss;
|
|
12
13
|
}
|
|
13
|
-
async
|
|
14
|
-
const sourceUrl =
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
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}` : ""}`);
|