/**
* 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
? `
}/${encodeURIComponent(it.id)}/file/${encodeURIComponent(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) {}
}