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:
14
web/app.py
14
web/app.py
@ -266,22 +266,30 @@ async def export_zip():
|
||||
if not confirmed:
|
||||
return JSONResponse({"error": "没有 confirmed 事件可导出"}, status_code=422)
|
||||
|
||||
# 校验门:任一 confirmed 有 error 即整体拒绝
|
||||
# 校验门:任一 confirmed 有 error 即整体拒绝。
|
||||
# 同步做预编译探测——捕获 CompileError(含 P2 scene 导出 gate D3:含 scene 的事件暂不可导出),
|
||||
# 把编译失败也并入 report,避免 compile 抛异常变成 500。编译成功的结果缓存复用,不重复编译。
|
||||
report = {}
|
||||
compiled = {}
|
||||
blocked = False
|
||||
for group, ir in confirmed:
|
||||
errs, warns = ir_core.validate(ir, dic, points_dir=_POINTSETS_DIR)
|
||||
if not errs:
|
||||
try:
|
||||
compiled[group] = ir_core.compile_ir(ir, dic)
|
||||
except ir_core.CompileError as e:
|
||||
errs = errs + ["[编译失败] %s" % e]
|
||||
report[group] = {"errors": errs, "warnings": warns}
|
||||
if errs:
|
||||
blocked = True
|
||||
if blocked:
|
||||
return JSONResponse({"error": "存在校验失败的 confirmed 事件,已拒绝导出",
|
||||
return JSONResponse({"error": "存在校验/编译失败的 confirmed 事件,已拒绝导出",
|
||||
"report": report}, status_code=422)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for group, ir in confirmed:
|
||||
rows = ir_core.compile_ir(ir, dic)
|
||||
rows = compiled[group]
|
||||
z.writestr(group + ".events.json",
|
||||
json.dumps(rows, ensure_ascii=False, indent=2))
|
||||
texts = ir_core.extract_texts(ir)
|
||||
|
||||
@ -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(); }
|
||||
|
||||
@ -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", [
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
283
web/static/scene_edit.js
Normal 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, "&").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 + 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 };
|
||||
})();
|
||||
@ -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; }
|
||||
|
||||
@ -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 生成可视 clip(start 偏移 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,
|
||||
|
||||
Reference in New Issue
Block a user