Files
story-edit-web/web/static/scene_edit.js
邓雨鹏 030f1ee34d fix(scene-edit): 轨道标签竖排截断——加宽标签列 54→96px + 名字单行省略号
中文 actor 名(神秘剑客/客栈小二)被挤成竖排单字、与 +/✕ 按钮抢宽换行。
- 标签列宽 54→96px,引入 LABEL_W 常量统一 clip/标尺左偏移与 CSS 宽度
- 名字包进 .se-lname:flex:1 + nowrap + 省略号;按钮 flex:none 不参与换行
2026-06-13 22:50:05 +08:00

285 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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 };
})();