|
|
@@ -0,0 +1,245 @@
|
|
|
+/**
|
|
|
+ * 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`);
|
|
|
+}
|