移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
511 lines
24 KiB
Python
511 lines
24 KiB
Python
/**
|
||
* 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 = '<option value="">(还没有项目)</option>';
|
||
return;
|
||
}
|
||
el.innerHTML = list.map(p =>
|
||
`<option value="${esc(p.project_id)}">${esc(p.title)} · ${esc(p.stage)}</option>`).join('');
|
||
_pid = sel || el.value || list[0].project_id;
|
||
el.value = _pid;
|
||
await store.loadProject(_pid);
|
||
renderHeader();
|
||
} catch (e) {
|
||
el.innerHTML = `<option value="">加载失败:${esc(e.message)}</option>`;
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<span style="color:var(--text2);font-size:11px">
|
||
当前阶段 <b style="color:var(--primary)">${esc(stageLabel(p.stage))}</b>
|
||
· 演员 ${cast} · 关键帧 ${kf} · 视频 ${vd}
|
||
${p._new ? ' · <span style="color:#f59e0b">尚未开始</span>' : ''}
|
||
</span>`;
|
||
}
|
||
|
||
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 `<button onclick="window._stuTab('${t.id}')" title="${esc(t.hint)}"
|
||
style="padding:6px 13px;border-radius:7px 7px 0 0;cursor:pointer;font-size:12.5px;
|
||
white-space:nowrap;border:1px solid ${on ? 'var(--primary)' : 'var(--border)'};
|
||
border-bottom:${on ? '1px solid var(--bg)' : '1px solid var(--border)'};
|
||
background:${on ? 'var(--bg)' : 'var(--bg3)'};
|
||
color:${on ? 'var(--text)' : 'var(--text2)'};
|
||
font-weight:${on ? '600' : '400'};position:relative;top:1px">
|
||
${t.label}</button>`;
|
||
}).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 = `<div style="padding:40px;text-align:center;color:var(--text2);font-size:12px">
|
||
⏳ 载入${esc(stageLabel(id))}…</div>`;
|
||
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 = `<div style="padding:30px;color:#ef4444;font-size:12px">
|
||
❌ 载入失败:${esc(e.message)}</div>`;
|
||
} 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 `
|
||
<button onclick="window._stuArtifacts()"
|
||
style="display:block;width:100%;text-align:left;margin-bottom:7px;padding:6px 8px;
|
||
background:rgba(139,92,246,.15);color:var(--text);font-weight:600;
|
||
border:1px solid #8b5cf6;border-radius:5px;cursor:pointer;font-size:11.5px">
|
||
📤 本项目产物</button>
|
||
<div style="font-size:10px;color:var(--text2);margin-bottom:5px;line-height:1.5">
|
||
点分类在此展开(不跳页)
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:3px">
|
||
${ASSETS.map(a => `
|
||
<button onclick="window._stuAsset('${a.kind}')" title="${esc(a.label)}"
|
||
style="${cell}">${a.label}</button>`).join('')}
|
||
</div>
|
||
<button onclick="window._stuOpenFull()"
|
||
style="display:block;width:100%;text-align:left;margin-top:7px;padding:5px 7px;
|
||
background:none;color:var(--primary);border:1px dashed var(--border);
|
||
border-radius:4px;cursor:pointer;font-size:10.5px">↗ 新标签页打开完整资源库</button>`;
|
||
}
|
||
|
||
/** 🖼 产物弹窗预览 —— 图片 / 音频 / 视频直接在弹窗里看,关掉就回到工作室
|
||
*
|
||
* 为什么不做新窗口打开:新标签页会跳出工作室(用户已填的内容在视觉上「丢失」),
|
||
* 而且要手动切回来。弹窗原地预览、关闭即回,不打断流程。
|
||
*/
|
||
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 = `<img src="${url}" style="max-width:92vw;max-height:80vh;object-fit:contain;
|
||
border-radius:8px;background:#0d0d10;display:block">`;
|
||
} else if (VID.includes(ext)) {
|
||
kind = '视频';
|
||
inner = `<video src="${url}" controls autoplay playsinline
|
||
style="max-width:92vw;max-height:80vh;border-radius:8px;background:#000;display:block"></video>`;
|
||
} else if (AUD.includes(ext)) {
|
||
kind = '音频';
|
||
inner = `<div style="text-align:center;padding:22px 30px">
|
||
<div style="font-size:34px;margin-bottom:12px">🎵</div>
|
||
<audio src="${url}" controls autoplay style="width:min(520px,84vw);display:block"></audio>
|
||
</div>`;
|
||
} else {
|
||
kind = '未知类型';
|
||
inner = `<div style="padding:26px;text-align:center;color:var(--text2);font-size:12px">
|
||
这个类型(.${esc(ext)})不能直接预览<br>
|
||
<a href="${url}" target="_blank" style="color:var(--primary)">新窗口打开</a></div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div id="_stuPreviewInner" onclick="event.stopPropagation()"
|
||
style="cursor:default;max-width:94vw;max-height:88vh;display:flex;
|
||
flex-direction:column;align-items:center;gap:9px"></div>`;
|
||
box.onclick = () => window._stuPreviewClose();
|
||
document.body.appendChild(box);
|
||
// Esc 关闭
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') window._stuPreviewClose();
|
||
});
|
||
}
|
||
document.getElementById('_stuPreviewInner').innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:10px;max-width:92vw">
|
||
<span style="color:#eee;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap">${kind} · ${esc(name)}</span>
|
||
<button onclick="window._stuPreviewClose()"
|
||
style="background:#2a2a30;color:#eee;border:1px solid #444;border-radius:5px;
|
||
padding:3px 12px;cursor:pointer;font-size:12px;flex:0 0 auto">✕ 关闭 (Esc)</button>
|
||
<a href="${url}" target="_blank"
|
||
style="color:#8ab4f8;font-size:11px;flex:0 0 auto;white-space:nowrap">↗ 新窗口</a>
|
||
</div>
|
||
${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 = '<div style="font-size:11px;color:var(--text2)">⏳ 载入产物…</div>';
|
||
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 = `<div style="font-size:11px;color:var(--text2);line-height:1.8">
|
||
这个项目还没有产物。<br>按流程走一遍(剧本 → 关键帧 → 视频 → 导出)就会出现。</div>`;
|
||
return;
|
||
}
|
||
body.innerHTML = `
|
||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:5px">
|
||
共 ${d.totals?.items || 0} 项 · ${groups.length} 类</div>
|
||
${groups.map(g => `
|
||
<div style="margin-bottom:9px">
|
||
<div style="font-size:11px;font-weight:600;margin-bottom:3px">
|
||
${esc(g.label)} <span style="color:var(--text2);font-weight:400">
|
||
(${g.items.length})</span></div>
|
||
${g.items.map(it => `
|
||
<div style="display:flex;align-items:center;gap:5px;padding:3px 5px;
|
||
border-bottom:1px solid var(--border);font-size:10px">
|
||
${it.url ? `<a href="javascript:void(0)"
|
||
onclick="window._stuPreview('${esc(it.url)}','${esc(it.name)}')"
|
||
style="color:var(--primary);text-decoration:none;cursor:zoom-in;
|
||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1"
|
||
title="点击预览:${esc(it.name)}">🔍 ${esc(it.name)}</a>`
|
||
: `<span style="flex:1;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap" title="${esc(it.name)}">${esc(it.name)}</span>`}
|
||
${it.meta ? `<span style="color:var(--text2);flex:0 0 auto">${esc(it.meta)}</span>` : ''}
|
||
</div>`).join('')}
|
||
</div>`).join('')}`;
|
||
} catch (e) {
|
||
body.innerHTML = `<div style="font-size:11px;color:#ef4444">❌ ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = `<div style="font-size:11px;color:var(--text2)">⏳ 载入${esc(a.label)}…</div>`;
|
||
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 = `
|
||
<div style="font-size:11px;color:var(--text2);line-height:1.8">
|
||
这一分类还没有内容。<br>
|
||
点下面「↗ 新标签页」去创建,建好后回本页刷新即可。</div>
|
||
<button onclick="window._stuOpenFull('${esc(a.view || '')}')"
|
||
style="margin-top:7px;width:100%;padding:5px;background:var(--bg2);color:var(--primary);
|
||
border:1px dashed var(--border);border-radius:4px;cursor:pointer;font-size:11px">
|
||
↗ 去创建</button>`;
|
||
return;
|
||
}
|
||
body.innerHTML = `
|
||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:5px">
|
||
${esc(a.label)} · 共 ${items.length} 个</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px">
|
||
${items.map(it => {
|
||
const refs = it.reference_images || {};
|
||
const first = Object.values(refs)[0] || it.thumb || '';
|
||
const img = first
|
||
? `<img src="/api/ave/assets/${encodeURIComponent(kind)}/${encodeURIComponent(it.id)}/file/${encodeURIComponent(first)}"
|
||
loading="lazy" style="width:100%;height:58px;object-fit:cover;border-radius:3px;background:#111">`
|
||
: `<div style="height:58px;display:flex;align-items:center;justify-content:center;
|
||
background:var(--bg2);border-radius:3px;font-size:16px">📄</div>`;
|
||
return `<div onclick="window._stuAssetPick('${esc(kind)}','${esc(it.id)}','${esc(it.name || it.id)}')"
|
||
title="${esc(it.name || it.id)}"
|
||
style="border:1px solid var(--border);border-radius:5px;padding:3px;cursor:pointer;
|
||
background:var(--bg2)">
|
||
${img}
|
||
<div style="font-size:9.5px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap">${esc(it.name || it.id)}</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>`;
|
||
} catch (e) {
|
||
body.innerHTML = `<div style="font-size:11px;color:#ef4444">❌ ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<style>
|
||
.stu-tabbar{display:flex;gap:4px;overflow-x:auto;border-bottom:1px solid var(--border);
|
||
padding-bottom:0;margin-bottom:0}
|
||
.stu-body{background:var(--bg);border:1px solid var(--border);border-top:none;
|
||
border-radius:0 0 8px 8px;padding:10px;min-height:60vh;
|
||
max-height:calc(100vh - 250px);overflow-y:auto}
|
||
</style>
|
||
<div style="max-width:1680px">
|
||
<!-- 顶部:项目 + 阶段状态 -->
|
||
<div style="display:flex;align-items:center;gap:11px;margin-bottom:9px;flex-wrap:wrap">
|
||
<div style="font-size:15px;font-weight:700">🎬 导演工作室</div>
|
||
<div id="stuMeta"></div>
|
||
<div style="margin-left:auto;display:flex;gap:7px;align-items:center">
|
||
<button onclick="window._stuToggleAssets()"
|
||
style="padding:5px 11px;border-radius:5px;cursor:pointer;font-size:11.5px;
|
||
background:var(--bg3);color:var(--text);border:1px solid var(--border)">
|
||
📦 资源面板</button>
|
||
<span style="font-size:10.5px;color:var(--text2);margin-right:2px"
|
||
title="这里切换项目,所有标签都会跟着切">📁 项目</span>
|
||
<select id="stuProject" onchange="window._stuProject(this.value)"
|
||
title="切换项目 —— 所有标签(分镜/关键帧/视频/后期)都会跟着换成这个项目的数据"
|
||
style="padding:5px 9px;background:var(--bg2);border:1px solid var(--primary);
|
||
color:var(--text);border-radius:5px;font-size:12px;max-width:280px"></select>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:grid;grid-template-columns:1fr auto;gap:11px;align-items:start">
|
||
<div style="min-width:0">
|
||
<!-- 7 个阶段标签 -->
|
||
<div id="stuTabs" class="stu-tabbar"></div>
|
||
<!-- 当前阶段内容 -->
|
||
<div id="studioBody" class="stu-body"></div>
|
||
</div>
|
||
<!-- 右侧常驻资源栏(可收起;点分类在本面板内展开,不跳转) -->
|
||
<div id="stuAssets" style="display:block;width:186px;background:var(--bg3);
|
||
border:1px solid var(--border);border-radius:8px;padding:8px;
|
||
max-height:calc(100vh - 210px);overflow-y:auto">
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:7px">
|
||
<div style="font-size:11.5px;font-weight:600">📦 资源库</div>
|
||
<button onclick="window._stuAssetBack()" id="stuAssetBack"
|
||
style="display:none;margin-left:auto;background:none;color:var(--primary);
|
||
border:1px solid var(--border);border-radius:4px;padding:1px 7px;
|
||
cursor:pointer;font-size:10.5px">← 返回</button>
|
||
</div>
|
||
<div id="stuAssetBody">${renderAssetMenu()}</div>
|
||
<div style="border-top:1px dashed var(--border);margin:8px 0 6px"></div>
|
||
<div style="font-size:10px;color:var(--text2);line-height:1.7">
|
||
当前项目:<b>${esc(_pid || '—')}</b><br>
|
||
数据存服务端,点资源不跳页、已填内容不丢
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="font-size:10.5px;color:var(--text2);margin-top:8px;line-height:1.7">
|
||
ℹ️ 旧页面(菜单「流程」下)仍可独立使用,过渡期后移除。
|
||
本页是整合后的主路径 —— 一条流水线走到底,不用来回跳页。
|
||
</div>
|
||
</div>`;
|
||
|
||
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(`✅ 已从创意库带入:<b>${(one || '').slice(0, 30)}</b>`);
|
||
}
|
||
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) {}
|
||
}
|