Polish navigation and pipeline flow
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 4m37s

This commit is contained in:
iye 2026-05-28 15:35:32 +08:00
parent bbe29622c2
commit df7b90934a
8 changed files with 782 additions and 66 deletions

View File

@ -20,6 +20,8 @@
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/>',
list: '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
chevronLeft: '<path d="m15 18-6-6 6-6"/>',
chevronRight: '<path d="m9 18 6-6-6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',

View File

@ -218,7 +218,13 @@ img, svg, video { display: block; max-width: 100%; }
.divider { height: 1px; background: var(--border-faint); margin: 16px 0; }
/* ─── App shell ─── */
.app { display: grid; grid-template-columns: 248px 1fr; min-height: 100vh; }
.app {
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
transition: grid-template-columns var(--t-base);
}
body.sidebar-collapsed .app { grid-template-columns: 96px 1fr; }
/* ─── Sidebar ─── */
aside.sidebar {
@ -229,12 +235,61 @@ aside.sidebar {
top: 0;
height: 100vh;
overflow-y: auto;
transition: padding var(--t-base);
}
.brand { display: flex; align-items: center; padding: 2px 8px 16px; min-height: 44px; }
.brand-logo { display: block; width: 142px; height: auto; margin: -8px 0 -6px -8px; object-fit: contain; }
.sidebar-head {
display: flex;
align-items: center;
padding: 2px 8px 16px;
min-height: 44px;
}
.brand { display: flex; align-items: center; min-width: 0; color: var(--accent-black); }
.brand-clip {
display: block;
width: 142px;
overflow: hidden;
transition: width var(--t-base);
}
.brand-logo { display: block; width: 142px; max-width: none; height: auto; margin: -8px 0 -6px -8px; object-fit: contain; }
.brand-mark, .flame { width: 22px; height: 22px; color: var(--heat); }
.brand-mark svg, .flame svg { width: 100%; height: 100%; }
.brand .name { font-weight: 600; font-size: 18px; letter-spacing: -.012em; color: var(--accent-black); }
.sidebar-toggle {
position: fixed;
top: 0;
left: 220px;
z-index: 70;
width: 28px;
height: 100vh;
border: 0;
border-radius: 0;
background: transparent;
color: var(--black-alpha-48);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 1;
pointer-events: auto;
transition: color var(--t-base), background var(--t-base);
}
.sidebar-toggle:hover,
.sidebar-toggle:focus-visible {
background: var(--black-alpha-4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: var(--accent-black);
outline: none;
}
.sidebar-toggle-icon {
display: block;
opacity: 0;
line-height: 1;
transition: opacity var(--t-base);
}
.sidebar-toggle:hover .sidebar-toggle-icon,
.sidebar-toggle:focus-visible .sidebar-toggle-icon { opacity: 1; }
.sidebar-toggle-icon--expand { display: none; }
/* sidebar search · Ctrl K Inter Bold 平铺 */
.search-box {
@ -323,6 +378,47 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
}
.user .em { font-size: 13px; color: var(--accent-black); }
body.sidebar-collapsed aside.sidebar { padding: 22px 12px; }
body.sidebar-collapsed .sidebar-head { gap: 6px; padding: 2px 0 16px; }
body.sidebar-collapsed .brand-clip {
width: 34px;
height: 34px;
display: flex;
align-items: center;
}
body.sidebar-collapsed .sidebar-toggle { left: 68px; }
body.sidebar-collapsed .sidebar-toggle-icon--collapse { display: none; }
body.sidebar-collapsed .sidebar-toggle-icon--expand { display: block; }
body.sidebar-collapsed .search-box {
justify-content: center;
gap: 0;
padding: 9px 0;
cursor: pointer;
}
body.sidebar-collapsed .search-box input,
body.sidebar-collapsed .search-box .kbd,
body.sidebar-collapsed aside.sidebar nav a span,
body.sidebar-collapsed aside.sidebar nav a .pill-mini,
body.sidebar-collapsed .user .em { display: none; }
body.sidebar-collapsed .nav-section {
height: 1px;
margin: 10px 0 8px;
padding: 0;
overflow: hidden;
color: transparent;
background: var(--border-faint);
}
body.sidebar-collapsed aside.sidebar nav a {
justify-content: center;
gap: 0;
padding: 10px 0;
}
body.sidebar-collapsed .aside-foot { padding-top: 12px; }
body.sidebar-collapsed .user {
justify-content: center;
padding: 8px 0;
}
/* ─── Main + grid background ─── */
main { position: relative; background: var(--background-base); min-width: 0; }
.grid-bg {
@ -2028,6 +2124,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
@media (max-width: 1100px) {
.app { grid-template-columns: 1fr; }
aside.sidebar { display: none; }
.sidebar-toggle { display: none; }
.stats { grid-template-columns: repeat(2, 1fr); }
.stat:nth-child(2) { border-right: 0; }
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }

View File

@ -8,6 +8,7 @@
============================================================ */
const ShellIcon = (name, opts) => window.IconKit ? window.IconKit.svg(name, opts) : '';
const SHELL_SIDEBAR_COLLAPSED_KEY = 'airshelf:sidebar-collapsed';
const NAV = [
{
@ -64,7 +65,7 @@ const SHELL_COMMANDS = [
window.Shell = {
render({ active = '', crumbs = [], balance = '¥327.40', topActions = '' } = {}) {
const navHtml = NAV.map(n => `
<a href="${n.href}" class="${active === n.id ? 'active' : ''}">
<a href="${n.href}" class="${active === n.id ? 'active' : ''}" title="${n.label}" aria-label="${n.label}">
${n.icon}
<span>${n.label}</span>
${n.badge ? `<span class="pill-mini">${n.badge}</span>` : ''}
@ -73,10 +74,16 @@ window.Shell = {
const sidebar = `
<aside class="sidebar">
<div class="brand">
<img class="brand-logo" src="assets/logo.png" alt="Airshelf">
<div class="sidebar-head">
<a class="brand" href="index.html" aria-label="Airshelf 工作台">
<span class="brand-clip"><img class="brand-logo" src="assets/logo.png" alt="Airshelf"></span>
</a>
</div>
<div class="search-box" onclick="Shell.openCommandPalette()">
<button class="sidebar-toggle" type="button" onclick="Shell.toggleSidebarCollapse()" aria-label="收窄导航" title="收窄导航">
<span class="sidebar-toggle-icon sidebar-toggle-icon--collapse">${ShellIcon('chevronLeft', { size: 18, strokeWidth: 1.8 })}</span>
<span class="sidebar-toggle-icon sidebar-toggle-icon--expand">${ShellIcon('chevronRight', { size: 18, strokeWidth: 1.8 })}</span>
</button>
<div class="search-box" onclick="Shell.openCommandPalette()" title="搜索">
${ShellIcon('search')}
<input id="global-search" placeholder="搜索" readonly aria-label="打开全局搜索"/>
<span class="kbd">Ctrl K</span>
@ -328,9 +335,11 @@ window.Shell = {
const app = document.createElement('div');
app.className = 'app';
app.innerHTML = sidebar + `<main>${decorations}${topbar}<div class="content" id="page-content">${cornerMarks}</div></main>`;
this.applySidebarCollapse(this.isSidebarCollapsed());
const src = document.getElementById('page');
document.body.prepend(app);
this.applySidebarCollapse(this.isSidebarCollapsed());
if (src) {
// 把页面 body 内容追加到 .content,保留 4 个 corner-mark SVG
document.getElementById('page-content').insertAdjacentHTML('beforeend', src.innerHTML);
@ -352,6 +361,26 @@ window.Shell = {
this._bindGlobalChrome();
},
isSidebarCollapsed() {
return localStorage.getItem(SHELL_SIDEBAR_COLLAPSED_KEY) === '1';
},
applySidebarCollapse(collapsed) {
document.body.classList.toggle('sidebar-collapsed', !!collapsed);
const btn = document.querySelector('.sidebar-toggle');
if (!btn) return;
const label = collapsed ? '展开导航' : '收窄导航';
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
btn.setAttribute('aria-pressed', collapsed ? 'true' : 'false');
},
toggleSidebarCollapse() {
const next = !this.isSidebarCollapsed();
localStorage.setItem(SHELL_SIDEBAR_COLLAPSED_KEY, next ? '1' : '0');
this.applySidebarCollapse(next);
},
_esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'

View File

@ -45,20 +45,20 @@
.io-side-h .ti { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }
.io-side-h .back-pill {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 12px 0 8px;
background: var(--background-lighter);
height: 34px; padding: 0 13px 0 11px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
color: var(--accent-black);
font-size: 12.5px; font-weight: 500;
font-size: 13px; font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.io-side-h .back-pill:hover {
background: var(--heat-12);
border-color: var(--heat-20);
color: var(--heat);
background: var(--black-alpha-4);
border-color: var(--black-alpha-24);
color: var(--accent-black);
}
.io-side-h .back-pill svg { width: 14px; height: 14px; }
.io-side-h .fold {

View File

@ -213,20 +213,20 @@
}
.mp-side-top .back-pill {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 12px 0 8px;
background: var(--background-lighter);
height: 34px; padding: 0 13px 0 11px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
color: var(--accent-black);
font-size: 12.5px; font-weight: 500;
font-size: 13px; font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.mp-side-top .back-pill:hover {
background: var(--heat-12);
border-color: var(--heat-20);
color: var(--heat);
background: var(--black-alpha-4);
border-color: var(--black-alpha-24);
color: var(--accent-black);
}
.mp-side-top .back-pill svg { width: 14px; height: 14px; }
.mp-side-top .fold {

View File

@ -12,6 +12,39 @@
/* ─── 顶部胶囊式 Stage 状态 · 注入到 .topbar 中部 ─── */
.topbar { position: relative; } /* 锚定 pill */
.pipeline-topbar-left {
display: inline-flex;
align-items: center;
gap: 12px;
min-width: 0;
max-width: min(36vw, 520px);
}
.pipeline-back {
height: 34px;
padding: 0 13px 0 11px;
border-radius: var(--r-pill);
flex: 0 0 auto;
}
.pipeline-back svg { width: 14px; height: 14px; }
.pipeline-topbar-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13.5px;
font-weight: 500;
color: var(--accent-black);
}
.pipeline-topbar-title .mono {
margin-left: 8px;
font-size: 10.5px;
font-weight: 400;
letter-spacing: .04em;
color: var(--black-alpha-48);
}
@media (max-width: 1500px) {
.pipeline-topbar-title { display: none; }
}
.stage-pill {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%);
@ -2541,12 +2574,140 @@ const PROJECT_TITLE = shortProductName(CURRENT_PRODUCT_NAME) + ' · 痛点种草
Shell.render({
active: 'projects',
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目', href: 'projects.html' }, { label: PROJECT_TITLE }]
crumbs: []
});
/* 渲染贯穿商品名 / 项目名 */
document.getElementById('page-title').textContent = PROJECT_TITLE + ' · 流水线 · Airshelf';
(function _injectPipelineTopbarLeft() {
const topbar = document.querySelector('.topbar');
const right = topbar?.querySelector('.right');
if (!topbar || !right) return;
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const title = esc(PROJECT_TITLE);
const left = document.createElement('div');
left.className = 'pipeline-topbar-left';
left.innerHTML = `
<a class="btn btn-ghost pipeline-back" href="projects.html" aria-label="返回视频项目">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
返回视频项目
</a>
<div class="pipeline-topbar-title" title="${title}">
${title}<span class="mono">// PIPELINE</span>
</div>
`;
topbar.insertBefore(left, right);
})();
const ProjectStore = (function () {
const safeId = (PROJECT_TITLE + '|' + CURRENT_PRODUCT_NAME).replace(/[^\w\u4e00-\u9fa5-]+/g, '_');
const key = 'airshelf:pipeline:' + safeId;
const defaults = {
product: CURRENT_PRODUCT_NAME,
title: PROJECT_TITLE,
currentStage: 1,
completedStage: 0,
fields: {},
actions: [],
jobs: {},
stage1: null,
stage2: {},
stage3: null,
stage4: null,
updatedAt: Date.now(),
};
let data;
try {
data = { ...defaults, ...(JSON.parse(localStorage.getItem(key) || '{}') || {}) };
} catch (e) {
data = { ...defaults };
}
function save() {
data.updatedAt = Date.now();
localStorage.setItem(key, JSON.stringify(data));
try {
const indexKey = 'airshelf:pipeline-index';
const list = JSON.parse(localStorage.getItem(indexKey) || '[]');
const compact = {
key,
title: data.title,
product: data.product,
currentStage: data.currentStage,
completedStage: data.completedStage,
updatedAt: data.updatedAt,
runningJobs: Object.values(data.jobs || {})
.filter(j => j.status === 'running')
.map(j => ({ stage: j.stage, label: j.label, finishAt: j.finishAt })),
};
const next = [compact, ...list.filter(item => item.key !== key)].slice(0, 30);
localStorage.setItem(indexKey, JSON.stringify(next));
} catch (e) {}
}
function record(type, detail = {}) {
data.actions = data.actions || [];
data.actions.unshift({ type, detail, at: Date.now() });
data.actions = data.actions.slice(0, 80);
save();
}
function setStage(n) {
data.currentStage = Number(n) || 1;
data.completedStage = Math.max(Number(data.completedStage) || 0, Math.max(0, data.currentStage - 1));
save();
}
function saveFieldsFrom(root = document) {
root.querySelectorAll('[id][contenteditable="true"], input[id], textarea[id], select[id]').forEach(el => {
if (el.type === 'file') return;
data.fields[el.id] = {
kind: el.matches('[contenteditable="true"]') ? 'text' : 'value',
value: el.matches('[contenteditable="true"]') ? el.textContent : el.value,
};
});
save();
}
function restoreFields(root = document) {
Object.entries(data.fields || {}).forEach(([id, item]) => {
const el = root.getElementById ? root.getElementById(id) : document.getElementById(id);
if (!el || item.value == null) return;
if (item.kind === 'text' && el.matches('[contenteditable="true"]')) el.textContent = item.value;
else if ('value' in el && el.type !== 'file') el.value = item.value;
});
}
function startJob(id, payload) {
data.jobs[id] = { ...payload, status: 'running', startedAt: Date.now(), updatedAt: Date.now() };
save();
}
function finishJob(id, patch = {}) {
if (!data.jobs[id]) return;
data.jobs[id] = { ...data.jobs[id], ...patch, status: 'done', finishedAt: Date.now(), updatedAt: Date.now() };
save();
}
function getJob(id) { return data.jobs?.[id] || null; }
function clearJob(id) { if (data.jobs?.[id]) { delete data.jobs[id]; save(); } }
function saveStage(name, value) {
data[name] = value;
save();
}
window.addEventListener('beforeunload', () => saveFieldsFrom());
document.addEventListener('input', (e) => {
if (e.target.closest('[contenteditable="true"], input[id], textarea[id], select[id]')) {
saveFieldsFrom();
}
});
return { key, data, save, record, setStage, saveFieldsFrom, restoreFields, startJob, finishJob, getJob, clearJob, saveStage };
})();
/* ─── 把 stage-pill anchor 注入 .topbar 中部(圆点全状态都靠 .sp-dot 实时同步)─── */
(function _injectStagePill() {
const anchor = document.getElementById('stage-pill-anchor');
@ -2594,17 +2755,19 @@ function activateStage(n) {
const cur = Number(n);
document.querySelectorAll('.stage').forEach(s => s.classList.remove('active'));
document.querySelector(`[data-stage-pane="${cur}"]`)?.classList.add('active');
ProjectStore.setStage(cur);
// 圆点状态:< cur done(森林绿) · = cur active(橙实心+光晕) · > cur → 默认(浅灰)
const completed = Math.max(Number(ProjectStore.data.completedStage) || 0, cur - 1);
document.querySelectorAll('#stage-pill .sp-dot').forEach(s => {
const i = +s.dataset.stage;
s.classList.remove('active', 'done');
if (i < cur) s.classList.add('done');
else if (i === cur) s.classList.add('active');
if (i === cur) s.classList.add('active');
else if (i <= completed) s.classList.add('done');
});
// 连接线 · idx+1 < cur 时染森林绿
document.querySelectorAll('#stage-pill .sp-line').forEach((ln, idx) => {
ln.classList.toggle('done', (idx + 1) < cur);
ln.classList.toggle('done', (idx + 1) <= completed);
});
// 全高度布局:所有 stage 操作模块 hug content、内容区域 fill content
@ -2616,6 +2779,7 @@ function activateStage(n) {
}
window.scrollTo({ top: 0, behavior: 'smooth' });
requestAnimationFrame(() => ProjectStore.restoreFields());
}
function readHash() {
const m = location.hash.match(/stage-(\d)/);
@ -2624,8 +2788,8 @@ function readHash() {
const q = new URLSearchParams(location.search);
const s = q.get('stage');
if (s) { activateStage(+s); return; }
// 兜底:默认 Stage 1 进行中(让 stage-pill 首个圆点点亮)
activateStage(1);
// 兜底:回到上次离开的 stage,等待生成时离开页面也能继续当前项目进度
activateStage(ProjectStore.data.currentStage || 1);
}
window.addEventListener('hashchange', readHash);
readHash();
@ -2670,11 +2834,12 @@ const Stage1 = (function () {
group.querySelectorAll('.script-tag').forEach((chip, i) => {
const t = chip.querySelector('.t');
const x = chip.querySelector('.x');
x.addEventListener('click', (e) => { e.stopPropagation(); scriptTags[kind].splice(i, 1); renderScriptTags(); });
x.addEventListener('click', (e) => { e.stopPropagation(); scriptTags[kind].splice(i, 1); saveState(); renderScriptTags(); });
t.addEventListener('blur', () => {
const v = (t.textContent || '').trim();
if (!v) { scriptTags[kind].splice(i, 1); renderScriptTags(); }
else { scriptTags[kind][i] = v; }
saveState();
});
t.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); t.blur(); } });
});
@ -2685,6 +2850,7 @@ const Stage1 = (function () {
btn.addEventListener('click', () => {
const kind = btn.parentElement.dataset.kind;
scriptTags[kind].push('');
saveState();
renderScriptTags();
const group = document.querySelector(`.script-tags .tag-group[data-kind="${kind}"]`);
const chips = group.querySelectorAll('.script-tag .t');
@ -2705,6 +2871,56 @@ const Stage1 = (function () {
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
function pushMsg(role, html) { chatMsgs.push({ role, html, time: now() }); }
function saveState() {
ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags });
}
function loadState() {
const saved = ProjectStore.data.stage1;
if (!saved) 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.scriptTags && Array.isArray(saved.scriptTags.char) && Array.isArray(saved.scriptTags.scene)) {
scriptTags = saved.scriptTags;
}
}
function getDefaultDraft() {
return [
{ id: 'sh1', painting: '中景慢推 · 深夜居家书桌全景。屏幕仍亮着 PPT,女主背影瘫在椅子上,屏幕冷光 + 台灯暖光对比。字幕"凌晨 02:14"淡入。', dialog: '(无台词 · BGM 渐起)', duration: 5 },
{ id: 'sh2', painting: '近景 · 卫生间镜前。女主低头看脸,T 区起皮、暗沉特写,冷白灯偏惨。', dialog: '"做完这版稿又是凌晨两点……(叹气)脸已经不能看了。"', duration: 5 },
{ id: 'sh3', painting: '俯拍特写 · 回到书桌,拉开抽屉。囤好的透真补水面膜露半角,手伸进去抽出一片。', dialog: '"还好抽屉里囤了透真玻尿酸面膜。"', duration: 5 },
{ id: 'sh4', painting: '桌面微距特写 · 撕开锡纸包装的瞬间。30g 厚精华液缓缓滴落,面膜布展开,质地拉丝可见。', dialog: '"30g 一片,精华液比普通面膜厚整整三倍。"', duration: 6 },
{ id: 'sh5', painting: '床头近景 · 女主敷好面膜闭眼躺下,台灯暖光打在脸侧。膜布贴合脸型,边缘服帖。', dialog: '"贴上去那一瞬间 —— 凉凉的,像把皮肤泡了一次澡。"', duration: 6 },
{ id: 'sh6', painting: '中景 · 第二天清晨化妆台。阳光透过窗帘,女主对镜上妆,皮肤透亮、粉底服帖。同事画外音"你最近用啥了"。', dialog: '"第二天脸是软的,粉底都不卡了。同事都跑来问。"', duration: 8 },
{ id: 'sh7', painting: '平铺俯拍 · 桌面五片装产品 + 单片包装。价格 "618 · 5 片 ¥39.9" 弹出,购物车图标右下角浮现。', dialog: '"618 五片 39.9,自用送人都合适。链接放评论区。"', duration: 5 },
];
}
function completeAiJob() {
const existing = new Set(shots.map(s => s.id));
getDefaultDraft().forEach(s => {
if (!existing.has(s.id)) shots.push(s);
});
chatMsgs = chatMsgs.filter(x => !(x.role === 'ai' && /ai-thinking/.test(x.html)));
if (!chatMsgs.some(x => x.html.includes('初稿完成'))) {
pushMsg('ai', '初稿完成。点击任意卡片文字可直接编辑;鼠标移到卡片之间会出现「+ 添加分镜」。');
}
ProjectStore.finishJob('stage1-script');
ProjectStore.record('stage1.script.ready', { shots: shots.length });
saveState();
renderChat();
renderShots();
}
function resumeAiJobIfNeeded() {
const job = ProjectStore.getJob('stage1-script');
if (!job || job.status !== 'running') return;
const remaining = Math.max(0, (job.finishAt || Date.now()) - Date.now());
if (!chatMsgs.some(x => /ai-thinking/.test(x.html))) {
pushMsg('ai', '<span class="ai-thinking">脚本生成仍在后台排队 <span class="dots"><span></span><span></span><span></span></span></span>');
}
saveState();
renderChat();
window.setTimeout(completeAiJob, remaining);
}
function renderChat() {
const body = $cb(); if (!body) return;
@ -2780,6 +2996,8 @@ const Stage1 = (function () {
const s = shots.find(x => x.id === id);
if (s) s[field] = v;
if (!v) el.dataset.empty = 'true';
ProjectStore.record('stage1.shot.edited', { id, field });
saveState();
});
});
body.querySelectorAll('[data-act]').forEach(btn => {
@ -2790,12 +3008,17 @@ const Stage1 = (function () {
const after = btn.dataset.after;
if (act === 'del') {
shots = shots.filter(x => x.id !== id);
ProjectStore.record('stage1.shot.deleted', { id });
saveState();
renderShots();
} else if (act === 'regen') {
Shell.toast('已请求重写本场', '↻ shot-' + id);
ProjectStore.record('stage1.shot.regen', { id });
} else if (act === 'add-here') {
const idx = shots.findIndex(x => x.id === after);
shots.splice(idx + 1, 0, { id: 'sh' + Date.now(), painting: '', dialog: '', duration: 5 });
ProjectStore.record('stage1.shot.added', { after });
saveState();
renderShots();
}
});
@ -2807,11 +3030,19 @@ const Stage1 = (function () {
function pickMode(m) {
mode = m;
ProjectStore.record('stage1.mode.selected', { mode: 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)
@ -2833,28 +3064,40 @@ const Stage1 = (function () {
// 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);
} else if (m === 'theme') {
pushMsg('ai', '好,请给我一句话主题(530 字),例如:<br>· 熬夜党的急救面膜<br>· 加班吃啥不内疚<br>下面输入框直接打就行,我会按这句话扩成一稿镜头脚本。');
saveState();
renderChat();
} else if (m === 'manual') {
pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框,我会按场自然切分并适配商品卖点。');
saveState();
renderChat();
}
}
function init() {
loadState();
renderChat();
renderShots();
resumeAiJobIfNeeded();
ProjectStore.restoreFields();
document.getElementById('chat-clear-btn')?.addEventListener('click', () => {
chatMsgs = []; mode = null; shots = []; scriptTags = { char: [], scene: [] };
ProjectStore.clearJob('stage1-script');
ProjectStore.record('stage1.cleared');
saveState();
renderChat(); renderShots();
});
bindTagAdders();
@ -2905,11 +3148,15 @@ const Stage1 = (function () {
? `<div class="hstack" style="gap:6px; flex-wrap:wrap; margin-bottom:6px;">${attachments.map(f => `<span class="pill" style="font-family:var(--font-mono); font-size:10.5px;">📎 ${f.name.replace(/</g, '&lt;')}</span>`).join('')}</div>`
: '';
pushMsg('user', fileTags + (v ? v.replace(/</g, '&lt;') : '<span class="muted-2">(已附加文件)</span>'));
const fileCt = attachments.length;
ta.value = '';
attachments = []; renderAttach();
ProjectStore.record('stage1.chat.sent', { hasText: !!v, files: fileCt });
saveState();
renderChat();
setTimeout(() => {
pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。');
saveState();
renderChat();
}, 400);
};
@ -4066,6 +4313,19 @@ const Stage2 = (function () {
let previewIdx = -1; // 主图正在「预览」哪一版(浏览态,不动采用状态)
let adoptedIdx = -1; // 真正被「采用」的那一版,决定商品资产生效版本
let generating = false;
const savedTri = ProjectStore.data.stage2?.productTri || null;
if (savedTri) {
if (Array.isArray(savedTri.versions)) versions.push(...savedTri.versions);
if (Number.isInteger(savedTri.previewIdx)) previewIdx = savedTri.previewIdx;
if (Number.isInteger(savedTri.adoptedIdx)) adoptedIdx = savedTri.adoptedIdx;
}
function saveTriState() {
ProjectStore.saveStage('stage2', {
...(ProjectStore.data.stage2 || {}),
productTri: { versions, previewIdx, adoptedIdx },
});
}
function prodName() {
return CURRENT_PRODUCT_NAME || (document.getElementById('asset-prod-card-name')?.textContent ?? '商品');
@ -4132,6 +4392,7 @@ const Stage2 = (function () {
previewIdx = idx;
renderHistory();
renderMain();
saveTriState();
}
// 显式「采用」当前预览版本 · 同步商品资产 + 隐藏缺三视图徽标
@ -4158,6 +4419,7 @@ const Stage2 = (function () {
}
renderHistory();
renderMain();
saveTriState();
if (fromClick) Shell.toast('已采用 ' + ver.label, prodName() + ' · 商品资产已更新为该版本');
}
@ -4170,12 +4432,8 @@ const Stage2 = (function () {
aigenBtn.disabled = true;
}
function start() {
if (generating) return;
generating = true;
pane.classList.add('show');
renderLoading();
setTimeout(() => {
function finishGeneration() {
if (!generating && !ProjectStore.getJob('stage2-product-tri')) return;
generating = false;
aigenBtn.disabled = false;
const now = new Date();
@ -4192,7 +4450,34 @@ const Stage2 = (function () {
renderMain();
Shell.toast('三视图已生成 ' + newVer.label, prodName() + ' · 预览中,满意请点「采用此版本」');
}
}, 1800);
ProjectStore.finishJob('stage2-product-tri');
ProjectStore.record('stage2.productTri.ready', { version: newVer.label });
saveTriState();
}
function start() {
if (generating) return;
generating = true;
pane.classList.add('show');
renderLoading();
ProjectStore.startJob('stage2-product-tri', {
stage: 2,
label: '商品三视图生成',
finishAt: Date.now() + 12000,
});
ProjectStore.record('stage2.productTri.started', { product: prodName() });
saveTriState();
setTimeout(finishGeneration, 1800);
}
function resumeGenerationIfNeeded() {
const job = ProjectStore.getJob('stage2-product-tri');
if (!job || job.status !== 'running') return;
generating = true;
pane.classList.add('show');
renderLoading();
const remaining = Math.max(0, (job.finishAt || Date.now()) - Date.now());
setTimeout(finishGeneration, remaining);
}
// 主图点击 → 放大查看
@ -4207,6 +4492,12 @@ const Stage2 = (function () {
e.stopPropagation();
start();
});
if (versions.length && previewIdx >= 0) {
pane.classList.add('show');
if (adoptedIdx >= 0) applyAdoption(false);
else { renderHistory(); renderMain(); }
}
resumeGenerationIfNeeded();
})();
}
return { init };
@ -4223,6 +4514,26 @@ const Stage3 = (function () {
{ id: 'sc3', name: '场 3 · 化妆台/产品定格', time: '30-45s', desc: '第二天早上,女主对镜化妆,皮肤透亮。淡入产品定格大图 + 价格标签 ¥39.9。', prompt: '中景 / 定格\n光线:晨光 + 暖色滤镜\n演员:林夕(精致妆面)\n结尾:产品大图 + 价格 + 购物车浮动', adopted: 0, versions: [{ ts: '14:30', label: 'v1' }] },
];
let curId = scenes[0].id;
const savedStage3 = ProjectStore.data.stage3;
if (savedStage3) {
if (savedStage3.curId) curId = savedStage3.curId;
if (Array.isArray(savedStage3.scenes)) {
savedStage3.scenes.forEach(ss => {
const s = scenes.find(x => x.id === ss.id);
if (!s) return;
if (Array.isArray(ss.versions)) s.versions = ss.versions;
if (Number.isInteger(ss.adopted)) s.adopted = ss.adopted;
if (typeof ss.prompt === 'string') s.prompt = ss.prompt;
});
}
}
function saveState() {
ProjectStore.saveStage('stage3', {
curId,
scenes: scenes.map(s => ({ id: s.id, prompt: s.prompt, adopted: s.adopted, versions: s.versions })),
});
}
function renderRow() {
const row = document.getElementById('sb-scenes-row');
@ -4233,7 +4544,7 @@ const Stage3 = (function () {
<div class="sub">${s.time}</div>
</div>`).join('');
row.querySelectorAll('.sb-scene-thumb').forEach(t => {
t.addEventListener('click', () => { curId = t.dataset.sid; renderAll(); });
t.addEventListener('click', () => { curId = t.dataset.sid; saveState(); renderAll(); });
});
}
function renderMain() {
@ -4241,7 +4552,12 @@ const Stage3 = (function () {
const v = s.versions[s.adopted];
document.getElementById('sb-main-img').innerHTML = `<span class="ph-frame">${s.name} · ${v.label}</span>`;
document.getElementById('sb-side-scene').textContent = s.name.split(' · ')[0];
document.getElementById('sb-prompt-edit').textContent = s.prompt;
const promptEdit = document.getElementById('sb-prompt-edit');
promptEdit.textContent = s.prompt;
promptEdit.oninput = () => {
s.prompt = promptEdit.textContent.trim();
saveState();
};
// history
const ct = document.getElementById('sb-history-ct');
const hist = document.getElementById('sb-history-row');
@ -4256,6 +4572,8 @@ const Stage3 = (function () {
hist.querySelectorAll('.sb-history-thumb').forEach(t => {
t.addEventListener('click', () => {
s.adopted = +t.dataset.vi;
ProjectStore.record('stage3.storyboard.version.selected', { scene: s.id, version: s.versions[s.adopted]?.label });
saveState();
renderMain();
Shell.toast('已切换至 ' + s.versions[s.adopted].label, s.name);
});
@ -4271,6 +4589,8 @@ const Stage3 = (function () {
const v = { ts: (new Date()).toTimeString().slice(0, 5), label: 'v' + (s.versions.length + 1) };
s.versions.push(v);
s.adopted = s.versions.length - 1;
ProjectStore.record('stage3.storyboard.rerun', { scene: s.id, version: v.label });
saveState();
Shell.toast('整张重跑', s.name + ' · ' + v.label);
renderAll();
});
@ -4288,8 +4608,26 @@ const Stage4 = (function () {
'v2': { title: '场 2 · 面膜包装/特写', time: '15-27s', info: [['场次', '场 2'], ['时长', '12.0s'], ['分辨率', '1080×1920 · 9:16'], ['模型', 'Seedance v2'], ['成本', '¥0.45']], versions: [{ ts: '14:35', label: 'v1' }, { ts: '14:52', label: 'v2' }], adopted: 1, prompt: '特写 / 缓推镜\n光线:柔光顶打 + 背景虚化\n关键道具:面膜包装、撕开瞬间\n氛围:精致、放心、产品感' },
'v3': { title: '场 3 · 化妆台/产品定格', time: '27-40s', info: [['场次', '场 3'], ['时长', '13.0s'], ['分辨率', '1080×1920 · 9:16'], ['模型', 'Seedance v2'], ['成本', '¥0.45']], versions: [{ ts: '14:40', label: 'v1' }], adopted: 0, prompt: '中景 / 定格\n光线:晨光 + 暖色滤镜\n演员:林夕(精致妆面)\n结尾:产品大图 + 价格 + 购物车浮动' },
};
const savedStage4 = ProjectStore.data.stage4;
if (savedStage4?.videos) {
Object.entries(savedStage4.videos).forEach(([id, sv]) => {
if (!VIDEOS[id]) return;
if (Array.isArray(sv.versions)) VIDEOS[id].versions = sv.versions;
if (Number.isInteger(sv.adopted)) VIDEOS[id].adopted = sv.adopted;
if (Number.isInteger(sv.preview)) VIDEOS[id].preview = sv.preview;
if (typeof sv.prompt === 'string') VIDEOS[id].prompt = sv.prompt;
});
}
let curVid = null;
function saveState() {
const videos = {};
Object.entries(VIDEOS).forEach(([id, v]) => {
videos[id] = { versions: v.versions, adopted: v.adopted, preview: v.preview, prompt: v.prompt };
});
ProjectStore.saveStage('stage4', { videos });
}
function getPreviewIndex(v) {
const idx = Number.isInteger(v.preview) ? v.preview : v.adopted;
return v.versions[idx] ? idx : Math.max(0, v.adopted || 0);
@ -4307,7 +4645,7 @@ const Stage4 = (function () {
const promptEl = document.getElementById('vd-prompt-edit');
if (promptEl) {
promptEl.textContent = v.prompt || '';
promptEl.oninput = () => { v.prompt = promptEl.textContent.trim(); };
promptEl.oninput = () => { v.prompt = promptEl.textContent.trim(); saveState(); };
}
document.getElementById('vd-history-ct').textContent = v.versions.length;
const row = document.getElementById('vd-history-row');
@ -4318,6 +4656,8 @@ const Stage4 = (function () {
row.querySelectorAll('.vd-history-thumb').forEach(t => {
t.addEventListener('click', () => {
v.preview = +t.dataset.vi;
ProjectStore.record('stage4.video.version.previewed', { id, version: v.versions[v.preview]?.label });
saveState();
openDetail(id);
});
});
@ -4347,6 +4687,8 @@ const Stage4 = (function () {
const v = VIDEOS[curVid];
v.adopted = getPreviewIndex(v);
v.preview = v.adopted;
ProjectStore.record('stage4.video.version.adopted', { id: curVid, version: v.versions[v.adopted]?.label });
saveState();
Shell.toast('已采用 ' + v.versions[v.adopted].label, v.title + ' · 拼接将用此版');
document.getElementById('video-detail-modal').classList.remove('show');
});
@ -4359,6 +4701,8 @@ const Stage4 = (function () {
const nv = { ts: (new Date()).toTimeString().slice(0, 5), label: 'v' + (v.versions.length + 1) };
v.versions.push(nv);
v.preview = v.versions.length - 1;
ProjectStore.record('stage4.video.rerun', { id: curVid, version: nv.label });
saveState();
Shell.toast('重跑中', v.title + ' · 约 30s · 新版预览中');
openDetail(curVid);
});

View File

@ -206,20 +206,20 @@
}
.pc-side-top .back-pill {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 12px 0 8px;
background: var(--background-lighter);
height: 34px; padding: 0 13px 0 11px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
color: var(--accent-black);
font-size: 12.5px; font-weight: 500;
font-size: 13px; font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.pc-side-top .back-pill:hover {
background: var(--heat-12);
border-color: var(--heat-20);
color: var(--heat);
background: var(--black-alpha-4);
border-color: var(--black-alpha-24);
color: var(--accent-black);
}
.pc-side-top .back-pill svg { width: 14px; height: 14px; }
.pc-side-top .fold {

View File

@ -348,9 +348,78 @@
---
## 5. 待继续确认页面
## 5. Stage 3 故事板页(已确认原则,待细化)
### 页面定位
故事板页是可选的预览步骤,不是视频生成的必经步骤。
### 已确认原则
- 故事板不是一定要生成。
- 用户可以跳过故事板,直接进入视频生成。
- 故事板的价值是提前预览视频大致画面和内容。
- 故事板有时会固定最终视频画面,反而可能让视频生成效果不如不使用故事板。
- 因此故事板不能作为强制前置条件。
- Stage 3 应该提供“生成故事板预览”和“跳过,直接生成视频”两条路径。
- 如果用户跳过故事板Stage 4 直接基于脚本和基础资产生成视频。
- 故事板提示词直接在原提示词基础上修改。
- 暂不做独立 AI 辅助输入框或 AI 聊天式提示词修改。
- 故事板绑定资产需要展示,但只做轻量引用说明。
- 绑定资产默认不展示缩略图。
- 绑定资产用文字标签 / 资产引用标签展示。
- Hover / Popover 时展示资产详情。
- Stage 3 不直接编辑资产,修改资产需要回到 Stage 2。
- 如果绑定资产在 Stage 2 被替换Stage 3 显示“绑定资产已更新,建议重新生成故事板”。
### 绑定资产展示
绑定资产展示的是引用关系,而不是图片预览。
示例:
- 商品:补水面膜
- 人物:女主、同事
- 场景:卫生间、书桌
或以标签形式展示:
- 商品 · 补水面膜
- 人物 · 女主
- 人物 · 同事
- 场景 · 卫生间
Hover 详情示例:
- 商品 · 补水面膜
- 商品图数量
- 商品三视图状态
- 来源:商品库
- 操作:查看 / 去基础资产修改
- 人物 · 女主
- 当前立绘状态
- 人物三视图状态
- 来源:项目内 / 人物库
- 操作:查看 / 去基础资产修改
### 提示词编辑
- 用户直接在原本提示词基础上修改。
- 提示词就是重新生成故事板的依据。
- 第一版不做 AI 辅助修改提示词。
- 可保留恢复原始提示词、重新生成等基础操作。
### 待继续确认
- 故事板是否进入页面后自动生成,还是由用户点击生成。
- 生成视频时,已生成故事板是否默认作为参考,还是由用户选择是否使用。
- 故事板历史版本如何展示。
---
## 6. 待继续确认页面
- Stage 4 视频页
- Stage 5 拼接导出页
- 商品库 / 商品详情
- 资产库
@ -363,38 +432,213 @@
---
## 5. Stage 3 故事板
## 7. Stage 5 拼接导出
### 页面定位
故事板页用于在生成视频前,让用户清晰看到接下来这条 15s 视频的大致内容和画面走向。
拼接导出页是最终成片工作台。
它负责把 Stage 4 采用的 AI 视频片段、用户上传的视频、资产库视频等素材自由拼接并完成字幕、BGM、转场和导出。
### 布局方向
参考剪映 / CapCut 的基础剪辑布局,但只保留轻量功能。
页面区域:
- 顶部项目名、Stage 导航、导出状态
- 左侧:素材池
- 中间上方:成片预览播放器
- 右侧:属性设置
- 底部:时间线
### 素材池
素材池包含:
- AI 生成视频片段
- 上传视频
- 批量上传视频
- 从资产库选择视频
上传视频时支持勾选:
- 保存到资产库,供以后复用
默认不勾选,避免资产库变乱。
### 时间线
第一版只做单主轨轻剪辑。
支持:
- 自动放入 Stage 4 当前采用的 AI 视频片段,按顺序排列
- 拖拽排序
- 添加上传视频
- 删除片段
- 替换片段
- 裁剪起止点
不做:
- 多轨自由叠加
- 关键帧
- 复杂音频混音
- 速度曲线
- 画中画
- 蒙版
- 贴纸 / 复杂特效
### 裁剪
需要支持裁剪。
选中时间线片段后,右侧属性区显示:
- 开始时间
- 结束时间
- 当前片段时长
- 应用裁剪
时间线片段两侧可保留轻量拖拽手柄。
### 字幕
字幕默认自动添加。
规则:
- 进入 Stage 5 后,系统根据脚本台词 / 旁白自动生成字幕。
- 字幕默认开启。
- 用户可以关闭字幕。
- 用户可以编辑字幕文字和时间。
- 第一版不强制支持识别用户上传视频语音生成字幕。
右侧属性区默认展示:
- 字幕开关
- 字幕来源:脚本台词
- 字幕样式
- 编辑字幕入口
字幕编辑使用轻量面板,不做复杂字幕工作台。
示例:
- 00:00 - 00:03 熬夜后皮肤干到起皮?
- 00:03 - 00:06 这片补水面膜我最近一直在用。
- 00:06 - 00:10 敷完脸会明显更透亮。
### BGM / 转场 / 导出
右侧属性区包含:
- BGM 开关
- BGM 选择
- 原声 / BGM 音量
- 转场开关
- 转场类型
- 导出比例,默认 9:16
- 清晰度,默认 1080P
- 格式,默认 MP4
BGM 默认开启,用户可修改或关闭。
转场默认开启,用户可修改或关闭。
### 导出成功
导出成功后不新增独立页面。
在 Stage 5 当前页面展示导出成功状态 / 结果面板。
导出中:
- 显示导出进度。
导出成功后展示:
- 成片预览播放器
- 下载 MP4
- 复制链接
- 查看成片资产
- 继续编辑
- 返回视频项目
导出成功后:
- 项目状态标记为已完成。
- 成片自动进入项目资产。
后续如果接入平台发布能力,再增加发布入口;第一版不做独立发布页。
---
## 6. Stage 4 视频页
### 页面定位
视频页只用于 AI 生成视频片段。
用户上传视频、使用自己的视频、与 AI 视频混合拼接,都放到 Stage 5 拼接导出页处理。
### 核心原则
- 不做分镜级故事板。
- 不按每个镜头单独生成故事板。
- 故事板按照“一条视频”的完整时长来展示。
- 当前版本单条视频上限是 15s因此故事板就是这条 15s 视频的整体视觉预览。
- 故事板需要让用户一眼明白:等下这条视频会讲什么、画面如何推进、商品如何出现。
- 视频生成以片段为单位。
- 每段视频独立生成。
- 单个视频生成片段上限是 15s。
- 长视频由多个 15s 内的视频片段组成,后续在 Stage 5 拼接。
- 视频一次只生成 1 条候选。
- 首次进入视频页后AI 可以自动生成视频,不需要用户手动点击生成。
- 视频有自己的生成提示词。
- 用户可以直接修改视频提示词。
- 用户可以不改提示词直接重跑。
- 每次重跑保留历史版本。
- 用户可以从历史版本中回选满意的一版。
### 与脚本页的关系
### 视频来源
- Stage 1 脚本页仍然可以按读秒流分镜展示,方便用户细看脚本。
- Stage 3 故事板页不继承“每个分镜一张图”的结构。
- 故事板应总结整条 15s 视频,而不是把脚本分镜逐张拆开。
Stage 4 只展示和管理 AI 生成视频片段。
### 初步页面方向
每个 AI 片段的视频来源是:
主体重点应放在一张完整故事板 / 故事板预览上。
- 当前采用的 AI 生成版本
- AI 历史版本中的某一版
页面需要展示:
### 故事板使用
- 当前视频脚本摘要
- 使用的商品资产、人物资产、场景资产
- 故事板整体预览
- 生成状态
- 重新生成 / 调整提示词 / 采用当前版本
- 历史版本回选
如果当前片段有故事板,默认使用故事板生成视频。
故事板可重跑。
每次重跑保留历史版本,用户可以在历史版本中选择满意的一版。
原因:
- 对小白用户来说,“使用 / 不使用故事板”解释成本高。
- 用户既然生成了故事板,视频结果和故事板不一致时容易产生困惑。
Stage 4 可以允许用户修改是否使用故事板,但默认不主动要求用户选择。
### 页面结构初稿
左侧:
- 片段列表
- 每段显示时间范围、来源、生成状态、当前采用版本
中间:
- 当前片段视频预览 / 生成状态
- 视频播放器
- 当前采用版本标识
右侧:
- 视频提示词编辑器
- 故事板使用状态 / 可修改
- 绑定输入资产引用
- 视频历史版本
底部:
- 重新生成
- 采用当前版本
- 进入拼接导出
### 待继续确认
- 视频历史版本如何展示。