Polish navigation and pipeline flow
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 4m37s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 4m37s
This commit is contained in:
parent
bbe29622c2
commit
df7b90934a
@ -20,6 +20,8 @@
|
|||||||
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||||
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
||||||
|
|||||||
@ -218,7 +218,13 @@ img, svg, video { display: block; max-width: 100%; }
|
|||||||
.divider { height: 1px; background: var(--border-faint); margin: 16px 0; }
|
.divider { height: 1px; background: var(--border-faint); margin: 16px 0; }
|
||||||
|
|
||||||
/* ─── App shell ─── */
|
/* ─── 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 ─── */
|
/* ─── Sidebar ─── */
|
||||||
aside.sidebar {
|
aside.sidebar {
|
||||||
@ -229,12 +235,61 @@ aside.sidebar {
|
|||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
transition: padding var(--t-base);
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; padding: 2px 8px 16px; min-height: 44px; }
|
.sidebar-head {
|
||||||
.brand-logo { display: block; width: 142px; height: auto; margin: -8px 0 -6px -8px; object-fit: contain; }
|
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, .flame { width: 22px; height: 22px; color: var(--heat); }
|
||||||
.brand-mark svg, .flame svg { width: 100%; height: 100%; }
|
.brand-mark svg, .flame svg { width: 100%; height: 100%; }
|
||||||
.brand .name { font-weight: 600; font-size: 18px; letter-spacing: -.012em; color: var(--accent-black); }
|
.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 平铺 */
|
/* sidebar search · Ctrl K Inter Bold 平铺 */
|
||||||
.search-box {
|
.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); }
|
.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 + grid background ─── */
|
||||||
main { position: relative; background: var(--background-base); min-width: 0; }
|
main { position: relative; background: var(--background-base); min-width: 0; }
|
||||||
.grid-bg {
|
.grid-bg {
|
||||||
@ -2028,6 +2124,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.app { grid-template-columns: 1fr; }
|
.app { grid-template-columns: 1fr; }
|
||||||
aside.sidebar { display: none; }
|
aside.sidebar { display: none; }
|
||||||
|
.sidebar-toggle { display: none; }
|
||||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
.stat:nth-child(2) { border-right: 0; }
|
.stat:nth-child(2) { border-right: 0; }
|
||||||
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }
|
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const ShellIcon = (name, opts) => window.IconKit ? window.IconKit.svg(name, opts) : '';
|
const ShellIcon = (name, opts) => window.IconKit ? window.IconKit.svg(name, opts) : '';
|
||||||
|
const SHELL_SIDEBAR_COLLAPSED_KEY = 'airshelf:sidebar-collapsed';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{
|
{
|
||||||
@ -64,7 +65,7 @@ const SHELL_COMMANDS = [
|
|||||||
window.Shell = {
|
window.Shell = {
|
||||||
render({ active = '', crumbs = [], balance = '¥327.40', topActions = '' } = {}) {
|
render({ active = '', crumbs = [], balance = '¥327.40', topActions = '' } = {}) {
|
||||||
const navHtml = NAV.map(n => `
|
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}
|
${n.icon}
|
||||||
<span>${n.label}</span>
|
<span>${n.label}</span>
|
||||||
${n.badge ? `<span class="pill-mini">${n.badge}</span>` : ''}
|
${n.badge ? `<span class="pill-mini">${n.badge}</span>` : ''}
|
||||||
@ -73,10 +74,16 @@ window.Shell = {
|
|||||||
|
|
||||||
const sidebar = `
|
const sidebar = `
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="sidebar-head">
|
||||||
<img class="brand-logo" src="assets/logo.png" alt="Airshelf">
|
<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>
|
||||||
<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')}
|
${ShellIcon('search')}
|
||||||
<input id="global-search" placeholder="搜索" readonly aria-label="打开全局搜索"/>
|
<input id="global-search" placeholder="搜索" readonly aria-label="打开全局搜索"/>
|
||||||
<span class="kbd">Ctrl K</span>
|
<span class="kbd">Ctrl K</span>
|
||||||
@ -328,9 +335,11 @@ window.Shell = {
|
|||||||
const app = document.createElement('div');
|
const app = document.createElement('div');
|
||||||
app.className = 'app';
|
app.className = 'app';
|
||||||
app.innerHTML = sidebar + `<main>${decorations}${topbar}<div class="content" id="page-content">${cornerMarks}</div></main>`;
|
app.innerHTML = sidebar + `<main>${decorations}${topbar}<div class="content" id="page-content">${cornerMarks}</div></main>`;
|
||||||
|
this.applySidebarCollapse(this.isSidebarCollapsed());
|
||||||
|
|
||||||
const src = document.getElementById('page');
|
const src = document.getElementById('page');
|
||||||
document.body.prepend(app);
|
document.body.prepend(app);
|
||||||
|
this.applySidebarCollapse(this.isSidebarCollapsed());
|
||||||
if (src) {
|
if (src) {
|
||||||
// 把页面 body 内容追加到 .content,保留 4 个 corner-mark SVG
|
// 把页面 body 内容追加到 .content,保留 4 个 corner-mark SVG
|
||||||
document.getElementById('page-content').insertAdjacentHTML('beforeend', src.innerHTML);
|
document.getElementById('page-content').insertAdjacentHTML('beforeend', src.innerHTML);
|
||||||
@ -352,6 +361,26 @@ window.Shell = {
|
|||||||
this._bindGlobalChrome();
|
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) {
|
_esc(s) {
|
||||||
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
|||||||
@ -45,20 +45,20 @@
|
|||||||
.io-side-h .ti { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }
|
.io-side-h .ti { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }
|
||||||
.io-side-h .back-pill {
|
.io-side-h .back-pill {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
height: 28px; padding: 0 12px 0 8px;
|
height: 34px; padding: 0 13px 0 11px;
|
||||||
background: var(--background-lighter);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-faint);
|
border: 1px solid var(--border-faint);
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
font-size: 12.5px; font-weight: 500;
|
font-size: 13px; font-weight: 500;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||||
}
|
}
|
||||||
.io-side-h .back-pill:hover {
|
.io-side-h .back-pill:hover {
|
||||||
background: var(--heat-12);
|
background: var(--black-alpha-4);
|
||||||
border-color: var(--heat-20);
|
border-color: var(--black-alpha-24);
|
||||||
color: var(--heat);
|
color: var(--accent-black);
|
||||||
}
|
}
|
||||||
.io-side-h .back-pill svg { width: 14px; height: 14px; }
|
.io-side-h .back-pill svg { width: 14px; height: 14px; }
|
||||||
.io-side-h .fold {
|
.io-side-h .fold {
|
||||||
|
|||||||
@ -213,20 +213,20 @@
|
|||||||
}
|
}
|
||||||
.mp-side-top .back-pill {
|
.mp-side-top .back-pill {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
height: 28px; padding: 0 12px 0 8px;
|
height: 34px; padding: 0 13px 0 11px;
|
||||||
background: var(--background-lighter);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-faint);
|
border: 1px solid var(--border-faint);
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
font-size: 12.5px; font-weight: 500;
|
font-size: 13px; font-weight: 500;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||||
}
|
}
|
||||||
.mp-side-top .back-pill:hover {
|
.mp-side-top .back-pill:hover {
|
||||||
background: var(--heat-12);
|
background: var(--black-alpha-4);
|
||||||
border-color: var(--heat-20);
|
border-color: var(--black-alpha-24);
|
||||||
color: var(--heat);
|
color: var(--accent-black);
|
||||||
}
|
}
|
||||||
.mp-side-top .back-pill svg { width: 14px; height: 14px; }
|
.mp-side-top .back-pill svg { width: 14px; height: 14px; }
|
||||||
.mp-side-top .fold {
|
.mp-side-top .fold {
|
||||||
|
|||||||
@ -12,6 +12,39 @@
|
|||||||
|
|
||||||
/* ─── 顶部胶囊式 Stage 状态 · 注入到 .topbar 中部 ─── */
|
/* ─── 顶部胶囊式 Stage 状态 · 注入到 .topbar 中部 ─── */
|
||||||
.topbar { position: relative; } /* 锚定 pill */
|
.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 {
|
.stage-pill {
|
||||||
position: absolute; left: 50%; top: 50%;
|
position: absolute; left: 50%; top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
@ -2541,12 +2574,140 @@ const PROJECT_TITLE = shortProductName(CURRENT_PRODUCT_NAME) + ' · 痛点种草
|
|||||||
|
|
||||||
Shell.render({
|
Shell.render({
|
||||||
active: 'projects',
|
active: 'projects',
|
||||||
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目', href: 'projects.html' }, { label: PROJECT_TITLE }]
|
crumbs: []
|
||||||
});
|
});
|
||||||
|
|
||||||
/* 渲染贯穿商品名 / 项目名 */
|
/* 渲染贯穿商品名 / 项目名 */
|
||||||
document.getElementById('page-title').textContent = PROJECT_TITLE + ' · 流水线 · Airshelf';
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 实时同步)─── */
|
/* ─── 把 stage-pill anchor 注入 .topbar 中部(圆点全状态都靠 .sp-dot 实时同步)─── */
|
||||||
(function _injectStagePill() {
|
(function _injectStagePill() {
|
||||||
const anchor = document.getElementById('stage-pill-anchor');
|
const anchor = document.getElementById('stage-pill-anchor');
|
||||||
@ -2594,17 +2755,19 @@ function activateStage(n) {
|
|||||||
const cur = Number(n);
|
const cur = Number(n);
|
||||||
document.querySelectorAll('.stage').forEach(s => s.classList.remove('active'));
|
document.querySelectorAll('.stage').forEach(s => s.classList.remove('active'));
|
||||||
document.querySelector(`[data-stage-pane="${cur}"]`)?.classList.add('active');
|
document.querySelector(`[data-stage-pane="${cur}"]`)?.classList.add('active');
|
||||||
|
ProjectStore.setStage(cur);
|
||||||
|
|
||||||
// 圆点状态:< cur → done(森林绿) · = cur → active(橙实心+光晕) · > 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 => {
|
document.querySelectorAll('#stage-pill .sp-dot').forEach(s => {
|
||||||
const i = +s.dataset.stage;
|
const i = +s.dataset.stage;
|
||||||
s.classList.remove('active', 'done');
|
s.classList.remove('active', 'done');
|
||||||
if (i < cur) s.classList.add('done');
|
if (i === cur) s.classList.add('active');
|
||||||
else if (i === cur) s.classList.add('active');
|
else if (i <= completed) s.classList.add('done');
|
||||||
});
|
});
|
||||||
// 连接线 · idx+1 < cur 时染森林绿
|
// 连接线 · idx+1 < cur 时染森林绿
|
||||||
document.querySelectorAll('#stage-pill .sp-line').forEach((ln, idx) => {
|
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
|
// 全高度布局:所有 stage 操作模块 hug content、内容区域 fill content
|
||||||
@ -2616,6 +2779,7 @@ function activateStage(n) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
requestAnimationFrame(() => ProjectStore.restoreFields());
|
||||||
}
|
}
|
||||||
function readHash() {
|
function readHash() {
|
||||||
const m = location.hash.match(/stage-(\d)/);
|
const m = location.hash.match(/stage-(\d)/);
|
||||||
@ -2624,8 +2788,8 @@ function readHash() {
|
|||||||
const q = new URLSearchParams(location.search);
|
const q = new URLSearchParams(location.search);
|
||||||
const s = q.get('stage');
|
const s = q.get('stage');
|
||||||
if (s) { activateStage(+s); return; }
|
if (s) { activateStage(+s); return; }
|
||||||
// 兜底:默认 Stage 1 进行中(让 stage-pill 首个圆点点亮)
|
// 兜底:回到上次离开的 stage,等待生成时离开页面也能继续当前项目进度
|
||||||
activateStage(1);
|
activateStage(ProjectStore.data.currentStage || 1);
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', readHash);
|
window.addEventListener('hashchange', readHash);
|
||||||
readHash();
|
readHash();
|
||||||
@ -2670,11 +2834,12 @@ const Stage1 = (function () {
|
|||||||
group.querySelectorAll('.script-tag').forEach((chip, i) => {
|
group.querySelectorAll('.script-tag').forEach((chip, i) => {
|
||||||
const t = chip.querySelector('.t');
|
const t = chip.querySelector('.t');
|
||||||
const x = chip.querySelector('.x');
|
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', () => {
|
t.addEventListener('blur', () => {
|
||||||
const v = (t.textContent || '').trim();
|
const v = (t.textContent || '').trim();
|
||||||
if (!v) { scriptTags[kind].splice(i, 1); renderScriptTags(); }
|
if (!v) { scriptTags[kind].splice(i, 1); renderScriptTags(); }
|
||||||
else { scriptTags[kind][i] = v; }
|
else { scriptTags[kind][i] = v; }
|
||||||
|
saveState();
|
||||||
});
|
});
|
||||||
t.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); t.blur(); } });
|
t.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); t.blur(); } });
|
||||||
});
|
});
|
||||||
@ -2685,6 +2850,7 @@ const Stage1 = (function () {
|
|||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const kind = btn.parentElement.dataset.kind;
|
const kind = btn.parentElement.dataset.kind;
|
||||||
scriptTags[kind].push('');
|
scriptTags[kind].push('');
|
||||||
|
saveState();
|
||||||
renderScriptTags();
|
renderScriptTags();
|
||||||
const group = document.querySelector(`.script-tags .tag-group[data-kind="${kind}"]`);
|
const group = document.querySelector(`.script-tags .tag-group[data-kind="${kind}"]`);
|
||||||
const chips = group.querySelectorAll('.script-tag .t');
|
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');
|
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||||
}
|
}
|
||||||
function pushMsg(role, html) { chatMsgs.push({ role, html, time: now() }); }
|
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() {
|
function renderChat() {
|
||||||
const body = $cb(); if (!body) return;
|
const body = $cb(); if (!body) return;
|
||||||
@ -2780,6 +2996,8 @@ const Stage1 = (function () {
|
|||||||
const s = shots.find(x => x.id === id);
|
const s = shots.find(x => x.id === id);
|
||||||
if (s) s[field] = v;
|
if (s) s[field] = v;
|
||||||
if (!v) el.dataset.empty = 'true';
|
if (!v) el.dataset.empty = 'true';
|
||||||
|
ProjectStore.record('stage1.shot.edited', { id, field });
|
||||||
|
saveState();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
body.querySelectorAll('[data-act]').forEach(btn => {
|
body.querySelectorAll('[data-act]').forEach(btn => {
|
||||||
@ -2790,12 +3008,17 @@ const Stage1 = (function () {
|
|||||||
const after = btn.dataset.after;
|
const after = btn.dataset.after;
|
||||||
if (act === 'del') {
|
if (act === 'del') {
|
||||||
shots = shots.filter(x => x.id !== id);
|
shots = shots.filter(x => x.id !== id);
|
||||||
|
ProjectStore.record('stage1.shot.deleted', { id });
|
||||||
|
saveState();
|
||||||
renderShots();
|
renderShots();
|
||||||
} else if (act === 'regen') {
|
} else if (act === 'regen') {
|
||||||
Shell.toast('已请求重写本场', '↻ shot-' + id);
|
Shell.toast('已请求重写本场', '↻ shot-' + id);
|
||||||
|
ProjectStore.record('stage1.shot.regen', { id });
|
||||||
} else if (act === 'add-here') {
|
} else if (act === 'add-here') {
|
||||||
const idx = shots.findIndex(x => x.id === after);
|
const idx = shots.findIndex(x => x.id === after);
|
||||||
shots.splice(idx + 1, 0, { id: 'sh' + Date.now(), painting: '', dialog: '', duration: 5 });
|
shots.splice(idx + 1, 0, { id: 'sh' + Date.now(), painting: '', dialog: '', duration: 5 });
|
||||||
|
ProjectStore.record('stage1.shot.added', { after });
|
||||||
|
saveState();
|
||||||
renderShots();
|
renderShots();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -2807,11 +3030,19 @@ const Stage1 = (function () {
|
|||||||
|
|
||||||
function pickMode(m) {
|
function pickMode(m) {
|
||||||
mode = m;
|
mode = m;
|
||||||
|
ProjectStore.record('stage1.mode.selected', { mode: m });
|
||||||
if (m === 'ai') {
|
if (m === 'ai') {
|
||||||
|
ProjectStore.startJob('stage1-script', {
|
||||||
|
stage: 1,
|
||||||
|
label: '脚本初稿生成',
|
||||||
|
finishAt: Date.now() + 6500,
|
||||||
|
});
|
||||||
pushMsg('user', '帮我 AI 全自动生成一稿脚本');
|
pushMsg('user', '帮我 AI 全自动生成一稿脚本');
|
||||||
|
saveState();
|
||||||
renderChat();
|
renderChat();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
pushMsg('ai', '<span class="ai-thinking">正在解析商品卖点与目标人群 <span class="dots"><span></span><span></span><span></span></span></span>');
|
pushMsg('ai', '<span class="ai-thinking">正在解析商品卖点与目标人群 <span class="dots"><span></span><span></span><span></span></span></span>');
|
||||||
|
saveState();
|
||||||
renderChat();
|
renderChat();
|
||||||
}, 300);
|
}, 300);
|
||||||
// 7 镜 · 0-40s · 与 Stage 2 / 4 的 3 场切分对齐(场 1 深夜办公桌 0-15s / 场 2 面膜包装 15-27s / 场 3 化妆台定格 27-40s)
|
// 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
|
// remove thinking msg
|
||||||
chatMsgs = chatMsgs.filter(x => !(x.role === 'ai' && /ai-thinking/.test(x.html)));
|
chatMsgs = chatMsgs.filter(x => !(x.role === 'ai' && /ai-thinking/.test(x.html)));
|
||||||
pushMsg('ai', '初稿完成。点击任意卡片文字可直接编辑;鼠标移到卡片之间会出现「+ 添加分镜」。');
|
pushMsg('ai', '初稿完成。点击任意卡片文字可直接编辑;鼠标移到卡片之间会出现「+ 添加分镜」。');
|
||||||
|
ProjectStore.finishJob('stage1-script');
|
||||||
|
ProjectStore.record('stage1.script.ready', { shots: shots.length });
|
||||||
|
saveState();
|
||||||
renderChat();
|
renderChat();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shots.push(draft[cur++]);
|
shots.push(draft[cur++]);
|
||||||
|
saveState();
|
||||||
renderShots();
|
renderShots();
|
||||||
setTimeout(step, 700);
|
setTimeout(step, 700);
|
||||||
};
|
};
|
||||||
setTimeout(step, 1100);
|
setTimeout(step, 1100);
|
||||||
} else if (m === 'theme') {
|
} else if (m === 'theme') {
|
||||||
pushMsg('ai', '好,请给我一句话主题(5–30 字),例如:<br>· 熬夜党的急救面膜<br>· 加班吃啥不内疚<br>下面输入框直接打就行,我会按这句话扩成一稿镜头脚本。');
|
pushMsg('ai', '好,请给我一句话主题(5–30 字),例如:<br>· 熬夜党的急救面膜<br>· 加班吃啥不内疚<br>下面输入框直接打就行,我会按这句话扩成一稿镜头脚本。');
|
||||||
|
saveState();
|
||||||
renderChat();
|
renderChat();
|
||||||
} else if (m === 'manual') {
|
} else if (m === 'manual') {
|
||||||
pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框,我会按场自然切分并适配商品卖点。');
|
pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框,我会按场自然切分并适配商品卖点。');
|
||||||
|
saveState();
|
||||||
renderChat();
|
renderChat();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
loadState();
|
||||||
renderChat();
|
renderChat();
|
||||||
renderShots();
|
renderShots();
|
||||||
|
resumeAiJobIfNeeded();
|
||||||
|
ProjectStore.restoreFields();
|
||||||
document.getElementById('chat-clear-btn')?.addEventListener('click', () => {
|
document.getElementById('chat-clear-btn')?.addEventListener('click', () => {
|
||||||
chatMsgs = []; mode = null; shots = []; scriptTags = { char: [], scene: [] };
|
chatMsgs = []; mode = null; shots = []; scriptTags = { char: [], scene: [] };
|
||||||
|
ProjectStore.clearJob('stage1-script');
|
||||||
|
ProjectStore.record('stage1.cleared');
|
||||||
|
saveState();
|
||||||
renderChat(); renderShots();
|
renderChat(); renderShots();
|
||||||
});
|
});
|
||||||
bindTagAdders();
|
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, '<')}</span>`).join('')}</div>`
|
? `<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, '<')}</span>`).join('')}</div>`
|
||||||
: '';
|
: '';
|
||||||
pushMsg('user', fileTags + (v ? v.replace(/</g, '<') : '<span class="muted-2">(已附加文件)</span>'));
|
pushMsg('user', fileTags + (v ? v.replace(/</g, '<') : '<span class="muted-2">(已附加文件)</span>'));
|
||||||
|
const fileCt = attachments.length;
|
||||||
ta.value = '';
|
ta.value = '';
|
||||||
attachments = []; renderAttach();
|
attachments = []; renderAttach();
|
||||||
|
ProjectStore.record('stage1.chat.sent', { hasText: !!v, files: fileCt });
|
||||||
|
saveState();
|
||||||
renderChat();
|
renderChat();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。');
|
pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。');
|
||||||
|
saveState();
|
||||||
renderChat();
|
renderChat();
|
||||||
}, 400);
|
}, 400);
|
||||||
};
|
};
|
||||||
@ -4066,6 +4313,19 @@ const Stage2 = (function () {
|
|||||||
let previewIdx = -1; // 主图正在「预览」哪一版(浏览态,不动采用状态)
|
let previewIdx = -1; // 主图正在「预览」哪一版(浏览态,不动采用状态)
|
||||||
let adoptedIdx = -1; // 真正被「采用」的那一版,决定商品资产生效版本
|
let adoptedIdx = -1; // 真正被「采用」的那一版,决定商品资产生效版本
|
||||||
let generating = false;
|
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() {
|
function prodName() {
|
||||||
return CURRENT_PRODUCT_NAME || (document.getElementById('asset-prod-card-name')?.textContent ?? '商品');
|
return CURRENT_PRODUCT_NAME || (document.getElementById('asset-prod-card-name')?.textContent ?? '商品');
|
||||||
@ -4132,6 +4392,7 @@ const Stage2 = (function () {
|
|||||||
previewIdx = idx;
|
previewIdx = idx;
|
||||||
renderHistory();
|
renderHistory();
|
||||||
renderMain();
|
renderMain();
|
||||||
|
saveTriState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式「采用」当前预览版本 · 同步商品资产 + 隐藏缺三视图徽标
|
// 显式「采用」当前预览版本 · 同步商品资产 + 隐藏缺三视图徽标
|
||||||
@ -4158,6 +4419,7 @@ const Stage2 = (function () {
|
|||||||
}
|
}
|
||||||
renderHistory();
|
renderHistory();
|
||||||
renderMain();
|
renderMain();
|
||||||
|
saveTriState();
|
||||||
if (fromClick) Shell.toast('已采用 ' + ver.label, prodName() + ' · 商品资产已更新为该版本');
|
if (fromClick) Shell.toast('已采用 ' + ver.label, prodName() + ' · 商品资产已更新为该版本');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4170,12 +4432,8 @@ const Stage2 = (function () {
|
|||||||
aigenBtn.disabled = true;
|
aigenBtn.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function finishGeneration() {
|
||||||
if (generating) return;
|
if (!generating && !ProjectStore.getJob('stage2-product-tri')) return;
|
||||||
generating = true;
|
|
||||||
pane.classList.add('show');
|
|
||||||
renderLoading();
|
|
||||||
setTimeout(() => {
|
|
||||||
generating = false;
|
generating = false;
|
||||||
aigenBtn.disabled = false;
|
aigenBtn.disabled = false;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -4192,7 +4450,34 @@ const Stage2 = (function () {
|
|||||||
renderMain();
|
renderMain();
|
||||||
Shell.toast('三视图已生成 ' + newVer.label, prodName() + ' · 预览中,满意请点「采用此版本」');
|
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();
|
e.stopPropagation();
|
||||||
start();
|
start();
|
||||||
});
|
});
|
||||||
|
if (versions.length && previewIdx >= 0) {
|
||||||
|
pane.classList.add('show');
|
||||||
|
if (adoptedIdx >= 0) applyAdoption(false);
|
||||||
|
else { renderHistory(); renderMain(); }
|
||||||
|
}
|
||||||
|
resumeGenerationIfNeeded();
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
return { init };
|
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' }] },
|
{ 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;
|
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() {
|
function renderRow() {
|
||||||
const row = document.getElementById('sb-scenes-row');
|
const row = document.getElementById('sb-scenes-row');
|
||||||
@ -4233,7 +4544,7 @@ const Stage3 = (function () {
|
|||||||
<div class="sub">${s.time}</div>
|
<div class="sub">${s.time}</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
row.querySelectorAll('.sb-scene-thumb').forEach(t => {
|
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() {
|
function renderMain() {
|
||||||
@ -4241,7 +4552,12 @@ const Stage3 = (function () {
|
|||||||
const v = s.versions[s.adopted];
|
const v = s.versions[s.adopted];
|
||||||
document.getElementById('sb-main-img').innerHTML = `<span class="ph-frame">${s.name} · ${v.label}</span>`;
|
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-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
|
// history
|
||||||
const ct = document.getElementById('sb-history-ct');
|
const ct = document.getElementById('sb-history-ct');
|
||||||
const hist = document.getElementById('sb-history-row');
|
const hist = document.getElementById('sb-history-row');
|
||||||
@ -4256,6 +4572,8 @@ const Stage3 = (function () {
|
|||||||
hist.querySelectorAll('.sb-history-thumb').forEach(t => {
|
hist.querySelectorAll('.sb-history-thumb').forEach(t => {
|
||||||
t.addEventListener('click', () => {
|
t.addEventListener('click', () => {
|
||||||
s.adopted = +t.dataset.vi;
|
s.adopted = +t.dataset.vi;
|
||||||
|
ProjectStore.record('stage3.storyboard.version.selected', { scene: s.id, version: s.versions[s.adopted]?.label });
|
||||||
|
saveState();
|
||||||
renderMain();
|
renderMain();
|
||||||
Shell.toast('已切换至 ' + s.versions[s.adopted].label, s.name);
|
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) };
|
const v = { ts: (new Date()).toTimeString().slice(0, 5), label: 'v' + (s.versions.length + 1) };
|
||||||
s.versions.push(v);
|
s.versions.push(v);
|
||||||
s.adopted = s.versions.length - 1;
|
s.adopted = s.versions.length - 1;
|
||||||
|
ProjectStore.record('stage3.storyboard.rerun', { scene: s.id, version: v.label });
|
||||||
|
saveState();
|
||||||
Shell.toast('整张重跑', s.name + ' · ' + v.label);
|
Shell.toast('整张重跑', s.name + ' · ' + v.label);
|
||||||
renderAll();
|
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氛围:精致、放心、产品感' },
|
'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结尾:产品大图 + 价格 + 购物车浮动' },
|
'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;
|
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) {
|
function getPreviewIndex(v) {
|
||||||
const idx = Number.isInteger(v.preview) ? v.preview : v.adopted;
|
const idx = Number.isInteger(v.preview) ? v.preview : v.adopted;
|
||||||
return v.versions[idx] ? idx : Math.max(0, v.adopted || 0);
|
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');
|
const promptEl = document.getElementById('vd-prompt-edit');
|
||||||
if (promptEl) {
|
if (promptEl) {
|
||||||
promptEl.textContent = v.prompt || '';
|
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;
|
document.getElementById('vd-history-ct').textContent = v.versions.length;
|
||||||
const row = document.getElementById('vd-history-row');
|
const row = document.getElementById('vd-history-row');
|
||||||
@ -4318,6 +4656,8 @@ const Stage4 = (function () {
|
|||||||
row.querySelectorAll('.vd-history-thumb').forEach(t => {
|
row.querySelectorAll('.vd-history-thumb').forEach(t => {
|
||||||
t.addEventListener('click', () => {
|
t.addEventListener('click', () => {
|
||||||
v.preview = +t.dataset.vi;
|
v.preview = +t.dataset.vi;
|
||||||
|
ProjectStore.record('stage4.video.version.previewed', { id, version: v.versions[v.preview]?.label });
|
||||||
|
saveState();
|
||||||
openDetail(id);
|
openDetail(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -4347,6 +4687,8 @@ const Stage4 = (function () {
|
|||||||
const v = VIDEOS[curVid];
|
const v = VIDEOS[curVid];
|
||||||
v.adopted = getPreviewIndex(v);
|
v.adopted = getPreviewIndex(v);
|
||||||
v.preview = v.adopted;
|
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 + ' · 拼接将用此版');
|
Shell.toast('已采用 ' + v.versions[v.adopted].label, v.title + ' · 拼接将用此版');
|
||||||
document.getElementById('video-detail-modal').classList.remove('show');
|
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) };
|
const nv = { ts: (new Date()).toTimeString().slice(0, 5), label: 'v' + (v.versions.length + 1) };
|
||||||
v.versions.push(nv);
|
v.versions.push(nv);
|
||||||
v.preview = v.versions.length - 1;
|
v.preview = v.versions.length - 1;
|
||||||
|
ProjectStore.record('stage4.video.rerun', { id: curVid, version: nv.label });
|
||||||
|
saveState();
|
||||||
Shell.toast('重跑中', v.title + ' · 约 30s · 新版预览中');
|
Shell.toast('重跑中', v.title + ' · 约 30s · 新版预览中');
|
||||||
openDetail(curVid);
|
openDetail(curVid);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -206,20 +206,20 @@
|
|||||||
}
|
}
|
||||||
.pc-side-top .back-pill {
|
.pc-side-top .back-pill {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
height: 28px; padding: 0 12px 0 8px;
|
height: 34px; padding: 0 13px 0 11px;
|
||||||
background: var(--background-lighter);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-faint);
|
border: 1px solid var(--border-faint);
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
font-size: 12.5px; font-weight: 500;
|
font-size: 13px; font-weight: 500;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||||
}
|
}
|
||||||
.pc-side-top .back-pill:hover {
|
.pc-side-top .back-pill:hover {
|
||||||
background: var(--heat-12);
|
background: var(--black-alpha-4);
|
||||||
border-color: var(--heat-20);
|
border-color: var(--black-alpha-24);
|
||||||
color: var(--heat);
|
color: var(--accent-black);
|
||||||
}
|
}
|
||||||
.pc-side-top .back-pill svg { width: 14px; height: 14px; }
|
.pc-side-top .back-pill svg { width: 14px; height: 14px; }
|
||||||
.pc-side-top .fold {
|
.pc-side-top .fold {
|
||||||
|
|||||||
292
电商AI平台/页面流程定稿.md
292
电商AI平台/页面流程定稿.md
@ -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 拼接导出页
|
- 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 4 只展示和管理 AI 生成视频片段。
|
||||||
- Stage 3 故事板页不继承“每个分镜一张图”的结构。
|
|
||||||
- 故事板应总结整条 15s 视频,而不是把脚本分镜逐张拆开。
|
|
||||||
|
|
||||||
### 初步页面方向
|
每个 AI 片段的视频来源是:
|
||||||
|
|
||||||
主体重点应放在一张完整故事板 / 故事板预览上。
|
- 当前采用的 AI 生成版本
|
||||||
|
- AI 历史版本中的某一版
|
||||||
|
|
||||||
页面需要展示:
|
### 故事板使用
|
||||||
|
|
||||||
- 当前视频脚本摘要
|
如果当前片段有故事板,默认使用故事板生成视频。
|
||||||
- 使用的商品资产、人物资产、场景资产
|
|
||||||
- 故事板整体预览
|
|
||||||
- 生成状态
|
|
||||||
- 重新生成 / 调整提示词 / 采用当前版本
|
|
||||||
- 历史版本回选
|
|
||||||
|
|
||||||
故事板可重跑。
|
原因:
|
||||||
每次重跑保留历史版本,用户可以在历史版本中选择满意的一版。
|
|
||||||
|
- 对小白用户来说,“使用 / 不使用故事板”解释成本高。
|
||||||
|
- 用户既然生成了故事板,视频结果和故事板不一致时容易产生困惑。
|
||||||
|
|
||||||
|
Stage 4 可以允许用户修改是否使用故事板,但默认不主动要求用户选择。
|
||||||
|
|
||||||
|
### 页面结构初稿
|
||||||
|
|
||||||
|
左侧:
|
||||||
|
|
||||||
|
- 片段列表
|
||||||
|
- 每段显示时间范围、来源、生成状态、当前采用版本
|
||||||
|
|
||||||
|
中间:
|
||||||
|
|
||||||
|
- 当前片段视频预览 / 生成状态
|
||||||
|
- 视频播放器
|
||||||
|
- 当前采用版本标识
|
||||||
|
|
||||||
|
右侧:
|
||||||
|
|
||||||
|
- 视频提示词编辑器
|
||||||
|
- 故事板使用状态 / 可修改
|
||||||
|
- 绑定输入资产引用
|
||||||
|
- 视频历史版本
|
||||||
|
|
||||||
|
底部:
|
||||||
|
|
||||||
|
- 重新生成
|
||||||
|
- 采用当前版本
|
||||||
|
- 进入拼接导出
|
||||||
|
|
||||||
|
### 待继续确认
|
||||||
|
|
||||||
|
- 视频历史版本如何展示。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user