中文 actor 名(神秘剑客/客栈小二)被挤成竖排单字、与 +/✕ 按钮抢宽换行。 - 标签列宽 54→96px,引入 LABEL_W 常量统一 clip/标尺左偏移与 CSS 宽度 - 名字包进 .se-lname:flex:1 + nowrap + 省略号;按钮 flex:none 不参与换行
285 lines
16 KiB
JavaScript
285 lines
16 KiB
JavaScript
// 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, "<").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 =
|
||
'<div class="modal scene-edit">' +
|
||
' <h3>演出段编排 · <span class="se-id"></span>' +
|
||
' <span class="se-psinfo"></span>' +
|
||
' <button class="modal-close se-x" style="float:right">关闭</button>' +
|
||
' </h3>' +
|
||
' <div class="se-toolbar">' +
|
||
' <button class="mini se-addtrack">+ 添加轨道</button>' +
|
||
' <button class="mini se-preview-btn primary">▶ 预览此段</button>' +
|
||
' <span class="se-tip">拖 clip 改起始 · 拖右缘改时长 · 单击选中编辑 · 网格吸附 0.1s</span>' +
|
||
' <span class="se-total"></span>' +
|
||
' </div>' +
|
||
' <div class="se-trackswrap"><div class="se-ruler"></div><div class="se-lanes"></div></div>' +
|
||
' <div class="se-lint"></div>' +
|
||
' <div class="se-prop"></div>' +
|
||
' <div class="se-preview"></div>' +
|
||
'</div>';
|
||
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 = "<span>" + s + "s</span>";
|
||
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 = '<span class="se-lname" title="' + esc(who) + '">' + esc(who) + '</span>' +
|
||
'<button class="se-addclip" title="加 clip">+</button>' +
|
||
'<button class="se-deltrack" title="删轨道">✕</button>';
|
||
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 = '<div class="se-empty">空演出段——点「+ 添加轨道」开始编排</div>';
|
||
|
||
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 = '<div class="se-prop-empty">单击一个 clip 编辑其属性</div>'; 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 = '<div class="se-prev-head">▶ 白模预览(从本演出段开始)</div><div class="se-prev-host"></div>';
|
||
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 };
|
||
})();
|