// 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, ">"); } 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 = ''; 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 = "" + s + "s"; 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"))) + '' + ''; 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 = '
空演出段——点「+ 添加轨道」开始编排
'; 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 = '
单击一个 clip 编辑其属性
'; 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 = '
▶ 白模预览(从本演出段开始)
'; 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 }; })();