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

@ -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,