@@ -438,6 +438,12 @@
}
/* 镜头脚本顶栏 · 自动从脚本抓取的人物/场景标签 · 可编辑/删除/添加 */
.shot-list > .pane-h { flex-wrap: wrap; row-gap: 8px; }
.shot-headline { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.script-brief-summary { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
.script-brief-pill { gap: 4px; padding: 3px 8px; font-size: 11px; }
.script-brief-pill .k { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .04em; }
.script-brief-pill .v { color: var(--accent-black); max-width: 116px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.script-tags { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-left: 6px; }
.script-tags .tag-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.script-tags .tg-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; flex-shrink: 0; }
@@ -465,6 +471,61 @@
.chat-mode:hover { background: var(--heat-12); border-color: var(--heat); color: var(--heat); }
.chat-mode.primary { background: var(--heat-12); border-color: var(--heat); color: var(--heat); }
.chat-mode svg { width: 13px; height: 13px; }
.script-brief-card { margin-top: 8px; padding: 12px; background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: flex; flex-direction: column; gap: 10px; }
.script-brief-row { display: grid; grid-template-columns: 56px minmax(0, 1fr); column-gap: 10px; row-gap: 4px; align-items: center; }
.script-brief-row .k { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }
.script-brief-row .why { grid-column: 2 / 3; font-size: 11.5px; color: var(--black-alpha-48); line-height: 1.5; }
.script-brief-select { position: relative; display: inline-flex; width: 100%; min-width: 0; }
.script-brief-value {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface);
color: var(--accent-black);
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.script-brief-value .v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
.script-brief-value::after {
content: '';
width: 5px;
height: 5px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(45deg) translateY(-1px);
transition: transform var(--t-base);
color: var(--black-alpha-48);
flex-shrink: 0;
}
.script-brief-value:hover {
background: var(--heat-12);
border-color: var(--heat-20);
color: var(--heat);
}
.script-brief-select.open .script-brief-value {
background: var(--heat-12);
border-color: var(--heat);
color: var(--heat);
}
.script-brief-select.open .script-brief-value::after {
color: var(--heat);
transform: rotate(225deg) translate(-1px, -1px);
}
.script-brief-select.open .chip-menu { display: block; }
.script-brief-select .chip-menu { min-width: 168px; right: 0; left: auto; z-index: 80; }
.script-brief-select .chip-menu .mi { width: 100%; border: 0; background: transparent; font-family: inherit; text-align: left; }
.script-brief-actions { display: grid; grid-template-columns: 56px minmax(0, 1fr); column-gap: 10px; align-items: center; padding-top: 2px; }
.script-brief-actions .action-row { grid-column: 2 / 3; display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
/* AI 思考态 typing indicator */
.ai-thinking .dots { display: inline-flex; gap: 3px; }
@@ -1626,8 +1687,15 @@
< div class = "stage-script" >
< div class = "pane shot-list" >
< div class = "pane-h" >
< strong > 镜头脚本 < / strong >
< span class = "muted-2 mono" id = "shots-meta" style = "font-size:11px;" > · 空 · 待生成 < / span >
< div class = "shot-headline" >
< strong > 镜头脚本 < / strong >
< span class = "muted-2 mono" id = "shots-meta" style = "font-size:11px;" > · 空 · 待生成< / span >
< / div >
< div class = "script-brief-summary" aria-label = "当前创作方向" >
< span class = "pill neutral script-brief-pill" > < span class = "k" > 来源< / span > < span class = "v" id = "brief-source" > 未选择< / span > < / span >
< span class = "pill neutral script-brief-pill" > < span class = "k" > 风格< / span > < span class = "v" id = "brief-style" > 待确认< / span > < / span >
< span class = "pill neutral script-brief-pill" > < span class = "k" > 人物< / span > < span class = "v" id = "brief-persona" > 待确认< / span > < / span >
< / div >
< div class = "script-tags" id = "script-tags" >
< div class = "tag-group" data-kind = "char" >
< span class = "tg-lbl" > // 人物< / span >
@@ -1662,7 +1730,7 @@
< div class = "chat-input" >
< div class = "chat-input-card" >
< div class = "chat-attach-row" id = "chat-attach-row" hidden > < / div >
< textarea class = "chat-input-area" id = "chat-textarea" placeholder = '聊聊你的脚本想法,或输入 "@" 引用镜头……' rows = "2" > < / textarea >
< textarea class = "chat-input-area" id = "chat-textarea" placeholder = "直接说怎么改,如:更像小红书种草 / 换成熬夜党" rows = "2" > < / textarea >
< div class = "chat-input-foot" >
< button class = "chat-icon-btn" id = "chat-upload-btn" title = "上传脚本附件" aria-label = "上传脚本附件" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.5" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M12 5v14M5 12h14" / > < / svg >
@@ -2802,10 +2870,177 @@ const Stage1 = (function () {
let chatMsgs = []; // [{ role, html, time }]
let mode = null;
let scriptTags = { char: [], scene: [] }; // 自动抓取的人物 / 场景 · 可编辑
const MODE_LABELS = { ai: 'AI 全生', theme: '一句话主题', manual: '自带脚本' };
const MODE_USER_COPY = {
ai: '我用 AI 全生',
theme: '我用一句话主题',
manual: '我用自带脚本',
};
const BRIEF_OPTIONS = {
style: ['真实测评', '痛点种草', '小红书种草', '开箱测评', '对比展示'],
persona: ['通勤敏感肌女生', '熬夜党通勤女性', '学生党女生', '精致宝妈', '成分党用户'],
};
function makeBrief(nextMode) {
if (!nextMode) {
return {
source: '未选择',
style: '待确认',
persona: '待确认',
styleNote: '选择脚本来源后由助手推荐',
personaNote: '选择脚本来源后由助手推荐',
};
}
return {
source: MODE_LABELS[nextMode] || '未选择',
style: nextMode === 'theme' ? '痛点种草' : '真实测评',
persona: '通勤敏感肌女生',
styleNote: nextMode === 'manual' ? '参考文本识别不足 · 根据商品类目推荐' : '根据商品信息推荐',
personaNote: '根据商品目标人群推荐',
};
}
let scriptBrief = makeBrief(null);
const $cb = () => document.getElementById('chat-body');
const $sb = () => document.getElementById('shots-body');
const $sm = () => document.getElementById('shots-meta');
const safeHtml = s => String(s || '').replace(/[& < >"']/g, m => ({ '& ': '& ', '< ': '< ', '>': '> ', '"': '" ', "'": '' ' }[m]));
function renderBriefSummary() {
const source = document.getElementById('brief-source');
const style = document.getElementById('brief-style');
const persona = document.getElementById('brief-persona');
if (source) source.textContent = scriptBrief.source || '未选择';
if (style) style.textContent = scriptBrief.style || '待确认';
if (persona) persona.textContent = scriptBrief.persona || '待确认';
}
function briefConfirmHtml(intro) {
const optionMenu = (kind, current) => `
< span class = "script-brief-select" >
< button class = "script-brief-value" type = "button" data-brief-act = "${kind}" aria-haspopup = "listbox" aria-expanded = "false" aria-label = "选择${kind === 'style' ? '脚本风格' : '人物设定'}" >
< span class = "v" > ${safeHtml(current)}< / span >
< / button >
< span class = "chip-menu align-right" role = "listbox" >
${BRIEF_OPTIONS[kind].map(opt => `
< button class = "mi${opt === current ? ' selected' : ''}" type = "button" data-brief-pick = "${kind}" data-value = "${safeHtml(opt)}" role = "option" aria-selected = "${opt === current ? 'true' : 'false'}" >
< svg class = "mi-check" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.7" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "m20 6-11 11-5-5" / > < / svg >
${safeHtml(opt)}
< / button > `).join('')}
< / span >
< / span > `;
return `${intro}
< div class = "script-brief-card" >
< div class = "script-brief-row" >
< span class = "k" > 风格< / span >
${optionMenu('style', scriptBrief.style)}
< span class = "why" > ${safeHtml(scriptBrief.styleNote)}< / span >
< / div >
< div class = "script-brief-row" >
< span class = "k" > 人物< / span >
${optionMenu('persona', scriptBrief.persona)}
< span class = "why" > ${safeHtml(scriptBrief.personaNote)}< / span >
< / div >
< div class = "script-brief-actions" >
< div class = "action-row" >
< button class = "btn btn-ghost" type = "button" data-brief-act = "reroll" > 重新推荐< / button >
< button class = "btn" type = "button" data-brief-act = "accept" > 确定< / button >
< / div >
< / div >
< / div > `;
}
function migrateBriefMessages() {
let changed = false;
chatMsgs = chatMsgs.map(msg => {
if (!msg || typeof msg.html !== 'string') return msg;
if (msg.role === 'ai' & & /script-brief-card/.test(msg.html)) {
const cardIndex = msg.html.indexOf('< div class = "script-brief-card" ' ) ;
const intro = cardIndex > -1 ? msg.html.slice(0, cardIndex).trim() : '';
changed = true;
return {
...msg,
html: briefConfirmHtml(intro || '已更新创作方向。确认后我会按这个方向重写镜头脚本。'),
};
}
if (msg.role === 'user' & & msg.html === '保持推荐,生成镜头脚本') {
changed = true;
return { ...msg, html: '确定' };
}
return msg;
});
return changed;
}
function tuneBriefByText(text) {
if (/小红书|种草/.test(text)) scriptBrief.style = '小红书种草';
else if (/测评|评测/.test(text)) scriptBrief.style = '真实测评';
else if (/痛点/.test(text)) scriptBrief.style = '痛点种草';
if (/熬夜/.test(text)) scriptBrief.persona = '熬夜党通勤女性';
else if (/学生/.test(text)) scriptBrief.persona = '学生党女生';
else if (/宝妈|妈妈/.test(text)) scriptBrief.persona = '精致宝妈';
}
function startScriptGeneration() {
ProjectStore.startJob('stage1-script', {
stage: 1,
label: '脚本初稿生成',
finishAt: Date.now() + 6500,
});
if (!chatMsgs.some(x => /正在解析商品卖点/.test(x.html))) {
pushMsg('ai', '< span class = "ai-thinking" > 正在解析商品卖点与创作方向 < span class = "dots" > < span > < / span > < span > < / span > < span > < / span > < / span > < / span > ');
}
ProjectStore.record('stage1.script.generate', { mode, brief: scriptBrief });
saveState();
renderChat();
window.setTimeout(completeAiJob, 6500);
}
function refreshLatestBriefMessage(intro) {
for (let i = chatMsgs.length - 1; i >= 0; i--) {
if (chatMsgs[i].role === 'ai' & & /script-brief-card/.test(chatMsgs[i].html)) {
chatMsgs[i].html = briefConfirmHtml(intro);
return;
}
}
pushMsg('ai', briefConfirmHtml(intro));
}
function handleBriefPick(kind, value) {
if (!value) return;
if (kind === 'style') {
scriptBrief.style = value;
scriptBrief.styleNote = '已手动选择';
} else if (kind === 'persona') {
scriptBrief.persona = value;
scriptBrief.personaNote = '已手动选择';
}
refreshLatestBriefMessage('已更新创作方向。确认后我会按这个方向重写镜头脚本。');
ProjectStore.record('stage1.brief.option.selected', { kind, value });
saveState();
renderChat();
}
function handleBriefAction(action, trigger) {
if (action === 'accept') {
pushMsg('user', '确定');
startScriptGeneration();
} else if (action === 'reroll') {
scriptBrief = {
...scriptBrief,
style: scriptBrief.style === '真实测评' ? '痛点种草' : '真实测评',
persona: scriptBrief.persona === '通勤敏感肌女生' ? '熬夜党通勤女性' : '通勤敏感肌女生',
styleNote: '已根据商品卖点重新推荐',
personaNote: '已根据使用场景重新推荐',
};
pushMsg('ai', briefConfirmHtml('我重新给你配了一组创作方向,确认后就生成镜头脚本。'));
saveState();
renderChat();
} else if (action === 'style') {
const box = trigger?.closest('.script-brief-select');
if (!box) return;
document.querySelectorAll('.script-brief-select.open').forEach(el => { if (el !== box) el.classList.remove('open'); });
box.classList.toggle('open');
trigger.setAttribute('aria-expanded', box.classList.contains('open') ? 'true' : 'false');
} else if (action === 'persona') {
const box = trigger?.closest('.script-brief-select');
if (!box) return;
document.querySelectorAll('.script-brief-select.open').forEach(el => { if (el !== box) el.classList.remove('open'); });
box.classList.toggle('open');
trigger.setAttribute('aria-expanded', box.classList.contains('open') ? 'true' : 'false');
}
}
/* 自动从脚本(painting + dialog 文本)抽取人物 / 场景关键词 · 白名单匹配 */
const CHAR_KEYWORDS = ['女主', '男主', '同事', '闺蜜', '男友', '女友', '妈妈', '爸爸', '老师', '同学', '朋友', '路人', '主播', '老板'];
@@ -2872,17 +3107,24 @@ const Stage1 = (function () {
}
function pushMsg(role, html) { chatMsgs.push({ role, html, time: now() }); }
function saveState() {
ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags });
ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags, scriptBrief });
renderBriefSummary();
}
function loadState() {
const saved = ProjectStore.data.stage1;
if (!saved) return;
if (!saved) { renderBriefSummary(); return; }
if (Array.isArray(saved.shots)) shots = saved.shots;
if (Array.isArray(saved.chatMsgs)) chatMsgs = saved.chatMsgs;
if (saved.mode) mode = saved.mode;
if (saved.scriptBrief & & typeof saved.scriptBrief === 'object') scriptBrief = { ...scriptBrief, ...saved.scriptBrief };
else if (mode) scriptBrief = makeBrief(mode);
if (saved.scriptTags & & Array.isArray(saved.scriptTags.char) & & Array.isArray(saved.scriptTags.scene)) {
scriptTags = saved.scriptTags;
}
if (migrateBriefMessages()) {
ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags, scriptBrief });
}
renderBriefSummary();
}
function getDefaultDraft() {
return [
@@ -2945,6 +3187,18 @@ const Stage1 = (function () {
}
return `< div class = "msg user" > < div class = "bubble" > ${msg.html}< / div > < div class = "time" > ${msg.time}< / div > < / div > `;
}).join('');
body.querySelectorAll('[data-brief-act]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
handleBriefAction(btn.dataset.briefAct, btn);
});
});
body.querySelectorAll('[data-brief-pick]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
handleBriefPick(btn.dataset.briefPick, btn.dataset.value);
});
});
body.scrollTop = body.scrollHeight;
}
@@ -3030,61 +3284,18 @@ const Stage1 = (function () {
function pickMode(m) {
mode = m;
scriptBrief = makeBrief(m);
ProjectStore.record('stage1.mode.selected', { mode: m });
pushMsg('user', MODE_USER_COPY[m] || '选择脚本来源');
if (m === 'ai') {
ProjectStore.startJob('stage1-script', {
stage: 1,
label: '脚本初稿生成',
finishAt: Date.now() + 6500,
});
pushMsg('user', '帮我 AI 全自动生成一稿脚本');
saveState();
renderChat();
setTimeout(() => {
pushMsg('ai', '< span class = "ai-thinking" > 正在解析商品卖点与目标人群 < span class = "dots" > < span > < / span > < span > < / span > < span > < / span > < / span > < / span > ');
saveState();
renderChat();
}, 300);
// 7 镜 · 0-40s · 与 Stage 2 / 4 的 3 场切分对齐(场 1 深夜办公桌 0-15s / 场 2 面膜包装 15-27s / 场 3 化妆台定格 27-40s)
const draft = [
// ─ 场 1 · 深夜办公桌(15s)─
{ id: 'sh1', painting: '中景慢推 · 深夜居家书桌全景。屏幕仍亮着 PPT,女主背影瘫在椅子上,屏幕冷光 + 台灯暖光对比。字幕"凌晨 02:14"淡入。', dialog: '(无台词 · BGM 渐起)', duration: 5 },
{ id: 'sh2', painting: '近景 · 卫生间镜前。女主低头看脸,T 区起皮、暗沉特写,冷白灯偏惨。', dialog: '"做完这版稿又是凌晨两点……(叹气)脸已经不能看了。"', duration: 5 },
{ id: 'sh3', painting: '俯拍特写 · 回到书桌,拉开抽屉。囤好的透真补水面膜露半角,手伸进去抽出一片。', dialog: '"还好抽屉里囤了透真玻尿酸面膜。"', duration: 5 },
// ─ 场 2 · 面膜包装/特写(12s)─
{ id: 'sh4', painting: '桌面微距特写 · 撕开锡纸包装的瞬间。30g 厚精华液缓缓滴落,面膜布展开,质地拉丝可见。', dialog: '"30g 一片,精华液比普通面膜厚整整三倍。"', duration: 6 },
{ id: 'sh5', painting: '床头近景 · 女主敷好面膜闭眼躺下,台灯暖光打在脸侧。膜布贴合脸型,边缘服帖。', dialog: '"贴上去那一瞬间 —— 凉凉的,像把皮肤泡了一次澡。"', duration: 6 },
// ─ 场 3 · 化妆台/产品定格(13s)─
{ id: 'sh6', painting: '中景 · 第二天清晨化妆台。阳光透过窗帘,女主对镜上妆,皮肤透亮、粉底服帖。同事画外音"你最近用啥了"。', dialog: '"第二天脸是软的,粉底都不卡了。同事都跑来问。"', duration: 8 },
{ id: 'sh7', painting: '平铺俯拍 · 桌面五片装产品 + 单片包装。价格 "618 · 5 片 ¥39.9" 弹出,购物车图标右下角浮现。', dialog: '"618 五片 39.9,自用送人都合适。链接放评论区。"', duration: 5 },
];
let cur = 0;
const step = () => {
if (cur >= draft.length) {
// remove thinking msg
chatMsgs = chatMsgs.filter(x => !(x.role === 'ai' & & /ai-thinking/.test(x.html)));
pushMsg('ai', '初稿完成。点击任意卡片文字可直接编辑;鼠标移到卡片之间会出现「+ 添加分镜」。');
ProjectStore.finishJob('stage1-script');
ProjectStore.record('stage1.script.ready', { shots: shots.length });
saveState();
renderChat();
return;
}
shots.push(draft[cur++]);
saveState();
renderShots();
setTimeout(step, 700);
};
setTimeout(step, 1100);
pushMsg('ai', briefConfirmHtml('我会根据商品信息直接生成第一版。生成前先确认创作方向。'));
} else if (m === 'theme') {
pushMsg('ai', '好,请给我一句话主题(5– 30 字),例如:< br > · 熬夜党的急救面膜< br > · 加班吃啥不内疚< br > 下面输入框直接打就行,我会按这句话扩成一稿镜头脚本 。');
saveState();
renderChat();
pushMsg('ai', '好,请给我一句话主题(5- 30 字),例如:< br > · 熬夜党的急救面膜< br > · 加班吃啥不内疚< br > 我会先根据这句话补全风格和人物设定,再让你确认 。');
} else if (m === 'manual') {
pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框, 我会按场自然切分并适配商品卖点 。');
saveState();
renderChat();
pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框。 我会先识别风格和人物设定;识别不到的部分,再用商品信息补齐 。');
}
saveState();
renderChat();
}
function init() {
@@ -3095,6 +3306,7 @@ const Stage1 = (function () {
ProjectStore.restoreFields();
document.getElementById('chat-clear-btn')?.addEventListener('click', () => {
chatMsgs = []; mode = null; shots = []; scriptTags = { char: [], scene: [] };
scriptBrief = makeBrief(null);
ProjectStore.clearJob('stage1-script');
ProjectStore.record('stage1.cleared');
saveState();
@@ -3104,6 +3316,10 @@ const Stage1 = (function () {
document.getElementById('chat-regen-btn')?.addEventListener('click', () => {
Shell.toast('已请求整体重写', 'POST /script/regen');
});
document.addEventListener('click', (e) => {
if (e.target.closest('.script-brief-select')) return;
document.querySelectorAll('.script-brief-select.open').forEach(el => el.classList.remove('open'));
});
const sendBtn = document.getElementById('chat-send-btn');
const ta = document.getElementById('chat-textarea');
const attachRow = document.getElementById('chat-attach-row');
@@ -3155,7 +3371,16 @@ const Stage1 = (function () {
saveState();
renderChat();
setTimeout(() => {
pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。' );
const directionIntent = /小红书|种草|测评|评测|痛点|熬夜|学生|宝妈|妈妈|人物|风格|口吻|换成/.test(v );
if (mode & & (!shots.length || directionIntent)) {
tuneBriefByText(v);
const intro = directionIntent
? '已更新创作方向。确认后我会按这个方向重写镜头脚本。'
: '我先根据你给的内容补全创作方向,确认后再生成镜头脚本。';
pushMsg('ai', briefConfirmHtml(intro));
} else {
pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。');
}
saveState();
renderChat();
}, 400);