// scene_edit.js — 演出段(scene)多轨时间线编辑模态。
// 直接编辑 App.ir 里某个 scene 节点的 tracks/clips:拖 clip 改 start、拖右缘改 dur、
// 增删 clip/轨道、设目标点/文本,实时重画 + 客户端校验 + 复用白模播放核预览。
// 时长口径复用 window.Timeline._clipDur(与播放/校验单一来源)。
// 暴露 window.SceneEdit = { open(sceneId, ir, ctx, pointsets, dict) }。
(function () {
const PX = 90, LANE_H = 34, GRID = 0.1, MIN_DUR = 0.2, LABEL_W = 96; // LABEL_W 须与 .se-lane-label 宽一致
let ir, ctx, dict, pointsets, sceneId, posMap = {}, sel = null;
let root = null, lanesEl = null, propEl = null, lintEl = null, previewEl = null, rulerWrapEl = null;
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); }
function snap(v) { return Math.max(0, Math.round(v / GRID) * GRID); }
function round1(v) { return Math.round(v * 10) / 10; }
function scene() { return (ir.nodes || []).find(n => n.id === sceneId); }
function pointNames() { return Object.keys(posMap); }
function actorSlots() { return ["P1"].concat((ir.roles || []).map(r => r.slot)); }
function actorName(s) { if (s === "P1") return "玩家"; const r = (ir.roles || []).find(x => x.slot === s); return r ? r.name : s; }
// 每个 actor 的 move from(跨轨续连,按 start 排序)→ Map(clip->{x,z})
function moveFroms(sc) {
const byActor = {};
(sc.tracks || []).forEach(tk => { if (tk.role === "camera") return; const a = tk.actor || "P1";
(tk.clips || []).forEach(c => { if (c.kind === "move") (byActor[a] = byActor[a] || []).push(c); }); });
const m = new Map();
Object.keys(byActor).forEach(a => { let prev = posMap[a] || { x: 0, z: 0 };
byActor[a].slice().sort((x, y) => (x.start || 0) - (y.start || 0)).forEach(c => {
m.set(c, prev); const to = posMap[c.to] || prev; prev = { x: to.x, z: to.z };
});
});
return m;
}
function durOf(c, froms) { return window.Timeline._clipDur(c, posMap, froms.get(c)); }
function sceneEnd(sc, froms) { let e = 0.1; (sc.tracks || []).forEach(tk => (tk.clips || []).forEach(c => { e = Math.max(e, (c.start || 0) + durOf(c, froms)); })); return e; }
function newClipId(sc) {
let i = 1, id; const has = id => (sc.tracks || []).some(tk => (tk.clips || []).some(c => c.id === id));
do { id = "c" + i++; } while (has(id));
return id;
}
// ---------- 打开 / 关闭 ----------
function open(_sceneId, _ir, _ctx, _pointsets, _dict) {
sceneId = _sceneId; ir = _ir; ctx = _ctx; pointsets = _pointsets || {}; dict = _dict || {}; sel = null;
const psName = (ir.stage || {}).point_set || ir.id;
const ps = pointsets[psName] || {};
posMap = {}; (ps.anchors || []).forEach(a => posMap[a.name] = { x: (a.pos || [0, 0, 0])[0], z: (a.pos || [0, 0, 0])[2] });
buildModal(psName, ps);
render();
}
function close() { try { window.Timeline.stop(); } catch (e) {} if (root) root.remove(); root = null; }
function buildModal(psName, ps) {
if (root) root.remove();
root = document.createElement("div");
root.className = "overlay se-overlay";
root.innerHTML =
'
' +
'
演出段编排 · ' +
' ' +
' ' +
'
' +
'
' +
' ' +
' ' +
' 拖 clip 改起始 · 拖右缘改时长 · 单击选中编辑 · 网格吸附 0.1s' +
' ' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
';
document.body.appendChild(root);
root.querySelector(".se-id").textContent = sceneId;
root.querySelector(".se-psinfo").textContent = " 点位集:" + psName + (ps.anchors && ps.anchors.length ? "(" + ps.anchors.length + " 点)" : "(无坐标,move 时长按 0 估)");
root.querySelector(".se-x").onclick = close;
root.querySelector(".se-addtrack").onclick = addTrack;
root.querySelector(".se-preview-btn").onclick = doPreview;
root.addEventListener("mousedown", e => { if (e.target === root) close(); }); // 点遮罩空白关闭
lanesEl = root.querySelector(".se-lanes");
rulerWrapEl = root.querySelector(".se-ruler");
propEl = root.querySelector(".se-prop");
lintEl = root.querySelector(".se-lint");
previewEl = root.querySelector(".se-preview");
}
// ---------- 渲染 ----------
function render() {
const sc = scene(); if (!sc) { close(); return; }
if (!sc.tracks) sc.tracks = [];
const froms = moveFroms(sc), total = sceneEnd(sc, froms);
const W = Math.max(total, 4) * PX + LABEL_W + 40;
root.querySelector(".se-total").textContent = "总时长 ≈ " + round1(total) + "s" + (sc.duration ? "(显式 " + sc.duration + "s)" : "");
// 标尺
rulerWrapEl.innerHTML = ""; rulerWrapEl.style.width = W + "px";
for (let s = 0; s <= Math.ceil(total) + 1; s++) {
const t = document.createElement("div"); t.className = "se-tick"; t.style.left = (s * PX + LABEL_W) + "px"; t.innerHTML = "" + s + "s";
rulerWrapEl.appendChild(t);
}
// 轨道
lanesEl.innerHTML = ""; lanesEl.style.width = W + "px";
sc.tracks.forEach((tk, ti) => {
const lane = document.createElement("div"); lane.className = "se-lane"; lane.style.height = LANE_H + "px";
const isCam = tk.role === "camera";
const label = document.createElement("div"); label.className = "se-lane-label";
const who = isCam ? "🎬 镜头" : actorName(tk.actor || "P1");
label.innerHTML = '' + esc(who) + '' +
'' +
'';
label.querySelector(".se-addclip").onclick = e => { e.stopPropagation(); addClip(ti); };
label.querySelector(".se-deltrack").onclick = e => { e.stopPropagation(); delTrack(ti); };
lane.appendChild(label);
(tk.clips || []).forEach((c, ci) => lane.appendChild(clipEl(tk, ti, c, ci, froms)));
lanesEl.appendChild(lane);
});
if (!sc.tracks.length) lanesEl.innerHTML = '空演出段——点「+ 添加轨道」开始编排
';
renderProp(); renderLint();
}
function clipEl(tk, ti, c, ci, froms) {
const dur = durOf(c, froms), el = document.createElement("div");
el.className = "se-clip k-" + (c.kind === "narration" ? "dialogue" : c.kind) + (sel && sel.ti === ti && sel.ci === ci ? " sel" : "");
el.style.left = ((c.start || 0) * PX + LABEL_W) + "px";
el.style.width = Math.max(14, dur * PX - 2) + "px";
el.textContent = clipLabel(c);
el.title = clipLabel(c) + "(start " + round1(c.start || 0) + "s · " + round1(dur) + "s" + (c.dur ? " 显式" : " 自动") + ")";
const handle = document.createElement("div"); handle.className = "se-resize"; el.appendChild(handle);
el.onmousedown = e => startDrag(e, tk, ti, c, ci, false);
handle.onmousedown = e => { e.stopPropagation(); startDrag(e, tk, ti, c, ci, true); };
return el;
}
function clipLabel(c) {
if (c.kind === "move") return "→ " + (c.to || "?");
if (c.kind === "dialogue" || c.kind === "narration") return (c.kind === "narration" ? "旁:" : "") + (c.text || "").slice(0, 12);
if (c.kind === "anim") return "♪ " + (c.ani || "?");
if (c.kind === "camera") return "🎬 " + (c.focus || "?");
if (c.kind === "wait") return "⏸ wait";
return c.kind;
}
// ---------- 拖拽:移动 start / 拉伸 dur ----------
function startDrag(e, tk, ti, c, ci, resize) {
if (e.button !== 0) return;
e.preventDefault();
const sc = scene(), froms = moveFroms(sc);
const startX = e.clientX, origStart = c.start || 0, origDur = durOf(c, froms);
let moved = false;
const onMove = ev => {
const dx = (ev.clientX - startX) / PX;
if (Math.abs(ev.clientX - startX) > 3) moved = true;
if (resize) { c.dur = Math.max(MIN_DUR, snap(origDur + dx)); }
else { c.start = snap(origStart + dx); }
render(); // 实时重画(含依赖时长/重叠 lint)
};
const onUp = () => {
document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp);
if (moved) commit();
else { sel = { ti, ci }; render(); } // 没拖动=选中
};
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}
// ---------- 增删 ----------
function addTrack() {
const sc = scene();
const choices = actorSlots().map(s => actorName(s) + "(" + s + ")").concat(["镜头轨"]);
const pick = prompt("添加轨道——输入序号:\n" + choices.map((c, i) => (i + 1) + ". " + c).join("\n"), "1");
if (pick == null) return;
const idx = parseInt(pick, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= choices.length) return;
if (idx === choices.length - 1) sc.tracks.push({ id: "tk_cam" + sc.tracks.length, role: "camera", clips: [] });
else { const slot = actorSlots()[idx]; sc.tracks.push({ id: "tk_" + slot.toLowerCase() + sc.tracks.length, actor: slot, clips: [] }); }
commit();
}
function delTrack(ti) {
const sc = scene();
if (!confirm("删除该轨道及其所有 clip?")) return;
sc.tracks.splice(ti, 1); sel = null; commit();
}
function addClip(ti) {
const sc = scene(), tk = sc.tracks[ti], isCam = tk.role === "camera";
const kinds = isCam ? ["camera"] : ["move", "dialogue", "narration", "anim", "wait"];
let kind = kinds[0];
if (kinds.length > 1) {
const pick = prompt("加 clip 类型——输入序号:\n" + kinds.map((k, i) => (i + 1) + ". " + k).join("\n"), "1");
if (pick == null) return; const ki = parseInt(pick, 10) - 1;
if (isNaN(ki) || ki < 0 || ki >= kinds.length) return; kind = kinds[ki];
}
const froms = moveFroms(sc);
let start = 0; (tk.clips || []).forEach(c => { start = Math.max(start, (c.start || 0) + durOf(c, froms)); });
const c = { id: newClipId(sc), kind, start: round1(start) };
if (kind === "move") c.to = pointNames()[0] || "";
else if (kind === "dialogue" || kind === "narration") c.text = "新台词";
else if (kind === "anim") c.ani = "idle";
else if (kind === "camera") c.focus = pointNames()[0] || "";
else if (kind === "wait") c.dur = 1.0;
(tk.clips = tk.clips || []).push(c);
sel = { ti, ci: tk.clips.length - 1 };
commit();
}
function delClip() {
if (!sel) return; const sc = scene(); const tk = sc.tracks[sel.ti]; if (!tk) return;
tk.clips.splice(sel.ci, 1); sel = null; commit();
}
// ---------- 选中 clip 的属性条 ----------
function renderProp() {
propEl.innerHTML = "";
if (!sel) { propEl.innerHTML = '单击一个 clip 编辑其属性
'; return; }
const sc = scene(), tk = sc.tracks[sel.ti]; if (!tk || !tk.clips[sel.ci]) { sel = null; return renderProp(); }
const c = tk.clips[sel.ci];
const wrap = document.createElement("div"); wrap.className = "se-prop-row";
const add = (label, node) => { const f = document.createElement("label"); f.className = "se-f"; f.appendChild(document.createTextNode(label)); f.appendChild(node); wrap.appendChild(f); };
const numIn = (val, on) => { const i = document.createElement("input"); i.type = "number"; i.step = "0.1"; i.value = val == null ? "" : val; i.oninput = () => on(i.value === "" ? null : Number(i.value)); return i; };
const txtIn = (val, on) => { const i = document.createElement("input"); i.type = "text"; i.value = val == null ? "" : val; i.oninput = () => on(i.value); return i; };
const selIn = (val, opts, on) => { const s = document.createElement("select"); opts.forEach(o => { const op = document.createElement("option"); op.value = o; op.textContent = o; if (o === val) op.selected = true; s.appendChild(op); }); s.onchange = () => on(s.value); return s; };
wrap.appendChild(Object.assign(document.createElement("span"), { className: "se-prop-kind", textContent: "#" + c.id + " · " + c.kind }));
add("start(s)", numIn(round1(c.start || 0), v => { c.start = Math.max(0, v || 0); commit(); }));
const froms = moveFroms(sc), autoDur = durOf(c, froms);
add("dur(s)", numIn(c.dur != null ? c.dur : "", v => { if (v == null) delete c.dur; else c.dur = Math.max(MIN_DUR, v); commit(); }));
if (c.dur != null) { const b = document.createElement("button"); b.className = "mini"; b.textContent = "恢复自动(" + round1(autoDur) + "s)"; b.onclick = () => { delete c.dur; commit(); }; wrap.appendChild(b); }
if (c.kind === "move") {
add("目标点 to", selIn(c.to, pointNames(), v => { c.to = v; commit(); }));
add("速度 speed", numIn(c.speed != null ? c.speed : "", v => { if (v == null) delete c.speed; else c.speed = v; commit(); }));
} else if (c.kind === "dialogue" || c.kind === "narration") {
add("文本", txtIn(c.text, v => { c.text = v; commitLight(); }));
} else if (c.kind === "anim") {
add("动画 ani", txtIn(c.ani, v => { c.ani = v; commitLight(); }));
add("朝向 angle", numIn(c.angle != null ? c.angle : "", v => { if (v == null) delete c.angle; else c.angle = v; commit(); }));
} else if (c.kind === "camera") {
add("对焦 focus", selIn(c.focus, pointNames(), v => { c.focus = v; commit(); }));
}
const del = document.createElement("button"); del.className = "mini se-delclip"; del.textContent = "删除此 clip"; del.onclick = delClip;
wrap.appendChild(del);
propEl.appendChild(wrap);
}
// ---------- 客户端轻量校验(镜像 validate.py scene 规则,即时反馈)----------
function renderLint() {
const sc = scene(), froms = moveFroms(sc), issues = [];
const movesByActor = {};
(sc.tracks || []).forEach((tk, ti) => {
const isCam = tk.role === "camera", actor = tk.actor;
if (!isCam && !actor) issues.push("第" + (ti + 1) + "条轨道未指定 actor");
(tk.clips || []).forEach(c => {
if ((c.start || 0) < 0) issues.push("clip " + c.id + " start<0");
if ((c.kind === "dialogue" || c.kind === "narration") && !String(c.text || "").trim()) issues.push("clip " + c.id + " 文本为空");
if (c.kind === "move" && !c.to) issues.push("clip " + c.id + " 缺目标点");
if (c.kind === "camera" && !c.focus) issues.push("clip " + c.id + " 缺对焦点");
if (c.kind === "move" && !isCam && actor) (movesByActor[actor] = movesByActor[actor] || []).push(c);
});
});
Object.keys(movesByActor).forEach(a => {
const arr = movesByActor[a].slice().sort((x, y) => (x.start || 0) - (y.start || 0));
for (let i = 1; i < arr.length; i++) {
if ((arr[i - 1].start || 0) + durOf(arr[i - 1], froms) > (arr[i].start || 0) + 1e-6)
issues.push(actorName(a) + " 的 move " + arr[i - 1].id + " 与 " + arr[i].id + " 时间重叠");
}
});
if (!issues.length) { lintEl.className = "se-lint ok"; lintEl.textContent = "✓ 本段无明显问题"; }
else { lintEl.className = "se-lint bad"; lintEl.innerHTML = "⚠ " + issues.map(esc).join(" · "); }
}
// ---------- 提交 / 预览 ----------
function commit() { if (ctx && ctx.onChange) ctx.onChange(false); render(); }
function commitLight() { if (ctx && ctx.onChange) ctx.onChange(false); renderLint();
// 文本编辑时不整体 render(避免输入框失焦),仅刷新本 clip 标签宽度与 lint
const sc = scene(), froms = moveFroms(sc);
const lane = lanesEl.children[sel.ti]; if (lane) { const cl = lane.querySelectorAll(".se-clip")[sel.ci]; if (cl) cl.firstChild.textContent = clipLabel(sc.tracks[sel.ti].clips[sel.ci]); }
}
function doPreview() {
previewEl.innerHTML = '▶ 白模预览(从本演出段开始)
';
const host = previewEl.querySelector(".se-prev-host");
try { window.Timeline.show(host, ir, dict, pointsets, sceneId); }
catch (e) { host.textContent = "预览失败:" + e.message; }
}
window.SceneEdit = { open, close };
})();