| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- /**
- * aigc_storyboard.mjs — AIGC 短剧/音乐分镜拆解与仿爆款长提示词生成(JSS-02)
- *
- * 输入(workdir 下):
- * shots.json {duration, shot_count, shots:[{i,start,end,dur}]}
- * frames.json [{i, t_ms, t_label, caption}] ← 视觉逐帧点评(python 管线产出)
- * transcript.json {data:{segments:[{bg,ed,text}], text}}
- * meta.json {desc, author, stats, tags, duration_ms}
- * comments.json [{text, likes, nickname}] ← 可选, VOC
- * classify.json {primary, mode} ← type_classify.mjs 产出
- *
- * 输出(workdir 下):
- * aigc_remake.json 结构化分镜库(人物/场景/风格/节奏注册表 + 分镜表)
- * aigc_prompt.md 可直接投喂 AIGC 创作智能体的长提示词(带 {{核心人物}}/{{核心产品}} 占位符)
- *
- * 纯 ESM / 零依赖 / Node 与浏览器双端。
- */
- // ---------- 工具 ----------
- const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
- const fmtTs = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
- const uniq = (a) => [...new Set(a)];
- const freq = (arr) => { const m = {}; for (const x of arr) m[x] = (m[x] || 0) + 1; return m; };
- const topN = (m, n) => Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ k, v }));
- // ---------- 歌词结构: 副歌检测(重复行聚类) ----------
- export function detectLyricStructure(segments) {
- const lines = segments.map((s) => ({ t: s.bg, text: (s.text || "").trim() })).filter((l) => l.text.length > 1);
- const norm = (t) => t.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "").toLowerCase();
- const counts = freq(lines.map((l) => norm(l.text)));
- const repeated = new Set(Object.keys(counts).filter((k) => counts[k] >= 2 && k.length >= 3));
- // 连续重复区段 = 副歌
- const sections = [];
- let cur = null;
- for (const l of lines) {
- const isChorus = repeated.has(norm(l.text));
- if (!cur || cur.chorus !== isChorus) {
- if (cur) sections.push(cur);
- cur = { chorus: isChorus, start: l.t, end: l.ed ?? l.t + 4000, lines: [l.text] };
- } else { cur.lines.push(l.text); cur.end = l.ed ?? l.t + 4000; }
- }
- if (cur) sections.push(cur);
- // 段落命名: 副歌/主歌/间奏(无词)
- let v = 0, c = 0;
- return sections.map((s, idx) => {
- let name;
- if (s.chorus) name = `副歌${++c || ""}`;
- else if (s.lines.join("").length < 4) name = "间奏/纯音乐";
- else name = `主歌${++v}`;
- return { idx, name, chorus: s.chorus, start: s.start, end: s.end, lyric: s.lines.join(" / ").slice(0, 120) };
- });
- }
- // ---------- 视觉注册表: 人物/场景/风格提取 ----------
- const CHAR_HINTS = ["女王", "女团", "和声", "舞者", "观众", "歌手", "男主", "女主", "主角", "机器人", "蜘蛛", "乐手", "乐队", "伴舞"];
- const SCENE_HINTS = ["舞台", "观众席", "后台", "城市", "街道", "夜景", "室内", "棚", "天空", "废墟", "宫殿", "洞穴", "森林", "海边", "机房", "大厅"];
- const STYLE_HINTS = ["霓虹", "赛博", "金属", "烟雾", "激光", "追光", "灯光", "金色", "红色", "紫色", "蓝色", "暗调", "高饱和", "质感", "毛发", "鳞片", "机械", "全息", "粒子", "镜头", "特写", "远景", "俯拍", "仰拍", "推近", "拉远"];
- function extractRegistry(captions) {
- const text = captions.join(" ");
- const pick = (hints) => topN(freq(hints.filter((h) => text.includes(h))), 10).map((x) => x.k);
- return {
- characters: pick(CHAR_HINTS),
- scenes: pick(SCENE_HINTS),
- styles: pick(STYLE_HINTS),
- };
- }
- // ---------- 分镜表合成: shot × frame × lyric × section ----------
- export function buildStoryboard({ shots = [], frames = [], segments = [], sections = [] } = {}) {
- const framesByShot = new Map(frames.map((f) => [f.i, f]));
- const seenSegs = new Set();
- const board = shots.map((s) => {
- const t0 = Math.round(s.start * 1000), t1 = Math.round(s.end * 1000);
- const frame = framesByShot.get(s.i) || {};
- const lyricSegs = segments
- .filter((g) => g.bg < t1 && (g.ed || g.bg + 4000) > t0);
- // 每个转写段只在它覆盖的第一个镜头显示全文, 后续镜头显示〃(避免长段刷屏)
- let lyric = "";
- if (lyricSegs.length) {
- const key = lyricSegs[0].bg;
- lyric = seenSegs.has(key) ? "〃(同段)" : lyricSegs[0].text.trim();
- seenSegs.add(key);
- }
- return {
- i: s.i, start: s.start, end: s.end, dur: s.dur,
- t_label: `${fmtTs(t0)}-${fmtTs(t1)}`,
- caption: frame.caption || "(待视觉点评)",
- lyric,
- // 镜头角色推断
- role: s.dur >= 4 ? "展示镜头(副歌记忆点/氛围铺陈)" :
- s.dur >= 2 ? "叙事镜头(主体动作/歌词同步)" : "节奏镜头(快切/卡点/反应)",
- };
- });
- // 段落归属(sections 秒坐标)
- for (const s of board) {
- const sec = sections.find((x) => s.start >= x.start && s.start < x.end);
- s.section = sec ? sec.name : "纯音乐";
- // AIGC 复刻提示: 视觉点评要素合成(可直接作为单镜生词提示词)
- const v = framesByShot.get(s.i)?.vision || {};
- s.remake_prompt = [v.char, v.scene, v.shot, v.style].filter(Boolean).join(" | ") ||
- s.caption;
- }
- return board;
- }
- // ---------- 评分体系(6维, 保留口播版的评分传统) ----------
- export function scoreVideo({ meta = {}, shots = [], frames = [], lyricDensity = 0 } = {}) {
- const stats = meta.stats || {};
- const digg = stats.digg_count || 0, share = stats.share_count || 0, collect = stats.collect_count || 0;
- const shareRate = digg ? share / digg : 0;
- const durs = shots.map((s) => s.dur);
- const avgShot = durs.length ? durs.reduce((a, b) => a + b, 0) / durs.length : 0;
- const visionCoverage = shots.length ? frames.filter((f) => f.caption && !f.caption.startsWith("(待")).length / shots.length : 0;
- const dims = [
- { name: "传播力(转发/赞比)", score: clamp(Math.round(shareRate * 100), 1, 100), note: `转发/点赞=${(shareRate * 100).toFixed(0)}%` },
- { name: "收藏价值(模仿意愿)", score: clamp(Math.round((collect / Math.max(digg, 1)) * 200), 1, 100), note: `收藏/点赞=${((collect / Math.max(digg, 1)) * 100).toFixed(0)}%` },
- { name: "节奏设计", score: clamp(Math.round(avgShot ? 110 - avgShot * 12 : 40), 1, 100), note: `平均镜头${avgShot.toFixed(1)}s` },
- { name: "镜头信息密度", score: clamp(Math.round(shots.length / 4), 1, 100), note: `${shots.length}个镜头` },
- { name: "音乐画面同步", score: clamp(Math.round(lyricDensity * 100) || 60, 1, 100), note: `歌词覆盖率${(lyricDensity * 100).toFixed(0)}%` },
- { name: "视觉可复制度", score: clamp(Math.round(visionCoverage * 100), 1, 100), note: `视觉点评覆盖${(visionCoverage * 100).toFixed(0)}%` },
- ];
- const total = Math.round(dims.reduce((s, d) => s + d.score, 0) / dims.length);
- return { dims, total };
- }
- // ---------- 仿爆款长提示词生成 ----------
- export function buildLongPrompt({ meta = {}, classify = {}, registry = {}, board = [], sections = [], score = {} } = {}) {
- const chorusSecs = sections.filter((s) => s.chorus);
- const dialogSecs = sections.filter((s) => s.type === "dialog" && s.content);
- const styleLine = registry.styles?.length ? registry.styles.slice(0, 8).join("、") : "电影感打光、高饱和舞台色";
- const charLine = registry.characters?.length ? registry.characters.slice(0, 6).join("、") : "核心角色、伴演群体";
- const sceneLine = registry.scenes?.length ? registry.scenes.slice(0, 5).join("、") : "主舞台、观众席";
- const isVariety = sections.some((s) => s.type === "dialog" || s.type === "mixed");
- const genreLine = isVariety
- ? `**混合体裁**: 「音乐现场 × 综艺选秀」结构(台词报幕/评委互动/晋级投票 + 唱段表演) —— 这是 AIGC 内容的体裁创新点, 复刻时必须保留综艺叙事骨架`
- : "**纯音乐现场**结构(前奏→主歌→副歌→间奏→收尾)";
- const shotLines = board.map((s) => {
- // sections 用秒, board.start 也是秒
- const sec = sections.find((x) => s.start >= x.start && s.start < x.end);
- const secName = sec ? sec.name : "纯音乐";
- return `| ${s.i + 1} | ${s.t_label} | ${s.dur}s | ${secName} | ${s.role.split("(")[0]} | ${s.remake_prompt || s.caption} | ${s.lyric ? `「${s.lyric.slice(0, 28)}${s.lyric.length > 28 ? "…" : ""}」` : "(音乐)"} |`;
- });
- return `# AIGC 爆款复刻 · 长提示词
- > 源片: ${meta.author || "?"}《${(meta.desc || "").split("#")[0].trim()}》(${fmtTs(meta.duration_ms || 0)}, ${classify.primaryLabel || "AIGC内容"})
- > 用法: 整段投喂给 AIGC 视频创作智能体, 替换 {{核心人物}} / {{核心产品或概念}} 后按分镜表逐镜生成。
- ## 一、角色设定(全局一致性锚点)
- - 核心人物: {{核心人物}} —— 原片为「${charLine}」; 复刻时保持: 形象特征贯穿全片不变(服装/配色/标志物/体型比例), 每个镜头生成时必须回读此锚点。
- - 核心产品或概念: {{核心产品或概念}} —— 原片为「音乐翻唱现场」; 替换后所有镜头的叙事焦点围绕它展开。
- - 群像: ${charLine}
- ## 二、全局视觉风格(所有分镜共用)
- - 风格关键词: ${styleLine}
- - 画面基调: 舞台级打光、${sceneLine} 为主场景、强色彩记忆点
- - 画质要求: 电影级质感、4K、细节丰富、无文字水印、人物面部稳定不畸变
- - 负面约束: 画面抖动、人物变形、多指、闪烁、风格漂移(每个镜头生成后与上一镜头对比主体一致性)
- ## 三、音频结构与体裁(复刻节拍骨架)
- - 体裁: ${genreLine}
- - 原片段落: ${sections.map((s) => s.name).slice(0, 8).join(" → ")}${sections.length > 8 ? " → …" : ""}
- - 换曲复刻: 保留「开场抓人→表演高潮→互动喘息→抉择收尾」的情绪曲线, 镜头时长配比可复用原片节奏
- ${dialogSecs.length ? `
- ## 三·B、台词本(综艺骨架, 换皮复刻)
- > 原片主持/评委台词(ASR转写), 复刻时按「报幕→悬念→互动→抉择」功能位替换内容:
- ${dialogSecs.map((s) => `- 【${s.name}】${(s.content || "").slice(0, 100)}`).join("\n")}
- ` : ""}
- ## 四、分镜表(共 ${board.length} 镜, 直接逐行生成)
- | # | 时间 | 时长 | 段落 | 镜头角色 | 逐镜生词提示(主体\\|场景\\|运镜\\|风格) | 歌词/音频同步 |
- |---|---|---|---|---|---|---|
- ${shotLines.join("\n")}
- ## 五、逐镜生成规则
- 1. 每个镜头独立生成, 但开头必须复述「角色锚点+风格关键词」再描述本镜画面(防风格漂移)。
- 2. 镜头角色含义: 展示镜头=大景别+氛围渲染+角色高光; 叙事镜头=中景+动作与歌词对齐; 节奏镜头=1秒内快切, 用于卡鼓点/反应镜头。
- 3. 副歌段落镜头优先分配最佳预算(最高画质/最复杂运镜)。
- 4. 快切镜头(<1s)可用上一镜头尾帧图作为图生视频起点, 保证连贯。
- 5. 全部镜头生成后按分镜表时间轴拼接, 音轨对齐歌词时间戳。
- ## 六、交付规格
- - 画幅: 9:16 竖屏 · 时长: 对齐原曲结构(可等比缩放) · 帧率: 24/30fps
- - 拼接后全片检查: 主体一致性 / 色调统一 / 节奏与音乐对齐
- ## 七、原片成绩(复刻目标基准)
- - ${meta.stats?.digg_count || "?"} 赞 · ${meta.stats?.share_count || "?"} 转发 · ${meta.stats?.collect_count || "?"} 收藏 · 综合${score.total ?? "?"}分
- - 爆款要素: 高密度快剪(平均镜头${(durs(board)).toFixed(1)}s) + 强视觉奇观(拟人角色舞台) + 热歌情感嫁接
- ---
- *由 skill-video-lapian/JSS-02 aigc_storyboard.mjs 自动生成*`;
- }
- const durs = (board) => board.length ? board.reduce((s, x) => s + x.dur, 0) / board.length : 0;
- // ---------- 主入口: 组装 aigc_remake.json + aigc_prompt.md ----------
- export async function generate(workdir) {
- const fs = await import("node:fs");
- const safe = (n) => { try { return JSON.parse(fs.readFileSync(`${workdir}/${n}`, "utf8")); } catch { return null; } };
- const shots = safe("shots.json") || {};
- const frames = safe("frames.json") || [];
- const tr = safe("transcript.json") || {};
- const segments = tr?.data?.segments || [];
- const meta = safe("meta.json") || {};
- const comments = safe("comments.json") || [];
- const classify = safe("classify.json") || {};
- // 段落结构优先级: 人工精修 sections.json > 歌词重复自动检测
- const sections = safe("sections.json")?.sections || detectLyricStructure(segments);
- const registry = extractRegistry(frames.map((f) => f.caption || "").concat((meta.desc || "").split(/[,。#\s]+/)));
- const board = buildStoryboard({ shots: shots.shots || [], frames, segments, sections });
- const lyricCov = segments.length ? board.filter((s) => s.lyric).length / Math.max(board.filter((s) => s.lyric !== "").length || board.length, 1) : 0;
- const score = scoreVideo({ meta, shots: board, frames, lyricDensity: clamp(lyricCov, 0, 1) });
- // 自动改进/复刻策略
- const stats = meta.stats || {};
- const spm = Math.round(board.length / ((shots.duration || 1) / 60));
- const suggestions = [
- `复刻要点: 保持 ${board.length} 镜/${spm}镜每分钟的高密度节奏, 展示镜头(≥4s)只留 ${board.filter((s) => s.dur >= 4).length} 个用作记忆点, 其余快切卡点`,
- `角色一致性是 AIGC 成败关键: 全片主体(${(registry.characters || []).slice(0, 3).join("/")})必须逐镜回读锚点, 建议首镜生图后用图生视频保持形象`,
- `音频驱动剪辑: 先定歌曲段落结构(本片 ${sections.length} 段), 镜头切换点对齐鼓点/段落边界, 而非自由分配`,
- stats.share_count > stats.collect_count
- ? `传播设计: 本片转发(${stats.share_count})远超收藏(${stats.collect_count}), 爆点在「社交谈资」(熟悉IP意外混搭) — 复刻时选认知度高的IP做反差组合`
- : `传播设计: 收藏偏高说明「教程感/复用价值」强, 复刻时在结尾加可复用的模板感设计`,
- `发布策略: 挂 #ai创作浪潮计划 等官方活动标签 + 音乐原声热歌, 是本赛道的流量杠杆`,
- ];
- const remake = {
- video: { aweme_id: meta.aweme_id, desc: meta.desc, author: meta.author, duration_s: (shots.duration || 0), url: `https://www.douyin.com/video/${meta.aweme_id}` },
- type: classify,
- structure: { sections, shot_count: board.length, avg_shot_s: +(durs(board)).toFixed(2) },
- registry,
- storyboard: board,
- score,
- suggestions,
- voc: { total: comments.length, top: comments.slice(0, 5).map((c) => ({ likes: c.likes, text: (c.text || "").slice(0, 60) })) },
- };
- fs.writeFileSync(`${workdir}/aigc_remake.json`, JSON.stringify(remake, null, 1));
- fs.writeFileSync(`${workdir}/aigc_prompt.md`, buildLongPrompt({ meta, classify, registry, board, sections, score }));
- return remake;
- }
- // CLI
- if (import.meta.url === `file://${process.argv[1]}`) {
- const wd = process.argv[2] || ".";
- const r = await generate(wd);
- console.log(`分镜 ${r.structure.shot_count} | 段落 ${r.structure.sections.length} | 综合 ${r.score.total} 分`);
- console.log(`→ ${wd}/aigc_remake.json + ${wd}/aigc_prompt.md`);
- }
|