| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135 |
- /**
- * type_classify.mjs — 短视频类型预判(JSS-01)
- *
- * 输入: meta(标题/标签/作者简介/时长/数据) + transcript(口播词, 可选) + sceneStats(镜头统计, 可选)
- * 输出: { primary, secondary[], confidence, evidence[], mode }
- * mode = "lapian"(方法论文拉片) | "aigc_storyboard"(AIGC分镜仿制拉片)
- *
- * 设计原则: 特征打分制, 每类有 title/tags/pattern/vo/duration 五维证据;
- * 无 LLM 依赖, 纯规则, 可在浏览器/Node 双端运行(纯 ESM, 零 import)。
- */
- // ---- 短视频类型体系(2026-08 平台实践版) -------------------------------
- export const TYPES = {
- talking_head: {
- label: "真人口播", mode: "lapian",
- patterns: ["口播", "观点", "干货", "认知", "思维", "老板", "创业", "商业", "财经", "成长", "IP", "操盘", "变现", "生意"],
- voHints: ["我", "我们", "为什么", "其实", "本质", "认知", "普通人", "记住"],
- },
- aigc_music: {
- label: "AIGC音乐现场", mode: "aigc_storyboard",
- patterns: ["ai", "aigc", "翻唱", "演唱", "现场", "音乐", "MV", "演唱会", "歌手", "和声", "舞台", "音综", "选秀", "综艺", "封神"],
- voHints: [],
- },
- aigc_shortdrama: {
- label: "AIGC短剧", mode: "aigc_storyboard",
- patterns: ["短剧", "剧情", "逆袭", "战神", "赘婿", "重生", "霸道", "复仇", "穿越", " ads ", "AI短剧"],
- voHints: ["他", "她", "只听", "只见", "这时", "下一秒", "众人"],
- },
- aigc_animal: {
- label: "AIGC动物拟人", mode: "aigc_storyboard",
- patterns: ["动物", "猫咪", "狗", "熊猫", "蜘蛛", "蛇", "海洋", "森林", "拟人", "童话"],
- },
- vlog: {
- label: "真人Vlog", mode: "lapian",
- patterns: ["vlog", "日常", "记录", "探店", "旅行", "一天", "生活"],
- },
- drama: {
- label: "真人剧情/段子", mode: "lapian",
- patterns: ["剧情", "段子", "反转", "夫妻", "婆媳", "职场", "搞笑"],
- },
- food: {
- label: "美食", mode: "lapian",
- patterns: ["美食", "探店", "做法", "食谱", "菜谱", "吃播", "食材"],
- },
- knowledge: {
- label: "知识科普", mode: "lapian",
- patterns: ["科普", "知识", "历史", "物理", "宇宙", "解构", "解读", "解读"],
- },
- };
- // 镜头节奏指纹(镜头/分钟): 口播≈5-15, 剧情叙事≈15-25, 音乐现场/卡点≈25-90
- export function rhythmScore(shotsPerMin) {
- if (shotsPerMin >= 45) return { aigcLikely: true, label: "超高密度卡点(音乐/燃向)" };
- if (shotsPerMin >= 22) return { aigcLikely: true, label: "高密度快剪(音乐现场/混剪)" };
- if (shotsPerMin >= 12) return { aigcLikely: false, label: "中密度叙事" };
- return { aigcLikely: false, label: "低密度口播/长take" };
- }
- export function classify({ meta = {}, transcript = "", sceneStats = {} } = {}) {
- const title = `${meta.desc || ""} ${meta.author_signature || ""}`.toLowerCase();
- const tags = (meta.tags || []).join(" ").toLowerCase();
- const text = transcript.toLowerCase();
- const evidence = [];
- const scores = {};
- for (const k of Object.keys(TYPES)) scores[k] = 0;
- // 1) 标签强证据(权重3, 直接命中类型名或同义词)
- for (const [key, def] of Object.entries(TYPES)) {
- for (const p of def.patterns) {
- const needle = p.toLowerCase().trim();
- if (!needle) continue;
- if (tags.includes(needle)) {
- scores[key] += 3;
- evidence.push(`标签命中[${def.label}]: #${needle}`);
- } else if (title.includes(needle)) {
- scores[key] += 2;
- evidence.push(`标题/简介命中[${def.label}]: "${needle}"`);
- }
- }
- }
- // 2) 口播词特征(权重1)
- if (text) {
- for (const [key, def] of Object.entries(TYPES)) {
- const hits = (def.voHints || []).filter((h) => text.includes(h)).length;
- if (hits >= 2) {
- scores[key] += 1;
- evidence.push(`口播特征[${def.label}] ${hits}处`);
- }
- }
- }
- // 3) 镜头节奏指纹(权重2, aigc_storyboard 系共分)
- if (sceneStats.shotsPerMin != null) {
- const r = rhythmScore(sceneStats.shotsPerMin);
- evidence.push(`节奏指纹: ${sceneStats.shotsPerMin}/分 → ${r.label}`);
- if (r.aigcLikely) {
- for (const k of ["aigc_music", "aigc_shortdrama", "aigc_animal"]) scores[k] += 2;
- }
- }
- // 4) AIGC 水印/标签强制信号
- const aigcMarkers = ["ai创作浪潮计划", "aigc", "ai绘画", "ai视频", "ai翻唱", "ai音乐", "即梦", "可灵", "vidu", "runway", "sora"];
- for (const mk of aigcMarkers) {
- if (tags.includes(mk) || title.includes(mk)) {
- for (const k of ["aigc_music", "aigc_shortdrama", "aigc_animal"]) scores[k] += 4;
- evidence.push(`AIGC强制信号: ${mk}`);
- break;
- }
- }
- const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]).filter(([, v]) => v > 0);
- const primary = ranked[0]?.[0] || "talking_head";
- const confidence = ranked.length
- ? Math.min(0.99, ranked[0][1] / Math.max(1, ranked.slice(0, 2).reduce((s, [, v]) => s + v, 0)))
- : 0.3;
- const def = TYPES[primary];
- return {
- primary, primaryLabel: def.label, mode: def.mode,
- secondary: ranked.slice(1, 3).map(([k]) => k),
- scores: ranked, confidence: Math.round(confidence * 100) / 100,
- evidence: [...new Set(evidence)].slice(0, 12),
- };
- }
- // CLI: node type_classify.mjs workdir
- if (import.meta.url === `file://${process.argv[1]}`) {
- const fs = await import("node:fs");
- const wd = process.argv[2] || ".";
- const safeJSON = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; } };
- const meta = safeJSON(`${wd}/meta.json`);
- const transcript = safeJSON(`${wd}/transcript.json`)?.data?.text || "";
- const s = safeJSON(`${wd}/shots.json`);
- const sceneStats = s.shot_count ? { shotsPerMin: Math.round(s.shot_count / (s.duration / 60)) } : {};
- const r = classify({ meta, transcript, sceneStats });
- console.log(JSON.stringify(r, null, 2));
- fs.writeFileSync(`${wd}/classify.json`, JSON.stringify(r, null, 2));
- }
|