Polish navigation and pipeline flow
This commit is contained in:
+361
-17
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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', '好,请给我一句话主题(5–30 字),例如:<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, '<')}</span>`).join('')}</div>`
|
||||
: '';
|
||||
pushMsg('user', fileTags + (v ? v.replace(/</g, '<') : '<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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user