Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/frontend/src/views/ave-studio.js
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

511 lines
24 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) {}
}