feat(web): 演出配置 Timeline 加「场景底图」选择器(venue 特写)

- 后端 /api/sceneshots:列 SceneShots 全部俯视底图(venue 特写) name->{url,bounds}
- timeline.js:底图优先级 ir.stage.backdrop(venue) > 点位集默认 shot;
  顶栏加底图下拉 renderMapInfo + applyBackdrop(换底+改投影范围+重画+回调)
- app.js:拉 /api/sceneshots;performSelect 传入;saveBackdrop 写 ir.stage.backdrop 并 PUT
- venue 特写与点位集同 map-local → 换底图后锚点自动落对位(无头实拍擂台验证)
- ir.stage.backdrop 是编辑器元数据:validate 不读、compile 不碰
@
This commit is contained in:
2026-06-15 12:01:14 +08:00
parent 65424a4dfb
commit 676df30c67
4 changed files with 96 additions and 7 deletions

View File

@ -219,6 +219,21 @@ async def pointsets():
return out
@app.get("/api/sceneshots")
async def sceneshots():
"""列出 SceneShots 目录里所有可用俯视底图venue 特写等name -> {url,bounds,w,h}。
供演出配置「场景底图」选择器:事件可挑一张当 Timeline 底图(写进 ir.stage.backdrop"""
out = {}
if os.path.isdir(_SCENESHOTS_DIR):
for fn in os.listdir(_SCENESHOTS_DIR):
if fn.endswith(".shot.json"):
name = fn[: -len(".shot.json")]
shot = _load_shot(name)
if shot:
out[name] = shot
return out
@app.get("/sceneshot/{name}.png")
async def sceneshot(name: str):
# 防目录穿越:只认纯文件名

View File

@ -3,6 +3,7 @@
const App = {
dict: { conditions: {}, grants: {} },
pointsets: {}, // name -> {mapId, points:[]}
sceneshots: {}, // name -> {url,bounds,w,h}(演出配置「场景底图」可选项)
events: [],
current: null, // 当前 group
ir: null, // 工作副本
@ -42,6 +43,7 @@
try {
App.dict = await (await api("/api/dictionary")).json();
App.pointsets = await (await api("/api/pointsets")).json();
try { App.sceneshots = await (await api("/api/sceneshots")).json(); } catch (_) { App.sceneshots = {}; }
} catch (e) { return; }
await loadList();
}
@ -252,12 +254,27 @@
host.appendChild(d);
});
}
let performIr = null; // 演出配置当前事件 IR底图选择写这里并持久化
async function performSelect(group) {
let d;
try { d = await (await api("/api/events/" + encodeURIComponent(group))).json(); } catch (e) { return; }
performCurrent = group;
performIr = d.ir;
performLoadList();
Timeline.show($("perform-main"), d.ir, App.dict, App.pointsets);
Timeline.show($("perform-main"), d.ir, App.dict, App.pointsets, {
sceneshots: App.sceneshots,
onPickBackdrop: name => saveBackdrop(group, name),
});
}
// 演出配置选底图 → 写 ir.stage.backdrop编辑器元数据validate/compile 都不读)并 PUT 持久化。
async function saveBackdrop(group, name) {
if (!performIr || group !== performCurrent) return;
performIr.stage = performIr.stage || {};
if (name) performIr.stage.backdrop = name; else delete performIr.stage.backdrop;
try {
await api("/api/events/" + encodeURIComponent(group), { method: "PUT", body: JSON.stringify({ ir: performIr, by: App.by }) });
toast(name ? ("底图已设为 " + name) : "已恢复默认底图");
} catch (e) { toast("底图保存失败"); }
}
// ---------- 导入 ----------

View File

@ -323,7 +323,9 @@ body.points-mode .mode-switch { border-color:#4a52a0; }
/* ---- 演出预览:上=舞台面板(自适应放大) / 下=时间轴面板(可拖高度) ---- */
.tl-stagepanel { flex:1 1 auto; min-height:150px; display:flex; flex-direction:column; }
.tl-mapinfo { flex:none; font-size:12px; color:#9a8f7e; margin-bottom:6px; }
.tl-mapinfo { flex:none; font-size:12px; color:#9a8f7e; margin-bottom:6px; display:flex; align-items:center; flex-wrap:wrap; gap:4px; }
.tl-bd-label { margin-left:10px; color:#7fae9c; }
.tl-bd-select { background:#1d1a13; color:#d8cda0; border:1px solid #4a4534; border-radius:4px; padding:1px 4px; font-size:12px; max-width:200px; }
.tl-stagewrap { position:relative; flex:1; min-height:0; background:#15130d; border:1px solid #3a322a;
border-radius:6px; overflow:hidden; }
.tl-stage { width:100%; height:100%; object-fit:contain; display:block; }

View File

@ -305,6 +305,9 @@
// ===================== 状态 & 挂载 =====================
let S, model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {}, pending = null, selNode = null, PX = 80, fitMode = false, lastDecisionId = null;
let shotBg = null, shotReady = false, shotBounds = null; // 真实场景正交俯视底图
// 场景底图选择器SHOTS=所有可选 venue 特写({name:{url,bounds}})curBackdrop=当前选中底图名(""=点位集默认)
// onPick=切换底图回调(app.js 写 ir.stage.backdrop 并持久化)baseBounds=无底图时回退的投影范围。
let SHOTS = {}, curPsName = "", curPs = {}, curBackdrop = "", onPick = null, baseBounds = null;
const TEMPLATE =
'<div class="tl-stagepanel">' +
@ -322,12 +325,21 @@
'<div class="tl-resizer" title="拖动调整时间轴高度"></div>' +
'<div class="tl-timelinepanel"><div class="tl-tracks"></div></div>';
function show(host, IR, DICT, POINTSETS, startId) {
function show(host, IR, DICT, POINTSETS, opts) {
stopPlay();
opts = opts || {};
const startId = opts.startId;
SHOTS = opts.sceneshots || {};
onPick = opts.onPickBackdrop || null;
const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {};
curPsName = psName; curPs = ps;
curBackdrop = (IR.stage || {}).backdrop || "";
S = prepare(IR, ps.anchors || []); model = S;
setupShot(ps.shot); // 有真实场景俯视底图 → 覆盖投影范围并异步加载drawStage 当舞台底
baseBounds = Object.assign({}, S.bounds); // 无底图时回退用
// 底图优先级:事件指定的 venue 特写(ir.stage.backdrop) > 点位集默认底图
const shot = (curBackdrop && SHOTS[curBackdrop]) ? SHOTS[curBackdrop] : ps.shot;
setupShot(shot); // 有真实场景俯视底图 → 覆盖投影范围并异步加载drawStage 当舞台底
host.innerHTML = TEMPLATE;
els = {
@ -345,9 +357,7 @@
timelinepanel: host.querySelector(".tl-timelinepanel"),
playhead: null,
};
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)"
: (shotBounds ? " · 真实坐标 · 已叠场景俯视底图" : " · 真实坐标"));
renderMapInfo();
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
els.play.onclick = () => playing ? stopPlay() : play();
@ -422,6 +432,51 @@
shotBg.src = shot.url;
}
// 顶部信息栏 + 场景底图选择器。底图列表来自 /api/sceneshotsvenue 特写等)。
function renderMapInfo() {
const mi = els.mapinfo; if (!mi) return;
mi.innerHTML = "";
const span = document.createElement("span");
span.textContent = "点位集:" + curPsName + (curPs.mapId ? "(地图 " + curPs.mapId + "" : "") +
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)"
: (shotBounds ? " · 真实坐标 · 已叠场景底图" : " · 真实坐标"));
mi.appendChild(span);
const names = Object.keys(SHOTS).sort();
if (names.length) {
const lab = document.createElement("label");
lab.className = "tl-bd-label"; lab.textContent = "场景底图:";
const sel = document.createElement("select");
sel.className = "tl-bd-select";
const def = document.createElement("option");
def.value = ""; def.textContent = curPs.shot ? "(点位集默认)" : "(无)";
sel.appendChild(def);
names.forEach(n => {
const o = document.createElement("option");
o.value = n; o.textContent = n;
if (n === curBackdrop) o.selected = true;
sel.appendChild(o);
});
sel.onchange = () => applyBackdrop(sel.value);
mi.appendChild(lab); mi.appendChild(sel);
}
}
// 切换 Timeline 底图:换底 + 改投影范围 + 重画 + 回调持久化(写 ir.stage.backdrop)。
// 锚点(点位集坐标)不变,因 venue 特写与点位集同为 map-local事件点会落在特写图正确位置上。
function applyBackdrop(name) {
curBackdrop = name;
const shot = (name && SHOTS[name]) ? SHOTS[name] : curPs.shot;
if (shot && Array.isArray(shot.bounds) && shot.bounds.length === 4) {
setupShot(shot);
} else { // 选「无/默认」且无点位集底图 → 回退基础范围
shotBg = null; shotReady = false; shotBounds = null;
if (baseBounds) S.bounds = Object.assign({}, baseBounds);
}
renderMapInfo();
renderFrame();
if (onPick) onPick(name); // app.js: ir.stage.backdrop = name 并 PUT 持久化
}
function restart() { startFrom(firstNode(S.IR)); }
function refreshTimeline() { orderRows(S); buildTracks(); }