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