feat(工作台): AI创作 分镜头表老视频先提炼选题再创作 + 修复 #/rewrites noPersonaNote 未定义 P0
- buildTopicBlock(): 有#选题段直接用;无(分镜头表等)带解析内容+提炼指令;空只带标题 - 账号详情视频列表 + #/rewrites 两入口同步改造,desc 注明分镜头表先提炼 - 修复 renderRewrites 分支引用未定义 noPersonaNote 导致弹窗打不开(ReferenceError) - 验证脚本 cdp-verify-aicreate.mjs(hook confirmRun 捕获 prompt,按 hasTopic 动态断言)
This commit is contained in:
1 parent
818d00a28c
commit
e91c8046c6
5 files changed
+394
-35
No files matched your search
@@ -0,0 +1,183 @@
|
||||
/* cdp-verify-aicreate.mjs — 验证 AI创作 弹窗 prompt 注入(09-01 改动闭环)
|
||||
* 覆盖点:①#/rewrites 页 AI创作 按钮(noPersonaNote P0 bug 修复后不再报错)
|
||||
* ②账号详情视频列表 AI创作 按钮(人设+视频选题注入)
|
||||
* 方法:CDP 9223 → 复用现有 8900 标签页 → hook window.confirmRun 捕获 opts(不真实执行任务)
|
||||
* 运行:node cdp-verify-aicreate.mjs
|
||||
*/
|
||||
const CDP = 'http://127.0.0.1:9223';
|
||||
|
||||
async function getWs(targetId) {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const t = tabs.find((x) => x.id === targetId);
|
||||
if (!t) throw new Error('target not found: ' + targetId);
|
||||
return t.webSocketDebuggerUrl;
|
||||
}
|
||||
function cdp(ws, method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++cdp._id;
|
||||
const w = new WebSocket(ws);
|
||||
const timer = setTimeout(() => { try { w.close(); } catch (e) {} reject(new Error('timeout: ' + method)); }, 30000);
|
||||
w.onopen = () => w.send(JSON.stringify({ id, method, params }));
|
||||
w.onmessage = (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id !== id) return;
|
||||
clearTimeout(timer);
|
||||
try { w.close(); } catch (e) {}
|
||||
m.error ? reject(new Error(method + ': ' + JSON.stringify(m.error))) : resolve(m.result);
|
||||
};
|
||||
w.onerror = () => { clearTimeout(timer); reject(new Error('ws error: ' + method)); };
|
||||
});
|
||||
}
|
||||
cdp._id = 0;
|
||||
|
||||
async function evalJs(ws, expr) {
|
||||
const r = await cdp(ws, 'Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error('eval err: ' + JSON.stringify(r.exceptionDetails).slice(0, 300));
|
||||
return r.result && r.result.value;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function waitFor(ws, expr, timeout = 40000, label = '') {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
try { if (await evalJs(ws, expr)) return true; } catch (e) {}
|
||||
await sleep(800);
|
||||
}
|
||||
throw new Error('waitFor timeout: ' + label + ' -> ' + expr);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const wb = tabs.find((t) => t.url.includes('localhost:8900') || t.url.includes('127.0.0.1:8900'));
|
||||
if (!wb) throw new Error('未找到 8900 工作台标签页,请先在内置浏览器打开工作台');
|
||||
const ws = await getWs(wb.id);
|
||||
console.log('▶ 复用标签页:', wb.url);
|
||||
|
||||
// ---------- ① #/rewrites 页 ----------
|
||||
console.log('\n【① #/rewrites 页 AI创作 按钮】');
|
||||
await evalJs(ws, `location.hash = '#/rewrites'`);
|
||||
await cdp(ws, 'Page.reload', { ignoreCache: true });
|
||||
await waitFor(ws, `!!document.querySelector('[data-rewrite]')`, 40000, 'rewrites 列表渲染');
|
||||
await sleep(500);
|
||||
|
||||
// hook confirmRun 捕获 opts
|
||||
await evalJs(ws, `window.__cap = null; window.__origConfirmRun = window.confirmRun;
|
||||
window.confirmRun = (o) => { window.__cap = o; }; true`);
|
||||
|
||||
// 找一条「有人设账号」的已解析视频按钮(俊希 3 / 旧梦留声机 1629 优先),并判断其解析内容是否含「# 选题」段
|
||||
const parsedInfo = await evalJs(ws, `(async () => {
|
||||
const prefer = [3, 1629];
|
||||
const btns = [...document.querySelectorAll('[data-rewrite]')];
|
||||
let fallback = null;
|
||||
for (const b of btns) {
|
||||
const r = JSON.parse(b.dataset.rewrite);
|
||||
if (!r.parsed) continue;
|
||||
let hasTopic = false;
|
||||
try { const vd = await api('/api/dsh/video-detail?videoId=' + encodeURIComponent(r.id)); hasTopic = !!extractVideoTopic((vd.parse && vd.parse.content) || ''); } catch (e) {}
|
||||
const info = { idx: btns.indexOf(b), name: r.name, title: r.title, accId: r.accountId, parsed: r.parsed, hasTopic };
|
||||
if (!fallback) fallback = info;
|
||||
if (prefer.includes(r.accountId)) return info;
|
||||
}
|
||||
return fallback;
|
||||
})()`);
|
||||
console.log('已解析视频:', JSON.stringify(parsedInfo));
|
||||
if (!parsedInfo) throw new Error('无已解析视频按钮可测');
|
||||
|
||||
await evalJs(ws, `document.querySelectorAll('[data-rewrite]')[${parsedInfo.idx}].click(); true`);
|
||||
await waitFor(ws, `!!window.__cap`, 30000, 'confirmRun 捕获(已解析)');
|
||||
const cap1 = await evalJs(ws, `JSON.stringify(window.__cap)`);
|
||||
const c1 = JSON.parse(cap1);
|
||||
console.log(' title:', c1.title, '| label:', c1.label);
|
||||
console.log(' target:', c1.target);
|
||||
const p1 = c1.prompt || '';
|
||||
console.log(' prompt 含【账号设定(人设卡)】:', p1.includes('【账号设定(人设卡)】'));
|
||||
console.log(' prompt 含【视频选题】:', p1.includes('【视频选题】'));
|
||||
// 有选题段 → 直接用选题;无选题段(分镜头表等)→ 带解析内容 + 提炼指令(09-01 用户要求)
|
||||
if (parsedInfo.hasTopic) {
|
||||
console.log(' prompt 含 # 选题:', p1.includes('# 选题'), '(视频实际含选题段: true)');
|
||||
} else {
|
||||
console.log(' prompt 含【视频解析内容】:', p1.includes('【视频解析内容(供提炼选题)】'), '(视频实际含选题段: false)');
|
||||
console.log(' prompt 含提炼指令:', p1.includes('提炼出该视频的选题'));
|
||||
}
|
||||
console.log(' prompt 含 noPersonaNote 泄漏:', p1.includes('undefined'));
|
||||
const ok1 = c1.title === 'AI创作'
|
||||
&& p1.includes('【账号设定(人设卡)】')
|
||||
&& (parsedInfo.hasTopic ? (p1.includes('【视频选题】') && p1.includes('# 选题')) : (p1.includes('【视频解析内容(供提炼选题)】') && p1.includes('提炼出该视频的选题')))
|
||||
&& !p1.includes('undefined');
|
||||
console.log(' ✅ 已解析分支断言:', ok1 ? 'PASS' : 'FAIL');
|
||||
|
||||
// 未解析视频按钮(parsed=false)
|
||||
await evalJs(ws, `window.__cap = null; true`);
|
||||
const unparsedInfo = await evalJs(ws, `(() => {
|
||||
const btns = [...document.querySelectorAll('[data-rewrite]')];
|
||||
for (const b of btns) { const r = JSON.parse(b.dataset.rewrite); if (!r.parsed) return { idx: btns.indexOf(b), name: r.name, title: r.title, url: r.url }; }
|
||||
return null;
|
||||
})()`);
|
||||
console.log('未解析视频:', JSON.stringify(unparsedInfo));
|
||||
if (unparsedInfo) {
|
||||
await evalJs(ws, `document.querySelectorAll('[data-rewrite]')[${unparsedInfo.idx}].click(); true`);
|
||||
await waitFor(ws, `!!window.__cap`, 30000, 'confirmRun 捕获(未解析)');
|
||||
const p2 = JSON.parse(await evalJs(ws, `JSON.stringify(window.__cap)`)).prompt || '';
|
||||
const ok2 = p2.includes('请先解析这个抖音视频') && !p2.includes('undefined');
|
||||
console.log(' 未解析分支(先解析再创作+无 undefined 泄漏):', ok2 ? 'PASS' : 'FAIL');
|
||||
} else {
|
||||
console.log(' 无未解析视频可测(跳过)');
|
||||
}
|
||||
await evalJs(ws, `window.confirmRun = window.__origConfirmRun; true`);
|
||||
|
||||
// ---------- ② 账号详情视频列表 ----------
|
||||
console.log('\n【② 账号详情视频列表 AI创作 按钮】');
|
||||
const accId = parsedInfo.accId || 1629;
|
||||
await evalJs(ws, `location.hash = '#/account/${accId}'`);
|
||||
await cdp(ws, 'Page.reload', { ignoreCache: true });
|
||||
await waitFor(ws, `!!document.querySelector('[data-rewrite]')`, 40000, '账号详情视频列表渲染');
|
||||
await sleep(500);
|
||||
|
||||
await evalJs(ws, `window.__cap = null; window.__origConfirmRun = window.confirmRun;
|
||||
window.confirmRun = (o) => { window.__cap = o; }; true`);
|
||||
|
||||
// 账号详情已解析视频(详情页视频都属于当前账号,取第一条 parsed 即可;同时判定其解析内容是否含选题段)
|
||||
const dt = await evalJs(ws, `(async () => {
|
||||
const btns = [...document.querySelectorAll('[data-rewrite]')];
|
||||
for (const b of btns) {
|
||||
const r = JSON.parse(b.dataset.rewrite);
|
||||
if (!r.parsed || !r.id) continue;
|
||||
let hasTopic = false;
|
||||
try { const vd = await api('/api/dsh/video-detail?videoId=' + encodeURIComponent(r.id)); hasTopic = !!extractVideoTopic((vd.parse && vd.parse.content) || ''); } catch (e) {}
|
||||
return { idx: btns.indexOf(b), name: r.name, title: r.title, id: r.id, parsed: r.parsed, hasTopic };
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
console.log('账号详情已解析视频:', JSON.stringify(dt));
|
||||
if (dt) {
|
||||
await evalJs(ws, `document.querySelectorAll('[data-rewrite]')[${dt.idx}].click(); true`);
|
||||
await waitFor(ws, `!!window.__cap`, 40000, '账号详情 confirmRun 捕获');
|
||||
const c3 = JSON.parse(await evalJs(ws, `JSON.stringify(window.__cap)`));
|
||||
const p3 = c3.prompt || '';
|
||||
console.log(' title:', c3.title, '| label:', c3.label);
|
||||
console.log(' prompt 含【账号设定(人设卡)】:', p3.includes('【账号设定(人设卡)】'));
|
||||
console.log(' prompt 含【视频选题】:', p3.includes('【视频选题】'));
|
||||
if (dt.hasTopic) {
|
||||
console.log(' prompt 含 # 选题:', p3.includes('# 选题'), '(视频实际含选题段: true)');
|
||||
} else {
|
||||
console.log(' prompt 含【视频解析内容】:', p3.includes('【视频解析内容(供提炼选题)】'), '(视频实际含选题段: false)');
|
||||
console.log(' prompt 含提炼指令:', p3.includes('提炼出该视频的选题'));
|
||||
}
|
||||
console.log(' prompt 含 undefined 泄漏:', p3.includes('undefined'));
|
||||
console.log(' prompt 长度:', p3.length);
|
||||
const ok3 = c3.title === 'AI创作'
|
||||
&& p3.includes('【账号设定(人设卡)】')
|
||||
&& p3.includes('【视频选题】')
|
||||
&& (dt.hasTopic ? p3.includes('# 选题') : (p3.includes('【视频解析内容(供提炼选题)】') && p3.includes('提炼出该视频的选题')))
|
||||
&& !p3.includes('undefined');
|
||||
console.log(' ✅ 账号详情分支断言:', ok3 ? 'PASS' : 'FAIL');
|
||||
} else {
|
||||
console.log(' 账号详情无可测已解析视频(跳过)');
|
||||
}
|
||||
await evalJs(ws, `window.confirmRun = window.__origConfirmRun; true`);
|
||||
|
||||
console.log('\n==== 验证完成 ====');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('❌', e.message); process.exit(1); });
|
||||
@@ -12,9 +12,40 @@ const fmtWan = (v) => {
|
||||
if (!Number.isFinite(n) || n <= 0) return '-';
|
||||
return n >= 10000 ? (n / 10000).toFixed(1) + '万' : String(n);
|
||||
};
|
||||
/* AI创作用账号人设:剔除「## 十一、当前需解决的问题」及之后的非创作章节(十二、设定分析说明等),只留创作素材(09-01 用户要求) */
|
||||
const personaForCreate = (text) => {
|
||||
const s = String(text || '').trim();
|
||||
const idx = s.indexOf('## 十一、');
|
||||
return idx >= 0 ? s.slice(0, idx).trim() : s;
|
||||
};
|
||||
/* 从视频解析 content 提取「# 选题」段(赛道/受众/视频主题/开场钩子/中间留人/结尾落点);无则返回 ''(09-01 用户要求:视频本身的选题=解析内容的选题段,非标题) */
|
||||
const extractVideoTopic = (content) => {
|
||||
const s = String(content || '');
|
||||
const i = s.indexOf('# 选题');
|
||||
if (i < 0) return '';
|
||||
const rest = s.slice(i + '# 选题'.length);
|
||||
const j = rest.search(/\n# /);
|
||||
return (j >= 0 ? rest.slice(0, j) : rest).trim();
|
||||
};
|
||||
/* AI创作 视频选题块:有「# 选题」段→直接用;无(老视频脚本为分镜头表等)→携带解析内容,创作 AI 先提炼选题再创作(09-01 用户要求) */
|
||||
async function buildTopicBlock(r, parsed) {
|
||||
if (!parsed) return { block: '', mode: 'none' };
|
||||
let content = '';
|
||||
if (r.id) {
|
||||
try {
|
||||
const vd = await api('/api/dsh/video-detail?videoId=' + encodeURIComponent(r.id));
|
||||
content = (vd.parse && vd.parse.content) || '';
|
||||
} catch (e) { /* 获取失败只带标题 */ }
|
||||
}
|
||||
const tc = extractVideoTopic(content);
|
||||
if (tc) return { block: `\n【视频选题】\n标题:《${r.title}》\n# 选题\n${tc}\n`, mode: 'topic' };
|
||||
const body = String(content || '').trim();
|
||||
if (!body) return { block: `\n【视频选题】\n标题:《${r.title}》\n`, mode: 'none' };
|
||||
// 无选题段(老视频脚本=分镜头表等)→ 完整解析内容作为提炼素材,prompt 先提炼选题再创作
|
||||
return { block: `\n【视频解析内容(供提炼选题)】\n标题:《${r.title}》\n${body}\n`, mode: 'content' };
|
||||
}
|
||||
/* 弹窗标题:书名号只包标题主体;#标签及共创/素材来源信息整体换一行显示 */
|
||||
const fmtTitleQ = (t) => {
|
||||
const s = String(t || '').trim();
|
||||
const fmtTitleQ = (t) => { const s = String(t || '').trim();
|
||||
const idx = s.indexOf('#');
|
||||
if (idx <= 0) return s; // 无 # 或 # 开头(纯标签标题)→ 原样显示
|
||||
const title = stripTitleQuotes(s.slice(0, idx));
|
||||
@@ -70,12 +101,13 @@ function bindBack(container) {
|
||||
async function confirmAiScript(accountName, btn) {
|
||||
// 判断账号是否有有效设定(persona 非空且非测试占位):有 →「基于当前账号设定」,无 →「先进行账号设定分析」
|
||||
const accId = btn && btn.dataset.accId ? btn.dataset.accId : '';
|
||||
let hasPersona = false;
|
||||
let hasPersona = false, personaContent = '';
|
||||
if (accId) {
|
||||
try {
|
||||
const r = await api('/api/dsh/account-persona?accountId=' + encodeURIComponent(accId));
|
||||
const content = String(r.content || '').trim();
|
||||
hasPersona = !!content && !/测试写入|连通性测试/.test(content);
|
||||
if (hasPersona) personaContent = personaForCreate(content); // 完整拼入,仅剔除「十一、当前需解决的问题」及之后非创作章节
|
||||
} catch (e) { hasPersona = false; }
|
||||
}
|
||||
const desc = hasPersona
|
||||
@@ -84,7 +116,7 @@ async function confirmAiScript(accountName, btn) {
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `
|
||||
<div class="modal">
|
||||
<h3>${svgIcon('pen', 18)} AI写脚本</h3>
|
||||
<h3>${svgIcon('pen', 18)} AI创作</h3>
|
||||
<p class="dim">${desc}</p>
|
||||
<div class="ai-topic">
|
||||
<textarea id="aiTopicInput" class="ai-topic-input" placeholder="输入选题方向(可选),如:亲子露营的搞笑瞬间"></textarea>
|
||||
@@ -116,10 +148,13 @@ async function confirmAiScript(accountName, btn) {
|
||||
mask.querySelector('[data-ok]').addEventListener('click', () => {
|
||||
const topic = mask.querySelector('#aiTopicInput').value.trim();
|
||||
close();
|
||||
const act = hasPersona ? '基于当前账号设定创作。' : '请先进行账号设定分析,再生成完整脚本。';
|
||||
const personaBlock = personaContent ? `\n【账号设定(人设卡)】\n${personaContent}\n` : '';
|
||||
const act = hasPersona
|
||||
? `请贴合上述账号设定(人设与内容规则)创作。`
|
||||
: `该账号暂无账号设定,请先为达人「${accountName}」提取账号设定(生成人设卡),再基于账号设定创作完整脚本。`;
|
||||
const prompt = topic
|
||||
? `请为达人「${accountName}」创作一条短视频脚本,选题方向:${topic}\n${act}`
|
||||
: `请为达人「${accountName}」创作一条短视频脚本。${act}`;
|
||||
? `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}\n选题方向:${topic}\n${act}`
|
||||
: `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}\n${act}`;
|
||||
executeTask({ prompt, label: '创作指令', btn });
|
||||
});
|
||||
}
|
||||
@@ -212,7 +247,7 @@ async function renderAccounts(view) {
|
||||
<td>${esc(a.location || '-')}</td>
|
||||
<td class="cell-content" title="${esc(a.bio || '')}">${esc((a.bio || '').slice(0, 50))}</td>
|
||||
<td>${a.video_count ?? 0}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-create='${esc(a.account_name)}' data-acc-id="${a.id}">AI写脚本</button></td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-create='${esc(a.account_name)}' data-acc-id="${a.id}">AI创作</button></td>
|
||||
</tr>`).join('');
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
@@ -302,7 +337,7 @@ async function renderAccountDetail(view, id) {
|
||||
<td>${fmtWan(v.comment_count)}</td>
|
||||
<td>${fmtWan(v.share_count)}</td>
|
||||
<td>${fmtDuration(v.duration)}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm ${(v.has_source || v.has_analysis) ? 'btn-view' : ''}" data-rewrite='${esc(JSON.stringify({ name: acc.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id, parsed: !!(v.has_source || v.has_analysis) }))}'>AI写作</button></td>
|
||||
<td class="cell-act"><button class="btn btn-sm ${(v.has_source || v.has_analysis) ? 'btn-view' : ''}" data-rewrite='${esc(JSON.stringify({ id: v.id, name: acc.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id, parsed: !!(v.has_source || v.has_analysis) }))}'>AI创作</button></td>
|
||||
</tr>`).join('');
|
||||
const tabBtns = [['info', '账号信息'], ['persona', '账号设定'], ['analysis', '数据分析']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
|
||||
let panel = '';
|
||||
@@ -376,19 +411,30 @@ async function renderAccountDetail(view, id) {
|
||||
if (cardEl) cardEl.classList.toggle('vs-hidden', viewMode !== 'card');
|
||||
if (mdEl) mdEl.classList.toggle('vs-hidden', viewMode !== 'text');
|
||||
}));
|
||||
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
|
||||
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', async () => {
|
||||
const r = JSON.parse(b.dataset.rewrite);
|
||||
const parsed = !!r.parsed;
|
||||
if (!persona) await loadPersona(); // 点击时才确保账号设定已加载(不阻塞页面首屏)
|
||||
const personaText = personaForCreate(persona || ''); // 剔除「十一、当前需解决的问题」及之后章节
|
||||
const personaBlock = personaText ? `\n【账号设定(人设卡)】\n${personaText}\n` : ''; // 完整拼入,不截断
|
||||
const noPersonaNote = personaText ? '' : `\n该账号暂无账号设定,请先为达人「${r.name}」提取账号设定(生成人设卡),再基于账号设定与选题创作。`;
|
||||
// 已解析 → 视频选题块:有「# 选题」段直接用;无(老视频脚本=分镜头表等)→ 带解析内容,AI 先提炼选题再创作
|
||||
const tb = await buildTopicBlock(r, parsed);
|
||||
const createTail = tb.mode === 'content'
|
||||
? `请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
|
||||
: `请结合账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`;
|
||||
confirmRun({
|
||||
title: 'AI改写',
|
||||
title: 'AI创作',
|
||||
desc: parsed
|
||||
? `该视频已解析,将基于解析内容为达人「${r.name}」改写一条全新故事脚本(新故事、新情节,不复刻原视频)。`
|
||||
: `该视频尚未解析,将先解析视频内容,再为达人「${r.name}」改写一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
|
||||
? tb.mode === 'content'
|
||||
? `该视频已解析(脚本为分镜头表格式,无选题段),将先提炼视频选题,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
|
||||
: `该视频已解析,将基于达人账号设定与视频选题,为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
|
||||
: `该视频尚未解析,将先解析视频内容,再基于达人账号设定为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
|
||||
target: `达人「${r.name}」|选题《${r.title}》`,
|
||||
prompt: parsed
|
||||
? `请为达人「${r.name}」改写一条短视频故事脚本。视频选题:《${r.title}》\n请基于账号设定与已解析的视频内容,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
|
||||
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」改写一条全新故事脚本:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》`,
|
||||
label: '改写指令',
|
||||
? `请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}`
|
||||
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」创作一条全新故事脚本:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》${personaBlock}${noPersonaNote}`,
|
||||
label: '创作指令',
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
@@ -414,7 +460,7 @@ async function renderVideoDetail(view, id) {
|
||||
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '视频不存在')}</div></div>`; bindBack(view); return; }
|
||||
const v = d.video, analysis = d.analysis, parse = d.parse || {};
|
||||
const infoGrid = (label, val, wide, raw) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${raw ? val : esc(fmt(val))}</div></div>`;
|
||||
const analysisToMd = (obj) => Object.entries(obj || {}).map(([k, val]) => `## ${k}\n\n${val}`).join('\n\n---\n\n');
|
||||
const analysisToMd = (obj) => Object.entries(obj || {}).map(([k, val]) => `# ${k}\n\n${val}`).join('\n\n---\n\n');
|
||||
|
||||
function load() {
|
||||
const titleVal = v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(cleanTitle(v.video_title))}</a>` : esc(cleanTitle(v.video_title));
|
||||
@@ -432,7 +478,7 @@ async function renderVideoDetail(view, id) {
|
||||
<div class="detail-body">
|
||||
<div class="info-card"><div class="info-grid">
|
||||
${infoGrid('视频标题', titleVal, true, true)}${infoGrid('账号', v.account_name)}${infoGrid('视频ID', v.aweme_id)}
|
||||
${infoGrid('点赞', v.like_display || v.like_count)}${infoGrid('评论', v.comment_count)}${infoGrid('分享', v.share_count)}
|
||||
${infoGrid('点赞', fmtWan(v.like_display || v.like_count))}${infoGrid('评论', fmtWan(v.comment_count))}${infoGrid('分享', fmtWan(v.share_count))}
|
||||
${infoGrid('收藏', v.collect_count)}${infoGrid('播放', (v.play_count == null || Number(v.play_count) === 0) ? null : v.play_count)}${infoGrid('时长', fmtDuration(v.duration))}
|
||||
${infoGrid('发布时间', v.publish_time)}${infoGrid('标签', v.tags, true)}
|
||||
</div></div>
|
||||
@@ -455,14 +501,14 @@ async function renderRewrites(view) {
|
||||
const rows = (d.items || []).map((v) => `<tr>
|
||||
<td>${v.account_id ? `<a class="link" href="#/account/${v.account_id}" title="查看账号详情">${esc(v.account_name || '-')}</a>` : esc(v.account_name || '-')}</td>
|
||||
<td class="cell-content">${v.video_url ? `<a class="title-plain" data-ext="${esc(v.video_url)}" title="在新标签打开原视频">${esc(cleanTitle(v.video_title) || '-')}</a>` : esc(cleanTitle(v.video_title) || '-')}</td>
|
||||
<td>${fmt(v.like_display || v.like_count)}</td>
|
||||
<td>${fmtWan(v.like_display || v.like_count)}</td>
|
||||
<td>${fmtWan(v.comment_count)}</td>
|
||||
<td>${fmtWan(v.share_count)}</td>
|
||||
<td>${fmtDuration(v.duration)}</td>
|
||||
<td>${fmt(v.publish_time)}</td>
|
||||
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
|
||||
<td>${v.rewrite_count > 0 ? `<button class="btn btn-sm btn-view" data-detail="${v.id}" title="查看改写脚本">${v.rewrite_count} 查看</button>` : '—'}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title) }))}'>AI写脚本</button></td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title), accountId: v.account_id, parsed: !!(v.has_source || v.has_analysis), url: v.video_url || '' }))}'>AI创作</button></td>
|
||||
</tr>`).join('');
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
@@ -490,14 +536,35 @@ async function renderRewrites(view) {
|
||||
view.querySelector('#rwParsed').addEventListener('change', (e) => { state.parsed = e.target.value; state.page = 1; load(); });
|
||||
bindPager(view, (p) => { state.page = p; load(); });
|
||||
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail)));
|
||||
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
|
||||
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', async () => {
|
||||
const r = JSON.parse(b.dataset.rewrite);
|
||||
const parsed = !!r.parsed;
|
||||
let personaBlock = '';
|
||||
if (r.accountId) {
|
||||
try {
|
||||
const pr = await api('/api/dsh/account-persona?accountId=' + encodeURIComponent(r.accountId));
|
||||
const c = String(pr.content || '').trim();
|
||||
if (c) personaBlock = `\n【账号设定(人设卡)】\n${personaForCreate(c)}\n`; // 剔除「十一、当前需解决的问题」及之后章节
|
||||
} catch (e) { /* 人设缺失不阻塞 */ }
|
||||
}
|
||||
const noPersonaNote = personaBlock ? '' : `\n该账号暂无账号设定,请先为达人「${r.name}」提取账号设定(生成人设卡),再基于账号设定与选题创作。`;
|
||||
// 已解析 → 视频选题块:有「# 选题」段直接用;无(老视频脚本=分镜头表等)→ 带解析内容,AI 先提炼选题再创作
|
||||
const tb = await buildTopicBlock(r, parsed);
|
||||
const createTail = tb.mode === 'content'
|
||||
? `请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
|
||||
: `请结合账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`;
|
||||
confirmRun({
|
||||
title: '改写脚本',
|
||||
desc: '将基于该视频,为达人改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。',
|
||||
target: `达人「${r.name}」|《${r.title}》`,
|
||||
prompt: `请为达人「${r.name}」基于视频《${r.title}》改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。`,
|
||||
label: '改写指令',
|
||||
title: 'AI创作',
|
||||
desc: parsed
|
||||
? tb.mode === 'content'
|
||||
? `该视频已解析(脚本为分镜头表格式,无选题段),将先提炼视频选题,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
|
||||
: `该视频已解析,将基于达人账号设定与视频选题,为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
|
||||
: `该视频尚未解析,将先解析视频内容,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
|
||||
target: `达人「${r.name}」|选题《${r.title}》`,
|
||||
prompt: parsed
|
||||
? `请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}`
|
||||
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」创作一条全新故事脚本${r.url ? ':' + r.url : ''}\n视频标题:《${r.title}》${personaBlock}${noPersonaNote}`,
|
||||
label: '创作指令',
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
html, body { height: 100%; overflow: hidden; } /* 09-01 禁止 html/body 滚动,消除 topbar 1px 边框导致的第二层滚动条;滚动统一由 main#view 承担 */
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
@@ -260,7 +260,7 @@ a.crumb-home:hover { color: var(--primary); }
|
||||
.crumb-sep { color: var(--dim); font-size: 16px; }
|
||||
.crumb-current { color: var(--text); font-weight: 600; font-size: 16px; }
|
||||
|
||||
main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
main#view { height: calc(100vh - 55px); overflow: auto; } /* 55px=topbar 实际高度(padding20+行高34+边框1);54px 时底部溢出 1px 触发第二层滚动条 */
|
||||
.view-empty { padding: 40px; text-align: center; color: var(--dim); }
|
||||
|
||||
/* ================= 首页 ================= */
|
||||
@@ -280,8 +280,15 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
|
||||
/* ================= 功能页通用 ================= */
|
||||
.page { padding: 20px 24px; max-width: 1440px; margin: 0 auto; }
|
||||
/* 账号详情页:仅内容区(信息卡/列表/面板)右移 10px,返回按钮与 tab 保持原位 */
|
||||
.page-detail .detail-body { padding-left: 10px; }
|
||||
/* ================= 视频详情页 md-body:内容相对 tab 缩进 20px + 标题字号收紧 ================= */
|
||||
.page-detail .md-body { font-size: 15px; margin-left: 20px; }
|
||||
.page-detail .md-body h1 { font-size: 20px; border-bottom: 1px solid var(--border); padding-bottom: 4px; margin: 14px 0 8px; }
|
||||
.page-detail .md-body h2 { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 3px; margin: 12px 0 6px; }
|
||||
.page-detail .md-body h3 { font-size: 15px; margin: 10px 0 6px; }
|
||||
.page-detail .md-body ul, .page-detail .md-body ol { margin: 6px 0 6px 18px; }
|
||||
.page-detail .md-body p { margin: 6px 0; }
|
||||
/* 账号详情页:仅内容区(信息卡/列表/面板)右移 10px,返回按钮与 tab 保持原位;视频详情页内容贴近左边(2026-09-01 改为 0) */
|
||||
.page-detail .detail-body { padding-left: 0; }
|
||||
.page-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.toolbar-note { font-size: 12px; color: var(--dim); text-align: right; margin: -14px 10px 16px auto; } /* 功能栏下方小字说明,右对齐 */
|
||||
@@ -363,11 +370,13 @@ th.th-id { width: 120px; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px 18px; }
|
||||
.info-item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.info-wide { grid-column: 1/-1; }
|
||||
.info-row .info-item.info-wide { flex: 1 1 auto; min-width: 0; max-width: 100%; } /* 09-01 修复横向溢出:info-wide 允许收缩,否则长内容把 main 撑出横向滚动条 */
|
||||
.info-label { font-size: 14px; color: var(--dim); }
|
||||
.info-value { font-size: 15px; color: var(--text); word-break: break-word; white-space: pre-wrap; line-height: 1.6; }
|
||||
/* 长文本默认最多 10 行,可点击展开/收起(账号定位等) */
|
||||
.clamp-text { display: -webkit-box; -webkit-line-clamp: 10; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.clamp-text.open { -webkit-line-clamp: unset; }
|
||||
/* 09-01 改用 block+max-height 裁剪:display:-webkit-box 下长句不折行,内容宽被撑到 1896px 导致 main 横向滚动条 */
|
||||
.clamp-text { display: block; max-height: 240px; overflow: hidden; word-break: break-word; overflow-wrap: anywhere; } /* 240px=10行×15px×1.6 */
|
||||
.clamp-text.open { max-height: none; }
|
||||
.clamp-btn { border: none; background: none; color: #4a90e2; cursor: pointer; font-size: 13px; padding: 2px 0; }
|
||||
.clamp-btn:hover { text-decoration: underline; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user