@xiaohhhh1/canvas-agent 0.4.39 → 0.4.40
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.
|
@@ -2,6 +2,8 @@ import type { AgentAttachment } from "../agent/types.js";
|
|
|
2
2
|
import { type FastMossIntegration } from "../integrations/fastmoss.js";
|
|
3
3
|
export type LocalVideoLearningSource = {
|
|
4
4
|
id?: string;
|
|
5
|
+
source?: string;
|
|
6
|
+
platform?: string;
|
|
5
7
|
sourceUrl?: string;
|
|
6
8
|
source_url?: string;
|
|
7
9
|
platformVideoId?: string;
|
|
@@ -29,6 +31,10 @@ type VideoArchiveUpload = {
|
|
|
29
31
|
headers?: Record<string, string>;
|
|
30
32
|
maxBytes?: number;
|
|
31
33
|
};
|
|
34
|
+
export declare function shouldUploadVideoArchive(input: {
|
|
35
|
+
kind: "uploaded-mp4" | "tiktok";
|
|
36
|
+
url: string;
|
|
37
|
+
}, publicUrl: string): boolean;
|
|
32
38
|
export declare class LocalVideoIntelligence {
|
|
33
39
|
private readonly fastmoss;
|
|
34
40
|
constructor(fastmoss: FastMossIntegration);
|
|
@@ -56,6 +62,10 @@ export declare class LocalVideoIntelligence {
|
|
|
56
62
|
};
|
|
57
63
|
}>;
|
|
58
64
|
}
|
|
65
|
+
export declare function verifiedVideoLearningSource(source: LocalVideoLearningSource): {
|
|
66
|
+
kind: "tiktok" | "uploaded-mp4";
|
|
67
|
+
url: string;
|
|
68
|
+
};
|
|
59
69
|
export declare function localVideoAnalysisPrompt(source: LocalVideoLearningSource, durationMs: number, transcript: string, attachments: AgentAttachment[]): string;
|
|
60
70
|
export declare function assertJointVisualAndSpokenEvidence(analysis: Record<string, unknown>): void;
|
|
61
71
|
export declare function timedTranscriptFromVtt(value: string): string;
|
|
@@ -9,28 +9,35 @@ const ANALYSIS_TIMEOUT_MS = 10 * 60_000;
|
|
|
9
9
|
const TRANSCRIPT_LANGUAGES = "en.*,es.*,zh.*,pt.*,fr.*,de.*,vi.*,th.*,id.*,ms.*,ja.*,ko.*";
|
|
10
10
|
const LOCAL_ASR_MODEL = "onnx-community/whisper-base";
|
|
11
11
|
let localAsrPipeline;
|
|
12
|
+
export function shouldUploadVideoArchive(input, publicUrl) {
|
|
13
|
+
return input.kind !== "uploaded-mp4" || input.url !== publicUrl;
|
|
14
|
+
}
|
|
12
15
|
export class LocalVideoIntelligence {
|
|
13
16
|
fastmoss;
|
|
14
17
|
constructor(fastmoss) {
|
|
15
18
|
this.fastmoss = fastmoss;
|
|
16
19
|
}
|
|
17
20
|
async archive(source, upload) {
|
|
18
|
-
const
|
|
21
|
+
const input = verifiedVideoLearningSource(source);
|
|
19
22
|
const uploadUrl = verifiedHttpsUrl(upload?.uploadUrl, "站内上传地址无效");
|
|
20
23
|
const publicUrl = verifiedHttpsUrl(upload?.publicUrl, "站内视频地址无效");
|
|
21
24
|
const maxBytes = Math.min(500 * 1024 * 1024, Math.max(1, Number(upload?.maxBytes || 100 * 1024 * 1024)));
|
|
22
25
|
const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-archive-"));
|
|
23
26
|
try {
|
|
24
|
-
const downloaded =
|
|
27
|
+
const downloaded = input.kind === "uploaded-mp4"
|
|
28
|
+
? await downloadUploadedMp4(input.url, workDir, maxBytes)
|
|
29
|
+
: await downloadVideoArchive(input.url, workDir, maxBytes);
|
|
25
30
|
const bytes = await readFile(downloaded.file);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
if (shouldUploadVideoArchive(input, publicUrl)) {
|
|
32
|
+
const response = await fetch(uploadUrl, {
|
|
33
|
+
method: "PUT",
|
|
34
|
+
headers: { "Content-Type": "video/mp4" },
|
|
35
|
+
body: bytes,
|
|
36
|
+
signal: AbortSignal.timeout(5 * 60_000),
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
throw new Error(`站内视频上传失败(HTTP ${response.status})`);
|
|
40
|
+
}
|
|
34
41
|
return {
|
|
35
42
|
archive: {
|
|
36
43
|
publicUrl,
|
|
@@ -50,11 +57,10 @@ export class LocalVideoIntelligence {
|
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
async analyze(source) {
|
|
53
|
-
const sourceUrl = verifiedTikTokSourceUrl(source);
|
|
54
60
|
const archivedVideoUrl = String(source.archivedVideoUrl || source.archived_video_url || "").trim();
|
|
55
61
|
const archivedTranscript = String(source.archivedTranscript || source.archived_transcript || "").trim();
|
|
56
62
|
const hasArchivedMedia = Boolean(archivedVideoUrl);
|
|
57
|
-
const mediaUrl = archivedVideoUrl ? verifiedHttpsUrl(archivedVideoUrl, "站内视频副本地址无效") :
|
|
63
|
+
const mediaUrl = archivedVideoUrl ? verifiedHttpsUrl(archivedVideoUrl, "站内视频副本地址无效") : verifiedTikTokSourceUrl(source);
|
|
58
64
|
const platformVideoId = String(source.platformVideoId || source.platform_video_id || "").trim();
|
|
59
65
|
const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-intelligence-"));
|
|
60
66
|
try {
|
|
@@ -68,7 +74,7 @@ export class LocalVideoIntelligence {
|
|
|
68
74
|
transcriptSource = transcript ? `local-asr:${LOCAL_ASR_MODEL}` : "local-asr:no-speech-detected";
|
|
69
75
|
}
|
|
70
76
|
else if (!transcript) {
|
|
71
|
-
transcript = await extractTimedTranscript(
|
|
77
|
+
transcript = await extractTimedTranscript(mediaUrl, workDir).catch(() => "");
|
|
72
78
|
transcriptSource = transcript ? "TikTok native/automatic timed captions via local yt-dlp" : "caption-unavailable";
|
|
73
79
|
}
|
|
74
80
|
if (!visualEvidence.frames.length)
|
|
@@ -100,6 +106,16 @@ function verifiedTikTokSourceUrl(source) {
|
|
|
100
106
|
}
|
|
101
107
|
return sourceUrl;
|
|
102
108
|
}
|
|
109
|
+
export function verifiedVideoLearningSource(source) {
|
|
110
|
+
if (String(source.source || "").trim() === "manual-upload") {
|
|
111
|
+
const sourceUrl = verifiedHttpsUrl(String(source.sourceUrl || source.source_url || "").trim(), "上传视频地址无效");
|
|
112
|
+
const pathname = new URL(sourceUrl).pathname;
|
|
113
|
+
if (!/\.mp4$/i.test(pathname))
|
|
114
|
+
throw new Error("手动上传学习只接受 MP4 文件");
|
|
115
|
+
return { kind: "uploaded-mp4", url: sourceUrl };
|
|
116
|
+
}
|
|
117
|
+
return { kind: "tiktok", url: verifiedTikTokSourceUrl(source) };
|
|
118
|
+
}
|
|
103
119
|
function verifiedHttpsUrl(value, message) {
|
|
104
120
|
try {
|
|
105
121
|
const url = new URL(value);
|
|
@@ -119,8 +135,9 @@ export function localVideoAnalysisPrompt(source, durationMs, transcript, attachm
|
|
|
119
135
|
`1. 每张图片是实际视频在指定毫秒的画面,文件名和时间映射如下。不能把封面、榜单或网页文字当视频内容。\n${frameTimeline}\n` +
|
|
120
136
|
`2. 下面口播证据来自原生字幕、自动字幕或本机音轨语音识别。只概述,不输出连续逐字稿;若为空,表示没有识别到人声,不代表视频没有音轨,也不得凭画面猜口播。\n` +
|
|
121
137
|
`3. segments 每段必须写 visual;若该段有人声,spokenText 必须概述口播;可见字幕写 onScreenText。画面、口播或字幕缺失就写 null。没有听觉音轨输入,audio 必须写 null,不得猜音乐、语气或音效。\n` +
|
|
122
|
-
`4.
|
|
123
|
-
`5.
|
|
138
|
+
`4. 必须先判断整条视频的主带货形式 primary,只能选 factory-demo/comedy/mini-drama/ugc-testimonial/review-demo/tutorial/problem-solution/price-shock/unboxing/expert-explainer/lifestyle/live-cut/comparison/other。secondary 可选其他辅助形式。不能把 Hook 或实验假设冒充带货形式。\n` +
|
|
139
|
+
`5. 0-3 秒至少按 500ms 证据判断;后续按镜头变化。所有机制、带货形式和“为什么可能爆”的假设都必须引用真实 startMs/endMs。榜单指标只是相关性,不是因果。\n` +
|
|
140
|
+
`6. 目标是学习、融合、进化:只提炼可迁移机制,不复制原文案、人物、视觉资产或连续镜头顺序。\n\n` +
|
|
124
141
|
`样本:${JSON.stringify({
|
|
125
142
|
sourceUrl: source.sourceUrl || source.source_url,
|
|
126
143
|
platformVideoId: source.platformVideoId || source.platform_video_id,
|
|
@@ -134,7 +151,8 @@ export function localVideoAnalysisPrompt(source, durationMs, transcript, attachm
|
|
|
134
151
|
durationMs,
|
|
135
152
|
})}\n\n` +
|
|
136
153
|
`带时间码的口播字幕:\n${transcript}\n\n` +
|
|
137
|
-
`只返回一个 JSON 对象,不要 Markdown。字段必须是:schemaVersion="commerce-video-intelligence-
|
|
154
|
+
`只返回一个 JSON 对象,不要 Markdown。字段必须是:schemaVersion="commerce-video-intelligence-v2";durationMs;language;summary;` +
|
|
155
|
+
`sellingFormat{primary,label,secondary,rationale,evidence[{startMs,endMs,observation}],confidence};` +
|
|
138
156
|
`hook{startMs,endMs,visual,spokenText,onScreenText,patternInterrupt,openLoop};productFirstSeenMs;` +
|
|
139
157
|
`segments[{startMs,endMs,role,visual,spokenText,onScreenText,audio,editing,confidence}](至少2段);` +
|
|
140
158
|
`mechanisms[{type,label,mechanism,whyItMayWork,evidence[{startMs,endMs,observation}],confidence,replicationRisk}](type 仅 hook/conflict/proof/pacing/trust/product-reveal/offer-framing/cta/audio);` +
|
|
@@ -292,6 +310,25 @@ async function downloadVideoArchive(sourceUrl, workDir, maxBytes) {
|
|
|
292
310
|
const transcript = await transcriptFromDirectory(workDir, lastError).catch(() => "");
|
|
293
311
|
return { file: video.file, transcript, media };
|
|
294
312
|
}
|
|
313
|
+
async function downloadUploadedMp4(sourceUrl, workDir, maxBytes) {
|
|
314
|
+
const response = await fetch(sourceUrl, { signal: AbortSignal.timeout(5 * 60_000) });
|
|
315
|
+
if (!response.ok)
|
|
316
|
+
throw new Error(`读取上传视频失败(HTTP ${response.status})`);
|
|
317
|
+
const declaredLength = Number(response.headers.get("content-length") || 0);
|
|
318
|
+
if (declaredLength > maxBytes)
|
|
319
|
+
throw new Error(`视频超过站内存储上限(${Math.ceil(maxBytes / 1024 / 1024)} MiB)`);
|
|
320
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
321
|
+
if (!bytes.length)
|
|
322
|
+
throw new Error("上传视频为空");
|
|
323
|
+
if (bytes.length > maxBytes)
|
|
324
|
+
throw new Error(`视频超过站内存储上限(${Math.ceil(maxBytes / 1024 / 1024)} MiB)`);
|
|
325
|
+
if (!bytes.subarray(0, 64).includes(Buffer.from("ftyp")))
|
|
326
|
+
throw new Error("上传文件不是可识别的 MP4");
|
|
327
|
+
const file = path.join(workDir, "manual-upload.mp4");
|
|
328
|
+
await writeFile(file, bytes);
|
|
329
|
+
const media = await inspectVideoFile(file, workDir);
|
|
330
|
+
return { file, transcript: "", media };
|
|
331
|
+
}
|
|
295
332
|
export async function inspectVideoFile(videoFile, workDir = path.dirname(videoFile)) {
|
|
296
333
|
const probe = await runProcess(process.platform === "win32" ? "ffprobe.exe" : "ffprobe", [
|
|
297
334
|
"-v", "error", "-show_entries", "stream=codec_type,codec_name,width,height,duration:format=duration,format_name", "-of", "json", videoFile,
|