@
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:
15
web/app.py
15
web/app.py
@ -219,6 +219,21 @@ async def pointsets():
|
|||||||
return out
|
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")
|
@app.get("/sceneshot/{name}.png")
|
||||||
async def sceneshot(name: str):
|
async def sceneshot(name: str):
|
||||||
# 防目录穿越:只认纯文件名
|
# 防目录穿越:只认纯文件名
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
const App = {
|
const App = {
|
||||||
dict: { conditions: {}, grants: {} },
|
dict: { conditions: {}, grants: {} },
|
||||||
pointsets: {}, // name -> {mapId, points:[]}
|
pointsets: {}, // name -> {mapId, points:[]}
|
||||||
|
sceneshots: {}, // name -> {url,bounds,w,h}(演出配置「场景底图」可选项)
|
||||||
events: [],
|
events: [],
|
||||||
current: null, // 当前 group
|
current: null, // 当前 group
|
||||||
ir: null, // 工作副本
|
ir: null, // 工作副本
|
||||||
@ -42,6 +43,7 @@
|
|||||||
try {
|
try {
|
||||||
App.dict = await (await api("/api/dictionary")).json();
|
App.dict = await (await api("/api/dictionary")).json();
|
||||||
App.pointsets = await (await api("/api/pointsets")).json();
|
App.pointsets = await (await api("/api/pointsets")).json();
|
||||||
|
try { App.sceneshots = await (await api("/api/sceneshots")).json(); } catch (_) { App.sceneshots = {}; }
|
||||||
} catch (e) { return; }
|
} catch (e) { return; }
|
||||||
await loadList();
|
await loadList();
|
||||||
}
|
}
|
||||||
@ -252,12 +254,27 @@
|
|||||||
host.appendChild(d);
|
host.appendChild(d);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let performIr = null; // 演出配置当前事件 IR(底图选择写这里并持久化)
|
||||||
async function performSelect(group) {
|
async function performSelect(group) {
|
||||||
let d;
|
let d;
|
||||||
try { d = await (await api("/api/events/" + encodeURIComponent(group))).json(); } catch (e) { return; }
|
try { d = await (await api("/api/events/" + encodeURIComponent(group))).json(); } catch (e) { return; }
|
||||||
performCurrent = group;
|
performCurrent = group;
|
||||||
|
performIr = d.ir;
|
||||||
performLoadList();
|
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("底图保存失败"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 导入 ----------
|
// ---------- 导入 ----------
|
||||||
|
|||||||
@ -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-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;
|
.tl-stagewrap { position:relative; flex:1; min-height:0; background:#15130d; border:1px solid #3a322a;
|
||||||
border-radius:6px; overflow:hidden; }
|
border-radius:6px; overflow:hidden; }
|
||||||
.tl-stage { width:100%; height:100%; object-fit:contain; display:block; }
|
.tl-stage { width:100%; height:100%; object-fit:contain; display:block; }
|
||||||
|
|||||||
@ -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 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; // 真实场景正交俯视底图
|
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 =
|
const TEMPLATE =
|
||||||
'<div class="tl-stagepanel">' +
|
'<div class="tl-stagepanel">' +
|
||||||
@ -322,12 +325,21 @@
|
|||||||
'<div class="tl-resizer" title="拖动调整时间轴高度"></div>' +
|
'<div class="tl-resizer" title="拖动调整时间轴高度"></div>' +
|
||||||
'<div class="tl-timelinepanel"><div class="tl-tracks"></div></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();
|
stopPlay();
|
||||||
|
opts = opts || {};
|
||||||
|
const startId = opts.startId;
|
||||||
|
SHOTS = opts.sceneshots || {};
|
||||||
|
onPick = opts.onPickBackdrop || null;
|
||||||
const psName = (IR.stage || {}).point_set || IR.id;
|
const psName = (IR.stage || {}).point_set || IR.id;
|
||||||
const ps = (POINTSETS || {})[psName] || {};
|
const ps = (POINTSETS || {})[psName] || {};
|
||||||
|
curPsName = psName; curPs = ps;
|
||||||
|
curBackdrop = (IR.stage || {}).backdrop || "";
|
||||||
S = prepare(IR, ps.anchors || []); model = S;
|
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;
|
host.innerHTML = TEMPLATE;
|
||||||
els = {
|
els = {
|
||||||
@ -345,9 +357,7 @@
|
|||||||
timelinepanel: host.querySelector(".tl-timelinepanel"),
|
timelinepanel: host.querySelector(".tl-timelinepanel"),
|
||||||
playhead: null,
|
playhead: null,
|
||||||
};
|
};
|
||||||
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + ")" : "") +
|
renderMapInfo();
|
||||||
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)"
|
|
||||||
: (shotBounds ? " · 真实坐标 · 已叠场景俯视底图" : " · 真实坐标"));
|
|
||||||
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
|
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
|
||||||
|
|
||||||
els.play.onclick = () => playing ? stopPlay() : play();
|
els.play.onclick = () => playing ? stopPlay() : play();
|
||||||
@ -422,6 +432,51 @@
|
|||||||
shotBg.src = shot.url;
|
shotBg.src = shot.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 顶部信息栏 + 场景底图选择器。底图列表来自 /api/sceneshots(venue 特写等)。
|
||||||
|
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 restart() { startFrom(firstNode(S.IR)); }
|
||||||
|
|
||||||
function refreshTimeline() { orderRows(S); buildTracks(); }
|
function refreshTimeline() { orderRows(S); buildTracks(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user