feat(timeline): P2 并行编排——scene 多轨编辑器 + 白模重叠预览

剧情 Timeline P2 前端 + 共享内核(与 SGame 源真同步):
- ir_core/IR_SCHEMA/样张:scene v0.3 + scene 校验 + 导出 gate(D3),与 SGame 仓逐字一致
- timeline.js:appendScene 按 authored start 铺多轨 clip(自然重叠预览),move from 同 actor 跨轨续连(D4);
  drawStage 改逐 actor 查对话→多人气泡同时计时;导出 _clipDur 纯函数;show() 加 startId 参;常量加 CAMERA_DUR
- scene_edit.js(新):演出段编辑模态——拖 clip 改 start(吸附 0.1s)、拖右缘改 dur、增删 clip/轨道、
  选中属性条精确编辑、客户端轻量 lint(镜像 validate.py)、▶ 预览此段(复用播放核)
- graph.js:scene 节点(KIND_CN/summary/nodeInner 列轨道)+双击进编辑模态
- form.js:右栏 renderScene 精确数值编辑(轨道/clip 的 start/dur/kind/目标)+打开编辑器按钮
- app.py export:捕获 CompileError 并入 report(scene 被拦时不再 500)
- test_scene.js:离线 10 断言全过(重叠确凿/晚 1.5s 起步/from 续连);gitignore 忽略本地 _localdemo.db

待浏览器目测拖拽编辑落 IR + 白模重叠演出。
This commit is contained in:
2026-06-13 22:34:29 +08:00
parent 06e639f0df
commit 021080dd56
14 changed files with 841 additions and 16 deletions

View File

@ -100,6 +100,7 @@
},
selectNode: id => { App.selectedNode = id; },
deleteNode: id => deleteNode(id),
editScene: id => { App.selectedNode = id; SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); },
});
function selectNode(id) { App.selectedNode = id; GraphUI.select(id); FormUI.renderNode(App.ir, id, ctx()); }
@ -402,6 +403,7 @@
onConnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
onDeleteSelected: id => deleteNode(id),
onEditScene: id => { App.selectedNode = id; GraphUI.select(id); SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); },
});
(async function () {
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }

View File

@ -5,7 +5,8 @@
(function () {
const NODE_KINDS = ["narration", "dialogue", "choice", "choice_once", "random",
"fight", "move", "anim", "reward", "out_ref"];
"fight", "move", "anim", "reward", "out_ref", "scene"];
const SCENE_CLIP_KINDS = ["move", "dialogue", "narration", "anim", "camera", "wait"];
// ---- DOM 小工具 ----
function el(tag, attrs, kids) {
@ -252,6 +253,8 @@
const seqs = (ir.sequences || []).map(s => ({ value: s.id, label: s.id }));
host.appendChild(field("引用子序列 ref", sel(node.ref, [{ value: "", label: "(无)" }].concat(seqs), v => { node.ref = v; ctx.onChange(false); })));
host.appendChild(field("出口接回 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })));
} else if (node.kind === "scene") {
renderScene(host, ir, node, ctx, tgt);
}
// 删除节点
@ -263,6 +266,70 @@
]));
};
// scene 内 clip 唯一 id
function newClipId(node) {
let i = 1, id; const has = x => (node.tracks || []).some(tk => (tk.clips || []).some(c => c.id === x));
do { id = "c" + i++; } while (has(id));
return id;
}
// ========== 演出段 scene 右栏(精确数值编辑,与画布拖拽互补)==========
function renderScene(host, ir, node, ctx, tgt) {
if (!node.tracks) node.tracks = [];
const actorSlots = [{ value: "P1", label: "P1 玩家" }].concat((ir.roles || []).map(r => ({ value: r.slot, label: r.slot + " " + r.name })));
host.appendChild(el("div", { class: "fld" }, [
el("button", { class: "mini primary", onclick: () => ctx.editScene && ctx.editScene(node.id) }, ["🎬 打开时间线编辑器(拖拽编排)"]),
]));
host.appendChild(el("div", { class: "row2" }, [
field("时长 duration(可选,缺省=自动)", num(node.duration, v => { if (v == null) delete node.duration; else node.duration = v; ctx.onChange(false); })),
field("出口 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })),
]));
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["轨道 tracks"]),
el("span", {}, [
el("button", { class: "mini", onclick: () => { node.tracks.push({ id: "tk" + node.tracks.length, actor: "P1", clips: [] }); ctx.onChange(true); } }, ["+演员轨"]),
el("button", { class: "mini", onclick: () => { node.tracks.push({ id: "tkcam" + node.tracks.length, role: "camera", clips: [] }); ctx.onChange(true); } }, ["+镜头轨"]),
]),
]));
(node.tracks || []).forEach((tk, ti) => {
const isCam = tk.role === "camera";
const tbox = el("div", { class: "fld optdet-like" });
tbox.appendChild(el("div", { class: "row2" }, [
isCam ? el("span", { class: "node-id" }, ["🎬 镜头轨"])
: sel(tk.actor || "P1", actorSlots, v => { tk.actor = v; ctx.onChange(false); }),
el("span", {}, [
el("button", { class: "mini", onclick: () => {
const c = { id: newClipId(node), kind: isCam ? "camera" : "move", start: 0 };
if (isCam) c.focus = (ctx.pointNames || [])[0] || ""; else c.to = (ctx.pointNames || [])[0] || "";
(tk.clips = tk.clips || []).push(c); ctx.onChange(true);
} }, ["clip"]),
el("button", { class: "mini", onclick: () => { if (confirm("删除该轨道?")) { node.tracks.splice(ti, 1); ctx.onChange(true); } } }, ["删轨"]),
]),
]));
(tk.clips || []).forEach((c, ci) => {
const kinds = isCam ? ["camera"] : SCENE_CLIP_KINDS.filter(k => k !== "camera");
const row = el("div", { class: "se-cliprow" }, [
sel(c.kind, kinds.map(k => ({ value: k, label: k })), v => { c.kind = v; ctx.onChange(true); }),
field("start", num(c.start, v => { c.start = v == null ? 0 : Math.max(0, v); ctx.onChange(false); })),
field("dur", num(c.dur, v => { if (v == null) delete c.dur; else c.dur = v; ctx.onChange(false); })),
]);
if (c.kind === "move") row.appendChild(field("to", sel(c.to, pointOpts(ir, ctx, c.to), v => { c.to = v; ctx.onChange(false); })));
else if (c.kind === "camera") row.appendChild(field("focus", sel(c.focus, pointOpts(ir, ctx, c.focus), v => { c.focus = v; ctx.onChange(false); })));
else if (c.kind === "dialogue" || c.kind === "narration") row.appendChild(field("文本", txt(c.text, v => { c.text = v; ctx.onChange(false); })));
else if (c.kind === "anim") row.appendChild(field("ani", txt(c.ani, v => { c.ani = v; ctx.onChange(false); })));
row.appendChild(el("button", { class: "mini", onclick: () => { tk.clips.splice(ci, 1); ctx.onChange(true); } }, ["✕"]));
tbox.appendChild(row);
});
if (!(tk.clips || []).length) tbox.appendChild(el("div", { class: "empty" }, ["无 clip"]));
box.appendChild(tbox);
});
if (!node.tracks.length) box.appendChild(el("div", { class: "empty" }, ["无轨道——点「+演员轨/+镜头轨」"]));
host.appendChild(box);
}
function renderEnding(host, ir, e, ctx) {
host.appendChild(field("结局摘要 summary", txt(e.summary, v => { e.summary = v; ctx.onChange(false); })));
host.appendChild(field("结果 result", sel(e.result || "success", [

View File

@ -31,6 +31,10 @@
if (n.kind === "anim") return ["动画 · " + nameOf(ir, names, n.actor), n.ani || ""];
if (n.kind === "reward") return ["奖励结算", ""];
if (n.kind === "out_ref") return ["引用子序列", "→ " + (n.ref || "")];
if (n.kind === "scene") {
const tks = n.tracks || [], nclip = tks.reduce((a, t) => a + ((t.clips || []).length), 0);
return ["演出段 (" + tks.length + "轨)", nclip + " clip" + (n.duration ? " · " + n.duration + "s" : "")];
}
if (n.kind === "ending") return ["★ 结局", n.summary || ""];
return [n.kind, ""];
}
@ -61,7 +65,7 @@
}
// ---------- 节点 HTML ----------
const KIND_CN = { narration: "旁白", dialogue: "对话", choice: "选择", choice_once: "一次性选择", random: "随机", fight: "战斗", move: "走位", anim: "动画", reward: "奖励", out_ref: "引用", ending: "结局" };
const KIND_CN = { narration: "旁白", dialogue: "对话", choice: "选择", choice_once: "一次性选择", random: "随机", fight: "战斗", move: "走位", anim: "动画", reward: "奖励", out_ref: "引用", scene: "演出段", ending: "结局" };
function nodeInner(ir, node) {
const names = roleNames(ir), end = isEnding(node);
const kind = end ? "ending" : node.kind;
@ -85,6 +89,16 @@
}
return label + chOpts;
}
// 演出段 scene列出各轨演员/镜头)+ clip 数 + 双击编辑提示
if (!end && node.kind === "scene") {
const tks = node.tracks || [];
const lines = tks.slice(0, 4).map(tk => {
const who = tk.role === "camera" ? "🎬 镜头" : nameOf(ir, names, tk.actor || "P1");
return '<div class="screl">' + esc(who) + ' · ' + ((tk.clips || []).length) + ' clip</div>';
}).join("");
const more = tks.length > 4 ? '<div class="screl more">…+' + (tks.length - 4) + ' 轨</div>' : "";
return label + '<div class="nbody scenebody">' + (lines || '<div class="screl more">(空演出段)</div>') + more + '<div class="schint">双击编辑时间线</div></div>';
}
// 线性 / 单出口:角色(可选)+ 文本
const sm = summary(ir, names, node);
const actor = node.speaker || node.actor;
@ -188,6 +202,14 @@
editor.on("connectionCreated", info => { if (building) return; handleConnect(info); });
editor.on("connectionRemoved", info => { if (building) return; handleDisconnect(info); });
editor.on("nodeRemoved", id => { if (building) return; const ir = dfId2ir[id]; if (ir && cb.onRemove) cb.onRemove(ir); });
// 双击 scene 节点 → 打开时间线编辑模态(演出段编排入口)
document.getElementById(containerId).addEventListener("dblclick", e => {
const nodeEl = e.target.closest && e.target.closest(".drawflow-node");
if (!nodeEl) return;
const irId = dfId2ir[nodeEl.id.replace("node-", "")];
const n = irId && findNode(irId);
if (n && n.kind === "scene" && cb.onEditScene) { e.stopPropagation(); e.preventDefault(); cb.onEditScene(irId); }
});
// 键盘 Del删画布选中节点 → 交给 app 做「缝合删除」;焦点在输入框时放行文本编辑;选中连线时交给 Drawflow
document.addEventListener("keydown", e => {
if (e.key !== "Delete" && e.key !== "Backspace") return;

View File

@ -135,6 +135,7 @@
<script src="form.js"></script>
<script src="playtest.js"></script>
<script src="timeline.js"></script>
<script src="scene_edit.js"></script>
<script src="app.js"></script>
</body>
</html>

283
web/static/scene_edit.js Normal file
View File

@ -0,0 +1,283 @@
// 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;
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 + 60;
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 + 56) + "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";
label.innerHTML = (isCam ? "🎬 镜头" : esc(actorName(tk.actor || "P1"))) +
'<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 + 56) + "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 };
})();

View File

@ -325,3 +325,62 @@ body.perform-mode .mode-switch { border-color:#2f7a60; }
.tl-clip.k-stop { background:#d87878; color:#fff; }
.tl-playhead { position:absolute; top:0; bottom:0; width:2px; background:#ff5a4a;
pointer-events:none; z-index:4; box-shadow:0 0 4px rgba(255,90,74,.7); }
/* ===== 演出段 scene 节点(分支图)===== */
#drawflow .kind-scene { background:#16241f; border-color:#3a7a64; }
#drawflow .kind-scene .nlabel { color:#7ee3c8; }
.drawflow-node .scenebody { padding-top:2px; }
.drawflow-node .scenebody .screl { font-size:11px; color:#bfe6d8; line-height:1.5; }
.drawflow-node .scenebody .screl.more { color:#7a8a82; }
.drawflow-node .scenebody .schint { font-size:10px; color:#5a8a7a; margin-top:3px; font-style:italic; }
/* ===== 演出段编辑模态 ===== */
.scene-edit { width:92vw; max-width:1280px; max-height:92vh; }
.se-psinfo { font-size:12px; color:#9a907e; font-weight:normal; }
.se-toolbar { display:flex; align-items:center; gap:10px; margin:4px 0 8px; flex-wrap:wrap; }
.se-toolbar .se-tip { font-size:11px; color:#8a8275; }
.se-toolbar .se-total { margin-left:auto; font-size:12px; color:#e6c878; }
.se-trackswrap { overflow-x:auto; background:#15130d; border:1px solid #2a2419; border-radius:6px; padding-bottom:4px; }
.se-ruler { position:relative; height:16px; border-bottom:1px solid #2a2419; min-width:100%; }
.se-tick { position:absolute; top:0; height:16px; border-left:1px solid #2a2419; }
.se-tick span { font-size:9px; color:#6a6256; padding-left:3px; }
.se-lanes { position:relative; min-width:100%; }
.se-lane { position:relative; border-bottom:1px solid #211c15; }
.se-lane-label { position:sticky; left:0; z-index:5; display:inline-flex; align-items:center; gap:3px;
width:54px; height:100%; background:#1d1810; color:#d8cda0; font-size:11px; padding:0 3px;
border-right:1px solid #2a2419; box-sizing:border-box; }
.se-lane-label button { background:none; border:none; color:#9a8f78; cursor:pointer; font-size:11px; padding:0 1px; }
.se-lane-label .se-addclip:hover { color:#7ee3c8; }
.se-lane-label .se-deltrack:hover { color:#e38f7e; }
.se-clip { position:absolute; top:5px; height:24px; line-height:24px; padding:0 6px; border-radius:4px;
font-size:11px; color:#1a1710; white-space:nowrap; overflow:hidden; cursor:grab; user-select:none;
box-shadow:0 1px 3px rgba(0,0,0,.5); }
.se-clip.sel { box-shadow:0 0 0 2px #ff5a4a, 0 1px 3px rgba(0,0,0,.5); z-index:3; }
.se-clip.k-dialogue { background:#7ec8e3; }
.se-clip.k-move { background:#e6c878; }
.se-clip.k-anim { background:#9ee37e; }
.se-clip.k-camera { background:#c89ee3; }
.se-clip.k-wait { background:#9a9488; color:#fff; }
.se-resize { position:absolute; top:0; right:0; width:7px; height:100%; cursor:ew-resize; background:rgba(0,0,0,.18); border-radius:0 4px 4px 0; }
.se-empty, .se-prop-empty { padding:14px; color:#8a8275; font-size:12px; text-align:center; }
.se-lint { margin:8px 0 4px; font-size:11.5px; padding:5px 8px; border-radius:5px; }
.se-lint.ok { color:#7ee3a0; background:rgba(60,120,80,.12); }
.se-lint.bad { color:#f0b070; background:rgba(160,90,40,.14); }
.se-prop { background:#19150f; border:1px solid #2a2419; border-radius:6px; padding:8px; min-height:40px; }
.se-prop-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.se-prop-kind { font-size:12px; color:#e6c878; font-weight:bold; }
.se-f { font-size:11px; color:#a89e88; display:flex; align-items:center; gap:4px; }
.se-f input, .se-f select { width:auto; min-width:64px; background:#221d16; color:#e8e0d4; border:1px solid #3a3326; border-radius:4px; padding:2px 4px; font-size:12px; }
.se-f input[type=number] { width:64px; }
.se-delclip { margin-left:auto; color:#e38f7e !important; }
.se-preview { margin-top:10px; }
.se-prev-head { font-size:12px; color:#7ee3c8; margin-bottom:4px; }
.se-prev-host { height:560px; background:#100e09; border:1px solid #2a2419; border-radius:6px; overflow:hidden; position:relative; }
/* scene 右栏 clip 行form.js 精确数值编辑) */
.se-cliprow { display:flex; align-items:flex-end; gap:5px; flex-wrap:wrap; padding:5px 0; border-top:1px dashed #2a2419; }
.se-cliprow .fld { margin:0; }
.se-cliprow .fld label { font-size:10px; }
.se-cliprow input[type=number] { width:52px; }
.se-cliprow select { min-width:60px; }
#edit-pane .optdet-like { border:1px solid #2a2419; border-radius:5px; padding:6px; margin-bottom:6px; }

View File

@ -5,9 +5,9 @@
// 暴露 window.Timeline = { show(host, ir, dict, pointsets), stop() }。
(function () {
// ---- 时长模型 ----
// ---- 时长模型(常量与 ir_core/validate.py 顶部共享口径,避免预览与校验/真机漂移)----
const CHAR_TIME = 0.07, TAIL_PAUSE = 0.9, MIN_DLG = 1.2;
const MOVE_SPEED = 3.0, ANIM_DUR = 1.0;
const MOVE_SPEED = 3.0, ANIM_DUR = 1.0, CAMERA_DUR = 2.0;
const PXMAX = 80, ROW_H = 30; // PXMAX=每秒最大像素;实际用动态 PX可适应宽度
const CAM_W = 14, CAM_H = 9;
@ -29,7 +29,13 @@
function collectAllRefs(IR) {
const refs = [], add = n => { if (n && !refs.includes(n)) refs.push(n); };
add("P1"); (IR.roles || []).forEach(r => add(r.slot));
const scan = arr => (arr || []).forEach(n => { add(n.speaker); add(n.actor); if (n.kind === "move") add(n.to); if (n.camera) add(n.camera); });
const scan = arr => (arr || []).forEach(n => {
add(n.speaker); add(n.actor); if (n.kind === "move") add(n.to); if (n.camera) add(n.camera);
if (n.kind === "scene") (n.tracks || []).forEach(tk => {
add(tk.actor);
(tk.clips || []).forEach(c => { if (c.kind === "move") add(c.to); if (c.kind === "camera") add(c.focus); });
});
});
scan(IR.nodes); (IR.sequences || []).forEach(s => scan(s.nodes));
return refs;
}
@ -72,7 +78,10 @@
// 在场角色:全事件里当过 speaker/actor 的 slot含 P1。舞台始终按位置画出他们
// 这样从中途节点开始也能看到各角色"在该处应有的位置"。
const used = [], addU = s => { if (s && !used.includes(s)) used.push(s); };
const scanU = arr => (arr || []).forEach(n => { addU(n.speaker); addU(n.actor); });
const scanU = arr => (arr || []).forEach(n => {
addU(n.speaker); addU(n.actor);
if (n.kind === "scene") (n.tracks || []).forEach(tk => { if (tk.role !== "camera") addU(tk.actor || "P1"); });
});
scanU(IR.nodes); (IR.sequences || []).forEach(s => scanU(s.nodes));
const ordU = [];
if (used.includes("P1")) ordU.push("P1");
@ -112,9 +121,21 @@
function replayPositions(S, path) {
const cur = {};
const applyMove = n => { if (n.kind === "move") { const to = S.posMap[n.to]; if (to) cur[n.actor || "P1"] = { x: to.x, z: to.z }; } };
// scene各 actor 取其(跨轨)最后一段 move 的目标点作为离场位置
const applyScene = n => {
const mv = {};
(n.tracks || []).forEach(tk => { if (tk.role === "camera") return; const a = tk.actor || "P1";
(tk.clips || []).forEach(c => { if (c.kind === "move") (mv[a] = mv[a] || []).push(c); }); });
Object.keys(mv).forEach(a => {
mv[a].sort((x, y) => (x.start || 0) - (y.start || 0));
const last = mv[a][mv[a].length - 1], to = last && S.posMap[last.to];
if (to) cur[a] = { x: to.x, z: to.z };
});
};
for (let i = 0; i < path.length - 1; i++) {
const n = S.nodes[path[i]]; if (!n) continue;
if (n.kind === "out_ref") {
if (n.kind === "scene") applyScene(n);
else if (n.kind === "out_ref") {
const sq = S.seqMap[n.ref];
if (sq) { const sm = {}; (sq.nodes || []).forEach(x => sm[x.id] = x); let sid = (sq.nodes[0] || {}).id, g = 0; while (sid && g++ < 100) { const sn = sm[sid]; if (!sn) break; applyMove(sn); sid = sn.next; } }
} else applyMove(n);
@ -148,6 +169,68 @@
}
}
// clip 时长派生(与 ir_core/validate.py 口径一致):显式 dur > 按 kind 派生。
function clipDur(S, c, actor, fromPos) {
if (typeof c.dur === "number" && c.dur > 0) return c.dur;
const k = c.kind;
if (k === "dialogue" || k === "narration") return dlgDur(c.text);
if (k === "anim") return ANIM_DUR;
if (k === "camera") return CAMERA_DUR;
if (k === "move") {
const from = fromPos || posOf(S, actor), to = S.posMap[c.to] || from;
const dist = Math.hypot(to.x - from.x, to.z - from.z);
return Math.max(0.3, dist / (c.speed || MOVE_SPEED));
}
if (k === "wait") return 0.5;
return 0.4;
}
// scene 演出段:把多轨 clips 按 authored start相对 S._t 偏移)铺进 S.clips自然支持重叠。
// move 的 from = 同 actor跨轨上一段 move 的 to无则取进入 scene 时的位置/初始锚点D4
function appendScene(S, n) {
const base = S._t, tracks = n.tracks || [];
// 1) 先按 actor 续连各 move 的 from跨轨合并、按 start 排序)
const movesByActor = {};
tracks.forEach(tk => { if (tk.role === "camera") return; const actor = tk.actor || "P1";
(tk.clips || []).forEach(c => { if (c.kind === "move") (movesByActor[actor] = movesByActor[actor] || []).push(c); }); });
const moveFrom = new Map();
Object.keys(movesByActor).forEach(actor => {
let prev = posOf(S, actor);
movesByActor[actor].slice().sort((a, b) => (a.start || 0) - (b.start || 0)).forEach(c => {
moveFrom.set(c, prev);
const to = S.posMap[c.to] || prev; prev = { x: to.x, z: to.z };
});
S.curPos[actor] = prev; // scene 结束后该 actor 的位置
});
// 2) 逐轨逐 clip 生成可视 clipstart 偏移 base
let sceneEnd = 0;
tracks.forEach(tk => {
const isCam = tk.role === "camera", actor = isCam ? null : (tk.actor || "P1");
(tk.clips || []).forEach(c => {
const start = base + (c.start || 0), dur = clipDur(S, c, actor, moveFrom.get(c));
sceneEnd = Math.max(sceneEnd, (c.start || 0) + dur);
const k = c.kind;
if (k === "dialogue" || k === "narration") {
S.clips.push({ row: "演员:" + actor, kind: "dialogue", start, dur, label: esc(c.text || ""), actor, text: c.text || "", nodeId: n.id });
useRow(S, "演员:" + actor);
} else if (k === "move") {
const from = moveFrom.get(c) || posOf(S, actor), to = S.posMap[c.to] || from;
S.clips.push({ row: "演员:" + actor, kind: "move", start, dur, label: "→ " + (c.to || ""), actor, from, to, nodeId: n.id });
useRow(S, "演员:" + actor);
} else if (k === "anim") {
S.clips.push({ row: "演员:" + actor, kind: "anim", start, dur, label: "动画 " + (c.ani || ""), actor, nodeId: n.id });
useRow(S, "演员:" + actor);
} else if (k === "camera") {
S.clips.push({ row: "镜头", kind: "camera", start, dur, label: "对焦 " + (c.focus || ""), focus: c.focus, nodeId: n.id });
useRow(S, "镜头");
}
// wait仅占位只计入时长不产可视 clip
});
});
const total = (typeof n.duration === "number" && n.duration > 0) ? Math.max(n.duration, sceneEnd) : sceneEnd;
S._t = base + Math.max(total, 0.1);
}
// 从 startId 走一段线性演出,遇分支/结局/断头停下。返回 {kind, node}。
function extendSegment(S, startId) {
let id = startId, guard = 0;
@ -165,6 +248,7 @@
S.clips.push({ row: "剧情", kind: (k === "fight" ? "fight" : "branch"), start: S._t, dur: 0.6, label: lbl, nodeId: id }); useRow(S, "剧情"); S._t += 0.6;
return { kind: k, node: n };
}
if (k === "scene") { appendScene(S, n); id = n.next; continue; }
if (k === "out_ref") {
const sq = S.seqMap[n.ref];
if (sq && (sq.nodes || []).length) {
@ -237,7 +321,7 @@
'<div class="tl-resizer" title="拖动调整时间轴高度"></div>' +
'<div class="tl-timelinepanel"><div class="tl-tracks"></div></div>';
function show(host, IR, DICT, POINTSETS) {
function show(host, IR, DICT, POINTSETS, startId) {
stopPlay();
const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {};
@ -281,7 +365,7 @@
document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up);
};
startFrom(firstNode(IR));
startFrom(startId && S.nodes[startId] ? startId : firstNode(IR));
}
function selectClip(c, el) {
@ -305,6 +389,7 @@
if (n.kind === "fight") return "【战斗】vs " + (n.camp2 || []).map(nm).join("、");
if (n.kind === "reward") return "奖励结算";
if (n.kind === "out_ref") return "引用·" + n.ref;
if (n.kind === "scene") return "演出段·" + (n.tracks || []).length + "轨";
return n.kind;
}
// 从指定节点开始:先重放途中走位算进入位置,再从该节点构建演出。
@ -398,18 +483,19 @@
ctx.fillStyle = "rgba(230,200,120,.9)"; ctx.font = "10px sans-serif"; ctx.textAlign = "left"; ctx.fillText("镜头", fp.x - bw / 2 + 4, fp.y - bh / 2 + 13);
ctx.strokeStyle = "rgba(230,200,120,.5)"; ctx.beginPath(); ctx.moveTo(fp.x - 6, fp.y); ctx.lineTo(fp.x + 6, fp.y); ctx.moveTo(fp.x, fp.y - 6); ctx.lineTo(fp.x, fp.y + 6); ctx.stroke();
const dlg = activeDialogue(model, tau);
actorsIn(model).forEach(actor => {
const wp = actorPosAt(model, actor, tau), p = worldToStage(wp), col = colorOf(model, actor);
const moving = model.clips.some(c => c.actor === actor && c.kind === "move" && tau >= c.start && tau < c.start + c.dur);
const anim = activeAnim(model, actor, tau);
// 每个 actor 独立查自己的对话 clip → 重叠演出时多人气泡同时计时呈现
const dlg = model.clips.find(c => c.kind === "dialogue" && c.actor === actor && tau >= c.start && tau < c.start + c.dur);
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.x, p.y, moving ? 9 : 8, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = "#1a1710"; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "center"; ctx.fillText(actor, p.x, p.y + 3);
ctx.fillStyle = "#d8cda0"; ctx.font = "11px sans-serif"; ctx.fillText(model.nm(actor), p.x, p.y + 22);
if (moving) { ctx.fillStyle = col; ctx.font = "9px sans-serif"; ctx.fillText("…走位", p.x, p.y - 12); }
if (anim) { ctx.fillStyle = col; ctx.font = "9px sans-serif"; ctx.fillText("♪" + (anim.label || ""), p.x, p.y - 12); }
if (dlg && dlg.actor === actor) {
if (dlg) {
const typed = (dlg.text || "").slice(0, Math.floor((tau - dlg.start) / CHAR_TIME));
drawBubble(ctx, p.x, p.y - 26, model.nm(actor) + "" + typed);
}
@ -534,8 +620,24 @@
return s;
}
// 纯函数版 clip 时长scene 编辑器复用单一口径posMap={点位名:{x,z}}fromPos=move 起点。
function clipDurPure(c, posMap, fromPos) {
if (typeof c.dur === "number" && c.dur > 0) return c.dur;
const k = c.kind;
if (k === "dialogue" || k === "narration") return dlgDur(c.text);
if (k === "anim") return ANIM_DUR;
if (k === "camera") return CAMERA_DUR;
if (k === "move") {
const from = fromPos || { x: 0, z: 0 }, to = (posMap && posMap[c.to]) || from;
return Math.max(0.3, Math.hypot(to.x - from.x, to.z - from.z) / (c.speed || MOVE_SPEED));
}
if (k === "wait") return 0.5;
return 0.4;
}
window.Timeline = {
show, stop: stopPlay, clear,
_clipDur: clipDurPure, // scene 编辑器复用
// 离线测试用:
_buildModel: buildModelAuto, _dlgDur: dlgDur,
_prepare: prepare, _extend: extendSegment, _runUntilPause: runUntilPause, _firstNode: firstNode, _orderRows: orderRows,