/** * views/ave-studio.js — 🎬 导演工作室(单页标签式全流程) * * 设计依据(业界实读): * - Liang80mrer/AI-Animation-Studio:11 步流程 + Modular Skill(标准接口传结构化数据) * + Cross-Skill Inheritance(下游继承上游约束) * - yfge/ai-video-studio:单一真相源,「把 AI 产出变成可追溯的生产资产」 * * 本页 = 一条流水线,7 个标签: * 💡创意 → 🌍设定 → 📝剧本 → 🎬分镜 → 🖼关键帧 → 🎞视频 → 🎙后期 * 右侧常驻「📦 资源」面板;数据全部走 modules/project-store.js(服务端单一真相源) */ import * as store from '../modules/project-store.js'; const TABS = [ { id: 'creative', label: '💡 创意', hint: '参数选型 → 候选 → 选中', mod: () => import('./creative-studio.js') }, { id: 'setting', label: '🌍 设定', hint: '世界观 / 人物关系 / 基调', mod: () => import('./studio-setting.js') }, { id: 'creative_card', label: '📋 创意卡', hint: '创意 + 设定 → 想清楚的故事卡(剧本的依据)', mod: () => import('./studio-creative-card.js') }, { id: 'script', label: '📝 剧本', hint: '按段落写:剧情/动作/表情/情绪/画面重点', mod: () => import('./studio-script.js') }, { id: 'shots', label: '🎬 分镜', hint: '导演风格 + 演员表 → 分镜表', mod: () => import('./ave-director.js') }, { id: 'kf_plan', label: '🖼 关键帧设计', hint: '把镜头拆成 2-5 张连续关键帧(每张单独提示词)', mod: () => import('./studio-keyframe-plan.js') }, { id: 'keyframes', label: '🖼 关键帧', hint: '逐镜生成 / 审图 / 采用', mod: () => import('./ave-keyframes.js') }, { id: 'videos', label: '🎞 视频', hint: '关键帧 → 视频片段', mod: () => import('./ave-keyframe-video.js') }, { id: 'post', label: '🎙 后期', hint: '去AI化 / 字幕 / 混音 / 成片', mod: () => import('./studio-post.js') }, ]; // 右侧常驻资源栏:点分类**在本面板内展开**(不跳转,避免工作室状态丢失) // 需要完整管理时用「↗ 新标签页」—— 新标签页不影响当前页 const ASSETS = [ { kind: 'characters', label: '🧑 角色', view: 'characters' }, { kind: 'locations', label: '🏞️ 场景', view: 'ave-locations' }, { kind: 'real_locations', label: '🏙️ 真实场地', view: 'ave-real-locations' }, { kind: 'props', label: '🧰 道具', view: 'ave-props' }, { kind: 'products', label: '📦 产品', view: 'ave-products' }, { kind: 'wardrobe', label: '👗 服装', view: 'ave-wardrobe' }, { kind: 'voices', label: '🔊 音色', view: 'ave-voices' }, { kind: 'bgm', label: '🎵 BGM', view: 'ave-bgm' }, { kind: 'sfx', label: '💥 音效', view: 'ave-sfx' }, { kind: 'ambience', label: '🌆 环境音', view: 'ave-ambience' }, ]; let _pid = ''; let _curTab = null; let _curMod = null; let _unsub = null; let _busy = false; // ── 项目 ──────────────────────────────────────────────────── async function loadProjects(sel) { const el = document.getElementById('stuProject'); if (!el) return; try { const list = await store.listProjects(); if (!list.length) { el.innerHTML = ''; return; } el.innerHTML = list.map(p => ``).join(''); _pid = sel || el.value || list[0].project_id; el.value = _pid; await store.loadProject(_pid); renderHeader(); } catch (e) { el.innerHTML = ``; } } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function renderHeader() { const p = store.getProject() || {}; const el = document.getElementById('stuMeta'); if (!el) return; const kf = Object.keys(p.keyframes || {}).length; const vd = Object.keys(p.videos || {}).length; const cast = (p.cast || []).length; el.innerHTML = ` 当前阶段 ${esc(stageLabel(p.stage))} · 演员 ${cast} · 关键帧 ${kf} · 视频 ${vd} ${p._new ? ' · 尚未开始' : ''} `; } function stageLabel(id) { const t = TABS.find(x => x.id === id); return t ? t.label : (id || '—'); } function renderTabs() { const bar = document.getElementById('stuTabs'); if (!bar) return; bar.innerHTML = TABS.map(t => { const on = t.id === _curTab; return ``; }).join(''); } // ── 标签切换 ──────────────────────────────────────────────── async function switchTab(id) { if (_busy) return; _busy = true; const body = document.getElementById('studioBody'); try { // 卸载上一个 if (_curMod && typeof _curMod.unloadView === 'function') { try { _curMod.unloadView(); } catch (e) { /* 忽略 */ } } _curTab = id; renderTabs(); body.innerHTML = `
⏳ 载入${esc(stageLabel(id))}…
`; body.dataset.embedded = '1'; // 通知服务端当前阶段(可追溯) try { await store.setStage(id); renderHeader(); } catch (e) { /* 非致命 */ } const t = TABS.find(x => x.id === id); if (!t) return; _curMod = await t.mod(); await _curMod.loadView(body); } catch (e) { body.innerHTML = `
❌ 载入失败:${esc(e.message)}
`; } finally { _busy = false; } } // ── 右侧资源栏(常驻 · 面板内展开,不跳转)──────────────────── let _curAssetKind = null; function renderAssetMenu() { const cell = 'padding:4px 6px;background:var(--bg2);color:var(--text);' + 'border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:11px;' + 'text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; return `
点分类在此展开(不跳页)
${ASSETS.map(a => ` `).join('')}
`; } /** 🖼 产物弹窗预览 —— 图片 / 音频 / 视频直接在弹窗里看,关掉就回到工作室 * * 为什么不做新窗口打开:新标签页会跳出工作室(用户已填的内容在视觉上「丢失」), * 而且要手动切回来。弹窗原地预览、关闭即回,不打断流程。 */ window._stuPreview = (url, name) => { const ext = String(name || '').split('.').pop().toLowerCase(); const IMG = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg']; const VID = ['mp4', 'webm', 'mov', 'm4v', 'mkv']; const AUD = ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac']; let inner, kind; if (IMG.includes(ext)) { kind = '图片'; inner = ``; } else if (VID.includes(ext)) { kind = '视频'; inner = ``; } else if (AUD.includes(ext)) { kind = '音频'; inner = `
🎵
`; } else { kind = '未知类型'; inner = `
这个类型(.${esc(ext)})不能直接预览
新窗口打开
`; } let box = document.getElementById('_stuPreviewBox'); if (!box) { box = document.createElement('div'); box.id = '_stuPreviewBox'; box.style.cssText = 'position:fixed;inset:0;z-index:3000;display:none;' + 'align-items:center;justify-content:center;background:rgba(0,0,0,.9);cursor:zoom-out'; box.innerHTML = `
`; box.onclick = () => window._stuPreviewClose(); document.body.appendChild(box); // Esc 关闭 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') window._stuPreviewClose(); }); } document.getElementById('_stuPreviewInner').innerHTML = `
${kind} · ${esc(name)} ↗ 新窗口
${inner}`; box.style.display = 'flex'; }; window._stuPreviewClose = () => { const box = document.getElementById('_stuPreviewBox'); if (!box) return; // 停掉播放,避免关掉还在响 box.querySelectorAll('video, audio').forEach(el => { try { el.pause(); } catch (e) {} }); box.style.display = 'none'; const inner = document.getElementById('_stuPreviewInner'); if (inner) inner.innerHTML = ''; }; /** 📤 本项目产物(脚本/关键帧/视频/音频/成片,按类型归类) */ async function loadProjectArtifacts() { const body = document.getElementById('stuAssetBody'); const back = document.getElementById('stuAssetBack'); if (!body) return; if (back) back.style.display = 'inline-block'; body.innerHTML = '
⏳ 载入产物…
'; try { const r = await fetch(`/api/ave/project/${encodeURIComponent(_pid)}/artifacts`); const d = await r.json(); const groups = d.groups || []; if (!groups.length) { body.innerHTML = `
这个项目还没有产物。
按流程走一遍(剧本 → 关键帧 → 视频 → 导出)就会出现。
`; return; } body.innerHTML = `
共 ${d.totals?.items || 0} 项 · ${groups.length} 类
${groups.map(g => `
${esc(g.label)} (${g.items.length})
${g.items.map(it => `
${it.url ? `🔍 ${esc(it.name)}` : `${esc(it.name)}`} ${it.meta ? `${esc(it.meta)}` : ''}
`).join('')}
`).join('')}`; } catch (e) { body.innerHTML = `
❌ ${esc(e.message)}
`; } } async function loadAssetList(kind) { const body = document.getElementById('stuAssetBody'); const back = document.getElementById('stuAssetBack'); if (!body) return; const a = ASSETS.find(x => x.kind === kind) || { label: kind }; if (back) back.style.display = 'inline-block'; body.innerHTML = `
⏳ 载入${esc(a.label)}…
`; try { const r = await fetch(`/api/ave/assets/${encodeURIComponent(kind)}`); const d = await r.json(); const items = d.items || d.assets || d.data || []; if (!items.length) { body.innerHTML = `
这一分类还没有内容。
点下面「↗ 新标签页」去创建,建好后回本页刷新即可。
`; return; } body.innerHTML = `
${esc(a.label)} · 共 ${items.length} 个
${items.map(it => { const refs = it.reference_images || {}; const first = Object.values(refs)[0] || it.thumb || ''; const img = first ? `` : `
📄
`; return `
${img}
${esc(it.name || it.id)}
`; }).join('')}
`; } catch (e) { body.innerHTML = `
❌ ${esc(e.message)}
`; } } function backToAssetMenu() { _curAssetKind = null; const body = document.getElementById('stuAssetBody'); const back = document.getElementById('stuAssetBack'); if (body) body.innerHTML = renderAssetMenu(); if (back) back.style.display = 'none'; } function toggleAssets() { const p = document.getElementById('stuAssets'); if (!p) return; const show = p.style.display === 'none'; p.style.display = show ? 'block' : 'none'; } // ── 入口 ──────────────────────────────────────────────────── export async function loadView(container) { _curTab = null; _curMod = null; _pid = ''; container.innerHTML = `
🎬 导演工作室
📁 项目
📦 资源库
${renderAssetMenu()}
当前项目:${esc(_pid || '—')}
数据存服务端,点资源不跳页、已填内容不丢
ℹ️ 旧页面(菜单「流程」下)仍可独立使用,过渡期后移除。 本页是整合后的主路径 —— 一条流水线走到底,不用来回跳页。
`; window._stuTab = (id) => switchTab(id); window._stuProject = async (v) => { _pid = v; await store.loadProject(_pid); renderHeader(); if (_curTab) await switchTab(_curTab); }; window._stuToggleAssets = toggleAssets; // 点分类 → 在本面板内展开(**不跳转**,工作室已填内容不丢) window._stuAsset = (kind) => { _curAssetKind = kind; loadAssetList(kind); }; // 📤 本项目产物(脚本/关键帧/视频/音频/成片) window._stuArtifacts = () => { _curAssetKind = '__artifacts__'; loadProjectArtifacts(); }; window._stuAssetBack = backToAssetMenu; // 新标签页打开完整资源库(不影响当前页) window._stuOpenFull = (view) => { const v = view || (_curAssetKind ? (ASSETS.find(a => a.kind === _curAssetKind) || {}).view : '') || 'characters'; window.open(`${location.pathname}#${v}`, '_blank'); }; // 点某个资产 → 资源库里打开详情(新标签页),或加入当前项目 window._stuAssetPick = (kind, id, name) => { const a = ASSETS.find(x => x.kind === kind) || {}; const go = confirm(`「${name}」\n\n` + `点「确定」→ 在新标签页打开资源库管理它\n` + `点「取消」→ 什么都不做`); if (go) window.open(`${location.pathname}#${a.view || 'characters'}`, '_blank'); }; await loadProjects(); // ★ 接收「创意库 → 带到导演工作室」的带入(写进项目对象,不再是临时传参) const gotCreative = await applyCreativeHandoff(); // 打开时跳到服务端记录的当前阶段(若刚带入了创意,直接去创意标签) const cur = gotCreative ? 'creative' : ((store.getProject() || {}).stage || 'creative'); await switchTab(cur); } /** 把「创意库」点『带到导演工作室』送来的创意,写进 project.creative * * 原来这套 handoff 是给「老导演页」的一次性传参(读一次就删), * 现在已经统一到工作室,所以在这里消费 —— 并且落到项目对象里, * 这样剧本阶段(读 project.creative)才真的拿得到。 */ async function applyCreativeHandoff() { let h = null; try { const raw = localStorage.getItem('ave_creative_handoff'); if (raw) { h = JSON.parse(raw); localStorage.removeItem('ave_creative_handoff'); } } catch (e) { return false; } if (!h) return false; const age = Date.now() - new Date(h.at || 0).getTime(); if (!(age >= 0 && age < 30 * 60 * 1000)) return false; // 仅 30 分钟内有效 try { const prev = ((store.getProject() || {}).creative) || {}; const picked = [...(prev.selected || [])]; const one = h.one_liner || h.title || ''; if (one && !picked.some(x => x && (x.cid === h.id || (x.one_liner || x.one_line) === one))) { picked.unshift({ cid: h.id, one_liner: one, gap_score: h.gap_score, from_library: true, scene_card: h.scene_card }); } await store.patchProject({ creative: { selected: picked, brief: h.brief || prev.brief || '', count: picked.length, inject_mode: h.inject_mode || prev.inject_mode, from_library: true, updated_at: new Date().toISOString(), }, }); if (window.showToast) { window.showToast(`✅ 已从创意库带入:${(one || '').slice(0, 30)}`); } return true; } catch (e) { return false; } } export function unloadView() { if (_curMod && typeof _curMod.unloadView === 'function') { try { _curMod.unloadView(); } catch (e) { /* 忽略 */ } } if (_unsub) { _unsub(); _unsub = null; } _curMod = null; _curTab = null; _curAssetKind = null; // 清理挂在 window 上的回调,避免残留 ['_stuTab', '_stuProject', '_stuToggleAssets', '_stuAsset', '_stuAssetBack', '_stuOpenFull', '_stuAssetPick', '_stuArtifacts'].forEach(k => { try { delete window[k]; } catch (e) {} }); // 关掉可能还开着的产物预览弹窗(它挂在 document.body 上,不会被 container 清掉) try { if (window._stuPreviewClose) window._stuPreviewClose(); } catch (e) {} }