type_classify.mjs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. /**
  2. * type_classify.mjs — 短视频类型预判(JSS-01)
  3. *
  4. * 输入: meta(标题/标签/作者简介/时长/数据) + transcript(口播词, 可选) + sceneStats(镜头统计, 可选)
  5. * 输出: { primary, secondary[], confidence, evidence[], mode }
  6. * mode = "lapian"(方法论文拉片) | "aigc_storyboard"(AIGC分镜仿制拉片)
  7. *
  8. * 设计原则: 特征打分制, 每类有 title/tags/pattern/vo/duration 五维证据;
  9. * 无 LLM 依赖, 纯规则, 可在浏览器/Node 双端运行(纯 ESM, 零 import)。
  10. */
  11. // ---- 短视频类型体系(2026-08 平台实践版) -------------------------------
  12. export const TYPES = {
  13. talking_head: {
  14. label: "真人口播", mode: "lapian",
  15. patterns: ["口播", "观点", "干货", "认知", "思维", "老板", "创业", "商业", "财经", "成长", "IP", "操盘", "变现", "生意"],
  16. voHints: ["我", "我们", "为什么", "其实", "本质", "认知", "普通人", "记住"],
  17. },
  18. aigc_music: {
  19. label: "AIGC音乐现场", mode: "aigc_storyboard",
  20. patterns: ["ai", "aigc", "翻唱", "演唱", "现场", "音乐", "MV", "演唱会", "歌手", "和声", "舞台", "音综", "选秀", "综艺", "封神"],
  21. voHints: [],
  22. },
  23. aigc_shortdrama: {
  24. label: "AIGC短剧", mode: "aigc_storyboard",
  25. patterns: ["短剧", "剧情", "逆袭", "战神", "赘婿", "重生", "霸道", "复仇", "穿越", " ads ", "AI短剧"],
  26. voHints: ["他", "她", "只听", "只见", "这时", "下一秒", "众人"],
  27. },
  28. aigc_animal: {
  29. label: "AIGC动物拟人", mode: "aigc_storyboard",
  30. patterns: ["动物", "猫咪", "狗", "熊猫", "蜘蛛", "蛇", "海洋", "森林", "拟人", "童话"],
  31. },
  32. vlog: {
  33. label: "真人Vlog", mode: "lapian",
  34. patterns: ["vlog", "日常", "记录", "探店", "旅行", "一天", "生活"],
  35. },
  36. drama: {
  37. label: "真人剧情/段子", mode: "lapian",
  38. patterns: ["剧情", "段子", "反转", "夫妻", "婆媳", "职场", "搞笑"],
  39. },
  40. food: {
  41. label: "美食", mode: "lapian",
  42. patterns: ["美食", "探店", "做法", "食谱", "菜谱", "吃播", "食材"],
  43. },
  44. knowledge: {
  45. label: "知识科普", mode: "lapian",
  46. patterns: ["科普", "知识", "历史", "物理", "宇宙", "解构", "解读", "解读"],
  47. },
  48. };
  49. // 镜头节奏指纹(镜头/分钟): 口播≈5-15, 剧情叙事≈15-25, 音乐现场/卡点≈25-90
  50. export function rhythmScore(shotsPerMin) {
  51. if (shotsPerMin >= 45) return { aigcLikely: true, label: "超高密度卡点(音乐/燃向)" };
  52. if (shotsPerMin >= 22) return { aigcLikely: true, label: "高密度快剪(音乐现场/混剪)" };
  53. if (shotsPerMin >= 12) return { aigcLikely: false, label: "中密度叙事" };
  54. return { aigcLikely: false, label: "低密度口播/长take" };
  55. }
  56. export function classify({ meta = {}, transcript = "", sceneStats = {} } = {}) {
  57. const title = `${meta.desc || ""} ${meta.author_signature || ""}`.toLowerCase();
  58. const tags = (meta.tags || []).join(" ").toLowerCase();
  59. const text = transcript.toLowerCase();
  60. const evidence = [];
  61. const scores = {};
  62. for (const k of Object.keys(TYPES)) scores[k] = 0;
  63. // 1) 标签强证据(权重3, 直接命中类型名或同义词)
  64. for (const [key, def] of Object.entries(TYPES)) {
  65. for (const p of def.patterns) {
  66. const needle = p.toLowerCase().trim();
  67. if (!needle) continue;
  68. if (tags.includes(needle)) {
  69. scores[key] += 3;
  70. evidence.push(`标签命中[${def.label}]: #${needle}`);
  71. } else if (title.includes(needle)) {
  72. scores[key] += 2;
  73. evidence.push(`标题/简介命中[${def.label}]: "${needle}"`);
  74. }
  75. }
  76. }
  77. // 2) 口播词特征(权重1)
  78. if (text) {
  79. for (const [key, def] of Object.entries(TYPES)) {
  80. const hits = (def.voHints || []).filter((h) => text.includes(h)).length;
  81. if (hits >= 2) {
  82. scores[key] += 1;
  83. evidence.push(`口播特征[${def.label}] ${hits}处`);
  84. }
  85. }
  86. }
  87. // 3) 镜头节奏指纹(权重2, aigc_storyboard 系共分)
  88. if (sceneStats.shotsPerMin != null) {
  89. const r = rhythmScore(sceneStats.shotsPerMin);
  90. evidence.push(`节奏指纹: ${sceneStats.shotsPerMin}/分 → ${r.label}`);
  91. if (r.aigcLikely) {
  92. for (const k of ["aigc_music", "aigc_shortdrama", "aigc_animal"]) scores[k] += 2;
  93. }
  94. }
  95. // 4) AIGC 水印/标签强制信号
  96. const aigcMarkers = ["ai创作浪潮计划", "aigc", "ai绘画", "ai视频", "ai翻唱", "ai音乐", "即梦", "可灵", "vidu", "runway", "sora"];
  97. for (const mk of aigcMarkers) {
  98. if (tags.includes(mk) || title.includes(mk)) {
  99. for (const k of ["aigc_music", "aigc_shortdrama", "aigc_animal"]) scores[k] += 4;
  100. evidence.push(`AIGC强制信号: ${mk}`);
  101. break;
  102. }
  103. }
  104. const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]).filter(([, v]) => v > 0);
  105. const primary = ranked[0]?.[0] || "talking_head";
  106. const confidence = ranked.length
  107. ? Math.min(0.99, ranked[0][1] / Math.max(1, ranked.slice(0, 2).reduce((s, [, v]) => s + v, 0)))
  108. : 0.3;
  109. const def = TYPES[primary];
  110. return {
  111. primary, primaryLabel: def.label, mode: def.mode,
  112. secondary: ranked.slice(1, 3).map(([k]) => k),
  113. scores: ranked, confidence: Math.round(confidence * 100) / 100,
  114. evidence: [...new Set(evidence)].slice(0, 12),
  115. };
  116. }
  117. // CLI: node type_classify.mjs workdir
  118. if (import.meta.url === `file://${process.argv[1]}`) {
  119. const fs = await import("node:fs");
  120. const wd = process.argv[2] || ".";
  121. const safeJSON = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; } };
  122. const meta = safeJSON(`${wd}/meta.json`);
  123. const transcript = safeJSON(`${wd}/transcript.json`)?.data?.text || "";
  124. const s = safeJSON(`${wd}/shots.json`);
  125. const sceneStats = s.shot_count ? { shotsPerMin: Math.round(s.shot_count / (s.duration / 60)) } : {};
  126. const r = classify({ meta, transcript, sceneStats });
  127. console.log(JSON.stringify(r, null, 2));
  128. fs.writeFileSync(`${wd}/classify.json`, JSON.stringify(r, null, 2));
  129. }