Polish navigation and pipeline flow

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
+361 -17
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);
});