/** * 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)); }